From da11cb64d0a4341e9d4305a447ee713fb140d69f Mon Sep 17 00:00:00 2001 From: t957095 Date: Fri, 18 Sep 2026 17:28:20 -0500 Subject: [PATCH 01/13] fix: LocalPath.IsPrefixOf for root paths A root path like 'A:\' (Windows) or '/' (Unix) ends with the directory separator, so the next character in the child path is not a separator and the old check failed. Adds the same 'value ends with separator' clause that AbsolutePath.IsPrefixOf already has, plus the rooted-path test from the issue and a Unix InlineData row. Closes ForNeVeR/TruePath#221 --- TruePath.Tests/LocalPathTests.cs | 9 +++++++++ TruePath/LocalPath.cs | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/TruePath.Tests/LocalPathTests.cs b/TruePath.Tests/LocalPathTests.cs index baeeb3d..fc7eeee 100644 --- a/TruePath.Tests/LocalPathTests.cs +++ b/TruePath.Tests/LocalPathTests.cs @@ -92,11 +92,20 @@ public void AbsolutePathIsNormalizedOnCreation() [InlineData("/foo", "/foo1/bar/", false)] [InlineData("/foo", "/foo1", false)] [InlineData("/foo", "/foo", true)] + [InlineData("/", "/frob", true)] public void IsPrefixOf(string prefix, string other, bool result) { Assert.Equal(result, new LocalPath(prefix).IsPrefixOf(new LocalPath(other))); } + [Fact] + public void IsPrefixOfRooted() + { + var root = new LocalPath(OperatingSystem.IsWindows() ? @"A:\" : "/"); + var subRoot = root / "frob"; + Assert.True(root.IsPrefixOf(subRoot)); + } + [Fact] public void RelativePathIsNormalizedOnCreation() { diff --git a/TruePath/LocalPath.cs b/TruePath/LocalPath.cs index a19d929..9d49b69 100644 --- a/TruePath/LocalPath.cs +++ b/TruePath/LocalPath.cs @@ -121,7 +121,9 @@ public override bool Equals(object? obj) public bool IsPrefixOf(LocalPath other) { if (!(Value.Length <= other.Value.Length && other.Value.StartsWith(Value))) return false; - return other.Value.Length == Value.Length || other.Value[Value.Length] == Separator; + return other.Value.Length == Value.Length || + Value[Value.Length - 1] == Separator || + other.Value[Value.Length] == Separator; } /// From 06fa1886c3da5d3a4772679fe67ab7637f3173a5 Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sat, 19 Sep 2026 23:12:47 +0200 Subject: [PATCH 02/13] PathStrings: fix the doc on normalizing "." and "a/.." --- TruePath/PathStrings.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/TruePath/PathStrings.cs b/TruePath/PathStrings.cs index 8f3ca29..1054176 100644 --- a/TruePath/PathStrings.cs +++ b/TruePath/PathStrings.cs @@ -28,10 +28,12 @@ public static class PathStrings /// / on Unix), /// /// - /// resolving any sequence of current and parent directory marks (subsequently, . and ..) - /// 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/../. will - /// be resolved to just foo), + /// resolving any sequence of current and parent directory references (subsequently, . and + /// ..) if possible (e.g. foo/../. 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 references that cannot be resolved are preserved, + /// since there is nothing above them to fold into: .. and ../.. are not affected by the + /// normalization, /// /// /// trimming all trailing separators (e.g. a/b/c/d//// is normalized to From 3256cc46a632d6da1a4f739f8fb55596865d89aa Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sat, 19 Sep 2026 23:13:26 +0200 Subject: [PATCH 03/13] LocalPath: document the empty path as the current directory --- TruePath/LocalPath.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/TruePath/LocalPath.cs b/TruePath/LocalPath.cs index 9d49b69..c00a229 100644 --- a/TruePath/LocalPath.cs +++ b/TruePath/LocalPath.cs @@ -10,6 +10,11 @@ namespace TruePath; /// A path pointing to a place in the local file system. /// It may be either absolute or relative. /// +/// An empty path designates the current directory. Normalizing an empty string, . or +/// a/.. produces it, and so does of a relative path consisting of a single segment +/// (e.g. the parent of foo). +/// +/// /// Always stored in a normalized form. Read the documentation on to /// know what form of normalization the path uses. /// @@ -54,6 +59,10 @@ public readonly struct LocalPath(string value) : IEquatable, ICompara public bool IsAbsolute => Path.IsPathRooted(Value); /// + /// + /// The parent of a relative path consisting of a single segment is the empty path (the current + /// directory), not . + /// public LocalPath? Parent { get From 08a8696bb97d7fe057ab536e326e412099eee246 Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sat, 19 Sep 2026 23:14:12 +0200 Subject: [PATCH 04/13] fix: LocalPath.IsPrefixOf no longer throws on an empty path An empty path is the normalized form of "", "." and "a/..", and is also the parent of any single-segment relative path, so it reaches IsPrefixOf easily. The trailing-separator check added by the first commit of PR #223 indexed Value[-1] on it and threw IndexOutOfRangeException. It now answers true: the empty path is the current directory, and so a prefix of every relative path. --- TruePath.Tests/LocalPathTests.cs | 13 +++++++++++++ TruePath/LocalPath.cs | 5 +++++ 2 files changed, 18 insertions(+) diff --git a/TruePath.Tests/LocalPathTests.cs b/TruePath.Tests/LocalPathTests.cs index fc7eeee..a600300 100644 --- a/TruePath.Tests/LocalPathTests.cs +++ b/TruePath.Tests/LocalPathTests.cs @@ -93,11 +93,24 @@ public void AbsolutePathIsNormalizedOnCreation() [InlineData("/foo", "/foo1", false)] [InlineData("/foo", "/foo", true)] [InlineData("/", "/frob", true)] + [InlineData("", "foo", true)] + [InlineData(".", "foo", true)] + [InlineData(".", "", true)] + [InlineData("a/..", "foo/bar", true)] public void IsPrefixOf(string prefix, string other, bool result) { Assert.Equal(result, new LocalPath(prefix).IsPrefixOf(new LocalPath(other))); } + [Fact] + public void EmptyParentIsPrefixOfAnyRelativePath() + { + var parent = new LocalPath("foo").Parent; + Assert.NotNull(parent); + Assert.Equal("", parent.Value.Value); + Assert.True(parent.Value.IsPrefixOf(new LocalPath("bar"))); + } + [Fact] public void IsPrefixOfRooted() { diff --git a/TruePath/LocalPath.cs b/TruePath/LocalPath.cs index c00a229..d61a2db 100644 --- a/TruePath/LocalPath.cs +++ b/TruePath/LocalPath.cs @@ -127,10 +127,15 @@ public override bool Equals(object? obj) public static LocalPath Create(string value) => new(value); /// + /// + /// An empty path designates the current directory, and is therefore a prefix of every relative path + /// (including itself). + /// public bool IsPrefixOf(LocalPath other) { if (!(Value.Length <= other.Value.Length && other.Value.StartsWith(Value))) return false; return other.Value.Length == Value.Length || + Value.Length == 0 || Value[Value.Length - 1] == Separator || other.Value[Value.Length] == Separator; } From e59da5f83b1a9c0a38e1e7130edac7ec3694e893 Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sat, 19 Sep 2026 23:15:07 +0200 Subject: [PATCH 05/13] LocalPath.IsPrefixOf: require matching absoluteness An absolute path is never a prefix of a relative one, nor the other way round: deciding that would mean resolving the relative path against the current directory, which this library never does. Previously the answer fell out of an incidental string comparison, so "." was reported as a prefix of "/foo" (every string starts with the empty one). The IsAbsolute check is only an approximation of the four kinds of path a LocalPath can hold; see the TODOs for #224. The ordinal comparison it guards disagrees with Equals on Windows and macOS: TODO for #225. --- TruePath.Tests/LocalPathTests.cs | 4 ++++ TruePath/LocalPath.cs | 28 ++++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/TruePath.Tests/LocalPathTests.cs b/TruePath.Tests/LocalPathTests.cs index a600300..e97cfc1 100644 --- a/TruePath.Tests/LocalPathTests.cs +++ b/TruePath.Tests/LocalPathTests.cs @@ -97,6 +97,10 @@ public void AbsolutePathIsNormalizedOnCreation() [InlineData(".", "foo", true)] [InlineData(".", "", true)] [InlineData("a/..", "foo/bar", true)] + [InlineData(".", "/foo", false)] + [InlineData("", "/foo", false)] + [InlineData("/", "foo", false)] + [InlineData("/foo", "foo/bar", false)] public void IsPrefixOf(string prefix, string other, bool result) { Assert.Equal(result, new LocalPath(prefix).IsPrefixOf(new LocalPath(other))); diff --git a/TruePath/LocalPath.cs b/TruePath/LocalPath.cs index d61a2db..b188121 100644 --- a/TruePath/LocalPath.cs +++ b/TruePath/LocalPath.cs @@ -56,6 +56,8 @@ public readonly struct LocalPath(string value) : IEquatable, ICompara /// will be an additional requirement for a path to be either a DOS device path or start from a disk letter. /// /// + // TODO[#224]: narrowing this to true absolute paths (kind 1 in the taxonomy at IsPrefixOf) requires updating + // IsPrefixOf in the same change: it relies on this property to tell rooted paths from relative ones. public bool IsAbsolute => Path.IsPathRooted(Value); /// @@ -128,11 +130,33 @@ public override bool Equals(object? obj) /// /// - /// An empty path designates the current directory, and is therefore a prefix of every relative path - /// (including itself). + /// + /// An empty path designates the current directory, and is therefore a prefix of every relative path + /// (including itself). + /// + /// + /// 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. + /// /// public bool IsPrefixOf(LocalPath other) { + // TODO[#224]: IsAbsolute is Path.IsPathRooted, which is too coarse for this algorithm. On Windows there are + // really four kinds of path, and no path of one kind should ever be considered a prefix of a path of another: + // 1. true absolute: C:\Windows + // 2. rooted diskless: \Windows + // 3. current on disk: C: (and C:Windows, relative to the current directory of drive C:) + // 4. true relative: Windows, ..\Windows + // AbsolutePath exists to cover kind 1 only, while LocalPath is applicable to all four. IsPathRooted answers + // true for kinds 1, 2 and 3 alike, so the check below only separates {1, 2, 3} from {4}. When IsAbsolute is + // eventually narrowed to kind 1 - as its own documentation anticipates - this comparison must not simply + // follow it: it needs the full four-way distinction. The path kind should then be extracted into a separate + // field or property and matched on here. + if (IsAbsolute != other.IsAbsolute) return false; + + // TODO[#225]: this comparison is ordinal, while Equals follows the platform default, so on Windows and macOS + // two paths may be equal and yet not prefixes of each other. if (!(Value.Length <= other.Value.Length && other.Value.StartsWith(Value))) return false; return other.Value.Length == Value.Length || Value.Length == 0 || From 858779f8356bd64733b22f40c34cdd650d0444a3 Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sat, 19 Sep 2026 23:15:45 +0200 Subject: [PATCH 06/13] StartsWith: define as the exact mirror of IsPrefixOf Both types implemented StartsWith as a raw string test, so it ignored segment boundaries: "/foo1".StartsWith("/foo") was true while "/foo".IsPrefixOf("/foo1") was false. Issue #43 asked for the two to be mirrors of each other, so express one through the other. BREAKING: StartsWith now compares whole path segments on both LocalPath and AbsolutePath. --- TruePath.Tests/LocalPathTests.cs | 8 ++++++-- TruePath/AbsolutePath.cs | 2 +- TruePath/IPath.cs | 23 ++++++++++++++++++++++- TruePath/LocalPath.cs | 2 +- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/TruePath.Tests/LocalPathTests.cs b/TruePath.Tests/LocalPathTests.cs index e97cfc1..d377b62 100644 --- a/TruePath.Tests/LocalPathTests.cs +++ b/TruePath.Tests/LocalPathTests.cs @@ -101,9 +101,13 @@ public void AbsolutePathIsNormalizedOnCreation() [InlineData("", "/foo", false)] [InlineData("/", "foo", false)] [InlineData("/foo", "foo/bar", false)] - public void IsPrefixOf(string prefix, string other, bool result) + public void IsPrefixOfAndStartsWith(string prefix, string other, bool result) { - Assert.Equal(result, new LocalPath(prefix).IsPrefixOf(new LocalPath(other))); + var a = new LocalPath(prefix); + var b = new LocalPath(other); + + Assert.Equal(result, a.IsPrefixOf(b)); + Assert.Equal(result, b.StartsWith(a)); } [Fact] diff --git a/TruePath/AbsolutePath.cs b/TruePath/AbsolutePath.cs index eb71479..2693ff2 100644 --- a/TruePath/AbsolutePath.cs +++ b/TruePath/AbsolutePath.cs @@ -85,7 +85,7 @@ public AbsolutePath PathRoot() => public string FileName => Underlying.FileName; /// - public bool StartsWith(AbsolutePath other) => Value.StartsWith(other.Value); + public bool StartsWith(AbsolutePath other) => other.IsPrefixOf(this); /// /// Creates a new path instance of type from the specified string value. diff --git a/TruePath/IPath.cs b/TruePath/IPath.cs index e7f01b1..6a61d03 100644 --- a/TruePath/IPath.cs +++ b/TruePath/IPath.cs @@ -38,7 +38,16 @@ public interface IPath where TPath : IPath #endif /// - /// Checks for a non-strict prefix: if the paths are equal, then they are still considered prefixes of each other. + /// Checks for a non-strict prefix: if the paths are equal, then they are still considered prefixes of each other, + /// so every path is a prefix of itself. + /// + /// + /// The comparison is performed over whole path segments: /foo is not a prefix of + /// /foo1, even though one path string starts with the other. + /// + /// + /// This is the exact inverse of : a.IsPrefixOf(b) means the same as + /// b.StartsWith(a). /// /// Note that currently this comparison is case-sensitive. bool IsPrefixOf(TPath other); @@ -47,6 +56,18 @@ public interface IPath where TPath : IPath /// Determines whether the current path starts with the specified path. /// /// The path to compare to the current path. + /// + /// Checks for a non-strict prefix: if the paths are equal, then each still starts with the other, so every path + /// starts with itself. + /// + /// + /// The comparison is performed over whole path segments: /foo1 does not start with + /// /foo, even though one path string starts with the other. + /// + /// + /// This is the exact inverse of : a.StartsWith(b) means the same as + /// b.IsPrefixOf(a). + /// /// Note that currently this comparison is case-sensitive. bool StartsWith(TPath other); diff --git a/TruePath/LocalPath.cs b/TruePath/LocalPath.cs index b188121..8886b33 100644 --- a/TruePath/LocalPath.cs +++ b/TruePath/LocalPath.cs @@ -119,7 +119,7 @@ public override bool Equals(object? obj) } /// - public bool StartsWith(LocalPath other) => Value.StartsWith(other.Value); + public bool StartsWith(LocalPath other) => other.IsPrefixOf(this); /// /// Creates a new instance from the specified string value. From 61f8e5c0d0338610e7d2e15f48862a8a974c6da6 Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sat, 19 Sep 2026 23:16:16 +0200 Subject: [PATCH 07/13] AbsolutePath: reuse the prefix matching from LocalPath AbsolutePath already wraps a LocalPath and delegates Value, Parent and FileName to it, but carried a character-for-character copy of the prefix algorithm, so every fix had to be made twice. Delegate instead. The empty-path and absoluteness branches are unreachable here, since an AbsolutePath is always rooted and never empty. IsPrefixOfShouldBeEquivalentToStartsWith compared against the string method rather than the path one, which is the very semantics that just changed; it now tests the property its name claims, on both platforms. --- TruePath.Tests/AbsolutePathTests.cs | 4 +--- TruePath/AbsolutePath.cs | 8 +------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/TruePath.Tests/AbsolutePathTests.cs b/TruePath.Tests/AbsolutePathTests.cs index c31c266..aaa74c4 100644 --- a/TruePath.Tests/AbsolutePathTests.cs +++ b/TruePath.Tests/AbsolutePathTests.cs @@ -149,15 +149,13 @@ public void ParentIsCalculatedCorrectly(string relativePath, string? expectedRel [InlineData("/home/user/documents", "/home/user")] public void IsPrefixOfShouldBeEquivalentToStartsWith(string pathA, string pathB) { - if (OperatingSystem.IsWindows()) return; - // Arrange var a = new AbsolutePath(pathA); var b = new AbsolutePath(pathB); // Act var isPrefix = a.IsPrefixOf(b); - var startsWith = b.Value.StartsWith(a.Value); + var startsWith = b.StartsWith(a); // Assert Assert.Equal(isPrefix, startsWith); diff --git a/TruePath/AbsolutePath.cs b/TruePath/AbsolutePath.cs index 2693ff2..4632b78 100644 --- a/TruePath/AbsolutePath.cs +++ b/TruePath/AbsolutePath.cs @@ -95,13 +95,7 @@ public AbsolutePath PathRoot() => public static AbsolutePath Create(string value) => new(value); /// - public bool IsPrefixOf(AbsolutePath other) - { - if (!(Value.Length <= other.Value.Length && other.Value.StartsWith(Value))) return false; - return other.Value.Length == Value.Length || - Value[Value.Length - 1] == Path.DirectorySeparatorChar || - other.Value[Value.Length] == Path.DirectorySeparatorChar; - } + public bool IsPrefixOf(AbsolutePath other) => Underlying.IsPrefixOf(other.Underlying); /// Gets or sets the current working directory as an AbsolutePath instance. /// The current working directory. From ec08aab706c50bb05fc90cbc7b7613187e74672e Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sat, 19 Sep 2026 23:16:28 +0200 Subject: [PATCH 08/13] Docs: update the changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 629fc43..8724a42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Changed +- **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 returns `false` whenever the two paths differ in absoluteness: an absolute path is never a prefix of a relative one, nor the other way round. Previously, the result depended on an incidental string comparison. + +### Fixed +- `LocalPath.IsPrefixOf` now correctly reports a root path (`/` or `X:\`) as a prefix of its children, and no longer throws on an empty path. + ## [1.12.0] - 2026-03-14 ### Changed - Update the dependencies. From 9947577b7bf7c1fef52c38d6ee799f0034da2a61 Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sun, 20 Sep 2026 01:28:17 +0200 Subject: [PATCH 09/13] fix: the empty path is not a prefix of a path escaping upwards The empty path is the current directory, but the arm added for it in this PR answered true for any relative path, so "" (equally ".", "a/.." or the parent of any single-segment relative path) was reported as a prefix of "../evil", which sits outside the current directory. A caller using IsPrefixOf or StartsWith as a containment check accepted the escape. Reported as ../evil rather than found by the tests, none of which exercised a ".." path. --- TruePath.Tests/LocalPathTests.cs | 13 ++++++++++++- TruePath/LocalPath.cs | 15 ++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/TruePath.Tests/LocalPathTests.cs b/TruePath.Tests/LocalPathTests.cs index d377b62..1658bea 100644 --- a/TruePath.Tests/LocalPathTests.cs +++ b/TruePath.Tests/LocalPathTests.cs @@ -101,6 +101,9 @@ public void AbsolutePathIsNormalizedOnCreation() [InlineData("", "/foo", false)] [InlineData("/", "foo", false)] [InlineData("/foo", "foo/bar", false)] + [InlineData("", "../evil", false)] + [InlineData(".", "..", false)] + [InlineData("", "..bar", true)] public void IsPrefixOfAndStartsWith(string prefix, string other, bool result) { var a = new LocalPath(prefix); @@ -111,7 +114,7 @@ public void IsPrefixOfAndStartsWith(string prefix, string other, bool result) } [Fact] - public void EmptyParentIsPrefixOfAnyRelativePath() + public void EmptyParentIsPrefixOfDescendantPath() { var parent = new LocalPath("foo").Parent; Assert.NotNull(parent); @@ -119,6 +122,14 @@ public void EmptyParentIsPrefixOfAnyRelativePath() Assert.True(parent.Value.IsPrefixOf(new LocalPath("bar"))); } + [Fact] + public void EmptyParentIsNotPrefixOfPathEscapingUpwards() + { + var parent = new LocalPath("foo").Parent; + Assert.NotNull(parent); + Assert.False(parent.Value.IsPrefixOf(new LocalPath("../evil"))); + } + [Fact] public void IsPrefixOfRooted() { diff --git a/TruePath/LocalPath.cs b/TruePath/LocalPath.cs index 8886b33..0876a4a 100644 --- a/TruePath/LocalPath.cs +++ b/TruePath/LocalPath.cs @@ -46,6 +46,10 @@ public readonly struct LocalPath(string value) : IEquatable, ICompara private static char Separator => Path.DirectorySeparatorChar; + private static bool StartsWithParentDirectoryReference(string value) => + value.Length >= 2 && value[0] == '.' && value[1] == '.' + && (value.Length == 2 || value[2] == Separator); + /// public string Value { get; } = PathStrings.Normalize(value); @@ -131,8 +135,9 @@ public override bool Equals(object? obj) /// /// /// - /// An empty path designates the current directory, and is therefore a prefix of every relative path - /// (including itself). + /// An empty path designates the current directory. It is a prefix of every relative path that stays + /// at or below that directory, which means every relative path that does not begin with a .. + /// reference. /// /// /// An absolute path is never a prefix of a relative one, and vice versa: such a comparison @@ -155,11 +160,15 @@ 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); + // TODO[#225]: this comparison is ordinal, while Equals follows the platform default, so on Windows and macOS // two paths may be equal and yet not prefixes of each other. if (!(Value.Length <= other.Value.Length && other.Value.StartsWith(Value))) return false; return other.Value.Length == Value.Length || - Value.Length == 0 || Value[Value.Length - 1] == Separator || other.Value[Value.Length] == Separator; } From efc9f5691ae111a2c9b85d0a9d0576c3211e09d9 Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sun, 20 Sep 2026 01:28:49 +0200 Subject: [PATCH 10/13] LocalPath.IsPrefixOf: compare path strings ordinally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-argument string.StartsWith overload compares under the current culture, which ignores collation-ignorable characters, so "­foo" was accepted as a prefix of "fooo/bar" - and likewise for AbsolutePath, which now delegates here. This also makes the TODO for #225 true as written: what remains is the mismatch between this ordinal comparison and the platform-default one that Equals uses. --- TruePath.Tests/LocalPathTests.cs | 2 ++ TruePath/LocalPath.cs | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/TruePath.Tests/LocalPathTests.cs b/TruePath.Tests/LocalPathTests.cs index 1658bea..d5c877b 100644 --- a/TruePath.Tests/LocalPathTests.cs +++ b/TruePath.Tests/LocalPathTests.cs @@ -104,6 +104,8 @@ public void AbsolutePathIsNormalizedOnCreation() [InlineData("", "../evil", false)] [InlineData(".", "..", false)] [InlineData("", "..bar", true)] + [InlineData("\u00ADfoo", "fooo/bar", false)] + [InlineData("\u00ADfoo", "\u00ADfoo/bar", true)] public void IsPrefixOfAndStartsWith(string prefix, string other, bool result) { var a = new LocalPath(prefix); diff --git a/TruePath/LocalPath.cs b/TruePath/LocalPath.cs index 0876a4a..350b0a1 100644 --- a/TruePath/LocalPath.cs +++ b/TruePath/LocalPath.cs @@ -167,7 +167,8 @@ public bool IsPrefixOf(LocalPath other) // TODO[#225]: this comparison is ordinal, while Equals follows the platform default, so on Windows and macOS // two paths may be equal and yet not prefixes of each other. - if (!(Value.Length <= other.Value.Length && other.Value.StartsWith(Value))) return false; + if (!(Value.Length <= other.Value.Length && other.Value.StartsWith(Value, StringComparison.Ordinal))) + return false; return other.Value.Length == Value.Length || Value[Value.Length - 1] == Separator || other.Value[Value.Length] == Separator; From 7b2e34d9feabb5cad2b87132f7163e24f3e00c83 Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sun, 20 Sep 2026 01:29:31 +0200 Subject: [PATCH 11/13] AbsolutePathTests: assert the expected result, not a tautology Once StartsWith is defined as other.IsPrefixOf(this), comparing a.IsPrefixOf(b) with b.StartsWith(a) compares a value with itself, so the test could not fail for any input. Give every row an expected result, and assert both directions against it: the equivalence is still stated, but it is now falsifiable. --- TruePath.Tests/AbsolutePathTests.cs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/TruePath.Tests/AbsolutePathTests.cs b/TruePath.Tests/AbsolutePathTests.cs index aaa74c4..95dd2f4 100644 --- a/TruePath.Tests/AbsolutePathTests.cs +++ b/TruePath.Tests/AbsolutePathTests.cs @@ -143,22 +143,19 @@ public void ParentIsCalculatedCorrectly(string relativePath, string? expectedRel } [Theory] - [InlineData("/home/user", "/home/user/documents")] - [InlineData("/home/usEr", "/home/User/documents")] - [InlineData("/home/user/documents", "/home/user/documents")] - [InlineData("/home/user/documents", "/home/user")] - public void IsPrefixOfShouldBeEquivalentToStartsWith(string pathA, string pathB) + [InlineData("/home/user", "/home/user/documents", true)] + [InlineData("/home/usEr", "/home/User/documents", false)] + [InlineData("/home/user/documents", "/home/user/documents", true)] + [InlineData("/home/user/documents", "/home/user", false)] + public void IsPrefixOfShouldBeEquivalentToStartsWith(string pathA, string pathB, bool expected) { // Arrange var a = new AbsolutePath(pathA); var b = new AbsolutePath(pathB); - // Act - var isPrefix = a.IsPrefixOf(b); - var startsWith = b.StartsWith(a); - // Assert - Assert.Equal(isPrefix, startsWith); + Assert.Equal(expected, a.IsPrefixOf(b)); + Assert.Equal(expected, b.StartsWith(a)); } [Theory] From 947f198b91a25238352913f874ff76f9bff414b4 Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sun, 20 Sep 2026 01:29:46 +0200 Subject: [PATCH 12/13] Docs: correct the changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8724a42..6f9d872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed - **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 returns `false` whenever the two paths differ in absoluteness: an absolute path is never a prefix of a relative one, nor the other way round. Previously, the result depended on an incidental string comparison. +- `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. Previously, it was a prefix of nothing but itself. ### Fixed -- `LocalPath.IsPrefixOf` now correctly reports a root path (`/` or `X:\`) as a prefix of its children, and no longer throws on an empty path. +- `LocalPath.IsPrefixOf` now correctly reports a root path (`/` or `X:\`) as a prefix of its children. +- `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. ## [1.12.0] - 2026-03-14 ### Changed From fe14595c58d8686c956df0b71a9546546d70de1b Mon Sep 17 00:00:00 2001 From: Friedrich von Never Date: Sun, 20 Sep 2026 01:39:17 +0200 Subject: [PATCH 13/13] Docs: drop the redundant details --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f9d872..6ee3ea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Changed - **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 returns `false` whenever the two paths differ in absoluteness: an absolute path is never a prefix of a relative one, nor the other way round. Previously, the result depended on an incidental string comparison. -- `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. Previously, it was a prefix of nothing but itself. +- `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 -- `LocalPath.IsPrefixOf` now correctly reports a root path (`/` or `X:\`) as a prefix of its children. - `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. ## [1.12.0] - 2026-03-14