Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/Framework.UnitTests/AbsolutePath_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
53 changes: 53 additions & 0 deletions src/Framework/PathHelpers/AbsolutePath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,59 @@ public AbsolutePath(string path, AbsolutePath basePath)
/// <param name="path">The path to convert.</param>
public static implicit operator string(AbsolutePath path) => path.Value;

/// <summary>
/// Returns the canonical form of this path.
/// </summary>
/// <returns>
/// An <see cref="AbsolutePath"/> representing the canonical form of the path.
/// </returns>
/// <remarks>
/// <para>
/// The canonical form of a path is exactly what <see cref="Path.GetFullPath(string)"/> would produce,
/// with the following properties:
/// <list type="bullet">
/// <item>All relative path segments ("." and "..") are resolved.</item>
/// <item>Directory separators are normalized to the platform convention (backslash on Windows).</item>
/// </list>
/// </para>
/// <para>
/// If the path is already in canonical form, returns the current instance to avoid unnecessary allocations.
/// Preserves the OriginalValue of the current instance.
/// </para>
/// </remarks>
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);
}

/// <summary>
/// Determines whether two <see cref="AbsolutePath"/> instances are equal.
/// </summary>
Expand Down