Skip to content
Closed
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
196 changes: 196 additions & 0 deletions src/Framework.UnitTests/AbsolutePath_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -213,5 +213,201 @@ public void AbsolutePath_UnixPathValidation_ShouldAcceptOnlyTrueAbsolutePaths(st
{
ValidatePathAcceptance(path, shouldBeAccepted);
}

#region GetCanonicalForm Tests

[Fact]
public void GetCanonicalForm_DefaultInstance_ShouldReturnSameInstance()
{
var absolutePath = default(AbsolutePath);
var result = absolutePath.GetCanonicalForm();

result.ShouldBe(absolutePath);
}

[Fact]
public void GetCanonicalForm_EmptyPath_ShouldReturnSameInstance()
{
var absolutePath = new AbsolutePath(string.Empty, ignoreRootedCheck: true);
var result = absolutePath.GetCanonicalForm();

result.ShouldBe(absolutePath);
}

[WindowsOnlyTheory]
// Current directory segments with pure separators
[InlineData("C:\\foo\\.\\bar")] // Backslash: current directory
[InlineData("C:/foo/./bar")] // Forward slash: current directory
// Parent directory segments with pure separators
[InlineData("C:\\foo\\..\\bar")] // Backslash: parent directory
[InlineData("C:/foo/../bar")] // Forward slash: parent directory
// Mixed separators with relative segments
[InlineData("C:\\foo\\./bar")] // Backslash then forward: current
[InlineData("C:/foo/.\\bar")] // Forward then backslash: current
[InlineData("C:\\foo\\../bar")] // Backslash then forward: parent
[InlineData("C:/foo/..\\bar")] // Forward then backslash: parent
// Trailing relative segments
[InlineData("C:\\foo\\bar\\.")] // Trailing current directory
[InlineData("C:\\foo\\bar\\..")] // Trailing parent directory
[InlineData("C:/foo/bar/.")] // Trailing current (forward slash)
[InlineData("C:/foo/bar/..")] // Trailing parent (forward slash)
// Root-level relative segments
[InlineData("C:\\.")] // Current dir at root
[InlineData("C:\\..")] // Parent dir at root
[InlineData("C:/.")] // Current dir at root (forward slash)
[InlineData("C:/..")] // Parent dir at root (forward slash)
// Separator normalization only (no relative segments)
[InlineData("C:/foo/bar")] // Forward slashes need normalization
public void GetCanonicalForm_WindowsPathNormalization_ShouldMatchPathGetFullPath(string inputPath)
{
ValidateGetCanonicalFormMatchesSystem(inputPath);
}

[WindowsOnlyTheory]
// Hidden files/folders - should NOT trigger normalization (false positive prevention)
[InlineData("C:\\.hidden")] // Hidden file at root
[InlineData("C:\\foo\\.hidden")] // Hidden file in folder
[InlineData("C:\\foo\\.hidden\\bar")] // Hidden folder
[InlineData("C:\\.nuget\\packages")] // .nuget folder
[InlineData("C:\\.config\\settings")] // .config folder
[InlineData("C:\\foo\\.git\\config")] // .git folder
[InlineData("C:\\foo\\.vs\\settings")] // .vs folder
// Files starting with dots but not relative segments
[InlineData("C:\\foo\\.gitignore")] // .gitignore file
[InlineData("C:\\foo\\.editorconfig")] // .editorconfig file
[InlineData("C:\\foo\\...")] // Triple dot (not relative)
[InlineData("C:\\foo\\....")] // Quad dot (not relative)
[InlineData("C:\\foo\\.hidden.txt")] // Hidden file with extension
public void GetCanonicalForm_WindowsHiddenFiles_ShouldReturnSameInstance(string inputPath)
{
var absolutePath = new AbsolutePath(inputPath, ignoreRootedCheck: true);
var result = absolutePath.GetCanonicalForm();

// Should return the exact same instance (no normalization needed)
ReferenceEquals(result.Value, absolutePath.Value).ShouldBeTrue(
$"Path '{inputPath}' should not trigger normalization but GetCanonicalForm returned a different instance");
result.Value.ShouldBe(inputPath);
}

[WindowsOnlyTheory]
// Simple paths already in canonical form
[InlineData("C:\\foo\\bar")] // Standard Windows path
[InlineData("C:\\")] // Root only
[InlineData("D:\\folder\\subfolder\\file.txt")] // Deep path
public void GetCanonicalForm_WindowsAlreadyCanonical_ShouldReturnSameInstance(string inputPath)
{
var absolutePath = new AbsolutePath(inputPath, ignoreRootedCheck: true);
var result = absolutePath.GetCanonicalForm();

ReferenceEquals(result.Value, absolutePath.Value).ShouldBeTrue(
$"Path '{inputPath}' is already canonical but GetCanonicalForm returned a different instance");
}

[UnixOnlyTheory]
// Current directory segments
[InlineData("/foo/./bar")] // Current directory reference
// Parent directory segments
[InlineData("/foo/../bar")] // Parent directory reference
// Trailing relative segments
[InlineData("/foo/bar/.")] // Trailing current directory
[InlineData("/foo/bar/..")] // Trailing parent directory
// Root-level relative segments
[InlineData("/.")] // Current dir at root
[InlineData("/..")] // Parent dir at root
// Multiple relative segments
[InlineData("/foo/./bar/../baz")] // Mixed current and parent
public void GetCanonicalForm_UnixPathNormalization_ShouldMatchPathGetFullPath(string inputPath)
{
ValidateGetCanonicalFormMatchesSystem(inputPath);
}

[UnixOnlyTheory]
// Hidden files/folders - should NOT trigger normalization
[InlineData("/.hidden")] // Hidden file at root
[InlineData("/foo/.hidden")] // Hidden file in folder
[InlineData("/foo/.hidden/bar")] // Hidden folder
[InlineData("/.nuget/packages")] // .nuget folder
[InlineData("/.config/settings")] // .config folder
[InlineData("/foo/.git/config")] // .git folder
[InlineData("/foo/.local/share")] // .local folder
// Files starting with dots but not relative segments
[InlineData("/foo/.gitignore")] // .gitignore file
[InlineData("/foo/.bashrc")] // .bashrc file
[InlineData("/foo/...")] // Triple dot (not relative)
[InlineData("/foo/....")] // Quad dot (not relative)
[InlineData("/foo/.hidden.txt")] // Hidden file with extension
// Backslash in Unix paths (part of filename, not separator)
[InlineData("/foo/bar\\baz")] // Backslash is part of name
[InlineData("/foo/.\\hidden")] // Backslash after dot (not a separator on Unix)
public void GetCanonicalForm_UnixHiddenFiles_ShouldReturnSameInstance(string inputPath)
{
var absolutePath = new AbsolutePath(inputPath, ignoreRootedCheck: true);
var result = absolutePath.GetCanonicalForm();

ReferenceEquals(result.Value, absolutePath.Value).ShouldBeTrue(
$"Path '{inputPath}' should not trigger normalization but GetCanonicalForm returned a different instance");
result.Value.ShouldBe(inputPath);
}

[UnixOnlyTheory]
// Simple paths already in canonical form
[InlineData("/foo/bar")] // Standard Unix path
[InlineData("/")] // Root only
[InlineData("/home/user/documents/file.txt")] // Deep path
public void GetCanonicalForm_UnixAlreadyCanonical_ShouldReturnSameInstance(string inputPath)
{
var absolutePath = new AbsolutePath(inputPath, ignoreRootedCheck: true);
var result = absolutePath.GetCanonicalForm();

ReferenceEquals(result.Value, absolutePath.Value).ShouldBeTrue(
$"Path '{inputPath}' is already canonical but GetCanonicalForm returned a different instance");
}

[Fact]
public void GetCanonicalForm_ShouldPreserveOriginalValue()
{
string originalValue = "original/relative/path";
string absoluteValue = NativeMethods.IsWindows ? "C:\\foo\\.\\bar" : "/foo/./bar";

var absolutePath = new AbsolutePath(absoluteValue, originalValue, ignoreRootedCheck: true);
var result = absolutePath.GetCanonicalForm();

// Original value should be preserved even after canonicalization
result.OriginalValue.ShouldBe(originalValue);
}

[WindowsOnlyTheory]
// UNC paths
[InlineData("\\\\server\\share\\path")] // Basic UNC path
[InlineData("\\\\server\\share\\.\\path")] // UNC with current dir segment
[InlineData("\\\\server\\share\\..\\path")] // UNC with parent dir segment
public void GetCanonicalForm_WindowsUNCPaths_ShouldMatchPathGetFullPath(string inputPath)
{
ValidateGetCanonicalFormMatchesSystem(inputPath);
}

[WindowsOnlyTheory]
// Multiple consecutive separators
[InlineData("C:\\foo\\\\bar")] // Double backslash
[InlineData("C://foo//bar")] // Double forward slash
public void GetCanonicalForm_MultipleConsecutiveSeparators_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);
}

