diff --git a/src/Framework.UnitTests/AbsolutePath_Tests.cs b/src/Framework.UnitTests/AbsolutePath_Tests.cs index 40a93551385..ef2ac95a9d1 100644 --- a/src/Framework.UnitTests/AbsolutePath_Tests.cs +++ b/src/Framework.UnitTests/AbsolutePath_Tests.cs @@ -219,6 +219,50 @@ public void AbsolutePath_UnixPathValidation_ShouldAcceptOnlyTrueAbsolutePaths(st ValidatePathAcceptance(path, shouldBeAccepted); } + [Fact] + public void GetCanonicalForm_NullPath_ShouldReturnSameInstance() + { + var absolutePath = new AbsolutePath(null!, null!, ignoreRootedCheck: true); + var result = absolutePath.GetCanonicalForm(); + + // Should return the same struct values when no normalization is needed + result.ShouldBe(absolutePath); + } + + + [WindowsOnlyTheory] + [InlineData("C:\\foo\\.\\bar")] // Current directory reference + [InlineData("C:\\foo\\..\\bar")] // Parent directory reference + [InlineData("C:\\foo/bar")] // Forward slash to backslash + [InlineData("C:\\foo\\bar")] // Simple Windows path (no normalization needed) + public void GetCanonicalForm_WindowsPathNormalization_ShouldMatchPathGetFullPath(string inputPath) + { + ValidateGetCanonicalFormMatchesSystem(inputPath); + } + + [UnixOnlyTheory] + [InlineData("/foo/./bar")] // Current directory reference + [InlineData("/foo/../bar")] // Parent directory reference + [InlineData("/foo/bar")] // Simple Unix path (no normalization needed) + [InlineData("/foo/bar\\baz")] // Simple Unix path with backslash that is not a path separator (no normalization needed) + public void GetCanonicalForm_UnixPathNormalization_ShouldMatchPathGetFullPath(string inputPath) + { + ValidateGetCanonicalFormMatchesSystem(inputPath); + } + + private static void ValidateGetCanonicalFormMatchesSystem(string inputPath) + { + var absolutePath = new AbsolutePath(inputPath, ignoreRootedCheck: true); + var result = absolutePath.GetCanonicalForm(); + var systemResult = Path.GetFullPath(inputPath); + + // Should match Path.GetFullPath behavior exactly + result.Value.ShouldBe(systemResult); + + // Should preserve original value + result.OriginalValue.ShouldBe(absolutePath.OriginalValue); + } + [WindowsOnlyFact] [UseInvariantCulture] public void AbsolutePath_NotRooted_ShouldThrowWithLocalizedMessage() diff --git a/src/Framework/PathHelpers/AbsolutePath.cs b/src/Framework/PathHelpers/AbsolutePath.cs index ca7d91598af..b8fe5a57813 100644 --- a/src/Framework/PathHelpers/AbsolutePath.cs +++ b/src/Framework/PathHelpers/AbsolutePath.cs @@ -125,6 +125,59 @@ public AbsolutePath(string path, AbsolutePath basePath) /// The path to convert. public static implicit operator string(AbsolutePath path) => path.Value; + /// + /// Returns the canonical form of this path. + /// + /// + /// An representing the canonical form of the path. + /// + /// + /// + /// The canonical form of a path is exactly what would produce, + /// with the following properties: + /// + /// All relative path segments ("." and "..") are resolved. + /// Directory separators are normalized to the platform convention (backslash on Windows). + /// + /// + /// + /// If the path is already in canonical form, returns the current instance to avoid unnecessary allocations. + /// Preserves the OriginalValue of the current instance. + /// + /// + internal AbsolutePath GetCanonicalForm() + { + if (string.IsNullOrEmpty(Value)) + { + return this; + } + + + // Note: this is a quick check to avoid calling Path.GetFullPath when it's not necessary, since it can be expensive. + // It should cover the most common cases and avoid the overhead of Path.GetFullPath in those cases. + + // Check for relative path segments "." and ".." + // In absolute path those segments can not appear in the beginning of the path, only after a path separator. + // This is not a precise full detection of relative segments. There is no false negatives as this might affect correctenes, but it may have false positives: + // like when there is a hidden file or directory starting with a dot, or on linux the backslash and dot can be part of the file name. + // In case of false positives we would call Path.GetFullPath and the result would still be correct. + + bool hasRelativeSegment = Value.Contains("/.") || Value.Contains("\\."); + + // Check if directory separator normalization is required (only on Windows: "/" to "\") + // On unix "\" is not a valid path separator, but is a part of the file/directory name, so no normalization is needed. + bool needsSeparatorNormalization = NativeMethods.IsWindows && Value.IndexOf(Path.AltDirectorySeparatorChar) >= 0; + + if (!hasRelativeSegment && !needsSeparatorNormalization) + { + return this; + } + + // Use Path.GetFullPath to resolve relative segments and normalize separators. + // Skip validation since Path.GetFullPath already ensures the result is absolute. + return new AbsolutePath(Path.GetFullPath(Value), OriginalValue, ignoreRootedCheck: true); + } + /// /// Determines whether two instances are equal. ///