Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- [#225](https://github.com/ForNeVeR/TruePath/issues/225): Make path prefix checks use the same platform-default case sensitivity as path equality.
- `LocalPath.IsPrefixOf` and `StartsWith` now compare path strings ordinally. Previously they used the current culture, which ignores collation-ignorable characters, so a path could be reported as a prefix of an unrelated one.
- Path normalization no longer drops the root separator when a rooted path ends with `..`: `C:\base\..` is now normalized to `C:\` (was `C:`, a path relative to the current directory of drive `C:`), and `/base/..` to `/` (was an empty path, i.e. the current directory). This affects `PathStrings.Normalize`, `LocalPath` and `AbsolutePath`.
- [#95](https://github.com/ForNeVeR/TruePath/issues/95): Path normalization now drops a `..` directly after the root, since the root has no parent: `/..` is normalized to `/`, and `C:\..\x` to `C:\x`, the same as `Path.GetFullPath` does. Thanks to @sinthu-14 for help!
- `LocalPath.RelativeTo` no longer throws an exception when either path is empty (i.e. designates the current directory).
- On .NET Standard 2.0 (e.g., .NET Framework), `LocalPath.RelativeTo` and `AbsolutePath.RelativeTo` now use a port of the .NET runtime's `Path.GetRelativePath`, and return the same results as on .NET 8+ (except for the stream syntax, see [#235](https://github.com/ForNeVeR/TruePath/issues/235)). Previously, the result was wrong for a destination equal to or above the base path, and for names containing `%XX` sequences.

Expand Down
107 changes: 102 additions & 5 deletions TruePath.Tests/AbsolutePathTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,18 @@ public void ConstructionTest()
[Fact]
public void PathRootReturnsRoot()
{
var root = Utils.SyntheticRoot;
var path = root / "foo" / "bar";
var root = Utils.SyntheticRoot;
var path = root / "foo" / "bar";

Assert.Equal(root, path.PathRoot);
Assert.Equal(root, path.PathRoot);
}

[Fact]
public void PathRootOfRootReturnsItself()
{
var root = Utils.SyntheticRoot;
var root = Utils.SyntheticRoot;

Assert.Equal(root, root.PathRoot);
Assert.Equal(root, root.PathRoot);
}

[Fact]
Expand Down Expand Up @@ -245,6 +245,65 @@ public void PathIsNormalizedOnCreation()
Assert.Equal(@"C:\Users\John Doe\Documents", absolutePath.Value);
}

/// <summary>
/// Appends <paramref name="relativePath"/> (written with <c>/</c>) to <see cref="Utils.SyntheticRootString"/>, using
/// the separators of the current platform.
/// </summary>
private static string SyntheticRootedPath(string relativePath) =>
Utils.SyntheticRootString + relativePath.Replace('/', Path.DirectorySeparatorChar);

[Theory]
[InlineData(".")]
[InlineData("./.")]
[InlineData("././.")]
public void ConstructorDropsCurrentDirectoryReferenceAfterRoot(string relativePath)
{
var absolutePath = new AbsolutePath(Utils.SyntheticRootString + relativePath);

Assert.Equal(Utils.SyntheticRootString, absolutePath.Value);
}

[Theory]
[InlineData("...")]
[InlineData("..SomeFolder")]
[InlineData("..00")]
[InlineData("..#")]
public void ConstructorKeepsNamesStartingWithDotsAfterRoot(string relativePath)
{
var absolutePath = new AbsolutePath(Utils.SyntheticRootString + relativePath);

Assert.Equal(SyntheticRootedPath(relativePath), absolutePath.Value);
}

[Theory]
[InlineData("../", "")]
[InlineData("../..", "")]
[InlineData("../../SomeFolder", "SomeFolder")]
[InlineData("../..SomeFolder", "..SomeFolder")]
[InlineData("../.SomeFolder", ".SomeFolder")]
[InlineData("../SomeFolder", "SomeFolder")]
[InlineData("../1123", "1123")]
[InlineData("../()", "()")]
[InlineData("./..", "")]
[InlineData("./../.", "")]
public void ConstructorDropsParentReferenceAfterRoot(string relativePath, string expectedRelativePath)
{
var absolutePath = new AbsolutePath(Utils.SyntheticRootString + relativePath);

Assert.Equal(SyntheticRootedPath(expectedRelativePath), absolutePath.Value);
}

[Fact]
public void AppendParentReferenceToRootReturnsRoot()
{
var root = Utils.SyntheticRoot;

Assert.Equal(root, root / "..");
Assert.Equal(root, root / ".." / "..");
Assert.Equal(root / "x", root / ".." / "x");
Assert.Equal(root / "x", root / "../x");
}

[Fact]
public void ConstructorThrowsOnNonRootedPath()
{
Expand Down Expand Up @@ -591,5 +650,43 @@ public void CurrentWorkingDirectoryGetsChanged()
using var _ = Utils.ChangeCurrentDirectory(path);
Assert.Equal(path, new AbsolutePath(Environment.CurrentDirectory));
}

[Fact]
public void AppendPathAboveRootOfAnotherDriveStaysAtRootOnWindows()
{
if (!OperatingSystem.IsWindows()) return;

// The current directory of the drive of the process current directory is the current directory itself.
var root = AbsolutePath.CurrentWorkingDirectory.PathRoot;
using var _ = Utils.ChangeCurrentDirectory(root);
var drive = root.Value.Substring(0, 2);
var basePath = Utils.NonCurrentSyntheticRoot / "base";

Assert.Equal(root / "x", basePath / (drive + @"..\x"));
Assert.Equal(root, basePath / (drive + ".."));
}

[Fact]
public void AppendPathAboveCurrentDirectoryOfAnotherDriveResolvesItOnWindows()
{
if (!OperatingSystem.IsWindows()) return;

// A subdirectory of the temporary directory always has a parent, even if the temporary directory is a root.
var directory = Temporary.CreateTempFolder().Canonicalize();
try
{
using (Utils.ChangeCurrentDirectory(directory))
{
var drive = directory.Value.Substring(0, 2);
var basePath = Utils.NonCurrentSyntheticRoot / "base";

Assert.Equal(directory.Parent!.Value / "x", basePath / (drive + @"..\x"));
}
}
finally
{
Directory.Delete(directory.Value);
}
}
}
}
14 changes: 14 additions & 0 deletions TruePath.Tests/LocalPathTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ public class LocalPathTests
[InlineData("foo", ".")]
[InlineData("foo/bar", "foo")]
[InlineData("/", null)]
[InlineData("..", null)]
[InlineData("../foo", ".")]
public void AbsolutePathParent(string relativePath, string? expectedRelativePath)
{
var root = Utils.SyntheticRoot;
Expand All @@ -18,6 +20,18 @@ public void AbsolutePathParent(string relativePath, string? expectedRelativePath
Assert.Equal(expectedPath, parent.Parent);
}

[Theory]
[InlineData("..")]
[InlineData("../..")]
[InlineData("../foo/..")]
public void RootedPathAboveRootHasNoParent(string relativePath)
{
var path = new LocalPath(Utils.SyntheticRootString + relativePath);

Assert.Equal(Utils.SyntheticRootString, path.Value);
Assert.Null(path.Parent);
}

[Theory]
[InlineData(".", "")]
[InlineData("..", "..")]
Expand Down
33 changes: 25 additions & 8 deletions TruePath.Tests/PathStringsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@ public void SeparatorsAreDeduplicated(string input, string expected)
[InlineData("foo/./bar/../var/./dar/..", "foo/var")]
[InlineData("foo/.bar", "foo/.bar")]
[InlineData("/.", "/")]
[InlineData("/..", "/..")]
[InlineData("/../..", "/../..")]
[InlineData("/../../foo/..", "/../..")]
[InlineData("/..", "/")]
[InlineData("/../..", "/")]
[InlineData("/../foo", "/foo")]
[InlineData("/../..foo", "/..foo")]
[InlineData("/../../foo/..", "/")]
[InlineData("x/foo/bar/../..", "x")]
[InlineData("x/foo/bar/.../.", "x/foo/bar/...")]
[InlineData("x/foo/..bar/", "x/foo/..bar")]
Expand Down Expand Up @@ -91,9 +93,11 @@ public void DotFoldersAreTraversedCorrectly(string input, string expected)
[InlineData("a/../../..", "../..")]
[InlineData("foo/./bar/../var/./dar/..", "foo/var")]
[InlineData("foo/.bar", "foo/.bar")]
[InlineData("/..", "/..")]
[InlineData("/../..", "/../..")]
[InlineData("/../../foo/..", "/../..")]
[InlineData("/..", "/")]
[InlineData("/../..", "/")]
[InlineData("/../foo", "/foo")]
[InlineData("/../..foo", "/..foo")]
[InlineData("/../../foo/..", "/")]
[InlineData("x/foo/bar/../..", "x")]
[InlineData("x/foo/bar/.../.", "x/foo/bar/...")]
[InlineData("x/foo/..bar/", "x/foo/..bar")]
Expand Down Expand Up @@ -148,19 +152,32 @@ public void Normalize_DriveLetterCheckDisabled_TreatsColonAsRegularPathCharacter

[Theory]
[InlineData("C:/", "C:/")]
[InlineData("C:/../file", "C:/../file")]
[InlineData("C:/../file", "C:/file")]
public void Normalize_DriveLetterCheckEnabled_PreservesWindowsDrivePrefix(string input, string expected)
{
var actual = PathStrings.Normalize(input, driveBasedSystem: true);

Assert.Equal(NormalizeSeparators(expected), actual);
}

[Theory]
[InlineData("C:..", "C:..")]
[InlineData(@"C:..\x", @"C:..\x")]
[InlineData(@"C:..\..", @"C:..\..")]
[InlineData(@"\..", @"\")]
[InlineData(@"\..\x", @"\x")]
public void ParentReferenceIsOnlyDroppedAfterRootOnWindows(string input, string expected)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;

Assert.Equal(expected, PathStrings.Normalize(input));
}

[Fact]
public void Normalize_DriveLetterHandling_MatchesCurrentPlatform()
{
var actual = PathStrings.Normalize("C:/../file");
var expected = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "C:/../file" : "file";
var expected = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "C:/file" : "file";

Assert.Equal(NormalizeSeparators(expected), actual);
}
Expand Down
2 changes: 1 addition & 1 deletion TruePath/AbsolutePath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ namespace TruePath;
/// </summary>
/// <param name="value">Path string to normalize.</param>
/// <param name="checkAbsoluteness">Flag indicating whether absoluteness of path should be checked</param>
/// <exception cref="ArgumentException">Thrown if the passed string does not represent an absolute path.</exception>>
/// <exception cref="ArgumentException">Thrown if the passed string does not represent an absolute path.</exception>
internal AbsolutePath(string value, bool checkAbsoluteness)
{
Underlying = new LocalPath(value);
Expand Down
16 changes: 10 additions & 6 deletions TruePath/LocalPath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,16 @@ public LocalPath? Parent
{
get
{
// For C:foo, the rest after the drive letter is a relative path, and follows the same rules.
var relativePart = Kind == PathKind.DriveCurrentDirectoryRelative ? Value[2..] : Value;
// TODO[#95]: a rooted path ending with ".." (C:\..) gets here as well, so walking up its parents never
// ends. This goes away once the normalization drops a ".." directly after the root.
if (relativePart == "" || relativePart == ".." || relativePart.EndsWith($"{Separator}.."))
return this / "..";
// Only a relative path can end with "..": the normalization drops a ".." directly after the root.
var kind = Kind;
if (kind is PathKind.Relative or PathKind.DriveCurrentDirectoryRelative)
{
// For C:foo, the rest after the drive letter is a relative path, and follows the same rules.
var relativePart = kind == PathKind.DriveCurrentDirectoryRelative ? Value[2..] : Value;
if (relativePart == "" || relativePart == ".." || relativePart.EndsWith($"{Separator}.."))
return this / "..";
}

return Path.GetDirectoryName(Value) is { } parent ? new(parent) : null;
}
}
Expand Down
32 changes: 16 additions & 16 deletions TruePath/PathStrings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ namespace TruePath;
public static class PathStrings
{
private const char VolumeSeparatorChar = ':';
private static readonly string ParentReferenceWithSeparator = $"..{Path.DirectorySeparatorChar}";
private static readonly string ParentReferenceWithAltSeparator = $"..{Path.AltDirectorySeparatorChar}";

/// <summary>Whether the current platform uses drive letters in paths (i.e. is Windows).</summary>
internal static readonly bool IsDriveBasedSystem = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
Expand All @@ -39,7 +41,9 @@ public static class PathStrings
/// to the current directory is normalized to an <b>empty</b> path: both <c>.</c> and <c>a/..</c>
/// become <c>&quot;&quot;</c>. Parent directory references that cannot be resolved are preserved,
/// since there is nothing above them to fold into: <c>..</c> and <c>../..</c> are not affected by the
/// normalization,
/// normalization. The only exception is a parent directory reference directly after the root: the root
/// has no parent, so such a reference is dropped (e.g. <c>/..</c> is normalized to <c>/</c>, and
/// <c>C:\..\x</c> to <c>C:\x</c>),
/// </item>
/// <item>
/// trimming <b>all</b> trailing separators (e.g. <c>a/b/c/d////</c> is normalized to
Expand Down Expand Up @@ -96,16 +100,16 @@ internal static string Normalize(string path, bool driveBasedSystem)
else if (written != 0
&& (
block is ".."
|| block.SequenceEqual($"..{Path.DirectorySeparatorChar}".AsSpan())
|| block.SequenceEqual($"..{Path.AltDirectorySeparatorChar}".AsSpan())
|| block.SequenceEqual(ParentReferenceWithSeparator.AsSpan())
|| block.SequenceEqual(ParentReferenceWithAltSeparator.AsSpan())
))
{
var alreadyWrittenPart = normalized[..(written - 1)];
var jump = alreadyWrittenPart.LastIndexOf(Path.DirectorySeparatorChar);

// Check if the last entry in the normalized path is "..": in this case, no need to skip (we keep a
// train of ../../.. in the normalized path's root because they are impossible to get rid of during
// normalization).
// train of ../../.. at the start of a relative path, including C:..\.., because they are impossible
// to get rid of during normalization).
var lastEntryStartIndex = jump + 1;
var lastEntry = alreadyWrittenPart[lastEntryStartIndex..];
if (lastEntry is "..")
Expand All @@ -126,9 +130,9 @@ block is ".."
skip = true;
}
else
// TODO[#95]: this keeps a ".." directly after the root (C:\.., /.., \..). The root has no parent,
// so it should be dropped, the same way Path.GetFullPath does: C:\.. is C:\.
skip = false;
// Only the root separator has been written (C:\.., /.., \..). The root has no parent, so the ".."
// is dropped, the same way Path.GetFullPath does: C:\.. is C:\.
skip = true;
}
else
skip = false;
Expand All @@ -152,7 +156,7 @@ block is ".."

// next iter
source = source.Slice(separator);
// append everything else if there`s no more '\' or '/'
// append everything else if there's no more '\' or '/'
if (last)
{
source.CopyTo(buffer);
Expand All @@ -161,14 +165,11 @@ block is ".."
}
}

