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..fa1cea3 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,40 @@ public void IsPrefixOfShouldBeEquivalentToStartsWith(string pathA, string pathB, Assert.Equal(expected, b.StartsWith(a)); } + [Fact] + public void IsPrefixOfFollowsPlatformCaseSensitivityForSameName() + { + var root = new AbsolutePath(OperatingSystem.IsWindows() ? @"A:\" : "/"); + var a = root / "Foo"; + var b = root / "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 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] [InlineData("sub", "sub/a.txt", true)] [InlineData("sub", "sub", true)] diff --git a/TruePath.Tests/LocalPathTests.cs b/TruePath.Tests/LocalPathTests.cs index d5c877b..ff07080 100644 --- a/TruePath.Tests/LocalPathTests.cs +++ b/TruePath.Tests/LocalPathTests.cs @@ -70,7 +70,7 @@ public void IsPrefixOfShouldBeEquivalentToStartsWith(string pathA, string pathB) // Act var isPrefix = a.IsPrefixOf(b); - var startsWith = b.Value.StartsWith(a.Value); + var startsWith = b.StartsWith(a); // Assert Assert.Equal(isPrefix, startsWith); @@ -115,6 +115,37 @@ public void IsPrefixOfAndStartsWith(string prefix, string other, bool result) Assert.Equal(result, b.StartsWith(a)); } + [Fact] + public void IsPrefixOfFollowsPlatformCaseSensitivityForSameName() + { + 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.False(a.IsPrefixOf(b)); + Assert.False(b.StartsWith(a)); + } + [Fact] public void EmptyParentIsPrefixOfDescendantPath() { 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; 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 ||