diff --git a/Auditor.sln b/Auditor.sln index 4698de2..9f5e593 100644 --- a/Auditor.sln +++ b/Auditor.sln @@ -11,6 +11,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{D09148E2-D EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "src\test\Tests\Tests.csproj", "{896C7174-7249-4CD8-A7F1-58F27D637BF1}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Storage", "src\Storage\Storage.csproj", "{61852A44-BC38-4CF4-8BA9-AF5335784ACB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -48,6 +50,18 @@ Global {896C7174-7249-4CD8-A7F1-58F27D637BF1}.Release|x64.Build.0 = Release|Any CPU {896C7174-7249-4CD8-A7F1-58F27D637BF1}.Release|x86.ActiveCfg = Release|Any CPU {896C7174-7249-4CD8-A7F1-58F27D637BF1}.Release|x86.Build.0 = Release|Any CPU + {61852A44-BC38-4CF4-8BA9-AF5335784ACB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61852A44-BC38-4CF4-8BA9-AF5335784ACB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61852A44-BC38-4CF4-8BA9-AF5335784ACB}.Debug|x64.ActiveCfg = Debug|Any CPU + {61852A44-BC38-4CF4-8BA9-AF5335784ACB}.Debug|x64.Build.0 = Debug|Any CPU + {61852A44-BC38-4CF4-8BA9-AF5335784ACB}.Debug|x86.ActiveCfg = Debug|Any CPU + {61852A44-BC38-4CF4-8BA9-AF5335784ACB}.Debug|x86.Build.0 = Debug|Any CPU + {61852A44-BC38-4CF4-8BA9-AF5335784ACB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61852A44-BC38-4CF4-8BA9-AF5335784ACB}.Release|Any CPU.Build.0 = Release|Any CPU + {61852A44-BC38-4CF4-8BA9-AF5335784ACB}.Release|x64.ActiveCfg = Release|Any CPU + {61852A44-BC38-4CF4-8BA9-AF5335784ACB}.Release|x64.Build.0 = Release|Any CPU + {61852A44-BC38-4CF4-8BA9-AF5335784ACB}.Release|x86.ActiveCfg = Release|Any CPU + {61852A44-BC38-4CF4-8BA9-AF5335784ACB}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {1D5C0BDD-9D0A-4849-86E0-FE904F9A1F3E} = {2D90C96D-0481-42E2-9526-6342B17FB37C} diff --git a/src/Storage/AsyncEnumerable.cs b/src/Storage/AsyncEnumerable.cs new file mode 100644 index 0000000..17273b3 --- /dev/null +++ b/src/Storage/AsyncEnumerable.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace DotNetRu.Auditor.Storage +{ + internal static class AsyncEnumerable + { + public static IAsyncEnumerable Empty() + { + return EmptyAsyncEnumerable.Instance; + } + + private class EmptyAsyncEnumerable : IAsyncEnumerable + { + public static readonly IAsyncEnumerable Instance = new EmptyAsyncEnumerable(); + + public async IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = new()) + { + await Task.Yield(); + yield break; + } + } + } +} diff --git a/src/Storage/FileSystem/FileSystemEntry.cs b/src/Storage/FileSystem/FileSystemEntry.cs new file mode 100644 index 0000000..2d115e3 --- /dev/null +++ b/src/Storage/FileSystem/FileSystemEntry.cs @@ -0,0 +1,34 @@ +using System; +using System.IO; + +namespace DotNetRu.Auditor.Storage.FileSystem +{ + public abstract class FileSystemEntry : IFileSystemEntry + { + protected FileSystemEntry(string fullName, bool exists) + { + FullName = Path.GetFullPath(fullName); + Name = Path.GetFileName(FullName); + Exists = exists; + } + + public string Name { get; } + + public string FullName { get; } + + public bool Exists { get; } + + protected string GetFullPath(string subPath) + { + var childrenPath = Path.Combine(FullName, subPath); + var childrenFullPath = Path.GetFullPath(childrenPath); + + if (!childrenFullPath.StartsWith(FullName, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException($"Children path «{childrenFullPath}» should be under the root «{FullName}»"); + } + + return childrenFullPath; + } + } +} diff --git a/src/Storage/FileSystem/IDirectory.cs b/src/Storage/FileSystem/IDirectory.cs new file mode 100644 index 0000000..2a20bde --- /dev/null +++ b/src/Storage/FileSystem/IDirectory.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace DotNetRu.Auditor.Storage.FileSystem +{ + /// + /// In fact, most operating systems do not support asynchronous enumeration of file system contents. + /// This is why there is no asynchronous API in the official BCL. But we have a Blazor consumer that + /// throws an exception when using synchronization operations, and a GutHub HTTP API implementation + /// that can perform real asynchronous operations. + /// + /// To respect I/O operations, we decided to use asynchronous abstractions. But we assume that real + /// asynchronous operations will be rare, so we decided to use ValueTask. + /// > + public interface IDirectory : IFileSystemEntry + { + ValueTask GetDirectoryInfoAsync(string subPath); + + ValueTask GetFileInfoAsync(string subPath); + + IAsyncEnumerable EnumerateDirectoriesAsync(); + + IAsyncEnumerable EnumerateFilesAsync(); + } +} diff --git a/src/Storage/FileSystem/IFile.cs b/src/Storage/FileSystem/IFile.cs new file mode 100644 index 0000000..7d2efc5 --- /dev/null +++ b/src/Storage/FileSystem/IFile.cs @@ -0,0 +1,12 @@ +using System.IO; +using System.Threading.Tasks; + +namespace DotNetRu.Auditor.Storage.FileSystem +{ + public interface IFile : IFileSystemEntry + { + ValueTask OpenForReadAsync(); + + ValueTask OpenForWriteAsync(); + } +} diff --git a/src/Storage/FileSystem/IFileSystem.cs b/src/Storage/FileSystem/IFileSystem.cs new file mode 100644 index 0000000..2cc2bf6 --- /dev/null +++ b/src/Storage/FileSystem/IFileSystem.cs @@ -0,0 +1,6 @@ +namespace DotNetRu.Auditor.Storage.FileSystem +{ + public interface IFileSystem : IDirectory + { + } +} diff --git a/src/Storage/FileSystem/IFileSystemEntry.cs b/src/Storage/FileSystem/IFileSystemEntry.cs new file mode 100644 index 0000000..ba9c584 --- /dev/null +++ b/src/Storage/FileSystem/IFileSystemEntry.cs @@ -0,0 +1,11 @@ +namespace DotNetRu.Auditor.Storage.FileSystem +{ + public interface IFileSystemEntry + { + string Name { get; } + + string FullName { get; } + + bool Exists { get; } + } +} diff --git a/src/Storage/FileSystem/NotFoundDirectory.cs b/src/Storage/FileSystem/NotFoundDirectory.cs new file mode 100644 index 0000000..0798bd3 --- /dev/null +++ b/src/Storage/FileSystem/NotFoundDirectory.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace DotNetRu.Auditor.Storage.FileSystem +{ + public class NotFoundDirectory : FileSystemEntry, IDirectory + { + public NotFoundDirectory(string fullName) + : base(fullName, false) + { + } + + public static ValueTask ToTask(string fullName) => ValueTask.FromResult(new NotFoundDirectory(fullName)); + + public ValueTask GetDirectoryInfoAsync(string subPath) => ToTask(subPath); + + public ValueTask GetFileInfoAsync(string subPath) => NotFoundFile.ToTask(subPath); + + public IAsyncEnumerable EnumerateDirectoriesAsync() => AsyncEnumerable.Empty(); + + public IAsyncEnumerable EnumerateFilesAsync() => AsyncEnumerable.Empty(); + } +} diff --git a/src/Storage/FileSystem/NotFoundFile.cs b/src/Storage/FileSystem/NotFoundFile.cs new file mode 100644 index 0000000..b69d33c --- /dev/null +++ b/src/Storage/FileSystem/NotFoundFile.cs @@ -0,0 +1,22 @@ +using System; +using System.IO; +using System.Threading.Tasks; + +namespace DotNetRu.Auditor.Storage.FileSystem +{ + public class NotFoundFile : FileSystemEntry, IFile + { + public NotFoundFile(string fullName) + : base(fullName, false) + { + } + + public static ValueTask ToTask(string fullName) => ValueTask.FromResult(new NotFoundFile(fullName)); + + public static Exception ToException(string fullName) => new FileNotFoundException($"Could not find file: {fullName}", fullName); + + public ValueTask OpenForReadAsync() => throw ToException(FullName); + + public ValueTask OpenForWriteAsync() => throw ToException(FullName); + } +} diff --git a/src/Storage/FileSystem/NullFileSystem.cs b/src/Storage/FileSystem/NullFileSystem.cs new file mode 100644 index 0000000..3facb96 --- /dev/null +++ b/src/Storage/FileSystem/NullFileSystem.cs @@ -0,0 +1,14 @@ +using System; + +namespace DotNetRu.Auditor.Storage.FileSystem +{ + public class NullFileSystem : NotFoundDirectory, IFileSystem + { + public static readonly NullFileSystem Instance = new NullFileSystem(); + + public NullFileSystem() + : base(String.Empty) + { + } + } +} diff --git a/src/Storage/FileSystem/Physical/PhysicalDirectory.cs b/src/Storage/FileSystem/Physical/PhysicalDirectory.cs new file mode 100644 index 0000000..0f07a83 --- /dev/null +++ b/src/Storage/FileSystem/Physical/PhysicalDirectory.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace DotNetRu.Auditor.Storage.FileSystem.Physical +{ + public class PhysicalDirectory : FileSystemEntry, IDirectory + { + public PhysicalDirectory(string fullName) + : base(fullName, Directory.Exists(fullName)) + { + } + + public ValueTask GetDirectoryInfoAsync(string subPath) + { + if (!Exists) + { + return NotFoundDirectory.ToTask(subPath); + } + + var fullDirectoryName = GetFullPath(subPath); + var directory = new PhysicalDirectory(fullDirectoryName); + return ValueTask.FromResult(directory); + } + + public ValueTask GetFileInfoAsync(string subPath) + { + if (!Exists) + { + return NotFoundFile.ToTask(subPath); + } + + var fullFileName = GetFullPath(subPath); + var file = new PhysicalFile(fullFileName); + return ValueTask.FromResult(file); + } + + public async IAsyncEnumerable EnumerateDirectoriesAsync() + { + if (!Exists) + { + yield break; + } + + await Task.Yield(); + + foreach (var directoryFullName in Directory.EnumerateDirectories(FullName)) + { + yield return new PhysicalDirectory(directoryFullName); + } + } + + public async IAsyncEnumerable EnumerateFilesAsync() + { + if (!Exists) + { + yield break; + } + + await Task.Yield(); + + foreach (var fileFullName in Directory.EnumerateFiles(FullName)) + { + yield return new PhysicalFile(fileFullName); + } + } + } +} diff --git a/src/Storage/FileSystem/Physical/PhysicalFile.cs b/src/Storage/FileSystem/Physical/PhysicalFile.cs new file mode 100644 index 0000000..ed66421 --- /dev/null +++ b/src/Storage/FileSystem/Physical/PhysicalFile.cs @@ -0,0 +1,35 @@ +using System.IO; +using System.Threading.Tasks; + +namespace DotNetRu.Auditor.Storage.FileSystem.Physical +{ + public sealed class PhysicalFile : FileSystemEntry, IFile + { + public PhysicalFile(string fullName) + : base(fullName, File.Exists(fullName)) + { + } + + public ValueTask OpenForReadAsync() + { + if (!Exists) + { + throw NotFoundFile.ToException(FullName); + } + + var inputStream = File.OpenRead(FullName); + return ValueTask.FromResult(inputStream); + } + + public ValueTask OpenForWriteAsync() + { + if (!Exists) + { + throw NotFoundFile.ToException(FullName); + } + + var outputStream = File.OpenWrite(FullName); + return ValueTask.FromResult(outputStream); + } + } +} diff --git a/src/Storage/FileSystem/Physical/PhysicalFileSystem.cs b/src/Storage/FileSystem/Physical/PhysicalFileSystem.cs new file mode 100644 index 0000000..1313a2e --- /dev/null +++ b/src/Storage/FileSystem/Physical/PhysicalFileSystem.cs @@ -0,0 +1,17 @@ +using System; +using System.IO; + +namespace DotNetRu.Auditor.Storage.FileSystem.Physical +{ + public sealed class PhysicalFileSystem : PhysicalDirectory, IFileSystem + { + public PhysicalFileSystem(string root) + : base(root) + { + if (!Path.IsPathRooted(root)) + { + throw new ArgumentException($"The path «{root}» must be absolute", nameof(root)); + } + } + } +} diff --git a/src/Storage/Storage.csproj b/src/Storage/Storage.csproj new file mode 100644 index 0000000..1fd7ba4 --- /dev/null +++ b/src/Storage/Storage.csproj @@ -0,0 +1,13 @@ + + + + net5.0 + DotNetRu.Auditor.Storage + DotNetRu.Auditor.Storage + + + + + + + diff --git a/src/test/Tests/Storage/FileSystem/FileSystemEntryTest.cs b/src/test/Tests/Storage/FileSystem/FileSystemEntryTest.cs new file mode 100644 index 0000000..6039d2d --- /dev/null +++ b/src/test/Tests/Storage/FileSystem/FileSystemEntryTest.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.IO; +using DotNetRu.Auditor.Storage.FileSystem; +using Xunit; + +namespace DotNetRu.Auditor.Tests.Storage.FileSystem +{ + public sealed class FileSystemEntryTest + { + private static readonly string PathRoot = Path.GetPathRoot(Directory.GetCurrentDirectory()) ?? String.Empty; + + [Fact] + public void ShouldResolveFullName() + { + // Arrange + const string name = "Test"; + + // Act + var entry = new TestEntry(name); + + // Assert + var fullName = entry.FullName; + Assert.EndsWith(name, fullName); + + var hasRoot = Path.IsPathRooted(fullName); + Assert.True(hasRoot); + } + + [Fact] + public void ShouldResolveName() + { + // Arrange + const string expectedName = "Test"; + var fullName = Path.Combine("A", "B", expectedName); + + // Act + var entry = new TestEntry(fullName); + + // Assert + Assert.Equal(expectedName, entry.Name); + } + + public static IEnumerable GetDataForFullPathTest() + { + // "C:\" + "Abc" => "C:\Abc" + yield return new object[] { PathRoot, "Abc", Path.Combine(PathRoot, "Abc") }; + // "C:\1" + ".\Abc" => "C:\1\Abc" + yield return new object[] { Path.Combine(PathRoot, "1"), Path.Combine(".", "Abc"), Path.Combine(PathRoot, "1", "Abc") }; + } + + [Theory] + [MemberData(nameof(GetDataForFullPathTest))] + public void ShouldResolveFullPath(string root, string subPath, string expectedFullPath) + { + // Act + var fullPath = GetFullPath(root, subPath); + + // Assert + Assert.Equal(expectedFullPath, fullPath); + } + + [Fact] + public void ShouldRaiseErrorWhenHackingRoot() + { + // Arrange + var root = Path.Combine(PathRoot, "A", "B"); + var subPath = Path.Combine("..", "..", "etc", "passwd"); + + // Act + void HackRoot() => GetFullPath(root, subPath); + + // Assert + Assert.Throws(HackRoot); + } + + private static string GetFullPath(string root, string subPath) + { + var entry = new TestEntry(root); + return entry.GetFullSubPath(subPath); + } + + private sealed class TestEntry : FileSystemEntry + { + public TestEntry(string fullName) + : base(fullName, true) + { + } + + public string GetFullSubPath(string subPath) => base.GetFullPath(subPath); + } + } +} diff --git a/src/test/Tests/Tests.csproj b/src/test/Tests/Tests.csproj index eb95259..43c1b02 100644 --- a/src/test/Tests/Tests.csproj +++ b/src/test/Tests/Tests.csproj @@ -22,6 +22,7 @@ +