#endregion
}
}
117 changes: 117 additions & 0 deletions src/Framework/PathHelpers/AbsolutePath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,123 @@ 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;
}

bool needsNormalization = HasRelativeSegmentOrConsecutiveSeparators(Value.AsSpan(), out bool hasAltSeparator);

// 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.
if (!needsNormalization && NativeMethods.IsWindows && hasAltSeparator)
{
needsNormalization = true;
}

if (!needsNormalization)
{
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>
/// Scans for path segments that are exactly "." or ".." (relative segments),
/// or consecutive directory separators.
/// Handles all separator combinations on Windows (/, \, and mixed).
/// </summary>
/// <param name="path">The path to scan.</param>
/// <param name="hasAltSeparator">Set to true if an alternate separator (/) is found on Windows.</param>
/// <returns>True if the path needs normalization.</returns>
private static bool HasRelativeSegmentOrConsecutiveSeparators(ReadOnlySpan<char> path, out bool hasAltSeparator)
{
hasAltSeparator = false;
bool previousWasSeparator = false;

for (int i = 0; i < path.Length; i++)
{
char c = path[i];
bool isSeparator = IsSeparator(c);

if (isSeparator)
{
// Track if we've seen an alternate separator (for Windows normalization)
if (c == Path.AltDirectorySeparatorChar)
{
hasAltSeparator = true;
}

// Check for consecutive separators (but skip UNC path prefix \\server)
if (previousWasSeparator && i > 1)
{
return true;
}

// Check for "/." or "/..": separator followed by one or two dots, then separator or end.
int nextPos = i + 1;
if (nextPos < path.Length && path[nextPos] == '.')
{
int afterDots = nextPos + 1;

// Check for "/." (single dot segment)
if (afterDots == path.Length || IsSeparator(path[afterDots]))
{
return true;
}

// Check for "/.." (double dot segment)
if (path[afterDots] == '.')
{
int afterTwoDots = afterDots + 1;
if (afterTwoDots == path.Length || IsSeparator(path[afterTwoDots]))
{
return true;
}
}
}
}

previousWasSeparator = isSeparator;
}

return false;
}

/// <summary>
/// Checks if a character is a directory separator.
/// On Windows, both '/' and '\' are separators. On Unix, only '/' is a separator.
/// </summary>
private static bool IsSeparator(char c)
{
return c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar;
}

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