diff --git a/CHANGELOG.md b/CHANGELOG.md
index 52e67f8..73f67ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/TruePath.Tests/AbsolutePathTests.cs b/TruePath.Tests/AbsolutePathTests.cs
index 489d8f0..b8302d1 100644
--- a/TruePath.Tests/AbsolutePathTests.cs
+++ b/TruePath.Tests/AbsolutePathTests.cs
@@ -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]
@@ -245,6 +245,65 @@ public void PathIsNormalizedOnCreation()
Assert.Equal(@"C:\Users\John Doe\Documents", absolutePath.Value);
}
+ ///
+ /// Appends (written with /) to , using
+ /// the separators of the current platform.
+ ///
+ 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()
{
@@ -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);
+ }
+ }
}
}
diff --git a/TruePath.Tests/LocalPathTests.cs b/TruePath.Tests/LocalPathTests.cs
index 550f150..dd5f3e0 100644
--- a/TruePath.Tests/LocalPathTests.cs
+++ b/TruePath.Tests/LocalPathTests.cs
@@ -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;
@@ -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("..", "..")]
diff --git a/TruePath.Tests/PathStringsTests.cs b/TruePath.Tests/PathStringsTests.cs
index 8627c47..f1166ba 100644
--- a/TruePath.Tests/PathStringsTests.cs
+++ b/TruePath.Tests/PathStringsTests.cs
@@ -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")]
@@ -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")]
@@ -148,7 +152,7 @@ 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);
@@ -156,11 +160,24 @@ public void Normalize_DriveLetterCheckEnabled_PreservesWindowsDrivePrefix(string
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);
}
diff --git a/TruePath/AbsolutePath.cs b/TruePath/AbsolutePath.cs
index fb8d2a2..c66ebdc 100644
--- a/TruePath/AbsolutePath.cs
+++ b/TruePath/AbsolutePath.cs
@@ -54,7 +54,7 @@ namespace TruePath;
///
/// Path string to normalize.
/// Flag indicating whether absoluteness of path should be checked
- /// Thrown if the passed string does not represent an absolute path.>
+ /// Thrown if the passed string does not represent an absolute path.
internal AbsolutePath(string value, bool checkAbsoluteness)
{
Underlying = new LocalPath(value);
diff --git a/TruePath/LocalPath.cs b/TruePath/LocalPath.cs
index 871fffa..454fa2b 100644
--- a/TruePath/LocalPath.cs
+++ b/TruePath/LocalPath.cs
@@ -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;
}
}
diff --git a/TruePath/PathStrings.cs b/TruePath/PathStrings.cs
index 4e23d24..24f17b6 100644
--- a/TruePath/PathStrings.cs
+++ b/TruePath/PathStrings.cs
@@ -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}";
/// Whether the current platform uses drive letters in paths (i.e. is Windows).
internal static readonly bool IsDriveBasedSystem = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
@@ -39,7 +41,9 @@ public static class PathStrings
/// to the current directory is normalized to an empty path: both . and a/..
/// become "". Parent directory references that cannot be resolved are preserved,
/// since there is nothing above them to fold into: .. and ../.. 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. /.. is normalized to /, and
+ /// C:\..\x to C:\x),
///
/// -
/// trimming all trailing separators (e.g. a/b/c/d//// is normalized to
@@ -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 "..")
@@ -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;
@@ -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);
@@ -161,14 +165,11 @@ block is ".."
}
}
- if (written == 0 && containsDriveLetter)
- {
- return path[..2];
- }
-
if (written == 0)
{
- return string.Empty;
+ if (array != null)
+ ArrayPool.Shared.Return(array);
+ return containsDriveLetter ? path[..2] : string.Empty;
}
// remove / at the end of path
@@ -188,7 +189,6 @@ block is ".."
result = new string(normalized[..written].ToArray());
}
- normalized.Slice(0, written);
if (array != null)
ArrayPool.Shared.Return(array);
return result;
diff --git a/docs/index.md b/docs/index.md
index 7a8ab68..941c4ce 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -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.