diff --git a/Directory.Packages.props b/Directory.Packages.props index 6f878800b..a00185179 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -35,7 +35,7 @@ - + diff --git a/Source/Testably.Abstractions.Testing/FileSystem/FileSystemInfoMock.cs b/Source/Testably.Abstractions.Testing/FileSystem/FileSystemInfoMock.cs index e90cf83b9..2acdc7d85 100644 --- a/Source/Testably.Abstractions.Testing/FileSystem/FileSystemInfoMock.cs +++ b/Source/Testably.Abstractions.Testing/FileSystem/FileSystemInfoMock.cs @@ -336,27 +336,14 @@ public void Refresh() /// public IFileSystemInfo? ResolveLinkTarget(bool returnFinalTarget) { - using IDisposable registration = RegisterPathMethod(nameof(ResolveLinkTarget), - returnFinalTarget); + using IDisposable registration = RegisterPathMethod( + nameof(ResolveLinkTarget), returnFinalTarget + ); - try - { - IStorageLocation? targetLocation = - _fileSystem.Storage.ResolveLinkTarget( - Location, - returnFinalTarget); - if (targetLocation != null) - { - return New(targetLocation, _fileSystem); - } + IStorageLocation? targetLocation + = _fileSystem.Storage.ResolveLinkTarget(Location, returnFinalTarget); - return null; - } - catch (IOException ex) when (ex.HResult != -2147024773) - { - throw ExceptionFactory.FileNameCannotBeResolved(Location.FullPath, - _fileSystem.Execute.IsWindows ? -2147022975 : -2146232800); - } + return targetLocation != null ? New(targetLocation, _fileSystem) : null; } #endif diff --git a/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs b/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs index 9453d6310..5f33a2844 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs @@ -292,15 +292,13 @@ internal static ArgumentOutOfRangeException TimerArgumentOutOfRange(string prope internal static TimeoutException TimerWaitTimeoutException(int executionCount, int timeout) => new($"The execution count {executionCount} was not reached in {timeout}ms."); -#if FEATURE_FILESYSTEM_UNIXFILEMODE - internal static UnauthorizedAccessException UnixFileModeAccessDenied(string path) + internal static UnauthorizedAccessException AccessDenied(string path) => new($"Access to the path '{path}' is denied.") { #if FEATURE_EXCEPTION_HRESULT HResult = -2147024891, #endif }; -#endif internal static PlatformNotSupportedException UnixFileModeNotSupportedOnThisPlatform() => new("Unix file modes are not supported on this platform.") diff --git a/Source/Testably.Abstractions.Testing/Storage/InMemoryContainer.cs b/Source/Testably.Abstractions.Testing/Storage/InMemoryContainer.cs index 0b5a3b131..bf7f43a1c 100644 --- a/Source/Testably.Abstractions.Testing/Storage/InMemoryContainer.cs +++ b/Source/Testably.Abstractions.Testing/Storage/InMemoryContainer.cs @@ -195,7 +195,7 @@ public IStorageAccessHandle RequestAccess(FileAccess access, FileShare share, if (!deleteAccess && !_fileSystem.UnixFileModeStrategy .IsAccessGranted(_location.FullPath, _extensibility, UnixFileMode, access)) { - throw ExceptionFactory.UnixFileModeAccessDenied(_location.FullPath); + throw ExceptionFactory.AccessDenied(_location.FullPath); } #endif diff --git a/Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs b/Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs index 54fe5c57c..3b1d878e1 100644 --- a/Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs +++ b/Source/Testably.Abstractions.Testing/Storage/InMemoryStorage.cs @@ -558,8 +558,10 @@ public IStorageContainer GetOrCreateContainer( #if FEATURE_FILESYSTEM_LINK /// - public IStorageLocation? ResolveLinkTarget(IStorageLocation location, - bool returnFinalTarget = false) + public IStorageLocation? ResolveLinkTarget( + IStorageLocation location, + bool returnFinalTarget = false + ) { if (!_containers.TryGetValue(location, out IStorageContainer? initialContainer) || initialContainer.LinkTarget == null) @@ -567,10 +569,17 @@ public IStorageContainer GetOrCreateContainer( return null; } - IStorageLocation? nextLocation = - _fileSystem.Storage.GetLocation(initialContainer.LinkTarget); + IStorageLocation? nextLocation + = _fileSystem.Storage.GetLocation(initialContainer.LinkTarget); + + if (!_containers.TryGetValue(nextLocation, out IStorageContainer? container)) + { + return nextLocation; + } + + ThrowOnLinkTypeChange(initialContainer, location, container); - if (returnFinalTarget && _containers.TryGetValue(nextLocation, out IStorageContainer? container) && container.LinkTarget != null) + if (returnFinalTarget && container.LinkTarget != null) { nextLocation = ResolveFinalLinkTarget(container, location); } @@ -755,7 +764,7 @@ private void CheckAndAdjustParentDirectoryTimes(IStorageLocation location) catch (UnauthorizedAccessException) { // On Unix, if the parent directory is not writable, we include the child path in the exception. - throw ExceptionFactory.UnixFileModeAccessDenied(location.FullPath); + throw ExceptionFactory.AccessDenied(location.FullPath); } #else using (parentContainer.RequestAccess(FileAccess.Write, FileShare.ReadWrite)) @@ -1022,11 +1031,14 @@ private bool IncludeItemInEnumeration( } #if FEATURE_FILESYSTEM_LINK - private IStorageLocation? ResolveFinalLinkTarget(IStorageContainer container, - IStorageLocation originalLocation) + private IStorageLocation? ResolveFinalLinkTarget( + IStorageContainer container, + IStorageLocation originalLocation + ) { int maxResolveLinks = _fileSystem.Execute.IsWindows ? 63 : 40; IStorageLocation? nextLocation = null; + for (int i = 1; i < maxResolveLinks; i++) { if (container.LinkTarget == null) @@ -1035,23 +1047,51 @@ private bool IncludeItemInEnumeration( } nextLocation = _fileSystem.Storage.GetLocation(container.LinkTarget); - if (!_containers.TryGetValue(nextLocation, - out IStorageContainer? nextContainer)) + + if (!_containers.TryGetValue(nextLocation, out IStorageContainer? nextContainer)) { return nextLocation; } + ThrowOnLinkTypeChange(container, originalLocation, nextContainer); + container = nextContainer; } if (container.LinkTarget != null) { throw ExceptionFactory.FileNameCannotBeResolved( - originalLocation.FullPath); + originalLocation.FullPath, _fileSystem.Execute.IsWindows ? -2147022975 : -2146232800 + ); } return nextLocation; } + + private void ThrowOnLinkTypeChange( + IStorageContainer previous, + IStorageLocation previousLocation, + IStorageContainer next + ) + { + if (!_fileSystem.Execute.IsWindows) + { + return; + } + + if (previous.Type == next.Type) + { + return; + } + + switch (previous.Type) + { + case FileSystemTypes.File: + throw ExceptionFactory.AccessDenied(previousLocation.FullPath); + case FileSystemTypes.Directory: + throw ExceptionFactory.InvalidDirectoryName(previousLocation.FullPath); + } + } #endif /// diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/DirectoryInfo/ResolveLinkTargetTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/DirectoryInfo/ResolveLinkTargetTests.cs index fec13d4a9..4fc2d3f93 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/DirectoryInfo/ResolveLinkTargetTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/DirectoryInfo/ResolveLinkTargetTests.cs @@ -1,24 +1,51 @@ #if FEATURE_FILESYSTEM_LINK using System.IO; +using System.Text.RegularExpressions; namespace Testably.Abstractions.Tests.FileSystem.DirectoryInfo; [FileSystemTests] public partial class ResolveLinkTargetTests { + #region Test Setup + + /// + /// The maximum number of symbolic links that are followed.
+ /// + ///
+ private int MaxResolveLinks => Test.RunsOnWindows ? 63 : 40; + + #endregion + [Theory] [AutoData] - public async Task ResolveLinkTarget_ShouldThrow(string path) + public async Task ResolveLinkTarget_FinalTargetWithTooManyLevels_ShouldThrowIOException( + string path, + string pathToFinalTarget + ) { - IFileSystemInfo link = FileSystem.Directory.CreateSymbolicLink(path, path + "-start"); + int maxLinks = MaxResolveLinks + 1; + FileSystem.Directory.CreateDirectory(pathToFinalTarget); + string previousPath = pathToFinalTarget; + + for (int i = 0; i < maxLinks; i++) + { + string newPath = $"{path}-{i}"; + IDirectoryInfo linkDirectoryInfo = FileSystem.DirectoryInfo.New(newPath); + linkDirectoryInfo.CreateAsSymbolicLink(previousPath); + previousPath = newPath; + } + + IDirectoryInfo directoryInfo = FileSystem.DirectoryInfo.New(previousPath); - // UNIX allows 43 and Windows 63 nesting, so 70 is plenty to force the exception - for (int i = 0; i < 70; i++) + void Act() { - link = FileSystem.Directory.CreateSymbolicLink($"{path}{i}", link.Name); + _ = directoryInfo.ResolveLinkTarget(true); } - await That(() => link.ResolveLinkTarget(true)).Throws(); + await That(Act).Throws() + .WithHResult(Test.RunsOnWindows ? -2147022975 : -2146232800).And + .WithMessageContaining($"'{directoryInfo.FullName}'"); } [Theory] @@ -53,10 +80,7 @@ IFileSystemInfo innerLink [Theory] [AutoData] - public async Task ResolveLinkTarget_ShouldReturnImmediateFile( - string path, - string pathToTarget - ) + public async Task ResolveLinkTarget_ShouldReturnImmediateFile(string path, string pathToTarget) { IDirectoryInfo targetDir = FileSystem.DirectoryInfo.New(pathToTarget); targetDir.Create(); @@ -125,5 +149,41 @@ IFileSystemInfo outerLink await That(resolvedTarget?.FullName).IsEqualTo(targetDir.FullName); } + + [Theory] + [AutoData] + public async Task ResolveLinkTarget_OfDifferentTypes_ShouldThrow( + string directoryName, + string fileLinkName, + string directoryLinkName + ) + { + IDirectoryInfo targetDirectory = FileSystem.Directory.CreateDirectory(directoryName); + + IFileSystemInfo fileSymLink = FileSystem.File.CreateSymbolicLink( + fileLinkName, targetDirectory.FullName + ); + + IFileSystemInfo dirSymLink = FileSystem.Directory.CreateSymbolicLink( + directoryLinkName, fileSymLink.FullName + ); + + string? Act() + { + return dirSymLink.ResolveLinkTarget(true)?.FullName; + } + + if (Test.RunsOnWindows) + { + await That(Act).Throws() + .WithMessage( + $@"^.*directory.*invalid.*\'{Regex.Escape(dirSymLink.FullName)}\'" + ).AsRegex().And.WithHResult(-2147024629); + } + else + { + await That(Act()).IsEqualTo(targetDirectory.FullName); + } + } } #endif diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/FileInfo/ResolveLinkTargetTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/FileInfo/ResolveLinkTargetTests.cs index 1c5f123aa..8906232ca 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/FileInfo/ResolveLinkTargetTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/FileInfo/ResolveLinkTargetTests.cs @@ -1,24 +1,55 @@ #if FEATURE_FILESYSTEM_LINK using System.IO; +using System.Threading; namespace Testably.Abstractions.Tests.FileSystem.FileInfo; [FileSystemTests] public partial class ResolveLinkTargetTests { + #region Test Setup + + /// + /// The maximum number of symbolic links that are followed.
+ /// + ///
+ private int MaxResolveLinks => Test.RunsOnWindows ? 63 : 40; + + #endregion + [Theory] [AutoData] - public async Task ResolveLinkTarget_ShouldThrow(string path) + public async Task ResolveLinkTarget_FinalTargetWithTooManyLevels_ShouldThrowIOException( + string path, + string pathToFinalTarget + ) { - IFileSystemInfo link = FileSystem.File.CreateSymbolicLink(path, path + "-start"); + int maxLinks = MaxResolveLinks + 1; + + await FileSystem.File.WriteAllTextAsync( + pathToFinalTarget, string.Empty, CancellationToken.None + ); + + string previousPath = pathToFinalTarget; + + for (int i = 0; i < maxLinks; i++) + { + string newPath = $"{path}-{i}"; + IFileInfo linkFileInfo = FileSystem.FileInfo.New(newPath); + linkFileInfo.CreateAsSymbolicLink(previousPath); + previousPath = newPath; + } - // UNIX allows 43 and Windows 63 nesting, so 70 is plenty to force the exception - for (int i = 0; i < 70; i++) + IFileInfo fileInfo = FileSystem.FileInfo.New(previousPath); + + void Act() { - link = FileSystem.File.CreateSymbolicLink($"{path}{i}", link.Name); + _ = fileInfo.ResolveLinkTarget(true); } - await That(() => link.ResolveLinkTarget(true)).Throws(); + await That(Act).Throws() + .WithHResult(Test.RunsOnWindows ? -2147022975 : -2146232800).And + .WithMessageContaining($"'{fileInfo.FullName}'"); } [Theory] @@ -51,10 +82,7 @@ string pathToTarget [Theory] [AutoData] - public async Task ResolveLinkTarget_ShouldReturnImmediateFile( - string path, - string pathToTarget - ) + public async Task ResolveLinkTarget_ShouldReturnImmediateFile(string path, string pathToTarget) { IFileInfo targetFile = FileSystem.FileInfo.New(pathToTarget); await targetFile.Create().DisposeAsync(); @@ -118,5 +146,40 @@ string pathToTarget await That(resolvedTarget?.FullName).IsEqualTo(targetFile.FullName); } + + [Theory] + [AutoData] + public async Task ResolveLinkTarget_OfDifferentTypes_ShouldThrow( + string fileName, + string directoryName, + string fileLinkName + ) + { + IFileInfo targetFile = FileSystem.FileInfo.New(fileName); + await targetFile.Create().DisposeAsync(); + + IFileSystemInfo dirSymLink + = FileSystem.Directory.CreateSymbolicLink(directoryName, targetFile.FullName); + + IFileSystemInfo fileSymLink + = FileSystem.File.CreateSymbolicLink(fileLinkName, dirSymLink.FullName); + + string? Act() + { + return fileSymLink.ResolveLinkTarget(true)?.FullName; + } + + if (Test.RunsOnWindows) + { + await That(Act) + .Throws().WithMessage( + $"Access to the path '{fileSymLink.FullName}' is denied." + ); + } + else + { + await That(Act()).IsEqualTo(targetFile.FullName); + } + } } #endif