From c00d0cd8d2785fd4dd2583df0c61f27d9d7d97fd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 05:32:42 +0000 Subject: [PATCH 1/2] [patch] Compute RelativePath.Make by comparing paths, not URIs Make built System.Uri values from file-system paths and unescaped the result, so a literal percent sequence in a name was decoded: a folder named "%2E%2E" came back as "..", escaping the base directory. Make now uses Path.GetRelativePath (through PathPolyfill), resolving a non-directory "from" from the directory that contains it as before. The netstandard2.0 fallback, which had the same Uri flaw, now compares path segments, so it also stops adding a trailing separator to file targets and returns "." for a directory relative to itself. Fixes #315 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A5MkmzQ4qxxRmHStdQ8oJA --- Semantics.Paths/Polyfills.cs | 74 +++++++++++++------ Semantics.Paths/SemanticRelativePath.cs | 46 +++--------- Semantics.Test/Paths/RelativePathMakeTests.cs | 61 +++++++++++++++ 3 files changed, 123 insertions(+), 58 deletions(-) create mode 100644 Semantics.Test/Paths/RelativePathMakeTests.cs diff --git a/Semantics.Paths/Polyfills.cs b/Semantics.Paths/Polyfills.cs index a05f1c1d..f682b0f9 100644 --- a/Semantics.Paths/Polyfills.cs +++ b/Semantics.Paths/Polyfills.cs @@ -3,7 +3,9 @@ namespace ktsu.Semantics.Paths; using System; +using System.Collections.Generic; using System.IO; +using System.Runtime.InteropServices; /// /// Provides polyfill methods for Path class for older frameworks. @@ -16,35 +18,71 @@ internal static class PathPolyfill /// The source path the result should be relative to. /// The destination path. /// The relative path, or path if the paths don't share the same root. - public static string GetRelativePath(string relativeTo, string path) - { + public static string GetRelativePath(string relativeTo, string path) => #if NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER - return Path.GetRelativePath(relativeTo, path); + Path.GetRelativePath(relativeTo, path); #else - // Fallback implementation for netstandard2.0 + GetRelativePathBySegments(relativeTo, path); +#endif + + /// + /// Computes a relative path by comparing path segments, for targets without 's own + /// GetRelativePath. Compiled on every target so it can be tested against that method. + /// + /// + /// A file-system path is not a URI: building one from a path decodes a literal %2E in a file + /// name into ., so the result can name a different file, or climb out of the base directory. + /// Comparing segments keeps every name exactly as written. + /// + /// The directory the result should be relative to. + /// The destination path. + /// The relative path, . when both name the same directory, or when the roots differ. + internal static string GetRelativePathBySegments(string relativeTo, string path) + { relativeTo = Path.GetFullPath(relativeTo); path = Path.GetFullPath(path); - Uri fromUri = new(AppendDirectorySeparatorChar(relativeTo)); - Uri toUri = new(AppendDirectorySeparatorChar(path)); + // Matches Path.GetRelativePath: case-insensitive where the platform's file system usually is. + StringComparison comparison = + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; - if (fromUri.Scheme != toUri.Scheme) + string fromRoot = Path.GetPathRoot(relativeTo) ?? string.Empty; + string toRoot = Path.GetPathRoot(path) ?? string.Empty; + if (!string.Equals(TrimSeparators(fromRoot), TrimSeparators(toRoot), comparison)) { return path; } - Uri relativeUri = fromUri.MakeRelativeUri(toUri); - string relativePath = Uri.UnescapeDataString(relativeUri.ToString()); + char[] separators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]; + string[] fromSegments = relativeTo.Substring(fromRoot.Length).Split(separators, StringSplitOptions.RemoveEmptyEntries); + string[] toSegments = path.Substring(toRoot.Length).Split(separators, StringSplitOptions.RemoveEmptyEntries); - if (string.Equals(toUri.Scheme, Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase)) + int common = 0; + while (common < fromSegments.Length && common < toSegments.Length + && string.Equals(fromSegments[common], toSegments[common], comparison)) { - relativePath = relativePath.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + common++; } - return relativePath; -#endif + List result = []; + for (int i = common; i < fromSegments.Length; i++) + { + result.Add(".."); + } + + for (int i = common; i < toSegments.Length; i++) + { + result.Add(toSegments[i]); + } + + return result.Count == 0 ? "." : string.Join(Path.DirectorySeparatorChar.ToString(), result); } + private static string TrimSeparators(string root) => + root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + /// /// Returns a value that indicates whether a path is fully qualified. /// @@ -126,16 +164,6 @@ public static string GetFullPath(string path, string basePath) } #if !(NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER) - private static string AppendDirectorySeparatorChar(string path) - { - if (!path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)) - { - return path + Path.DirectorySeparatorChar; - } - - return path; - } - private static bool IsDirectorySeparator(char c) => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar; #endif diff --git a/Semantics.Paths/SemanticRelativePath.cs b/Semantics.Paths/SemanticRelativePath.cs index b0cfa851..c7e4a80a 100644 --- a/Semantics.Paths/SemanticRelativePath.cs +++ b/Semantics.Paths/SemanticRelativePath.cs @@ -20,45 +20,21 @@ public static TRelativePath Make(TFromPath fr Ensure.NotNull(from); Ensure.NotNull(to); - FileInfo fromInfo = new(Path.GetFullPath(from.WeakString)); - FileInfo toInfo = new(Path.GetFullPath(to.WeakString)); + string fromPath = Path.GetFullPath(from.WeakString); + string toPath = Path.GetFullPath(to.WeakString); + + // A path that is not a directory is resolved from the directory that contains it. + string baseDirectory = IsDirectoryPath(from) + ? fromPath + : Path.GetDirectoryName(fromPath) ?? fromPath; + + // Compare paths, not URIs: a URI decodes a literal "%2E" in a file name, which can name a + // different file or climb out of the base directory. + string relativePath = PathPolyfill.GetRelativePath(baseDirectory, toPath); // Use unix-style separators because they work on windows too const string separator = "/"; const string altSeparator = "\\"; - - string fromPath = Path.GetFullPath(fromInfo.FullName); -#if NETSTANDARD2_0 - fromPath = StringPolyfill.Replace(fromPath, altSeparator, separator, StringComparison.Ordinal); -#else - fromPath = fromPath.Replace(altSeparator, separator, StringComparison.Ordinal); -#endif - string toPath = Path.GetFullPath(toInfo.FullName); -#if NETSTANDARD2_0 - toPath = StringPolyfill.Replace(toPath, altSeparator, separator, StringComparison.Ordinal); -#else - toPath = toPath.Replace(altSeparator, separator, StringComparison.Ordinal); -#endif - - // Handle directory paths - ensure they end with separator - bool fromIsDirectory = IsDirectoryPath(from); - bool toIsDirectory = IsDirectoryPath(to); - - if (fromIsDirectory && !fromPath.EndsWith(separator, StringComparison.Ordinal)) - { - fromPath += separator; - } - - if (toIsDirectory && !toPath.EndsWith(separator, StringComparison.Ordinal)) - { - toPath += separator; - } - - Uri fromUri = new(fromPath); - Uri toUri = new(toPath); - - Uri relativeUri = fromUri.MakeRelativeUri(toUri); - string relativePath = Uri.UnescapeDataString(relativeUri.ToString()); #if NETSTANDARD2_0 relativePath = StringPolyfill.Replace(relativePath, altSeparator, separator, StringComparison.Ordinal); #else diff --git a/Semantics.Test/Paths/RelativePathMakeTests.cs b/Semantics.Test/Paths/RelativePathMakeTests.cs new file mode 100644 index 00000000..00b890bf --- /dev/null +++ b/Semantics.Test/Paths/RelativePathMakeTests.cs @@ -0,0 +1,61 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Test.Paths; + +using System.IO; +using ktsu.Semantics.Paths; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +public class RelativePathMakeTests +{ + // '%' is an ordinary file-name character on every platform, so none of these is an escape. + [TestMethod] + [DataRow("%2E%2E")] + [DataRow("v1%2E0")] + [DataRow("100%41")] + [DataRow("%7Ebackup")] + [DataRow("50%20off")] + public void Make_KeepsPercentSequencesInNamesAsWritten(string folder) + { + string fromValue = TestPaths.Absolute("home", "u", "proj"); + string toValue = TestPaths.Absolute("home", "u", "proj", folder, "notes.txt"); + AbsoluteDirectoryPath from = AbsoluteDirectoryPath.Create(fromValue); + AbsoluteFilePath to = AbsoluteFilePath.Create(toValue); + + RelativePath relative = RelativePath.Make(from, to); + + Assert.AreEqual(Path.Combine(folder, "notes.txt"), relative.WeakString); + Assert.AreEqual(toValue, Path.GetFullPath(Path.Combine(fromValue, relative.WeakString))); + } + + [TestMethod] + public void Make_FromAFile_IsRelativeToItsDirectory() + { + AbsoluteFilePath from = AbsoluteFilePath.Create(TestPaths.Absolute("base", "folder", "a.txt")); + AbsoluteFilePath to = AbsoluteFilePath.Create(TestPaths.Absolute("base", "other", "b.txt")); + + RelativePath relative = RelativePath.Make(from, to); + + Assert.AreEqual(Path.Combine("..", "other", "b.txt"), relative.WeakString); + } + + [TestMethod] + [DataRow(new[] { "home", "u", "proj" }, new[] { "home", "u", "proj", "%2E%2E", "notes.txt" })] + [DataRow(new[] { "home", "u", "proj" }, new[] { "home", "u", "proj", "src", "file.cs" })] + [DataRow(new[] { "home", "u", "proj" }, new[] { "home", "u", "proj", "src" })] + [DataRow(new[] { "home", "u", "proj" }, new[] { "home", "u", "proj" })] + [DataRow(new[] { "home", "u", "proj", "a", "b" }, new[] { "home", "u", "other", "c" })] + [DataRow(new[] { "home", "u", "proj" }, new[] { "home" })] + [DataRow(new[] { "home", "u", "proj" }, new[] { "home", "u", "proj with space", "x" })] + [DataRow(new string[0], new[] { "home", "u" })] + public void SegmentFallback_MatchesPathGetRelativePath(string[] from, string[] to) + { + string fromValue = TestPaths.Absolute(from); + string toValue = TestPaths.Absolute(to); + + Assert.AreEqual( + Path.GetRelativePath(fromValue, toValue), + PathPolyfill.GetRelativePathBySegments(fromValue, toValue)); + } +} From 495c40aca638b7e5f3df0e89712d5f5e1a479e5e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 05:37:38 +0000 Subject: [PATCH 2/2] Build expected paths with Path.Join in RelativePathMakeTests Path.Combine drops earlier arguments when a later one is rooted, which would mask a rooted result from Make. Path.Join keeps every segment. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01A5MkmzQ4qxxRmHStdQ8oJA --- Semantics.Test/Paths/RelativePathMakeTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Semantics.Test/Paths/RelativePathMakeTests.cs b/Semantics.Test/Paths/RelativePathMakeTests.cs index 00b890bf..515bbd38 100644 --- a/Semantics.Test/Paths/RelativePathMakeTests.cs +++ b/Semantics.Test/Paths/RelativePathMakeTests.cs @@ -25,8 +25,8 @@ public void Make_KeepsPercentSequencesInNamesAsWritten(string folder) RelativePath relative = RelativePath.Make(from, to); - Assert.AreEqual(Path.Combine(folder, "notes.txt"), relative.WeakString); - Assert.AreEqual(toValue, Path.GetFullPath(Path.Combine(fromValue, relative.WeakString))); + Assert.AreEqual(Path.Join(folder, "notes.txt"), relative.WeakString); + Assert.AreEqual(toValue, Path.GetFullPath(Path.Join(fromValue, relative.WeakString))); } [TestMethod] @@ -37,7 +37,7 @@ public void Make_FromAFile_IsRelativeToItsDirectory() RelativePath relative = RelativePath.Make(from, to); - Assert.AreEqual(Path.Combine("..", "other", "b.txt"), relative.WeakString); + Assert.AreEqual(Path.Join("..", "other", "b.txt"), relative.WeakString); } [TestMethod]