diff --git a/Semantics.Paths/Implementations/AbsoluteDirectoryPath.cs b/Semantics.Paths/Implementations/AbsoluteDirectoryPath.cs index 313c6c1e..615fe7db 100644 --- a/Semantics.Paths/Implementations/AbsoluteDirectoryPath.cs +++ b/Semantics.Paths/Implementations/AbsoluteDirectoryPath.cs @@ -193,49 +193,29 @@ protected override IDirectoryPath CreateDirectoryPath(string directoryPath) => } /// - /// Determines whether this directory is a child of the specified parent path using efficient span comparison. + /// Determines whether this directory is inside the specified parent directory. /// /// The potential parent path to check against. /// if this path is a child of the parent path; otherwise, . /// - /// This method uses span-based comparison for better performance than string concatenation. - /// It normalizes both paths before comparison to handle different separator styles. + /// Both paths are normalized before comparison to handle different separator styles. Case is + /// ignored on Windows and significant on every other platform. Every path below a filesystem + /// root is a child of that root. /// public bool IsChildOf(AbsoluteDirectoryPath parentPath) { Ensure.NotNull(parentPath); - // Get normalized paths using span semantics for comparison - ReadOnlySpan thisPathSpan = Path.GetFullPath(WeakString).AsSpan(); - ReadOnlySpan parentPathSpan = Path.GetFullPath(parentPath.WeakString).AsSpan(); - - // A path cannot be a child of itself - if (thisPathSpan.SequenceEqual(parentPathSpan)) - { - return false; - } - - // Check if this path starts with the parent path followed by a separator - if (!thisPathSpan.StartsWith(parentPathSpan, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - // Ensure there's a separator after the parent path (not just a prefix match) - int nextIndex = parentPathSpan.Length; - return nextIndex < thisPathSpan.Length && - (thisPathSpan[nextIndex] == Path.DirectorySeparatorChar || - thisPathSpan[nextIndex] == Path.AltDirectorySeparatorChar); + return PathContainment.IsStrictlyInside(Path.GetFullPath(WeakString), Path.GetFullPath(parentPath.WeakString)); } /// - /// Determines whether this directory is a parent of the specified child path using efficient span comparison. + /// Determines whether the specified child directory is inside this directory. /// /// The potential child path to check against. /// if this path is a parent of the child path; otherwise, . /// - /// This method uses span-based comparison for better performance than string concatenation. - /// It normalizes both paths before comparison to handle different separator styles. + /// The same check as , with the arguments swapped. /// public bool IsParentOf(AbsoluteDirectoryPath childPath) { diff --git a/Semantics.Paths/Implementations/AbsoluteFilePath.cs b/Semantics.Paths/Implementations/AbsoluteFilePath.cs index f0dd29bd..5584c967 100644 --- a/Semantics.Paths/Implementations/AbsoluteFilePath.cs +++ b/Semantics.Paths/Implementations/AbsoluteFilePath.cs @@ -80,62 +80,20 @@ public AbsoluteFilePath RemoveExtension() } /// - /// Determines whether this path is a child of the specified parent path using efficient span comparison. + /// Determines whether this file is inside the specified parent directory. /// /// The potential parent path to check against. /// if this path is a child of the parent path; otherwise, . /// - /// This method uses span-based comparison for better performance than string concatenation. - /// It normalizes both paths before comparison to handle different separator styles. + /// Both paths are normalized before comparison to handle different separator styles. Case is + /// ignored on Windows and significant on every other platform. Every file below a filesystem + /// root is a child of that root. /// public bool IsChildOf(AbsoluteDirectoryPath parentPath) { Ensure.NotNull(parentPath); - // Get normalized paths using span semantics for comparison -#if NETSTANDARD2_0 - string thisPathSpan = Path.GetFullPath(WeakString); - string parentPathSpan = Path.GetFullPath(parentPath.WeakString); - - // A path cannot be a child of itself - if (string.Equals(thisPathSpan, parentPathSpan, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - // Check if this path starts with the parent path followed by a separator - if (!thisPathSpan.StartsWith(parentPathSpan, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - // Ensure there's a separator after the parent path (not just a prefix match) - int nextIndex = parentPathSpan.Length; - return nextIndex < thisPathSpan.Length && - (thisPathSpan[nextIndex] == Path.DirectorySeparatorChar || - thisPathSpan[nextIndex] == Path.AltDirectorySeparatorChar); -#else - ReadOnlySpan thisPathSpan = Path.GetFullPath(WeakString).AsSpan(); - ReadOnlySpan parentPathSpan = Path.GetFullPath(parentPath.WeakString).AsSpan(); - - // A path cannot be a child of itself - if (thisPathSpan.SequenceEqual(parentPathSpan)) - { - return false; - } - - // Check if this path starts with the parent path followed by a separator - if (!thisPathSpan.StartsWith(parentPathSpan, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - // Ensure there's a separator after the parent path (not just a prefix match) - int nextIndex = parentPathSpan.Length; - return nextIndex < thisPathSpan.Length && - (thisPathSpan[nextIndex] == Path.DirectorySeparatorChar || - thisPathSpan[nextIndex] == Path.AltDirectorySeparatorChar); -#endif + return PathContainment.IsStrictlyInside(Path.GetFullPath(WeakString), Path.GetFullPath(parentPath.WeakString)); } /// diff --git a/Semantics.Paths/Utilities/PathContainment.cs b/Semantics.Paths/Utilities/PathContainment.cs new file mode 100644 index 00000000..5709ff66 --- /dev/null +++ b/Semantics.Paths/Utilities/PathContainment.cs @@ -0,0 +1,61 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Paths; + +using System.IO; +#if !NET5_0_OR_GREATER +using System.Runtime.InteropServices; +#endif + +/// +/// The containment rule shared by , +/// and . +/// +internal static class PathContainment +{ + /// + /// Gets the comparison used to match a parent path against a child path. + /// + /// + /// Windows filesystems ignore case, so there C:\Docs\a is inside C:\docs. Everywhere + /// else, macOS included, paths are compared ordinally, the same way path equality and hashing + /// already are. APFS is case-insensitive by default but can be formatted case-sensitive, so on + /// macOS the ordinal comparison is the one that cannot report a sibling directory as a child. + /// + internal static StringComparison Comparison { get; } = +#if NET5_0_OR_GREATER + OperatingSystem.IsWindows() +#else + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) +#endif + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + + /// + /// Determines whether is strictly inside . + /// + /// The full path of the candidate child. + /// The full path of the candidate parent directory. + /// + /// if the child starts with the parent and continues past a directory + /// boundary; for the same path, a sibling sharing a name prefix, or an + /// unrelated path. + /// + internal static bool IsStrictlyInside(string childFullPath, string parentFullPath) + { + // A path is never inside itself, however its case is spelled, so the child must be longer. + if (childFullPath.Length <= parentFullPath.Length + || !childFullPath.StartsWith(parentFullPath, Comparison)) + { + return false; + } + + // A root ("/", "C:\") already ends in a separator, so anything longer than it is inside it. + // Otherwise the match must end at a separator, so that "/home2" is not inside "/home". + return IsSeparator(parentFullPath[parentFullPath.Length - 1]) + || IsSeparator(childFullPath[parentFullPath.Length]); + } + + private static bool IsSeparator(char c) => + c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar; +} diff --git a/Semantics.Test/Paths/PathContainmentTests.cs b/Semantics.Test/Paths/PathContainmentTests.cs new file mode 100644 index 00000000..8966c899 --- /dev/null +++ b/Semantics.Test/Paths/PathContainmentTests.cs @@ -0,0 +1,100 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Test.Paths; + +using System; +using ktsu.Semantics.Paths; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests for the containment checks , +/// and . +/// +[TestClass] +public class PathContainmentTests +{ + private static AbsoluteDirectoryPath Dir(string path) => AbsoluteDirectoryPath.Create(path); + + private static AbsoluteFilePath File(string path) => AbsoluteFilePath.Create(path); + + // Windows filesystems ignore case, so there a differently-cased directory is the same directory. + // Everywhere else the library compares paths case-sensitively, and so do containment checks. + private static bool CaseInsensitive => OperatingSystem.IsWindows(); + + [TestMethod] + public void DirectoryUnderDifferentlyCasedSibling_IsChildOnlyWhereCaseIsIgnored() + { + AbsoluteDirectoryPath parent = Dir(TestPaths.Absolute("home", "user", "docs")); + AbsoluteDirectoryPath other = Dir(TestPaths.Absolute("home", "user", "DOCS", "report")); + + Assert.AreEqual(CaseInsensitive, other.IsChildOf(parent)); + Assert.AreEqual(CaseInsensitive, parent.IsParentOf(other)); + } + + [TestMethod] + public void FileUnderDifferentlyCasedSibling_IsChildOnlyWhereCaseIsIgnored() + { + AbsoluteDirectoryPath parent = Dir(TestPaths.Absolute("home", "user", "docs")); + AbsoluteFilePath other = File(TestPaths.Absolute("home", "user", "DOCS", "report.txt")); + + Assert.AreEqual(CaseInsensitive, other.IsChildOf(parent)); + } + + [TestMethod] + public void DifferentlyCasedSpellingOfTheSameDirectory_IsNeverAChild() + { + AbsoluteDirectoryPath lower = Dir(TestPaths.Absolute("home", "user", "docs")); + AbsoluteDirectoryPath upper = Dir(TestPaths.Absolute("home", "user", "DOCS")); + + Assert.IsFalse(upper.IsChildOf(lower)); + Assert.IsFalse(lower.IsParentOf(upper)); + } + + [TestMethod] + public void DirectoryDirectlyUnderRoot_IsChildOfRoot() + { + AbsoluteDirectoryPath root = Dir(TestPaths.Root); + AbsoluteDirectoryPath home = Dir(TestPaths.Absolute("home")); + + Assert.IsTrue(home.IsChildOf(root)); + Assert.IsTrue(root.IsParentOf(home)); + Assert.AreEqual(root, home.Parent); + } + + [TestMethod] + public void FileDirectlyUnderRoot_IsChildOfRoot() + { + AbsoluteDirectoryPath root = Dir(TestPaths.Root); + + Assert.IsTrue(File(TestPaths.Absolute("etc.txt")).IsChildOf(root)); + } + + [TestMethod] + public void DeepPath_IsChildOfRoot() + { + AbsoluteDirectoryPath root = Dir(TestPaths.Root); + + Assert.IsTrue(Dir(TestPaths.Absolute("home", "user")).IsChildOf(root)); + Assert.IsTrue(File(TestPaths.Absolute("home", "user", "a.txt")).IsChildOf(root)); + } + + [TestMethod] + public void Root_IsNotAChildOfItself() + { + AbsoluteDirectoryPath root = Dir(TestPaths.Root); + + Assert.IsFalse(root.IsChildOf(root)); + Assert.IsFalse(root.IsParentOf(root)); + } + + [TestMethod] + public void SiblingSharingANamePrefix_IsNotAChild() + { + AbsoluteDirectoryPath home = Dir(TestPaths.Absolute("home")); + + Assert.IsFalse(Dir(TestPaths.Absolute("home2")).IsChildOf(home)); + Assert.IsFalse(Dir(TestPaths.Absolute("home2", "user")).IsChildOf(home)); + Assert.IsFalse(File(TestPaths.Absolute("home2.txt")).IsChildOf(home)); + Assert.IsFalse(home.IsParentOf(Dir(TestPaths.Absolute("home2")))); + } +}