diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e67f8..73f67ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - [#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. - Path normalization no longer drops the root separator when a rooted path ends with `..`: `C:\base\..` is now normalized to `C:\` (was `C:`, a path relative to the current directory of drive `C:`), and `/base/..` to `/` (was an empty path, i.e. the current directory). This affects `PathStrings.Normalize`, `LocalPath` and `AbsolutePath`. +- [#95](https://github.com/ForNeVeR/TruePath/issues/95): Path normalization now drops a `..` directly after the root, since the root has no parent: `/..` is normalized to `/`, and `C:\..\x` to `C:\x`, the same as `Path.GetFullPath` does. Thanks to @sinthu-14 for help! - `LocalPath.RelativeTo` no longer throws an exception when either path is empty (i.e. designates the current directory). - On .NET Standard 2.0 (e.g., .NET Framework), `LocalPath.RelativeTo` and `AbsolutePath.RelativeTo` now use a port of the .NET runtime's `Path.GetRelativePath`, and return the same results as on .NET 8+ (except for the stream syntax, see [#235](https://github.com/ForNeVeR/TruePath/issues/235)). Previously, the result was wrong for a destination equal to or above the base path, and for names containing `%XX` sequences. diff --git a/TruePath.Tests/AbsolutePathTests.cs b/TruePath.Tests/AbsolutePathTests.cs index 489d8f0..b8302d1 100644 --- a/TruePath.Tests/AbsolutePathTests.cs +++ b/TruePath.Tests/AbsolutePathTests.cs @@ -19,18 +19,18 @@ public void ConstructionTest() [Fact] public void PathRootReturnsRoot() { - var root = Utils.SyntheticRoot; - var path = root / "foo" / "bar"; + var root = Utils.SyntheticRoot; + var path = root / "foo" / "bar"; - Assert.Equal(root, path.PathRoot); + Assert.Equal(root, path.PathRoot); } [Fact] public void PathRootOfRootReturnsItself() { - var root = Utils.SyntheticRoot; + var root = Utils.SyntheticRoot; - Assert.Equal(root, root.PathRoot); + Assert.Equal(root, root.PathRoot); } [Fact] @@ -245,6 +245,65 @@ public void PathIsNormalizedOnCreation() Assert.Equal(@"C:\Users\John Doe\Documents", absolutePath.Value); } + /// + /// Appends (written with /) to , using + /// the separators of the current platform. + /// + private static string SyntheticRootedPath(string relativePath) => + Utils.SyntheticRootString + relativePath.Replace('/', Path.DirectorySeparatorChar); + + [Theory] + [InlineData(".")] + [InlineData("./.")] + [InlineData("././.")] + public void ConstructorDropsCurrentDirectoryReferenceAfterRoot(string relativePath) + { + var absolutePath = new AbsolutePath(Utils.SyntheticRootString + relativePath); + + Assert.Equal(Utils.SyntheticRootString, absolutePath.Value); + } + + [Theory] + [InlineData("...")] + [InlineData("..SomeFolder")] + [InlineData("..00")] + [InlineData("..#")] + public void ConstructorKeepsNamesStartingWithDotsAfterRoot(string relativePath) + { + var absolutePath = new AbsolutePath(Utils.SyntheticRootString + relativePath); + + Assert.Equal(SyntheticRootedPath(relativePath), absolutePath.Value); + } + + [Theory] + [InlineData("../", "")] + [InlineData("../..", "")] + [InlineData("../../SomeFolder", "SomeFolder")] + [InlineData("../..SomeFolder", "..SomeFolder")] + [InlineData("../.SomeFolder", ".SomeFolder")] + [InlineData("../SomeFolder", "SomeFolder")] + [InlineData("../1123", "1123")] + [InlineData("../()", "()")] + [InlineData("./..", "")] + [InlineData("./../.", "")] + public void ConstructorDropsParentReferenceAfterRoot(string relativePath, string expectedRelativePath) + { + var absolutePath = new AbsolutePath(Utils.SyntheticRootString + relativePath); + + Assert.Equal(SyntheticRootedPath(expectedRelativePath), absolutePath.Value); + } + + [Fact] + public void AppendParentReferenceToRootReturnsRoot() + { + var root = Utils.SyntheticRoot; + + Assert.Equal(root, root / ".."); + Assert.Equal(root, root / ".." / ".."); + Assert.Equal(root / "x", root / ".." / "x"); + Assert.Equal(root / "x", root / "../x"); + } + [Fact] public void ConstructorThrowsOnNonRootedPath() { @@ -591,5 +650,43 @@ public void CurrentWorkingDirectoryGetsChanged() using var _ = Utils.ChangeCurrentDirectory(path); Assert.Equal(path, new AbsolutePath(Environment.CurrentDirectory)); } + + [Fact] + public void AppendPathAboveRootOfAnotherDriveStaysAtRootOnWindows() + { + if (!OperatingSystem.IsWindows()) return; + + // The current directory of the drive of the process current directory is the current directory itself. + var root = AbsolutePath.CurrentWorkingDirectory.PathRoot; + using var _ = Utils.ChangeCurrentDirectory(root); + var drive = root.Value.Substring(0, 2); + var basePath = Utils.NonCurrentSyntheticRoot / "base"; + + Assert.Equal(root / "x", basePath / (drive + @"..\x")); + Assert.Equal(root, basePath / (drive + "..")); + } + + [Fact] + public void AppendPathAboveCurrentDirectoryOfAnotherDriveResolvesItOnWindows() + { + if (!OperatingSystem.IsWindows()) return; + + // A subdirectory of the temporary directory always has a parent, even if the temporary directory is a root. + var directory = Temporary.CreateTempFolder().Canonicalize(); + try + { + using (Utils.ChangeCurrentDirectory(directory)) + { + var drive = directory.Value.Substring(0, 2); + var basePath = Utils.NonCurrentSyntheticRoot / "base"; + + Assert.Equal(directory.Parent!.Value / "x", basePath / (drive + @"..\x")); + } + } + finally + { + Directory.Delete(directory.Value); + } + } } } diff --git a/TruePath.Tests/LocalPathTests.cs b/TruePath.Tests/LocalPathTests.cs index 550f150..dd5f3e0 100644 --- a/TruePath.Tests/LocalPathTests.cs +++ b/TruePath.Tests/LocalPathTests.cs @@ -10,6 +10,8 @@ public class LocalPathTests [InlineData("foo", ".")] [InlineData("foo/bar", "foo")] [InlineData("/", null)] + [InlineData("..", null)] + [InlineData("../foo", ".")] public void AbsolutePathParent(string relativePath, string? expectedRelativePath) { var root = Utils.SyntheticRoot; @@ -18,6 +20,18 @@ public void AbsolutePathParent(string relativePath, string? expectedRelativePath Assert.Equal(expectedPath, parent.Parent); } + [Theory] + [InlineData("..")] + [InlineData("../..")] + [InlineData("../foo/..")] + public void RootedPathAboveRootHasNoParent(string relativePath) + { + var path = new LocalPath(Utils.SyntheticRootString + relativePath); + + Assert.Equal(Utils.SyntheticRootString, path.Value); + Assert.Null(path.Parent); + } + [Theory] [InlineData(".", "")] [InlineData("..", "..")] diff --git a/TruePath.Tests/PathStringsTests.cs b/TruePath.Tests/PathStringsTests.cs index 8627c47..f1166ba 100644 --- a/TruePath.Tests/PathStringsTests.cs +++ b/TruePath.Tests/PathStringsTests.cs @@ -56,9 +56,11 @@ public void SeparatorsAreDeduplicated(string input, string expected) [InlineData("foo/./bar/../var/./dar/..", "foo/var")] [InlineData("foo/.bar", "foo/.bar")] [InlineData("/.", "/")] - [InlineData("/..", "/..")] - [InlineData("/../..", "/../..")] - [InlineData("/../../foo/..", "/../..")] + [InlineData("/..", "/")] + [InlineData("/../..", "/")] + [InlineData("/../foo", "/foo")] + [InlineData("/../..foo", "/..foo")] + [InlineData("/../../foo/..", "/")] [InlineData("x/foo/bar/../..", "x")] [InlineData("x/foo/bar/.../.", "x/foo/bar/...")] [InlineData("x/foo/..bar/", "x/foo/..bar")] @@ -91,9 +93,11 @@ public void DotFoldersAreTraversedCorrectly(string input, string expected) [InlineData("a/../../..", "../..")] [InlineData("foo/./bar/../var/./dar/..", "foo/var")] [InlineData("foo/.bar", "foo/.bar")] - [InlineData("/..", "/..")] - [InlineData("/../..", "/../..")] - [InlineData("/../../foo/..", "/../..")] + [InlineData("/..", "/")] + [InlineData("/../..", "/")] + [InlineData("/../foo", "/foo")] + [InlineData("/../..foo", "/..foo")] + [InlineData("/../../foo/..", "/")] [InlineData("x/foo/bar/../..", "x")] [InlineData("x/foo/bar/.../.", "x/foo/bar/...")] [InlineData("x/foo/..bar/", "x/foo/..bar")] @@ -148,7 +152,7 @@ public void Normalize_DriveLetterCheckDisabled_TreatsColonAsRegularPathCharacter [Theory] [InlineData("C:/", "C:/")] - [InlineData("C:/../file", "C:/../file")] + [InlineData("C:/../file", "C:/file")] public void Normalize_DriveLetterCheckEnabled_PreservesWindowsDrivePrefix(string input, string expected) { var actual = PathStrings.Normalize(input, driveBasedSystem: true); @@ -156,11 +160,24 @@ public void Normalize_DriveLetterCheckEnabled_PreservesWindowsDrivePrefix(string Assert.Equal(NormalizeSeparators(expected), actual); } + [Theory] + [InlineData("C:..", "C:..")] + [InlineData(@"C:..\x", @"C:..\x")] + [InlineData(@"C:..\..", @"C:..\..")] + [InlineData(@"\..", @"\")] + [InlineData(@"\..\x", @"\x")] + public void ParentReferenceIsOnlyDroppedAfterRootOnWindows(string input, string expected) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; + + Assert.Equal(expected, PathStrings.Normalize(input)); + } + [Fact] public void Normalize_DriveLetterHandling_MatchesCurrentPlatform() { var actual = PathStrings.Normalize("C:/../file"); - var expected = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "C:/../file" : "file"; + var expected = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "C:/file" : "file"; Assert.Equal(NormalizeSeparators(expected), actual); } diff --git a/TruePath/AbsolutePath.cs b/TruePath/AbsolutePath.cs index fb8d2a2..c66ebdc 100644 --- a/TruePath/AbsolutePath.cs +++ b/TruePath/AbsolutePath.cs @@ -54,7 +54,7 @@ namespace TruePath; /// /// Path string to normalize. /// Flag indicating whether absoluteness of path should be checked - /// Thrown if the passed string does not represent an absolute path.> + /// Thrown if the passed string does not represent an absolute path. internal AbsolutePath(string value, bool checkAbsoluteness) { Underlying = new LocalPath(value); diff --git a/TruePath/LocalPath.cs b/TruePath/LocalPath.cs index 871fffa..454fa2b 100644 --- a/TruePath/LocalPath.cs +++ b/TruePath/LocalPath.cs @@ -148,12 +148,16 @@ public LocalPath? Parent { get { - // For C:foo, the rest after the drive letter is a relative path, and follows the same rules. - var relativePart = Kind == PathKind.DriveCurrentDirectoryRelative ? Value[2..] : Value; - // TODO[#95]: a rooted path ending with ".." (C:\..) gets here as well, so walking up its parents never - // ends. This goes away once the normalization drops a ".." directly after the root. - if (relativePart == "" || relativePart == ".." || relativePart.EndsWith($"{Separator}..")) - return this / ".."; + // Only a relative path can end with "..": the normalization drops a ".." directly after the root. + var kind = Kind; + if (kind is PathKind.Relative or PathKind.DriveCurrentDirectoryRelative) + { + // For C:foo, the rest after the drive letter is a relative path, and follows the same rules. + var relativePart = kind == PathKind.DriveCurrentDirectoryRelative ? Value[2..] : Value; + if (relativePart == "" || relativePart == ".." || relativePart.EndsWith($"{Separator}..")) + return this / ".."; + } + return Path.GetDirectoryName(Value) is { } parent ? new(parent) : null; } } diff --git a/TruePath/PathStrings.cs b/TruePath/PathStrings.cs index 4e23d24..24f17b6 100644 --- a/TruePath/PathStrings.cs +++ b/TruePath/PathStrings.cs @@ -14,6 +14,8 @@ namespace TruePath; public static class PathStrings { private const char VolumeSeparatorChar = ':'; + private static readonly string ParentReferenceWithSeparator = $"..{Path.DirectorySeparatorChar}"; + private static readonly string ParentReferenceWithAltSeparator = $"..{Path.AltDirectorySeparatorChar}"; /// Whether the current platform uses drive letters in paths (i.e. is Windows). internal static readonly bool IsDriveBasedSystem = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); @@ -39,7 +41,9 @@ public static class PathStrings /// 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, + /// normalization. The only exception is a parent directory reference directly after the root: the root + /// has no parent, so such a reference is dropped (e.g. /.. is normalized to /, and + /// C:\..\x to C:\x), /// /// /// trimming all trailing separators (e.g. a/b/c/d//// is normalized to @@ -96,16 +100,16 @@ internal static string Normalize(string path, bool driveBasedSystem) else if (written != 0 && ( block is ".." - || block.SequenceEqual($"..{Path.DirectorySeparatorChar}".AsSpan()) - || block.SequenceEqual($"..{Path.AltDirectorySeparatorChar}".AsSpan()) + || block.SequenceEqual(ParentReferenceWithSeparator.AsSpan()) + || block.SequenceEqual(ParentReferenceWithAltSeparator.AsSpan()) )) { var alreadyWrittenPart = normalized[..(written - 1)]; var jump = alreadyWrittenPart.LastIndexOf(Path.DirectorySeparatorChar); // Check if the last entry in the normalized path is "..": in this case, no need to skip (we keep a - // train of ../../.. in the normalized path's root because they are impossible to get rid of during - // normalization). + // train of ../../.. at the start of a relative path, including C:..\.., because they are impossible + // to get rid of during normalization). var lastEntryStartIndex = jump + 1; var lastEntry = alreadyWrittenPart[lastEntryStartIndex..]; if (lastEntry is "..") @@ -126,9 +130,9 @@ block is ".." skip = true; } else - // TODO[#95]: this keeps a ".." directly after the root (C:\.., /.., \..). The root has no parent, - // so it should be dropped, the same way Path.GetFullPath does: C:\.. is C:\. - skip = false; + // Only the root separator has been written (C:\.., /.., \..). The root has no parent, so the ".." + // is dropped, the same way Path.GetFullPath does: C:\.. is C:\. + skip = true; } else skip = false; @@ -152,7 +156,7 @@ block is ".." // next iter source = source.Slice(separator); - // append everything else if there`s no more '\' or '/' + // append everything else if there's no more '\' or '/' if (last) { source.CopyTo(buffer); @@ -161,14 +165,11 @@ block is ".." } } - if (written == 0 && containsDriveLetter) - { - return path[..2]; - } - if (written == 0) { - return string.Empty; + if (array != null) + ArrayPool.Shared.Return(array); + return containsDriveLetter ? path[..2] : string.Empty; } // remove / at the end of path @@ -188,7 +189,6 @@ block is ".." result = new string(normalized[..written].ToArray()); } - normalized.Slice(0, written); if (array != null) ArrayPool.Shared.Return(array); return result; diff --git a/docs/index.md b/docs/index.md index 7a8ab68..941c4ce 100644 --- a/docs/index.md +++ b/docs/index.md @@ -49,9 +49,11 @@ The paths are stored in the **normalized form**. - All the `Path.AltDirectorySeparatorChar` are converted to `Path.DirectorySeparatorChar` (e.g. `/` to `\` on Windows). - Any repeated separators in the input are collapsed to only one separator (e.g. `//` to just `/` on Unix). -- Any sequence of current and parent directory marks (subsequently, `.` and `..`) is resolved 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/bar/../.` will be resolved to just `foo`). +- Any sequence of current and parent directory marks (subsequently, `.` and `..`) is resolved if possible: e.g. + `foo/bar/../.` 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 marks at the start of a relative path cannot be resolved, + and are preserved: `..` and `../..` are not affected by the normalization. A parent directory mark directly after the + root is dropped, since the root has no parent: `/..` is normalized to `/`, and `C:\..\x` to `C:\x`. - All trailing separators are trimmed (e.g. `a/b/c/d////` is normalized to `a/b/c/d`), except for the case of the root folder: `/` (Unix) or `X:\` (Windows) keeps one final separator (because it would become an empty string or a reference to the current path on disk `X:` without the trailing separator). Note that the normalization operation will not perform any file IO, and is purely string manipulation.