From 9fa7610c227255288591a06ad1f8654f691e3cb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sat, 20 Apr 2024 17:10:44 +0200 Subject: [PATCH 1/3] Add GetFullPath --- .../Helpers/ExceptionFactory.cs | 15 ++ .../Helpers/Execute.LinuxPath.cs | 57 ++++++- .../Helpers/Execute.SimulatedPath.cs | 144 ++++++++++++++---- .../Helpers/Execute.WindowsPath.cs | 108 +++++++++++++ .../FileSystemClassGenerator.cs | 1 + 5 files changed, 297 insertions(+), 28 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs b/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs index 2744173aa..74a9acfef 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs @@ -29,6 +29,14 @@ internal static ArgumentException AppendAccessOnlyInWriteOnlyMode( #endif }; + internal static ArgumentException BasePathNotFullyQualified(string paramName) + => new("Basepath argument is not fully qualified.", paramName) + { +#if FEATURE_EXCEPTION_HRESULT + HResult = -2147024809 +#endif + }; + internal static IOException CannotCreateFileAsAlreadyExists(Execute execute, string path) => new( $"Cannot create '{path}' because a file or directory with the same name already exists.", @@ -127,6 +135,13 @@ internal static NotSupportedException NotSupportedSafeFileHandle() internal static NotSupportedException NotSupportedTimerWrapping() => new("You cannot wrap an existing Timer in the MockTimeSystem instance!"); + internal static ArgumentException NullCharacterInPath(string paramName) +#if NET8_0_OR_GREATER + => new("Null character in path.", paramName); +#else + => new("Illegal characters in path.", paramName); +#endif + internal static PlatformNotSupportedException OperationNotSupportedOnThisPlatform() => new("Operation is not supported on this platform.") { diff --git a/Source/Testably.Abstractions.Testing/Helpers/Execute.LinuxPath.cs b/Source/Testably.Abstractions.Testing/Helpers/Execute.LinuxPath.cs index 42a38341c..203f4c515 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/Execute.LinuxPath.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/Execute.LinuxPath.cs @@ -1,4 +1,5 @@ -using System.Text; +using System; +using System.Text; namespace Testably.Abstractions.Testing.Helpers; @@ -18,6 +19,54 @@ private class LinuxPath(MockFileSystem fileSystem) : SimulatedPath(fileSystem) /// public override char VolumeSeparatorChar => '/'; + private readonly MockFileSystem _fileSystem = fileSystem; + + /// + public override string GetFullPath(string path) + { + path.EnsureValidArgument(_fileSystem, nameof(path)); + + if (!IsPathRooted(path)) + { + path = Combine(_fileSystem.Storage.CurrentDirectory, path); + } + + // We would ideally use realpath to do this, but it resolves symlinks and requires that the file actually exist. + string collapsedString = RemoveRelativeSegments(path, GetRootLength(path)); + + string result = collapsedString.Length == 0 + ? $"{DirectorySeparatorChar}" + : collapsedString; + + if (result.Contains('\0', StringComparison.Ordinal)) + { + throw ExceptionFactory.NullCharacterInPath(nameof(path)); + } + + return result; + } + +#if FEATURE_PATH_RELATIVE + /// + public override string GetFullPath(string path, string basePath) + { + path.EnsureValidArgument(_fileSystem, nameof(path)); + basePath.EnsureValidArgument(_fileSystem, nameof(basePath)); + + if (!IsPathFullyQualified(basePath)) + { + throw ExceptionFactory.BasePathNotFullyQualified(nameof(basePath)); + } + + if (IsPathFullyQualified(path)) + { + return GetFullPath(path); + } + + return GetFullPath(Combine(basePath, path)); + } +#endif + /// public override char[] GetInvalidFileNameChars() => ['\0', '/']; @@ -65,6 +114,12 @@ protected override bool IsDirectorySeparator(char c) protected override bool IsEffectivelyEmpty(string path) => string.IsNullOrEmpty(path); + /// + /// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L77 + /// + protected override bool IsPartiallyQualified(string path) + => !IsPathRooted(path); + /// /// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L39 /// diff --git a/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs b/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs index a21c282b3..cca4ad123 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; @@ -259,35 +260,11 @@ public ReadOnlySpan GetFileNameWithoutExtension(ReadOnlySpan path) } /// - public string GetFullPath(string path) - { - path.EnsureValidArgument(fileSystem, nameof(path)); - - string? pathRoot = System.IO.Path.GetPathRoot(path); - string? directoryRoot = - System.IO.Path.GetPathRoot(fileSystem.Storage.CurrentDirectory); - if (!string.IsNullOrEmpty(pathRoot) && !string.IsNullOrEmpty(directoryRoot)) - { - if (char.ToUpperInvariant(pathRoot[0]) != char.ToUpperInvariant(directoryRoot[0])) - { - return System.IO.Path.GetFullPath(path); - } - - if (pathRoot.Length < directoryRoot.Length) - { - path = path.Substring(pathRoot.Length); - } - } - - return System.IO.Path.GetFullPath(System.IO.Path.Combine( - fileSystem.Storage.CurrentDirectory, - path)); - } + public abstract string GetFullPath(string path); #if FEATURE_PATH_RELATIVE /// - public string GetFullPath(string path, string basePath) - => System.IO.Path.GetFullPath(path, basePath); + public abstract string GetFullPath(string path, string basePath); #endif /// @@ -353,7 +330,14 @@ public bool IsPathFullyQualified(ReadOnlySpan path) #if FEATURE_PATH_RELATIVE /// public bool IsPathFullyQualified(string path) - => System.IO.Path.IsPathFullyQualified(path); + { + if (path == null) + { + throw new ArgumentNullException(nameof(path)); + } + + return !IsPartiallyQualified(path); + } #endif #if FEATURE_SPAN @@ -533,6 +517,8 @@ string NormalizePath(string path, bool ignoreStartingSeparator) protected abstract bool IsDirectorySeparator(char c); protected abstract bool IsEffectivelyEmpty(string path); + protected abstract bool IsPartiallyQualified(string path); + #if FEATURE_PATH_JOIN || FEATURE_PATH_ADVANCED private string JoinInternal(string?[] paths) { @@ -582,6 +568,110 @@ protected string RandomString(int length) .Select(s => s[fileSystem.RandomSystem.Random.Shared.Next(s.Length)]).ToArray()); } + /// + /// Remove relative segments from the given path (without combining with a root). + /// + protected string RemoveRelativeSegments(string path, int rootLength) + { + Debug.Assert(rootLength > 0); + bool flippedSeparator = false; + + StringBuilder sb = new(); + + int skip = rootLength; + // We treat "\.." , "\." and "\\" as a relative segment. We want to collapse the first separator past the root presuming + // the root actually ends in a separator. Otherwise the first segment for RemoveRelativeSegments + // in cases like "\\?\C:\.\" and "\\?\C:\..\", the first segment after the root will be ".\" and "..\" which is not considered as a relative segment and hence not be removed. + if (IsDirectorySeparator(path[skip - 1])) + { + skip--; + } + + // Remove "//", "/./", and "/../" from the path by copying each character to the output, + // except the ones we're removing, such that the builder contains the normalized path + // at the end. + if (skip > 0) + { + sb.Append(path.Substring(0, skip)); + } + + for (int i = skip; i < path.Length; i++) + { + char c = path[i]; + + if (IsDirectorySeparator(c) && i + 1 < path.Length) + { + // Skip this character if it's a directory separator and if the next character is, too, + // e.g. "parent//child" => "parent/child" + if (IsDirectorySeparator(path[i + 1])) + { + continue; + } + + // Skip this character and the next if it's referring to the current directory, + // e.g. "parent/./child" => "parent/child" + if ((i + 2 == path.Length || IsDirectorySeparator(path[i + 2])) && + path[i + 1] == '.') + { + i++; + continue; + } + + // Skip this character and the next two if it's referring to the parent directory, + // e.g. "parent/child/../grandchild" => "parent/grandchild" + if (i + 2 < path.Length && + (i + 3 == path.Length || IsDirectorySeparator(path[i + 3])) && + path[i + 1] == '.' && path[i + 2] == '.') + { + // Unwind back to the last slash (and if there isn't one, clear out everything). + int s; + for (s = sb.Length - 1; s >= skip; s--) + { + if (IsDirectorySeparator(sb[s])) + { + sb.Length = + i + 3 >= path.Length && s == skip + ? s + 1 + : s; // to avoid removing the complete "\tmp\" segment in cases like \\?\C:\tmp\..\, C:\tmp\.. + break; + } + } + + if (s < skip) + { + sb.Length = skip; + } + + i += 2; + continue; + } + } + + // Normalize the directory separator if needed + if (c != DirectorySeparatorChar && c == AltDirectorySeparatorChar) + { + c = DirectorySeparatorChar; + flippedSeparator = true; + } + + sb.Append(c); + } + + // If we haven't changed the source path, return the original + if (!flippedSeparator && sb.Length == path.Length) + { + return path; + } + + // We may have eaten the trailing separator from the root when we started and not replaced it + if (skip != rootLength && sb.Length < rootLength) + { + sb.Append(path[rootLength - 1]); + } + + return sb.ToString(); + } + private bool TryGetExtensionIndex(string path, [NotNullWhen(true)] out int? dotIndex) { for (int i = path.Length - 1; i >= 0; i--) diff --git a/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs b/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs index 17502e624..236010902 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs @@ -20,6 +20,85 @@ private sealed class WindowsPath(MockFileSystem fileSystem) : SimulatedPath(file /// public override char VolumeSeparatorChar => ':'; + private readonly MockFileSystem _fileSystem = fileSystem; + + /// + public override string GetFullPath(string path) + { + path.EnsureValidArgument(_fileSystem, nameof(path)); + + if (path.Length >= 4 + && path[0] == '\\' + && (path[1] == '\\' || path[1] == '?') + && path[2] == '?' + && path[3] == '\\') + { + return path; + } + + string? pathRoot = GetPathRoot(path); + string? directoryRoot = GetPathRoot(_fileSystem.Storage.CurrentDirectory); + string candidate; + if (!string.IsNullOrEmpty(pathRoot) && !string.IsNullOrEmpty(directoryRoot)) + { + if (char.ToUpperInvariant(pathRoot[0]) != char.ToUpperInvariant(directoryRoot[0])) + { + candidate = path; + } + else if (pathRoot.Length < directoryRoot.Length) + { + candidate = Combine(_fileSystem.Storage.CurrentDirectory, + path.Substring(pathRoot.Length)); + } + else + { + candidate = Combine(_fileSystem.Storage.CurrentDirectory, path); + } + } + else + { + candidate = Combine(_fileSystem.Storage.CurrentDirectory, path); + } + + string fullPath = + NormalizeDirectorySeparators(RemoveRelativeSegments(candidate, + GetRootLength(candidate))); + fullPath = fullPath.TrimEnd('.'); + + if (fullPath.Contains('\0', StringComparison.Ordinal)) + { + throw ExceptionFactory.NullCharacterInPath(nameof(path)); + } + + if (fullPath.Length > 2 && fullPath[1] == ':' && fullPath[2] != DirectorySeparatorChar) + { + return fullPath.Substring(0, 2) + DirectorySeparatorChar + fullPath.Substring(2); + } + + return fullPath; + } + +#if FEATURE_PATH_RELATIVE + /// + public override string GetFullPath(string path, string basePath) + { + path.EnsureValidArgument(_fileSystem, nameof(path)); + basePath.EnsureValidArgument(_fileSystem, nameof(basePath)); + + if (!IsPathFullyQualified(basePath)) + { + throw ExceptionFactory.BasePathNotFullyQualified(nameof(basePath)); + } + + if (IsPathFullyQualified(path)) + { + return GetFullPath(path); + } + + return GetFullPath(Combine(basePath, path)); + } +#endif + /// public override char[] GetInvalidFileNameChars() => [ @@ -190,6 +269,35 @@ protected override bool IsEffectivelyEmpty(string path) return path.All(c => c == ' '); } + /// + /// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Windows.cs#L250 + /// + protected override bool IsPartiallyQualified(string path) + { + if (path.Length < 2) + { + // It isn't fixed, it must be relative. There is no way to specify a fixed + // path with one character (or less). + return true; + } + + if (IsDirectorySeparator(path[0])) + { + // There is no valid way to specify a relative path with two initial slashes or + // \? as ? isn't valid for drive relative paths and \??\ is equivalent to \\?\ + return !(path[1] == '?' || IsDirectorySeparator(path[1])); + } + + // The only way to specify a fixed path that doesn't begin with two slashes + // is the drive, colon, slash format- i.e. C:\ + return !(path.Length >= 3 + && path[1] == VolumeSeparatorChar + && IsDirectorySeparator(path[2]) + // To match old behavior we'll check the drive character for validity as the path is technically + // not qualified if you don't have a valid drive. "=:\" is the "=" file's default data stream. + && IsValidDriveChar(path[0])); + } + /// /// Returns true if the given character is a valid drive letter /// diff --git a/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs b/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs index 13d82cabd..bdc31b758 100644 --- a/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs +++ b/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs @@ -238,6 +238,7 @@ private bool IncludeSimulatedTests(ClassModel @class) "GetExtensionTests", "GetFileNameTests", "GetFileNameWithoutExtensionTests", + "GetFullPathTests", "GetPathRootTests", "GetRandomFileNameTests", "GetTempPathTests", From 49b9a646f34a19628d4a644df9dc9d728bfeca39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sat, 20 Apr 2024 23:00:41 +0200 Subject: [PATCH 2/3] Skip failing simulated test on .Net Framework --- .../Helpers/PathHelperTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/Testably.Abstractions.Testing.Tests/Helpers/PathHelperTests.cs b/Tests/Testably.Abstractions.Testing.Tests/Helpers/PathHelperTests.cs index 95727aca4..2d351c9a4 100644 --- a/Tests/Testably.Abstractions.Testing.Tests/Helpers/PathHelperTests.cs +++ b/Tests/Testably.Abstractions.Testing.Tests/Helpers/PathHelperTests.cs @@ -127,6 +127,8 @@ public void public void ThrowCommonExceptionsIfPathIsInvalid_WithInvalidCharacters( char invalidChar) { + Skip.If(Test.IsNetFramework); + MockFileSystem fileSystem = new(i => i .SimulatingOperatingSystem(SimulationMode.Windows)); string path = invalidChar + "path"; From e9f524e2a5d87b3bc2b07c04cbf644c000e7e83e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sat, 20 Apr 2024 23:14:31 +0200 Subject: [PATCH 3/3] Initialize simulated tests --- .../Helpers/Execute.WindowsPath.cs | 2 +- .../FileSystemClassGenerator.cs | 43 ++++++++++++++++--- .../FileSystem/Path/GetPathRootTests.cs | 12 ++++++ 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs b/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs index 236010902..c42c0f03d 100644 --- a/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs +++ b/Source/Testably.Abstractions.Testing/Helpers/Execute.WindowsPath.cs @@ -128,7 +128,7 @@ public override char[] GetInvalidPathChars() => } return IsPathRooted(path) - ? path.Substring(0, Math.Min(3, path.Length)) + ? path.Substring(0, GetRootLength(path)) : string.Empty; } diff --git a/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs b/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs index bdc31b758..56ee80963 100644 --- a/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs +++ b/Tests/Helpers/Testably.Abstractions.Tests.SourceGenerator/ClassGenerators/FileSystemClassGenerator.cs @@ -141,20 +141,31 @@ public override void SkipIfLongRunningTestsShouldBeSkipped() namespace {@class.Namespace}.{@class.Name} {{ // ReSharper disable once UnusedMember.Global - public sealed class LinuxFileSystemTests : {@class.Name} + public sealed class LinuxFileSystemTests : {@class.Name}, IDisposable {{ /// - public override string BasePath => ""/""; + public override string BasePath => _directoryCleaner.BasePath; + + private readonly IDirectoryCleaner _directoryCleaner; + public LinuxFileSystemTests() : this(new MockFileSystem(i => i.SimulatingOperatingSystem(SimulationMode.Linux))) {{ }} + private LinuxFileSystemTests(MockFileSystem mockFileSystem) : base( new Test(OSPlatform.Linux), mockFileSystem, mockFileSystem.TimeSystem) {{ + _directoryCleaner = FileSystem + .SetCurrentDirectoryToEmptyTemporaryDirectory(); }} + + /// + public void Dispose() + => _directoryCleaner.Dispose(); + /// public override void SkipIfBrittleTestsShouldBeSkipped(bool condition = true) {{ @@ -169,10 +180,13 @@ public override void SkipIfLongRunningTestsShouldBeSkipped() #endif #if !NETFRAMEWORK // ReSharper disable once UnusedMember.Global - public sealed class MacFileSystemTests : {@class.Name} + public sealed class MacFileSystemTests : {@class.Name}, IDisposable {{ /// - public override string BasePath => ""/""; + public override string BasePath => _directoryCleaner.BasePath; + + private readonly IDirectoryCleaner _directoryCleaner; + public MacFileSystemTests() : this(new MockFileSystem(i => i.SimulatingOperatingSystem(SimulationMode.MacOS))) {{ @@ -182,7 +196,14 @@ private MacFileSystemTests(MockFileSystem mockFileSystem) : base( mockFileSystem, mockFileSystem.TimeSystem) {{ + _directoryCleaner = FileSystem + .SetCurrentDirectoryToEmptyTemporaryDirectory(); }} + + /// + public void Dispose() + => _directoryCleaner.Dispose(); + /// public override void SkipIfBrittleTestsShouldBeSkipped(bool condition = true) {{ @@ -197,10 +218,13 @@ public override void SkipIfLongRunningTestsShouldBeSkipped() #endif #if !NETFRAMEWORK // ReSharper disable once UnusedMember.Global - public sealed class WindowsFileSystemTests : {@class.Name} + public sealed class WindowsFileSystemTests : {@class.Name}, IDisposable {{ /// - public override string BasePath => ""C:\\""; + public override string BasePath => _directoryCleaner.BasePath; + + private readonly IDirectoryCleaner _directoryCleaner; + public WindowsFileSystemTests() : this(new MockFileSystem(i => i.SimulatingOperatingSystem(SimulationMode.Windows))) {{ @@ -210,7 +234,14 @@ private WindowsFileSystemTests(MockFileSystem mockFileSystem) : base( mockFileSystem, mockFileSystem.TimeSystem) {{ + _directoryCleaner = FileSystem + .SetCurrentDirectoryToEmptyTemporaryDirectory(); }} + + /// + public void Dispose() + => _directoryCleaner.Dispose(); + /// public override void SkipIfBrittleTestsShouldBeSkipped(bool condition = true) {{ diff --git a/Tests/Testably.Abstractions.Tests/FileSystem/Path/GetPathRootTests.cs b/Tests/Testably.Abstractions.Tests/FileSystem/Path/GetPathRootTests.cs index bb436e4ac..ae832a11a 100644 --- a/Tests/Testably.Abstractions.Tests/FileSystem/Path/GetPathRootTests.cs +++ b/Tests/Testably.Abstractions.Tests/FileSystem/Path/GetPathRootTests.cs @@ -25,6 +25,18 @@ public void GetPathRoot_RootedDrive_ShouldReturnDriveOnWindows(string path) result.Should().Be(path); } + [SkippableTheory] + [InlineData("D:some-path", "D:")] + [InlineData("D:\\some-path", "D:\\")] + public void GetPathRoot_RootedDriveWithPath_ShouldReturnDriveOnWindows(string path, string expected) + { + Skip.IfNot(Test.RunsOnWindows); + + string? result = FileSystem.Path.GetPathRoot(path); + + result.Should().Be(expected); + } + [SkippableTheory] [AutoData] public void GetPathRoot_ShouldReturnDefaultValue(string path)