diff --git a/CHANGELOG.md b/CHANGELOG.md index 629fc43..6ee3ea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ 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. +- `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` 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 - Update the dependencies. diff --git a/TruePath.Tests/AbsolutePathTests.cs b/TruePath.Tests/AbsolutePathTests.cs index c31c266..95dd2f4 100644 --- a/TruePath.Tests/AbsolutePathTests.cs +++ b/TruePath.Tests/AbsolutePathTests.cs @@ -143,24 +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) { - 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); - // Assert - Assert.Equal(isPrefix, startsWith); + Assert.Equal(expected, a.IsPrefixOf(b)); + Assert.Equal(expected, b.StartsWith(a)); } [Theory] diff --git a/TruePath.Tests/LocalPathTests.cs b/TruePath.Tests/LocalPathTests.cs index baeeb3d..d5c877b 100644 --- a/TruePath.Tests/LocalPathTests.cs +++ b/TruePath.Tests/LocalPathTests.cs @@ -92,9 +92,52 @@ public void AbsolutePathIsNormalizedOnCreation() [InlineData("/foo", "/foo1/bar/", false)] [InlineData("/foo", "/foo1", false)] [InlineData("/foo", "/foo", true)] - public void IsPrefixOf(string prefix, string other, bool result) + [InlineData("/", "/frob", true)] + [InlineData("", "foo", true)] + [InlineData(".", "foo", true)] + [InlineData(".", "", true)] + [InlineData("a/..", "foo/bar", true)] + [InlineData(".", "/foo", false)] + [InlineData("", "/foo", false)] + [InlineData("/", "foo", false)] + [InlineData("/foo", "foo/bar", false)] + [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) { - 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] + public void EmptyParentIsPrefixOfDescendantPath() + { + 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 EmptyParentIsNotPrefixOfPathEscapingUpwards() + { + var parent = new LocalPath("foo").Parent; + Assert.NotNull(parent); + Assert.False(parent.Value.IsPrefixOf(new LocalPath("../evil"))); + } + + [Fact] + public void IsPrefixOfRooted() + { + var root = new LocalPath(OperatingSystem.IsWindows() ? @"A:\" : "/"); + var subRoot = root / "frob"; + Assert.True(root.IsPrefixOf(subRoot)); } [Fact] diff --git a/TruePath/AbsolutePath.cs b/TruePath/AbsolutePath.cs index eb71479..4632b78 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. @@ -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. 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 a19d929..350b0a1 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. /// @@ -41,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); @@ -51,9 +60,15 @@ 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); /// + /// + /// The parent of a relative path consisting of a single segment is the empty path (the current + /// directory), not . + /// public LocalPath? Parent { get @@ -108,7 +123,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. @@ -118,10 +133,45 @@ public override bool Equals(object? obj) public static LocalPath Create(string value) => new(value); /// + /// + /// + /// 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 + /// 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) { - if (!(Value.Length <= other.Value.Length && other.Value.StartsWith(Value))) return false; - return other.Value.Length == Value.Length || other.Value[Value.Length] == Separator; + // 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; + + // 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, StringComparison.Ordinal))) + return false; + return other.Value.Length == Value.Length || + Value[Value.Length - 1] == Separator || + other.Value[Value.Length] == Separator; } /// 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