From 31f4fb2ab1c897d06a2033cf6bde34124b88c6b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 16:31:15 +0000 Subject: [PATCH] [patch] Strip every trailing separator when canonicalizing a path SemanticPath.MakeCanonical removed at most one trailing directory separator, so "/home/user/a//" canonicalized to "/home/user/a/". That was not equal to "/home/user/a", and IsChildOf reported it as a child of itself. The trim now loops until the path ends in a non-separator or is a root, so "//" and "C:\\" still canonicalize to the root. Fixes ktsu-dev/Semantics#308 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Co2rZq6e7TjwF2FMUzNoXu --- Semantics.Paths/SemanticPath.cs | 14 ++++++----- Semantics.Test/Paths/PathContainmentTests.cs | 25 ++++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/Semantics.Paths/SemanticPath.cs b/Semantics.Paths/SemanticPath.cs index 3d7a6bb..b41daa7 100644 --- a/Semantics.Paths/SemanticPath.cs +++ b/Semantics.Paths/SemanticPath.cs @@ -116,9 +116,9 @@ protected override string MakeCanonical(string input) // Normalize directory separators to the current platform's preferred separator canonical = canonical.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); - // Remove trailing directory separator (except for root paths) + // Remove every trailing directory separator, so "/a//" is "/a", but stop at a root path string separator = new([Path.DirectorySeparatorChar]); - if (canonical.EndsWith(separator) && canonical.Length > separator.Length) + while (canonical.EndsWith(separator) && canonical.Length > separator.Length) { // Check if this is a Windows root path (e.g., "C:\") #if NET5_0_OR_GREATER @@ -139,14 +139,16 @@ protected override string MakeCanonical(string input) #endif // Only remove trailing separator if it's not a root path - if (!isWindowsRoot && !isUnixRoot) + if (isWindowsRoot || isUnixRoot) { + break; + } + #if NETSTANDARD2_0 - canonical = canonical.Substring(0, canonical.Length - separator.Length); + canonical = canonical.Substring(0, canonical.Length - separator.Length); #else - canonical = canonical[..^separator.Length]; + canonical = canonical[..^separator.Length]; #endif - } } return canonical; diff --git a/Semantics.Test/Paths/PathContainmentTests.cs b/Semantics.Test/Paths/PathContainmentTests.cs index 8966c89..12e3712 100644 --- a/Semantics.Test/Paths/PathContainmentTests.cs +++ b/Semantics.Test/Paths/PathContainmentTests.cs @@ -97,4 +97,29 @@ public void SiblingSharingANamePrefix_IsNotAChild() Assert.IsFalse(File(TestPaths.Absolute("home2.txt")).IsChildOf(home)); Assert.IsFalse(home.IsParentOf(Dir(TestPaths.Absolute("home2")))); } + + [TestMethod] + [DataRow(2)] + [DataRow(3)] + public void RepeatedTrailingSeparators_CanonicalizeToTheSameDirectory(int separatorCount) + { + string plain = TestPaths.Absolute("home", "user", "a"); + AbsoluteDirectoryPath doubled = Dir(plain + new string(TestPaths.Separator, separatorCount)); + AbsoluteDirectoryPath single = Dir(plain); + + Assert.AreEqual(plain, doubled.WeakString); + Assert.AreEqual(single, doubled); + Assert.IsFalse(doubled.IsChildOf(single)); + Assert.IsFalse(single.IsChildOf(doubled)); + Assert.IsFalse(single.IsParentOf(doubled)); + } + + [TestMethod] + public void RootWithRepeatedTrailingSeparators_StaysTheRoot() + { + AbsoluteDirectoryPath root = Dir(TestPaths.Root + TestPaths.Separator + TestPaths.Separator); + + Assert.AreEqual(TestPaths.Root, root.WeakString); + Assert.IsFalse(root.IsChildOf(Dir(TestPaths.Root))); + } }