From e8a12ca446928c9a2925be30eeb0a0d4a81ad8f2 Mon Sep 17 00:00:00 2001 From: sinthu-14 Date: Fri, 25 Sep 2026 13:31:24 +0200 Subject: [PATCH 01/10] Fixes constructing invalid absolute paths. --- TruePath.Tests/AbsolutePathTests.cs | 111 ++++++++++++++++++++++++++-- TruePath/AbsolutePath.cs | 10 +++ 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/TruePath.Tests/AbsolutePathTests.cs b/TruePath.Tests/AbsolutePathTests.cs index 489d8f0..6187294 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,107 @@ public void PathIsNormalizedOnCreation() Assert.Equal(@"C:\Users\John Doe\Documents", absolutePath.Value); } + [Theory] + [InlineData(@"/.")] + [InlineData(@"/./.")] + [InlineData(@"/././.")] + public void ConstructorCreatesValidPathWithDotInUnix(string path) + { + if (OperatingSystem.IsWindows()) return; + const string expectedPath = @"/"; + + var absolutePath = new AbsolutePath(path); + + Assert.Equal(expectedPath, absolutePath.Value); + } + + + [Theory] + [InlineData(@"/...")] + [InlineData(@"/..SomeFolder")] + [InlineData(@"/..00")] + [InlineData(@"/..#")] + public void ConstructorCreatesValidPathCorrectlyInUnix(string path) + { + if (OperatingSystem.IsWindows()) return; + + var absolutePath = new AbsolutePath(path); + + Assert.Equal(path, absolutePath.Value); + } + + [Theory] + [InlineData(@"/../")] + [InlineData(@"/../..")] + [InlineData(@"/../../SomeFolder")] + [InlineData(@"/../.SomeFolder")] + [InlineData(@"/../SomeFolder")] + [InlineData(@"/../1123")] + [InlineData(@"/../()")] + [InlineData(@"/./..")] + [InlineData(@"/./../.")] + + public void ConstructorThrowsOnInvalidPathInUnix(string path) + { + if (OperatingSystem.IsWindows()) return; + string expectedMessage = $"Path \"{path}\" is not valid."; + + var ex = Assert.Throws(() => new AbsolutePath(path)); + + Assert.Equal(expectedMessage, ex.Message); + } + + [Theory] + [InlineData(@"C:\.")] + [InlineData(@"C:\.\.")] + [InlineData(@"C:\.\.\.")] + public void ConstructorCreatesValidPathWithDotInWindows(string path) + { + if (OperatingSystem.IsWindows() is false) return; + const string expectedPath = @"C:\"; + + var absolutePath = new AbsolutePath(path); + + Assert.Equal(expectedPath, absolutePath.Value); + } + + + [Theory] + [InlineData(@"C:\...")] + [InlineData(@"C:\..SomeFolder")] + [InlineData(@"C:\..00")] + [InlineData(@"C:\..#")] + + public void ConstructorCreatesValidPathCorrectlyInWindows(string path) + { + if (OperatingSystem.IsWindows() is false) return; + + var absolutePath = new AbsolutePath(path); + + Assert.Equal(path, absolutePath.Value); + } + + [Theory] + [InlineData(@"C:\..\")] + [InlineData(@"C:\..\..")] + [InlineData(@"C:\..\..SomeFolder")] + [InlineData(@"C:\..\.SomeFolder")] + [InlineData(@"C:\..\SomeFolder")] + [InlineData(@"C:\..\1123")] + [InlineData(@"C:\..\()")] + [InlineData(@"C:\.\..")] + [InlineData(@"C:\.\..\.")] + + public void ConstructorThrowsOnInvalidPathInWindows(string path) + { + if (OperatingSystem.IsWindows() is false) return; + string expectedMessage = $"Path \"{path}\" is not valid."; + + var ex = Assert.Throws(() => new AbsolutePath(path)); + + Assert.Equal(expectedMessage, ex.Message); + } + [Fact] public void ConstructorThrowsOnNonRootedPath() { diff --git a/TruePath/AbsolutePath.cs b/TruePath/AbsolutePath.cs index fb8d2a2..a9e7f70 100644 --- a/TruePath/AbsolutePath.cs +++ b/TruePath/AbsolutePath.cs @@ -55,12 +55,22 @@ 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 dot-dot directory directly after root. E.g. C:/../ or /../> + internal AbsolutePath(string value, bool checkAbsoluteness) { Underlying = new LocalPath(value); if (checkAbsoluteness && Underlying.IsAbsolute is false) throw new ArgumentException($"Path \"{value}\" is not absolute."); + + var pathRoot = Path.GetPathRoot(Underlying.Value)!; + var pathWithoutRoot = Underlying.Value[pathRoot.Length..]; + if (pathWithoutRoot.StartsWith("../") || pathWithoutRoot.StartsWith("..\\") || + pathWithoutRoot.Equals("..")) + { + throw new ArgumentException($"Path \"{value}\" is not valid."); + } } /// From 1a5306becbe3b8d5ac64e0f74de0f9b2f021a6b5 Mon Sep 17 00:00:00 2001 From: sinthu-14 Date: Fri, 25 Sep 2026 14:11:13 +0200 Subject: [PATCH 02/10] Fix formatting and spelling issues. --- TruePath.Tests/AbsolutePathTests.cs | 5 ----- TruePath/AbsolutePath.cs | 4 +++- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/TruePath.Tests/AbsolutePathTests.cs b/TruePath.Tests/AbsolutePathTests.cs index 6187294..d22837d 100644 --- a/TruePath.Tests/AbsolutePathTests.cs +++ b/TruePath.Tests/AbsolutePathTests.cs @@ -259,7 +259,6 @@ public void ConstructorCreatesValidPathWithDotInUnix(string path) Assert.Equal(expectedPath, absolutePath.Value); } - [Theory] [InlineData(@"/...")] [InlineData(@"/..SomeFolder")] @@ -284,7 +283,6 @@ public void ConstructorCreatesValidPathCorrectlyInUnix(string path) [InlineData(@"/../()")] [InlineData(@"/./..")] [InlineData(@"/./../.")] - public void ConstructorThrowsOnInvalidPathInUnix(string path) { if (OperatingSystem.IsWindows()) return; @@ -309,13 +307,11 @@ public void ConstructorCreatesValidPathWithDotInWindows(string path) Assert.Equal(expectedPath, absolutePath.Value); } - [Theory] [InlineData(@"C:\...")] [InlineData(@"C:\..SomeFolder")] [InlineData(@"C:\..00")] [InlineData(@"C:\..#")] - public void ConstructorCreatesValidPathCorrectlyInWindows(string path) { if (OperatingSystem.IsWindows() is false) return; @@ -335,7 +331,6 @@ public void ConstructorCreatesValidPathCorrectlyInWindows(string path) [InlineData(@"C:\..\()")] [InlineData(@"C:\.\..")] [InlineData(@"C:\.\..\.")] - public void ConstructorThrowsOnInvalidPathInWindows(string path) { if (OperatingSystem.IsWindows() is false) return; diff --git a/TruePath/AbsolutePath.cs b/TruePath/AbsolutePath.cs index a9e7f70..bd6578f 100644 --- a/TruePath/AbsolutePath.cs +++ b/TruePath/AbsolutePath.cs @@ -55,7 +55,9 @@ 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 dot-dot directory directly after root. E.g. C:/../ or /../> + /// + /// Thrown if the passed string matches the dot-dot directory directly after root (e.g. C:/../ or /../) + /// > internal AbsolutePath(string value, bool checkAbsoluteness) { From a1df2013750c5f3ba6114966932dbc4c8fb1447d Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sun, 4 Oct 2026 21:55:31 +0200 Subject: [PATCH 03/10] (#95) AbsolutePath: cleanup and minor improvements --- TruePath/AbsolutePath.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/TruePath/AbsolutePath.cs b/TruePath/AbsolutePath.cs index bd6578f..1cd01e2 100644 --- a/TruePath/AbsolutePath.cs +++ b/TruePath/AbsolutePath.cs @@ -56,9 +56,9 @@ namespace TruePath; /// 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 matches the dot-dot directory directly after root (e.g. C:/../ or /../) + /// Thrown if the passed string matches the dot-dot directory directly after root (e.g. C:\.. on Windows, or + /// /.. on Unix). /// > - internal AbsolutePath(string value, bool checkAbsoluteness) { Underlying = new LocalPath(value); @@ -68,8 +68,7 @@ internal AbsolutePath(string value, bool checkAbsoluteness) var pathRoot = Path.GetPathRoot(Underlying.Value)!; var pathWithoutRoot = Underlying.Value[pathRoot.Length..]; - if (pathWithoutRoot.StartsWith("../") || pathWithoutRoot.StartsWith("..\\") || - pathWithoutRoot.Equals("..")) + if (pathWithoutRoot == ".." || pathWithoutRoot.StartsWith(".." + Path.DirectorySeparatorChar)) { throw new ArgumentException($"Path \"{value}\" is not valid."); } From f6456825f31ead22b7d75e0e066403d0f99818f1 Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sun, 4 Oct 2026 22:11:44 +0200 Subject: [PATCH 04/10] (#95) AbsolutePath: improve the root detection --- TruePath/AbsolutePath.cs | 23 ++++++++++++++--------- TruePath/LocalPath.cs | 2 +- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/TruePath/AbsolutePath.cs b/TruePath/AbsolutePath.cs index 1cd01e2..6aa04ee 100644 --- a/TruePath/AbsolutePath.cs +++ b/TruePath/AbsolutePath.cs @@ -54,11 +54,11 @@ 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. /// /// Thrown if the passed string matches the dot-dot directory directly after root (e.g. C:\.. on Windows, or /// /.. on Unix). - /// > + /// internal AbsolutePath(string value, bool checkAbsoluteness) { Underlying = new LocalPath(value); @@ -66,12 +66,11 @@ internal AbsolutePath(string value, bool checkAbsoluteness) if (checkAbsoluteness && Underlying.IsAbsolute is false) throw new ArgumentException($"Path \"{value}\" is not absolute."); - var pathRoot = Path.GetPathRoot(Underlying.Value)!; - var pathWithoutRoot = Underlying.Value[pathRoot.Length..]; - if (pathWithoutRoot == ".." || pathWithoutRoot.StartsWith(".." + Path.DirectorySeparatorChar)) - { + // An absolute path is rooted at "/" on Unix, or at a drive root like "C:\" on Windows (see LocalPath.Kind). + // TODO[#24]: UNC and DOS device paths have longer roots; this has to account for them once they are supported. + var rootLength = PathStrings.IsDriveBasedSystem ? 3 : 1; + if (LocalPath.StartsWithParentDirectoryReference(Value.AsSpan(rootLength))) throw new ArgumentException($"Path \"{value}\" is not valid."); - } } /// @@ -79,13 +78,19 @@ internal AbsolutePath(string value, bool checkAbsoluteness) /// rules stated in . /// /// Path string to normalize. - /// Thrown if the passed string does not represent an absolute path. + /// + /// Thrown if the passed string does not represent an absolute path, or has the dot-dot directory directly after + /// root (e.g. C:\.. on Windows, or /.. on Unix). + /// public AbsolutePath(string value) : this(value, checkAbsoluteness: true) { } /// /// Creates an instance by converting a object. /// - /// Thrown if the passed path is not absolute. + /// + /// Thrown if the passed path is not absolute, or has the dot-dot directory directly after root (e.g. C:\.. + /// on Windows, or /.. on Unix). + /// public AbsolutePath(LocalPath localPath) : this(localPath.Value, checkAbsoluteness: true) { } /// diff --git a/TruePath/LocalPath.cs b/TruePath/LocalPath.cs index 871fffa..246c8e8 100644 --- a/TruePath/LocalPath.cs +++ b/TruePath/LocalPath.cs @@ -53,7 +53,7 @@ public readonly struct LocalPath(string value) : IEquatable, ICompara private static char Separator => Path.DirectorySeparatorChar; - private static bool StartsWithParentDirectoryReference(ReadOnlySpan value) => + internal static bool StartsWithParentDirectoryReference(ReadOnlySpan value) => value.Length >= 2 && value[0] == '.' && value[1] == '.' && (value.Length == 2 || value[2] == Separator); From 8d35cf3c4e91725850d4f5a3a29458aaa2cbbde4 Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sun, 4 Oct 2026 22:45:00 +0200 Subject: [PATCH 05/10] (#95) AbsolutePath: document and verify the new behavior when / can't resolve the new relative path on another drive --- CHANGELOG.md | 1 + TruePath.Tests/AbsolutePathTests.cs | 29 ++++++++++++++++++++++++++++- TruePath/AbsolutePath.cs | 10 ++++++++++ TruePath/LocalPath.cs | 4 ++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e67f8..1da63e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - On Windows, `LocalPath.ResolveToCurrentDirectory` now resolves `\x` against the drive of the current directory, and `D:x` against the current directory of drive `D:`. - **Breaking:** `LocalPath.StartsWith` and `AbsolutePath.StartsWith` now compare whole path segments instead of raw strings, which makes them exact inverses of `IsPrefixOf` as originally intended in [#43](https://github.com/ForNeVeR/TruePath/issues/43). For example, `new LocalPath("/foo1").StartsWith(new LocalPath("/foo"))` is now `false`, where it used to be `true`. - `LocalPath.IsPrefixOf` now treats an empty path — the normalized form of `""`, `"."` and `"a/.."`, and the parent of any single-segment relative path — as the current directory, so it is a prefix of every path of kind `PathKind.Relative` that does not begin with a `..` reference. +- **Breaking:** [#95](https://github.com/ForNeVeR/TruePath/issues/95): `AbsolutePath` now throws an `ArgumentException` for a path with `..` directly after the root, such as `C:\..` or `/..`. This includes the results of the `/` operator, e.g. `C:\ / ..`, or on Windows `C:\base / D:..\x` if the current directory of drive `D:` is its root. - The platform-default path comparers (`LocalPath.PlatformDefaultComparer`, `AbsolutePath.PlatformDefaultComparer`) are now case-insensitive on iOS and tvOS as well, matching the .NET runtime. ### Fixed diff --git a/TruePath.Tests/AbsolutePathTests.cs b/TruePath.Tests/AbsolutePathTests.cs index d22837d..55556ef 100644 --- a/TruePath.Tests/AbsolutePathTests.cs +++ b/TruePath.Tests/AbsolutePathTests.cs @@ -411,7 +411,6 @@ public void AppendPathRelativeToAnotherDriveResolvesItOnWindows() [InlineData("c:x")] [InlineData("")] [InlineData("..")] - [InlineData(@"D:..\x")] public void AppendAlwaysReturnsAbsolutePathOnWindows(string appended) { if (!OperatingSystem.IsWindows()) return; @@ -687,5 +686,33 @@ public void CurrentWorkingDirectoryGetsChanged() using var _ = Utils.ChangeCurrentDirectory(path); Assert.Equal(path, new AbsolutePath(Environment.CurrentDirectory)); } + + [Fact] + public void AppendPathAboveRootOfAnotherDriveThrowsOnWindows() + { + 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.Throws(() => basePath / (drive + @"..\x")); + Assert.Throws(() => basePath / (drive + "..")); + } + + [Fact] + public void AppendPathAboveCurrentDirectoryOfAnotherDriveResolvesItOnWindows() + { + if (!OperatingSystem.IsWindows()) return; + + var directory = new AbsolutePath(Path.GetTempPath()).Canonicalize(); + using var _ = Utils.ChangeCurrentDirectory(directory); + var drive = directory.Value.Substring(0, 2); + var basePath = Utils.NonCurrentSyntheticRoot / "base"; + + Assert.Equal(directory.Parent!.Value / "x", basePath / (drive + @"..\x")); + } } } diff --git a/TruePath/AbsolutePath.cs b/TruePath/AbsolutePath.cs index 6aa04ee..703bb1d 100644 --- a/TruePath/AbsolutePath.cs +++ b/TruePath/AbsolutePath.cs @@ -177,6 +177,16 @@ public static AbsolutePath CurrentWorkingDirectory /// path rooted without a drive letter keeps the drive of the base path: C:\base / \x is C:\x. /// /// + /// + /// + /// Thrown if the result has the dot-dot directory directly after root: e.g. when appending .. to + /// C:\ on Windows, or to / on Unix. + /// + /// + /// On Windows, in a rare corner case, this also depends on the state of the process: C:\base / D:..\x + /// throws if the current directory of drive D: is its root, or isn't tracked by the process. + /// + /// /// C++ standard: path appends (fs.path.append) public static AbsolutePath operator /(AbsolutePath basePath, LocalPath b) { diff --git a/TruePath/LocalPath.cs b/TruePath/LocalPath.cs index 246c8e8..50a750c 100644 --- a/TruePath/LocalPath.cs +++ b/TruePath/LocalPath.cs @@ -440,6 +440,10 @@ private static string Join(LocalPath basePath, string relativePath) /// ), so the result is always absolute. /// /// + /// + /// Thrown in the same cases as : e.g. for + /// D:..\x on Windows if the current directory of drive D: is its root. + /// public AbsolutePath ResolveToCurrentDirectory() => AbsolutePath.CurrentWorkingDirectory / this; /// Converts an to a . From ec6ecf73acca7f0772df7ce94c02f66a1260a2dc Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sun, 4 Oct 2026 23:03:22 +0200 Subject: [PATCH 06/10] =?UTF-8?q?(#95)=20PathStrings:=20rework=20how=20we?= =?UTF-8?q?=20resolve=20/..=20=E2=80=94=20we=20drop=20the=20..?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- TruePath.Tests/AbsolutePathTests.cs | 69 +++++++++++++++++------------ TruePath.Tests/LocalPathTests.cs | 14 ++++++ TruePath.Tests/PathStringsTests.cs | 33 ++++++++++---- TruePath/AbsolutePath.cs | 30 +------------ TruePath/LocalPath.cs | 8 +--- TruePath/PathStrings.cs | 10 +++-- 7 files changed, 89 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1da63e6..14010ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - On Windows, `LocalPath.ResolveToCurrentDirectory` now resolves `\x` against the drive of the current directory, and `D:x` against the current directory of drive `D:`. - **Breaking:** `LocalPath.StartsWith` and `AbsolutePath.StartsWith` now compare whole path segments instead of raw strings, which makes them exact inverses of `IsPrefixOf` as originally intended in [#43](https://github.com/ForNeVeR/TruePath/issues/43). For example, `new LocalPath("/foo1").StartsWith(new LocalPath("/foo"))` is now `false`, where it used to be `true`. - `LocalPath.IsPrefixOf` now treats an empty path — the normalized form of `""`, `"."` and `"a/.."`, and the parent of any single-segment relative path — as the current directory, so it is a prefix of every path of kind `PathKind.Relative` that does not begin with a `..` reference. -- **Breaking:** [#95](https://github.com/ForNeVeR/TruePath/issues/95): `AbsolutePath` now throws an `ArgumentException` for a path with `..` directly after the root, such as `C:\..` or `/..`. This includes the results of the `/` operator, e.g. `C:\ / ..`, or on Windows `C:\base / D:..\x` if the current directory of drive `D:` is its root. - The platform-default path comparers (`LocalPath.PlatformDefaultComparer`, `AbsolutePath.PlatformDefaultComparer`) are now case-insensitive on iOS and tvOS as well, matching the .NET runtime. ### Fixed @@ -33,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. Previously, such a `..` was preserved, so an `AbsolutePath` could designate a location above the root (e.g. the result of `/ ..` applied to the root), and walking up the `LocalPath.Parent` chain of such a path never ended. A path relative to the current directory of a drive (`C:..`) is not affected. This affects `PathStrings.Normalize`, `LocalPath` and `AbsolutePath`. - `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 55556ef..943c72b 100644 --- a/TruePath.Tests/AbsolutePathTests.cs +++ b/TruePath.Tests/AbsolutePathTests.cs @@ -274,23 +274,22 @@ public void ConstructorCreatesValidPathCorrectlyInUnix(string path) } [Theory] - [InlineData(@"/../")] - [InlineData(@"/../..")] - [InlineData(@"/../../SomeFolder")] - [InlineData(@"/../.SomeFolder")] - [InlineData(@"/../SomeFolder")] - [InlineData(@"/../1123")] - [InlineData(@"/../()")] - [InlineData(@"/./..")] - [InlineData(@"/./../.")] - public void ConstructorThrowsOnInvalidPathInUnix(string path) + [InlineData(@"/../", @"/")] + [InlineData(@"/../..", @"/")] + [InlineData(@"/../../SomeFolder", @"/SomeFolder")] + [InlineData(@"/../.SomeFolder", @"/.SomeFolder")] + [InlineData(@"/../SomeFolder", @"/SomeFolder")] + [InlineData(@"/../1123", @"/1123")] + [InlineData(@"/../()", @"/()")] + [InlineData(@"/./..", @"/")] + [InlineData(@"/./../.", @"/")] + public void ConstructorDropsParentReferenceAfterRootInUnix(string path, string expectedPath) { if (OperatingSystem.IsWindows()) return; - string expectedMessage = $"Path \"{path}\" is not valid."; - var ex = Assert.Throws(() => new AbsolutePath(path)); + var absolutePath = new AbsolutePath(path); - Assert.Equal(expectedMessage, ex.Message); + Assert.Equal(expectedPath, absolutePath.Value); } [Theory] @@ -322,23 +321,34 @@ public void ConstructorCreatesValidPathCorrectlyInWindows(string path) } [Theory] - [InlineData(@"C:\..\")] - [InlineData(@"C:\..\..")] - [InlineData(@"C:\..\..SomeFolder")] - [InlineData(@"C:\..\.SomeFolder")] - [InlineData(@"C:\..\SomeFolder")] - [InlineData(@"C:\..\1123")] - [InlineData(@"C:\..\()")] - [InlineData(@"C:\.\..")] - [InlineData(@"C:\.\..\.")] - public void ConstructorThrowsOnInvalidPathInWindows(string path) + [InlineData(@"C:\..\", @"C:\")] + [InlineData(@"C:\..\..", @"C:\")] + [InlineData(@"C:\..\..SomeFolder", @"C:\..SomeFolder")] + [InlineData(@"C:\..\.SomeFolder", @"C:\.SomeFolder")] + [InlineData(@"C:\..\SomeFolder", @"C:\SomeFolder")] + [InlineData(@"C:\..\1123", @"C:\1123")] + [InlineData(@"C:\..\()", @"C:\()")] + [InlineData(@"C:\.\..", @"C:\")] + [InlineData(@"C:\.\..\.", @"C:\")] + [InlineData(@"C:/../SomeFolder", @"C:\SomeFolder")] + public void ConstructorDropsParentReferenceAfterRootInWindows(string path, string expectedPath) { if (OperatingSystem.IsWindows() is false) return; - string expectedMessage = $"Path \"{path}\" is not valid."; - var ex = Assert.Throws(() => new AbsolutePath(path)); + var absolutePath = new AbsolutePath(path); - Assert.Equal(expectedMessage, ex.Message); + Assert.Equal(expectedPath, 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] @@ -411,6 +421,7 @@ public void AppendPathRelativeToAnotherDriveResolvesItOnWindows() [InlineData("c:x")] [InlineData("")] [InlineData("..")] + [InlineData(@"D:..\x")] public void AppendAlwaysReturnsAbsolutePathOnWindows(string appended) { if (!OperatingSystem.IsWindows()) return; @@ -688,7 +699,7 @@ public void CurrentWorkingDirectoryGetsChanged() } [Fact] - public void AppendPathAboveRootOfAnotherDriveThrowsOnWindows() + public void AppendPathAboveRootOfAnotherDriveStaysAtRootOnWindows() { if (!OperatingSystem.IsWindows()) return; @@ -698,8 +709,8 @@ public void AppendPathAboveRootOfAnotherDriveThrowsOnWindows() var drive = root.Value.Substring(0, 2); var basePath = Utils.NonCurrentSyntheticRoot / "base"; - Assert.Throws(() => basePath / (drive + @"..\x")); - Assert.Throws(() => basePath / (drive + "..")); + Assert.Equal(root / "x", basePath / (drive + @"..\x")); + Assert.Equal(root, basePath / (drive + "..")); } [Fact] 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 703bb1d..c66ebdc 100644 --- a/TruePath/AbsolutePath.cs +++ b/TruePath/AbsolutePath.cs @@ -55,22 +55,12 @@ 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 matches the dot-dot directory directly after root (e.g. C:\.. on Windows, or - /// /.. on Unix). - /// internal AbsolutePath(string value, bool checkAbsoluteness) { Underlying = new LocalPath(value); if (checkAbsoluteness && Underlying.IsAbsolute is false) throw new ArgumentException($"Path \"{value}\" is not absolute."); - - // An absolute path is rooted at "/" on Unix, or at a drive root like "C:\" on Windows (see LocalPath.Kind). - // TODO[#24]: UNC and DOS device paths have longer roots; this has to account for them once they are supported. - var rootLength = PathStrings.IsDriveBasedSystem ? 3 : 1; - if (LocalPath.StartsWithParentDirectoryReference(Value.AsSpan(rootLength))) - throw new ArgumentException($"Path \"{value}\" is not valid."); } /// @@ -78,19 +68,13 @@ internal AbsolutePath(string value, bool checkAbsoluteness) /// rules stated in . /// /// Path string to normalize. - /// - /// Thrown if the passed string does not represent an absolute path, or has the dot-dot directory directly after - /// root (e.g. C:\.. on Windows, or /.. on Unix). - /// + /// Thrown if the passed string does not represent an absolute path. public AbsolutePath(string value) : this(value, checkAbsoluteness: true) { } /// /// Creates an instance by converting a object. /// - /// - /// Thrown if the passed path is not absolute, or has the dot-dot directory directly after root (e.g. C:\.. - /// on Windows, or /.. on Unix). - /// + /// Thrown if the passed path is not absolute. public AbsolutePath(LocalPath localPath) : this(localPath.Value, checkAbsoluteness: true) { } /// @@ -177,16 +161,6 @@ public static AbsolutePath CurrentWorkingDirectory /// path rooted without a drive letter keeps the drive of the base path: C:\base / \x is C:\x. /// /// - /// - /// - /// Thrown if the result has the dot-dot directory directly after root: e.g. when appending .. to - /// C:\ on Windows, or to / on Unix. - /// - /// - /// On Windows, in a rare corner case, this also depends on the state of the process: C:\base / D:..\x - /// throws if the current directory of drive D: is its root, or isn't tracked by the process. - /// - /// /// C++ standard: path appends (fs.path.append) public static AbsolutePath operator /(AbsolutePath basePath, LocalPath b) { diff --git a/TruePath/LocalPath.cs b/TruePath/LocalPath.cs index 50a750c..aa5ae10 100644 --- a/TruePath/LocalPath.cs +++ b/TruePath/LocalPath.cs @@ -53,7 +53,7 @@ public readonly struct LocalPath(string value) : IEquatable, ICompara private static char Separator => Path.DirectorySeparatorChar; - internal static bool StartsWithParentDirectoryReference(ReadOnlySpan value) => + private static bool StartsWithParentDirectoryReference(ReadOnlySpan value) => value.Length >= 2 && value[0] == '.' && value[1] == '.' && (value.Length == 2 || value[2] == Separator); @@ -150,8 +150,6 @@ public LocalPath? Parent { // 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 / ".."; return Path.GetDirectoryName(Value) is { } parent ? new(parent) : null; @@ -440,10 +438,6 @@ private static string Join(LocalPath basePath, string relativePath) /// ), so the result is always absolute. /// /// - /// - /// Thrown in the same cases as : e.g. for - /// D:..\x on Windows if the current directory of drive D: is its root. - /// public AbsolutePath ResolveToCurrentDirectory() => AbsolutePath.CurrentWorkingDirectory / this; /// Converts an to a . diff --git a/TruePath/PathStrings.cs b/TruePath/PathStrings.cs index 4e23d24..71bacfd 100644 --- a/TruePath/PathStrings.cs +++ b/TruePath/PathStrings.cs @@ -39,7 +39,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 @@ -126,9 +128,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; From 6c8bfc2cff2d3186837bc464a9f5da4c2fa2590d Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sun, 4 Oct 2026 23:21:18 +0200 Subject: [PATCH 07/10] (#95) Review fixes: robust cross-drive test, folded root tests, Parent gating, docs - AbsolutePathTests: resolve against a fresh temporary subdirectory, so the cross-drive test doesn't depend on TEMP having a parent. - AbsolutePathTests: fold the duplicated Unix/Windows constructor theories into cross-platform ones built on the synthetic root. - LocalPath.Parent: only check for a trailing ".." in relative paths, since the normalization drops a ".." directly after the root. - Update the normalization docs and the stale comment in PathStrings. --- TruePath.Tests/AbsolutePathTests.cs | 131 ++++++++++------------------ TruePath/LocalPath.cs | 14 ++- TruePath/PathStrings.cs | 4 +- docs/index.md | 8 +- 4 files changed, 64 insertions(+), 93 deletions(-) diff --git a/TruePath.Tests/AbsolutePathTests.cs b/TruePath.Tests/AbsolutePathTests.cs index 943c72b..b8302d1 100644 --- a/TruePath.Tests/AbsolutePathTests.cs +++ b/TruePath.Tests/AbsolutePathTests.cs @@ -245,99 +245,52 @@ public void PathIsNormalizedOnCreation() Assert.Equal(@"C:\Users\John Doe\Documents", absolutePath.Value); } - [Theory] - [InlineData(@"/.")] - [InlineData(@"/./.")] - [InlineData(@"/././.")] - public void ConstructorCreatesValidPathWithDotInUnix(string path) - { - if (OperatingSystem.IsWindows()) return; - const string expectedPath = @"/"; - - var absolutePath = new AbsolutePath(path); - - Assert.Equal(expectedPath, 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(@"/..SomeFolder")] - [InlineData(@"/..00")] - [InlineData(@"/..#")] - public void ConstructorCreatesValidPathCorrectlyInUnix(string path) + [InlineData(".")] + [InlineData("./.")] + [InlineData("././.")] + public void ConstructorDropsCurrentDirectoryReferenceAfterRoot(string relativePath) { - if (OperatingSystem.IsWindows()) return; + var absolutePath = new AbsolutePath(Utils.SyntheticRootString + relativePath); - var absolutePath = new AbsolutePath(path); - - Assert.Equal(path, absolutePath.Value); + Assert.Equal(Utils.SyntheticRootString, absolutePath.Value); } [Theory] - [InlineData(@"/../", @"/")] - [InlineData(@"/../..", @"/")] - [InlineData(@"/../../SomeFolder", @"/SomeFolder")] - [InlineData(@"/../.SomeFolder", @"/.SomeFolder")] - [InlineData(@"/../SomeFolder", @"/SomeFolder")] - [InlineData(@"/../1123", @"/1123")] - [InlineData(@"/../()", @"/()")] - [InlineData(@"/./..", @"/")] - [InlineData(@"/./../.", @"/")] - public void ConstructorDropsParentReferenceAfterRootInUnix(string path, string expectedPath) + [InlineData("...")] + [InlineData("..SomeFolder")] + [InlineData("..00")] + [InlineData("..#")] + public void ConstructorKeepsNamesStartingWithDotsAfterRoot(string relativePath) { - if (OperatingSystem.IsWindows()) return; - - var absolutePath = new AbsolutePath(path); + var absolutePath = new AbsolutePath(Utils.SyntheticRootString + relativePath); - Assert.Equal(expectedPath, absolutePath.Value); + Assert.Equal(SyntheticRootedPath(relativePath), absolutePath.Value); } [Theory] - [InlineData(@"C:\.")] - [InlineData(@"C:\.\.")] - [InlineData(@"C:\.\.\.")] - public void ConstructorCreatesValidPathWithDotInWindows(string path) + [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) { - if (OperatingSystem.IsWindows() is false) return; - const string expectedPath = @"C:\"; + var absolutePath = new AbsolutePath(Utils.SyntheticRootString + relativePath); - var absolutePath = new AbsolutePath(path); - - Assert.Equal(expectedPath, absolutePath.Value); - } - - [Theory] - [InlineData(@"C:\...")] - [InlineData(@"C:\..SomeFolder")] - [InlineData(@"C:\..00")] - [InlineData(@"C:\..#")] - public void ConstructorCreatesValidPathCorrectlyInWindows(string path) - { - if (OperatingSystem.IsWindows() is false) return; - - var absolutePath = new AbsolutePath(path); - - Assert.Equal(path, absolutePath.Value); - } - - [Theory] - [InlineData(@"C:\..\", @"C:\")] - [InlineData(@"C:\..\..", @"C:\")] - [InlineData(@"C:\..\..SomeFolder", @"C:\..SomeFolder")] - [InlineData(@"C:\..\.SomeFolder", @"C:\.SomeFolder")] - [InlineData(@"C:\..\SomeFolder", @"C:\SomeFolder")] - [InlineData(@"C:\..\1123", @"C:\1123")] - [InlineData(@"C:\..\()", @"C:\()")] - [InlineData(@"C:\.\..", @"C:\")] - [InlineData(@"C:\.\..\.", @"C:\")] - [InlineData(@"C:/../SomeFolder", @"C:\SomeFolder")] - public void ConstructorDropsParentReferenceAfterRootInWindows(string path, string expectedPath) - { - if (OperatingSystem.IsWindows() is false) return; - - var absolutePath = new AbsolutePath(path); - - Assert.Equal(expectedPath, absolutePath.Value); + Assert.Equal(SyntheticRootedPath(expectedRelativePath), absolutePath.Value); } [Fact] @@ -718,12 +671,22 @@ public void AppendPathAboveCurrentDirectoryOfAnotherDriveResolvesItOnWindows() { if (!OperatingSystem.IsWindows()) return; - var directory = new AbsolutePath(Path.GetTempPath()).Canonicalize(); - using var _ = Utils.ChangeCurrentDirectory(directory); - var drive = directory.Value.Substring(0, 2); - var basePath = Utils.NonCurrentSyntheticRoot / "base"; + // 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")); + Assert.Equal(directory.Parent!.Value / "x", basePath / (drive + @"..\x")); + } + } + finally + { + Directory.Delete(directory.Value); + } } } } diff --git a/TruePath/LocalPath.cs b/TruePath/LocalPath.cs index aa5ae10..454fa2b 100644 --- a/TruePath/LocalPath.cs +++ b/TruePath/LocalPath.cs @@ -148,10 +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; - 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 71bacfd..83537b6 100644 --- a/TruePath/PathStrings.cs +++ b/TruePath/PathStrings.cs @@ -106,8 +106,8 @@ block is ".." 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 "..") 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. From 11f039fa1278a1157ada9c5962d778ed8051f604 Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sun, 4 Oct 2026 23:21:46 +0200 Subject: [PATCH 08/10] PathStrings.Normalize: return the pooled buffer on every exit, avoid per-segment allocations - Return the rented buffer when the path normalizes to an empty path or a bare drive, too; previously, these early exits skipped it. - Compare ".." followed by a separator against preallocated strings instead of interpolating them for every segment. - Remove a no-op Slice call. --- TruePath/PathStrings.cs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/TruePath/PathStrings.cs b/TruePath/PathStrings.cs index 83537b6..ab1262d 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); @@ -98,8 +100,8 @@ 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)]; @@ -163,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 @@ -190,7 +189,6 @@ block is ".." result = new string(normalized[..written].ToArray()); } - normalized.Slice(0, written); if (array != null) ArrayPool.Shared.Return(array); return result; From 6f5e73c06d807234f4c638409c08a3aee683f928 Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sun, 4 Oct 2026 23:28:01 +0200 Subject: [PATCH 09/10] PathStrings: minor comment fix --- TruePath/PathStrings.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TruePath/PathStrings.cs b/TruePath/PathStrings.cs index ab1262d..24f17b6 100644 --- a/TruePath/PathStrings.cs +++ b/TruePath/PathStrings.cs @@ -156,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); From e5b35d0859b6bdf6cd6cc8cfc81305b0bf2d5384 Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sun, 4 Oct 2026 23:29:26 +0200 Subject: [PATCH 10/10] (#95) Docs: drop redundant wording, mention the contributor --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14010ba..73f67ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +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. Previously, such a `..` was preserved, so an `AbsolutePath` could designate a location above the root (e.g. the result of `/ ..` applied to the root), and walking up the `LocalPath.Parent` chain of such a path never ended. A path relative to the current directory of a drive (`C:..`) is not affected. 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.