diff --git a/CHANGELOG.md b/CHANGELOG.md index 606ed38..78d8f34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - `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 relative path that does not begin with a `..` reference. ### 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. diff --git a/TruePath.Tests/LocalPathTests.cs b/TruePath.Tests/LocalPathTests.cs index ff07080..baa48e8 100644 --- a/TruePath.Tests/LocalPathTests.cs +++ b/TruePath.Tests/LocalPathTests.cs @@ -104,6 +104,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) diff --git a/TruePath/LocalPath.cs b/TruePath/LocalPath.cs index 5d03cfc..f18e34b 100644 --- a/TruePath/LocalPath.cs +++ b/TruePath/LocalPath.cs @@ -50,6 +50,32 @@ private static bool StartsWithParentDirectoryReference(string value) => value.Length >= 2 && value[0] == '.' && value[1] == '.' && (value.Length == 2 || value[2] == Separator); + /// + /// Splits a normalized path into the number of its leading .. references and the rest of the path. + /// + private static (int ParentReferences, string Remainder) SplitLeadingParentReferences(string value) + { + var count = 0; + while (StartsWithParentDirectoryReference(value)) + { + count++; + value = value.Length == 2 ? "" : value.Substring(3); + } + + return (count, value); + } + + private static bool IsSegmentPrefix(string prefix, string path) + { + if (prefix.Length == 0) return true; + if (!(prefix.Length <= path.Length && + path.StartsWith(prefix, PlatformDefaultPathComparer.DefaultStringComparison))) + return false; + return path.Length == prefix.Length || + prefix[prefix.Length - 1] == Separator || + path[prefix.Length] == Separator; + } + /// public string Value { get; } = PathStrings.Normalize(value); @@ -140,6 +166,12 @@ public override bool Equals(object? obj) /// reference. /// /// + /// 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 ../..). + /// + /// /// An absolute path is never a prefix of a relative one, and vice versa: such a comparison /// would require resolving the relative path against the current directory, which this type never does. Any /// pair of paths differing in is reported as unrelated. @@ -160,17 +192,21 @@ public bool IsPrefixOf(LocalPath other) // field or property and matched on here. if (IsAbsolute != other.IsAbsolute) return false; - // The empty path is the current directory, so every path at or below it has it as a prefix - but one - // starting with a ".." reference points outside it. Normalization only ever keeps such references at the - // very start of a path, so testing the first segment is enough. - if (Value.Length == 0) return !StartsWithParentDirectoryReference(other.Value); + if (IsAbsolute) return IsSegmentPrefix(Value, other.Value); - if (!(Value.Length <= other.Value.Length && - other.Value.StartsWith(Value, PlatformDefaultPathComparer.DefaultStringComparison))) - return false; - return other.Value.Length == Value.Length || - Value[Value.Length - 1] == Separator || - other.Value[Value.Length] == Separator; + // 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, rest) = SplitLeadingParentReferences(Value); + var (otherParentReferences, otherRest) = SplitLeadingParentReferences(other.Value); + + // 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 && rest.Length == 0; + + return IsSegmentPrefix(rest, otherRest); } ///