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);
}
///