diff --git a/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs b/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs index 61b07112..6f027030 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs @@ -85,6 +85,22 @@ internal static FileNotFoundException FileNotFound(string path) #endif }; + internal static IOException FileSharingViolation() + => new("The process cannot access the file because it is being used by another process.") + { +#if FEATURE_EXCEPTION_HRESULT + HResult = -2147024864, +#endif + }; + + internal static IOException FileSharingViolation(string path) + => new($"The process cannot access the file '{path}' because it is being used by another process.") + { +#if FEATURE_EXCEPTION_HRESULT + HResult = -2147024864, +#endif + }; + internal static ArgumentException HandleIsInvalid(string? paramName = "handle") => new("Invalid handle.", paramName); @@ -297,6 +313,14 @@ internal static UnauthorizedAccessException AccessDenied(string path) #endif }; + internal static IOException IOAccessDenied(string path) + => new($"Access to the path '{path}' is denied.") + { +#if FEATURE_EXCEPTION_HRESULT + HResult = -2147024891, +#endif + }; + internal static PlatformNotSupportedException UnixFileModeNotSupportedOnThisPlatform() => new("Unix file modes are not supported on this platform.") { diff --git a/Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs b/Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs index 0312254e..d62ed253 100644 --- a/Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs +++ b/Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs @@ -144,7 +144,9 @@ public bool DeleteContainer( } ValidateContainerType(container.Type, expectedType, _fileSystem.Execute, location); - + + ValidateDeleteHandle(container, location.FullPath); + if (container.Type == FileSystemTypes.Directory) { IEnumerable children = @@ -992,6 +994,8 @@ private bool IncludeItemInEnumeration( return null; } + ValidateMoveHandle(container, source.FullPath); + if (container.Type == FileSystemTypes.Directory && source.FullPath.Equals(destination.FullPath, _fileSystem.Execute.IsNetFramework ? StringComparison.OrdinalIgnoreCase @@ -1197,6 +1201,53 @@ private static void ValidateContainerType( } } + private void ValidateDeleteHandle(IStorageContainer container, string fullPath) + { + if (!_fileSystem.Execute.IsWindows || container.Type != FileSystemTypes.Directory) + { + return; + } + + string? currentDirectory + = _fileSystem.Directory.GetCurrentDirectory().NormalizePath(_fileSystem); + + string currentDirectoryWithSeparator + = currentDirectory + _fileSystem.Path.DirectorySeparatorChar; + + string fullPathWithSeparator = fullPath + _fileSystem.Path.DirectorySeparatorChar; + + if (currentDirectoryWithSeparator.StartsWith( + fullPathWithSeparator, _fileSystem.Execute.StringComparisonMode + )) + { + throw ExceptionFactory.FileSharingViolation(currentDirectory); + } + } + + private void ValidateMoveHandle(IStorageContainer container, string fullPath) + { + if (!_fileSystem.Execute.IsWindows || container.Type != FileSystemTypes.Directory) + { + return; + } + + string? currentDirectory + = _fileSystem.Directory.GetCurrentDirectory().NormalizePath(_fileSystem) + + _fileSystem.Path.DirectorySeparatorChar; + + string fullPathWithSeparator = fullPath + _fileSystem.Path.DirectorySeparatorChar; + + if (currentDirectory.Equals(fullPathWithSeparator, _fileSystem.Execute.StringComparisonMode)) + { + throw ExceptionFactory.FileSharingViolation(); + } + + if (currentDirectory.StartsWith(fullPathWithSeparator, _fileSystem.Execute.StringComparisonMode)) + { + throw ExceptionFactory.IOAccessDenied(fullPath); + } + } + private static void ValidateExpression(string expression) { if (expression.Contains('\0', StringComparison.Ordinal)) diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/Directory/DeleteTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/Directory/DeleteTests.cs index 2421ef42..064194dd 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/Directory/DeleteTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/Directory/DeleteTests.cs @@ -306,4 +306,71 @@ await That(Act).Throws() : $"'{FileSystem.Path.Combine(BasePath, path)}'"); await That(FileSystem.Directory.Exists(path)).IsTrue(); } + + [Theory] + [AutoData] + [InlineData(null)] + public async Task Delete_CurrentDirectory_ShouldThrowIOException_OnWindows(string? nested) + { + // Arrange + string directory = FileSystem.Directory.GetCurrentDirectory(); + string expectedExceptionDirectory = directory; + + if (nested != null) + { + string nestedDirectory = FileSystem.Path.Combine(directory, nested); + FileSystem.Directory.CreateDirectory(nestedDirectory); + FileSystem.Directory.SetCurrentDirectory(nestedDirectory); + expectedExceptionDirectory = nestedDirectory; + } + + // Act + void Act() + { + FileSystem.Directory.Delete(directory, true); + } + + try + { + // Assert + await That(Act).ThrowsExactly().OnlyIf(Test.RunsOnWindows).Which + .HasMessage( + $"The process cannot access the file '*{expectedExceptionDirectory}' because it is being used by another process." + ).AsWildcard(); + } + finally + { + if (Test.RunsOnWindows) + { + // Cleanup + FileSystem.Directory.SetCurrentDirectory(BasePath); + } + } + } + + [Theory] + [InlineData("next")] + [InlineData("next", "sub")] + public async Task Delete_DirNextToCurrentDirectory_ShouldNotThrow(params string[] paths) + { + // Arrange + string directory = FileSystem.Directory.GetCurrentDirectory(); + // Intended missing separator, we want to test that the handle does not affect similar paths + string nextTo = directory + FileSystem.Path.Combine(paths); + + FileSystem.Directory.CreateDirectory(nextTo); + FileSystem.Directory.SetCurrentDirectory(nextTo); + + // Act + void Act() + { + FileSystem.Directory.Delete(directory, true); + } + + // Assert + await That(Act).DoesNotThrow(); + + await That(FileSystem.Directory.Exists(directory)).IsFalse(); + await That(FileSystem.Directory.Exists(nextTo)).IsTrue(); + } } diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/Directory/MoveTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/Directory/MoveTests.cs index eff07ff9..19b62df4 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/Directory/MoveTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/Directory/MoveTests.cs @@ -268,4 +268,84 @@ await That( SearchOption.AllDirectories)) .HasSingle(); } + + [Fact] + public async Task Move_CurrentDirectory_ShouldThrowIOException_OnWindows() + { + // Arrange + string directory = FileSystem.Directory.GetCurrentDirectory(); + string newPath = FileSystem.Path.GetFullPath("../new"); + + // Act + void Act() + { + FileSystem.Directory.Move(directory, newPath); + } + + // Assert + await That(Act).ThrowsExactly().OnlyIf(Test.RunsOnWindows).Which.HasMessage( + "The process cannot access the file because it is being used by another process." + ); + } + + [Theory] + [AutoData] + public async Task Move_NestedCurrentDirectory_ShouldThrowIOException_OnWindows(string nested) + { + // Arrange + string directory = FileSystem.Directory.GetCurrentDirectory(); + string newPath = FileSystem.Path.GetFullPath("../new"); + + string nestedDirectory = FileSystem.Path.Combine(directory, nested); + FileSystem.Directory.CreateDirectory(nestedDirectory); + FileSystem.Directory.SetCurrentDirectory(nestedDirectory); + + // Act + void Act() + { + FileSystem.Directory.Move(directory, newPath); + } + + // Assert + try + { + await That(Act).ThrowsExactly().OnlyIf(Test.RunsOnWindows).Which + .HasMessage($"Access to the path '{directory}' is denied."); + } + finally + { + if (Test.RunsOnWindows) + { + // Cleanup + FileSystem.Directory.SetCurrentDirectory(BasePath); + } + } + } + + [Theory] + [InlineData("next")] + [InlineData("next", "sub")] + public async Task Move_DirNextToCurrentDirectory_ShouldNotThrow(params string[] paths) + { + // Arrange + string directory = FileSystem.Directory.GetCurrentDirectory(); + // Intended missing separator, we want to test that the handle does not affect similar paths + string nextTo = directory + FileSystem.Path.Combine(paths); + string moveTarget = FileSystem.Path.Combine(nextTo, "move-target"); + + FileSystem.Directory.CreateDirectory(nextTo); + FileSystem.Directory.SetCurrentDirectory(nextTo); + + // Act + void Act() + { + FileSystem.Directory.Move(directory, moveTarget); + } + + // Assert + await That(Act).DoesNotThrow(); + + await That(FileSystem.Directory.Exists(directory)).IsFalse(); + await That(FileSystem.Directory.Exists(nextTo)).IsTrue(); + } } diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/DirectoryInfo/DeleteTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/DirectoryInfo/DeleteTests.cs index 0d2d8ee8..481fc28c 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/DirectoryInfo/DeleteTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/DirectoryInfo/DeleteTests.cs @@ -143,4 +143,71 @@ await That(Act).Throws() await That(sut.Exists).IsTrue(); await That(FileSystem.Directory.Exists(sut.FullName)).IsTrue(); } + + [Theory] + [AutoData] + [InlineData(null)] + public async Task Delete_CurrentDirectory_ShouldThrowIOException_OnWindows(string? nested) + { + // Arrange + string directory = FileSystem.Directory.GetCurrentDirectory(); + string expectedExceptionDirectory = directory; + + if (nested != null) + { + string nestedDirectory = FileSystem.Path.Combine(directory, nested); + FileSystem.Directory.CreateDirectory(nestedDirectory); + FileSystem.Directory.SetCurrentDirectory(nestedDirectory); + expectedExceptionDirectory = nestedDirectory; + } + + // Act + void Act() + { + FileSystem.DirectoryInfo.New(directory).Delete(true); + } + + try + { + // Assert + await That(Act).ThrowsExactly().OnlyIf(Test.RunsOnWindows).Which + .HasMessage( + $"The process cannot access the file '*{expectedExceptionDirectory}' because it is being used by another process." + ).AsWildcard(); + } + finally + { + if (Test.RunsOnWindows) + { + // Cleanup + FileSystem.Directory.SetCurrentDirectory(BasePath); + } + } + } + + [Theory] + [InlineData("next")] + [InlineData("next", "sub")] + public async Task Delete_DirNextToCurrentDirectory_ShouldNotThrow(params string[] paths) + { + // Arrange + string directory = FileSystem.Directory.GetCurrentDirectory(); + // Intended missing separator, we want to test that the handle does not affect similar paths + string nextTo = directory + FileSystem.Path.Combine(paths); + + FileSystem.Directory.CreateDirectory(nextTo); + FileSystem.Directory.SetCurrentDirectory(nextTo); + + // Act + void Act() + { + FileSystem.DirectoryInfo.New(directory).Delete(true); + } + + // Assert + await That(Act).DoesNotThrow(); + + await That(FileSystem.Directory.Exists(directory)).IsFalse(); + await That(FileSystem.Directory.Exists(nextTo)).IsTrue(); + } } diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/DirectoryInfo/MoveToTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/DirectoryInfo/MoveToTests.cs index 0fec7ca3..e9daa460 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/DirectoryInfo/MoveToTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/DirectoryInfo/MoveToTests.cs @@ -169,4 +169,84 @@ await That( SearchOption.AllDirectories)) .HasSingle(); } + + [Fact] + public async Task MoveTo_CurrentDirectory_ShouldThrowIOException_OnWindows() + { + // Arrange + string directory = FileSystem.Directory.GetCurrentDirectory(); + string newPath = FileSystem.Path.GetFullPath("../new"); + + // Act + void Act() + { + FileSystem.DirectoryInfo.New(directory).MoveTo(newPath); + } + + // Assert + await That(Act).ThrowsExactly().OnlyIf(Test.RunsOnWindows).Which.HasMessage( + "The process cannot access the file because it is being used by another process." + ); + } + + [Theory] + [AutoData] + public async Task MoveTo_NestedCurrentDirectory_ShouldThrowIOException_OnWindows(string nested) + { + // Arrange + string directory = FileSystem.Directory.GetCurrentDirectory(); + string newPath = FileSystem.Path.GetFullPath("../new"); + + string nestedDirectory = FileSystem.Path.Combine(directory, nested); + FileSystem.Directory.CreateDirectory(nestedDirectory); + FileSystem.Directory.SetCurrentDirectory(nestedDirectory); + + // Act + void Act() + { + FileSystem.DirectoryInfo.New(directory).MoveTo(newPath); + } + + try + { + // Assert + await That(Act).ThrowsExactly().OnlyIf(Test.RunsOnWindows).Which + .HasMessage($"Access to the path '*{directory}' is denied.").AsWildcard(); + } + finally + { + if (Test.RunsOnWindows) + { + // Cleanup + FileSystem.Directory.SetCurrentDirectory(BasePath); + } + } + } + + [Theory] + [InlineData("next")] + [InlineData("next", "sub")] + public async Task Move_DirNextToCurrentDirectory_ShouldNotThrow(params string[] paths) + { + // Arrange + string directory = FileSystem.Directory.GetCurrentDirectory(); + // Intended missing separator, we want to test that the handle does not affect similar paths + string nextTo = directory + FileSystem.Path.Combine(paths); + string moveTarget = FileSystem.Path.Combine(nextTo, "move-target"); + + FileSystem.Directory.CreateDirectory(nextTo); + FileSystem.Directory.SetCurrentDirectory(nextTo); + + // Act + void Act() + { + FileSystem.DirectoryInfo.New(directory).MoveTo(moveTarget); + } + + // Assert + await That(Act).DoesNotThrow(); + + await That(FileSystem.Directory.Exists(directory)).IsFalse(); + await That(FileSystem.Directory.Exists(nextTo)).IsTrue(); + } }