From 89d8cbedef74c795c038f29c62dcc81258e324c9 Mon Sep 17 00:00:00 2001 From: RghvGrv Date: Sat, 26 Sep 2026 02:01:28 +0530 Subject: [PATCH] LocalPath.IsPrefixOf: account for leading .. references (#226) Relative paths are compared by the number of their leading .. references and the remaining segments, instead of as plain strings. --- CHANGELOG.md | 1 + TruePath.Tests/LocalPathTests.cs | 14 +++++++++++++ TruePath/LocalPath.cs | 36 ++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 638fbd3..14c50e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - The platform-default path comparers (`LocalPath.PlatformDefaultComparer`, `AbsolutePath.PlatformDefaultComparer`) are now case-insensitive on iOS and tvOS as well, matching the .NET runtime. ### Fixed +- [#226](https://github.com/ForNeVeR/TruePath/issues/226): `LocalPath.IsPrefixOf` and `StartsWith` now take leading `..` references of relative paths into account instead of comparing them as strings. For example, `new LocalPath("..").IsPrefixOf(new LocalPath("foo"))` is now `true`, and `new LocalPath("..").IsPrefixOf(new LocalPath("../.."))` is now `false`. - [#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`. diff --git a/TruePath.Tests/LocalPathTests.cs b/TruePath.Tests/LocalPathTests.cs index b1f8ee8..550f150 100644 --- a/TruePath.Tests/LocalPathTests.cs +++ b/TruePath.Tests/LocalPathTests.cs @@ -119,6 +119,17 @@ public void AbsolutePathIsNormalizedOnCreation() [InlineData("", "../evil", false)] [InlineData(".", "..", false)] [InlineData("", "..bar", true)] + [InlineData("..", "foo", true)] + [InlineData("..", "", true)] + [InlineData("..", "..", true)] + [InlineData("..", "../foo", true)] + [InlineData("../..", "../foo", true)] + [InlineData("..", "../..", false)] + [InlineData("../foo", "bar", false)] + [InlineData("../foo", "../foo/bar", true)] + [InlineData("../foo", "../foobar", false)] + [InlineData("foo", "../foo", false)] + [InlineData("..", "..bar", true)] [InlineData("\u00ADfoo", "fooo/bar", false)] [InlineData("\u00ADfoo", "\u00ADfoo/bar", true)] public void IsPrefixOfAndStartsWith(string prefix, string other, bool result) @@ -394,6 +405,9 @@ public void KindOnUnix(string path, PathKind expected) [InlineData("C:Windows", @"C:Windows\System32", true)] [InlineData("C:Win", "C:Windows", false)] [InlineData("C:", @"C:..\x", false)] + [InlineData("C:..", "C:Windows", true)] + [InlineData("C:..", @"C:..\..", false)] + [InlineData("D:..", "C:Windows", false)] [InlineData("C:", "D:Windows", false)] [InlineData("C:", @"C:\Windows", false)] [InlineData(@"C:\", @"C:Windows", false)] diff --git a/TruePath/LocalPath.cs b/TruePath/LocalPath.cs index 22ca2b2..871fffa 100644 --- a/TruePath/LocalPath.cs +++ b/TruePath/LocalPath.cs @@ -57,6 +57,21 @@ private static bool StartsWithParentDirectoryReference(ReadOnlySpan value) value.Length >= 2 && value[0] == '.' && value[1] == '.' && (value.Length == 2 || value[2] == Separator); + /// + /// Strips the leading .. references off a normalized path, and returns their number. + /// + private static int SkipLeadingParentReferences(ref ReadOnlySpan value) + { + var count = 0; + while (StartsWithParentDirectoryReference(value)) + { + count++; + value = value.Length == 2 ? ReadOnlySpan.Empty : value.Slice(3); + } + + return count; + } + /// public string Value { get; } = PathStrings.Normalize(value); @@ -210,6 +225,12 @@ public override bool Equals(object? obj) /// current directory, which this type never does. /// /// + /// For relative paths, leading .. references are taken into account as levels above the current + /// directory rather than compared as strings. A bare train of .. references is a prefix of every relative + /// path starting at or below it (e.g. .. is a prefix of foo), while a path starting lower is + /// never a prefix of one starting higher (e.g. .. is not a prefix of ../..). + /// + /// /// On Windows, paths relative to the current directory of a drive (such as C:Windows) are only related /// if they have the same drive letter. A bare drive (C:) designates that drive's current directory, and /// behaves the same way as the empty path does for relative paths. @@ -235,6 +256,21 @@ public bool IsPrefixOf(LocalPath other) path = path.Slice(2); } + if (kind is PathKind.Relative or PathKind.DriveCurrentDirectoryRelative) + { + // A relative path starts some number of levels above the current directory: one per leading ".." + // reference. Normalization only ever keeps such references at the very start of a path, so a relative + // path is fully described by that number and the segments that follow. + var parentReferences = SkipLeadingParentReferences(ref prefix); + var otherParentReferences = SkipLeadingParentReferences(ref path); + + // A path starting higher up contains the other one only if it is a bare train of ".." references: + // otherwise, it descends into a directory whose name would only be known after resolving the current + // directory. A path starting lower down never contains one starting higher up. + if (parentReferences != otherParentReferences) + return parentReferences > otherParentReferences && prefix.Length == 0; + } + return IsSegmentPrefix(prefix, path); }