if (written == 0 && containsDriveLetter)
{
return path[..2];
}

if (written == 0)
{
return string.Empty;
if (array != null)
ArrayPool<char>.Shared.Return(array);
return containsDriveLetter ? path[..2] : string.Empty;
}

// remove / at the end of path
Expand All @@ -188,7 +189,6 @@ block is ".."
result = new string(normalized[..written].ToArray());
}

normalized.Slice(0, written);
if (array != null)
ArrayPool<char>.Shared.Return(array);
return result;
Expand Down
8 changes: 5 additions & 3 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@ The paths are stored in the **normalized form**.

- All the `Path.AltDirectorySeparatorChar` are converted to `Path.DirectorySeparatorChar` (e.g. `/` to `\` on Windows).
- Any repeated separators in the input are collapsed to only one separator (e.g. `//` to just `/` on Unix).
- Any sequence of current and parent directory marks (subsequently, `.` and `..`) is resolved if possible (meaning they
will not be replaced if they are in the root position: paths such as `.` or `../..` will not be affected by the
normalization, while e.g. `foo/bar/../.` will be resolved to just `foo`).
- Any sequence of current and parent directory marks (subsequently, `.` and `..`) is resolved if possible: e.g.
`foo/bar/../.` is resolved to just `foo`. A path that resolves to the current directory is normalized to an empty
path (both `.` and `a/..` become `""`). Parent directory marks at the start of a relative path cannot be resolved,
and are preserved: `..` and `../..` are not affected by the normalization. A parent directory mark directly after the
root is dropped, since the root has no parent: `/..` is normalized to `/`, and `C:\..\x` to `C:\x`.
- All trailing separators are trimmed (e.g. `a/b/c/d////` is normalized to `a/b/c/d`), except for the case of the root folder: `/` (Unix) or `X:\` (Windows) keeps one final separator (because it would become an empty string or a reference to the current path on disk `X:` without the trailing separator).

Note that the normalization operation will not perform any file IO, and is purely string manipulation.
Expand Down
Loading