From 86568fea02e54fc76dc903a15c9b1bbac135dba5 Mon Sep 17 00:00:00 2001 From: amirani8137 Date: Sat, 19 Sep 2026 18:48:00 -0700 Subject: [PATCH 1/2] Align path prefix checks with platform case sensitivity --- CHANGELOG.md | 1 + TruePath.Tests/AbsolutePathTests.cs | 17 ++++++++++++++++- TruePath.Tests/LocalPathTests.cs | 19 ++++++++++++++++++- TruePath/AbsolutePath.cs | 2 +- .../Comparers/PlatformDefaultPathComparer.cs | 19 +++++++------------ TruePath/IPath.cs | 4 ++-- TruePath/LocalPath.cs | 7 +++---- 7 files changed, 48 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ee3ea5..606ed38 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 +- [#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. ## [1.12.0] - 2026-03-14 diff --git a/TruePath.Tests/AbsolutePathTests.cs b/TruePath.Tests/AbsolutePathTests.cs index 95dd2f4..69d0bd7 100644 --- a/TruePath.Tests/AbsolutePathTests.cs +++ b/TruePath.Tests/AbsolutePathTests.cs @@ -144,7 +144,6 @@ public void ParentIsCalculatedCorrectly(string relativePath, string? expectedRel [Theory] [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) @@ -158,6 +157,22 @@ public void IsPrefixOfShouldBeEquivalentToStartsWith(string pathA, string pathB, Assert.Equal(expected, b.StartsWith(a)); } + [Theory] + [InlineData("Foo", "foo")] + [InlineData("Foo", "foo/file.txt")] + [InlineData("Foo", "foobar")] + public void IsPrefixOfFollowsPlatformDefaultCaseSensitivity(string prefix, string other) + { + var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/"); + var a = root / prefix; + var b = root / other; + var expected = (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()) && other != "foobar"; + + Assert.Equal(expected, a.IsPrefixOf(b)); + Assert.Equal(expected, b.StartsWith(a)); + if (other == "foo") Assert.Equal(a == b, a.IsPrefixOf(b)); + } + [Theory] [InlineData("sub", "sub/a.txt", true)] [InlineData("sub", "sub", true)] diff --git a/TruePath.Tests/LocalPathTests.cs b/TruePath.Tests/LocalPathTests.cs index d5c877b..8cfe723 100644 --- a/TruePath.Tests/LocalPathTests.cs +++ b/TruePath.Tests/LocalPathTests.cs @@ -70,7 +70,9 @@ public void IsPrefixOfShouldBeEquivalentToStartsWith(string pathA, string pathB) // Act var isPrefix = a.IsPrefixOf(b); - var startsWith = b.Value.StartsWith(a.Value); + var startsWith = b.Value.StartsWith(a.Value, OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); // Assert Assert.Equal(isPrefix, startsWith); @@ -115,6 +117,21 @@ public void IsPrefixOfAndStartsWith(string prefix, string other, bool result) Assert.Equal(result, b.StartsWith(a)); } + [Theory] + [InlineData("Foo", "foo")] + [InlineData("Foo", "foo/bar")] + [InlineData("Foo", "foobar")] + public void IsPrefixOfFollowsPlatformDefaultCaseSensitivity(string prefix, string other) + { + var a = new LocalPath(prefix); + var b = new LocalPath(other); + var expected = (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()) && other != "foobar"; + + Assert.Equal(expected, a.IsPrefixOf(b)); + Assert.Equal(expected, b.StartsWith(a)); + if (other == "foo") Assert.Equal(a == b, a.IsPrefixOf(b)); + } + [Fact] public void EmptyParentIsPrefixOfDescendantPath() { diff --git a/TruePath/AbsolutePath.cs b/TruePath/AbsolutePath.cs index 4632b78..cbbb336 100644 --- a/TruePath/AbsolutePath.cs +++ b/TruePath/AbsolutePath.cs @@ -137,7 +137,7 @@ public static AbsolutePath CurrentWorkingDirectory public override string ToString() => Value; /// Compares the path with another. - /// Note that currently this comparison is case-sensitive. + /// Uses for platform-default case sensitivity. public bool Equals(AbsolutePath other) => Equals(other, PlatformDefaultComparer); /// diff --git a/TruePath/Comparers/PlatformDefaultPathComparer.cs b/TruePath/Comparers/PlatformDefaultPathComparer.cs index e5bb5af..b751c6a 100644 --- a/TruePath/Comparers/PlatformDefaultPathComparer.cs +++ b/TruePath/Comparers/PlatformDefaultPathComparer.cs @@ -21,19 +21,14 @@ namespace TruePath.Comparers; /// internal class PlatformDefaultPathComparer : IPathComparer where TPath : IPath { - private readonly StringComparer _stringComparer; + internal static readonly StringComparison DefaultStringComparison = + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; - public PlatformDefaultPathComparer() - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - _stringComparer = StringComparer.OrdinalIgnoreCase; - } - else - { - _stringComparer = StringComparer.Ordinal; - } - } + private readonly StringComparer _stringComparer = DefaultStringComparison == StringComparison.OrdinalIgnoreCase + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; public bool Equals(TPath? x, TPath? y) { diff --git a/TruePath/IPath.cs b/TruePath/IPath.cs index 6a61d03..99cfa69 100644 --- a/TruePath/IPath.cs +++ b/TruePath/IPath.cs @@ -49,7 +49,7 @@ public interface IPath where TPath : IPath /// This is the exact inverse of : a.IsPrefixOf(b) means the same as /// b.StartsWith(a). /// - /// Note that currently this comparison is case-sensitive. + /// Case sensitivity follows the path type's platform-default comparer. bool IsPrefixOf(TPath other); /// @@ -68,7 +68,7 @@ public interface IPath where TPath : IPath /// This is the exact inverse of : a.StartsWith(b) means the same as /// b.IsPrefixOf(a). /// - /// Note that currently this comparison is case-sensitive. + /// Case sensitivity follows the path type's platform-default comparer. bool StartsWith(TPath other); #if NET8_0_OR_GREATER diff --git a/TruePath/LocalPath.cs b/TruePath/LocalPath.cs index 350b0a1..8d4496b 100644 --- a/TruePath/LocalPath.cs +++ b/TruePath/LocalPath.cs @@ -88,7 +88,7 @@ public LocalPath? Parent public override string ToString() => Value; /// Compares the path with another. - /// Note that currently this comparison is case-sensitive. + /// Uses for platform-default case sensitivity. public bool Equals(LocalPath other) => Equals(other, PlatformDefaultComparer); /// @@ -165,9 +165,8 @@ public bool IsPrefixOf(LocalPath other) // 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, StringComparison.Ordinal))) + 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 || From 68357ac7caa959980414d3db717033b4a960aeef Mon Sep 17 00:00:00 2001 From: amirani8137 Date: Sun, 20 Sep 2026 15:46:02 -0700 Subject: [PATCH 2/2] Tests: split case-sensitivity prefix tests, use StartsWith as oracle Co-Authored-By: Claude Sonnet 5 --- TruePath.Tests/AbsolutePathTests.cs | 40 +++++++++++++++++++-------- TruePath.Tests/LocalPathTests.cs | 42 +++++++++++++++++++---------- TruePath.Tests/Utils.cs | 3 +++ 3 files changed, 60 insertions(+), 25 deletions(-) diff --git a/TruePath.Tests/AbsolutePathTests.cs b/TruePath.Tests/AbsolutePathTests.cs index 69d0bd7..fa1cea3 100644 --- a/TruePath.Tests/AbsolutePathTests.cs +++ b/TruePath.Tests/AbsolutePathTests.cs @@ -157,20 +157,38 @@ public void IsPrefixOfShouldBeEquivalentToStartsWith(string pathA, string pathB, Assert.Equal(expected, b.StartsWith(a)); } - [Theory] - [InlineData("Foo", "foo")] - [InlineData("Foo", "foo/file.txt")] - [InlineData("Foo", "foobar")] - public void IsPrefixOfFollowsPlatformDefaultCaseSensitivity(string prefix, string other) + [Fact] + public void IsPrefixOfFollowsPlatformCaseSensitivityForSameName() { var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/"); - var a = root / prefix; - var b = root / other; - var expected = (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()) && other != "foobar"; + var a = root / "Foo"; + var b = root / "foo"; - Assert.Equal(expected, a.IsPrefixOf(b)); - Assert.Equal(expected, b.StartsWith(a)); - if (other == "foo") Assert.Equal(a == b, a.IsPrefixOf(b)); + Assert.Equal(Utils.IsPlatformCaseInsensitive(), a.IsPrefixOf(b)); + Assert.Equal(Utils.IsPlatformCaseInsensitive(), b.StartsWith(a)); + Assert.Equal(a == b, a.IsPrefixOf(b)); + } + + [Fact] + public void IsPrefixOfFollowsPlatformCaseSensitivityForDescendant() + { + var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/"); + var a = root / "Foo"; + var b = root / "foo/file.txt"; + + Assert.Equal(Utils.IsPlatformCaseInsensitive(), a.IsPrefixOf(b)); + Assert.Equal(Utils.IsPlatformCaseInsensitive(), b.StartsWith(a)); + } + + [Fact] + public void IsPrefixOfRequiresWholeSegmentRegardlessOfCase() + { + var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/"); + var a = root / "Foo"; + var b = root / "foobar"; + + Assert.False(a.IsPrefixOf(b)); + Assert.False(b.StartsWith(a)); } [Theory] diff --git a/TruePath.Tests/LocalPathTests.cs b/TruePath.Tests/LocalPathTests.cs index 8cfe723..ff07080 100644 --- a/TruePath.Tests/LocalPathTests.cs +++ b/TruePath.Tests/LocalPathTests.cs @@ -70,9 +70,7 @@ public void IsPrefixOfShouldBeEquivalentToStartsWith(string pathA, string pathB) // Act var isPrefix = a.IsPrefixOf(b); - var startsWith = b.Value.StartsWith(a.Value, OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal); + var startsWith = b.StartsWith(a); // Assert Assert.Equal(isPrefix, startsWith); @@ -117,19 +115,35 @@ public void IsPrefixOfAndStartsWith(string prefix, string other, bool result) Assert.Equal(result, b.StartsWith(a)); } - [Theory] - [InlineData("Foo", "foo")] - [InlineData("Foo", "foo/bar")] - [InlineData("Foo", "foobar")] - public void IsPrefixOfFollowsPlatformDefaultCaseSensitivity(string prefix, string other) + [Fact] + public void IsPrefixOfFollowsPlatformCaseSensitivityForSameName() { - var a = new LocalPath(prefix); - var b = new LocalPath(other); - var expected = (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()) && other != "foobar"; + var a = new LocalPath("Foo"); + var b = new LocalPath("foo"); + + Assert.Equal(Utils.IsPlatformCaseInsensitive(), a.IsPrefixOf(b)); + Assert.Equal(Utils.IsPlatformCaseInsensitive(), b.StartsWith(a)); + Assert.Equal(a == b, a.IsPrefixOf(b)); + } + + [Fact] + public void IsPrefixOfFollowsPlatformCaseSensitivityForDescendant() + { + var a = new LocalPath("Foo"); + var b = new LocalPath("foo/bar"); + + Assert.Equal(Utils.IsPlatformCaseInsensitive(), a.IsPrefixOf(b)); + Assert.Equal(Utils.IsPlatformCaseInsensitive(), b.StartsWith(a)); + } + + [Fact] + public void IsPrefixOfRequiresWholeSegmentRegardlessOfCase() + { + var a = new LocalPath("Foo"); + var b = new LocalPath("foobar"); - Assert.Equal(expected, a.IsPrefixOf(b)); - Assert.Equal(expected, b.StartsWith(a)); - if (other == "foo") Assert.Equal(a == b, a.IsPrefixOf(b)); + Assert.False(a.IsPrefixOf(b)); + Assert.False(b.StartsWith(a)); } [Fact] diff --git a/TruePath.Tests/Utils.cs b/TruePath.Tests/Utils.cs index ccc8111..1535e33 100644 --- a/TruePath.Tests/Utils.cs +++ b/TruePath.Tests/Utils.cs @@ -23,6 +23,9 @@ internal static string ToNonCanonicalCase(this string path) return nonCanonicalPath; } + internal static bool IsPlatformCaseInsensitive() => + OperatingSystem.IsWindows() || OperatingSystem.IsMacOS(); + internal static bool RunsOnCi() { return Environment.GetEnvironmentVariable("CI") is not null;