From 586103368ea81c4246c548e0f574f9fa8ec2300e Mon Sep 17 00:00:00 2001 From: carlossanlop Date: Fri, 21 Feb 2020 20:42:28 -0800 Subject: [PATCH 1/7] Add methods that remove path redundant segments --- .../Tests/System/IO/PathInternal.Tests.cs | 340 ------------ .../src/System/IO/Path.Unix.cs | 2 +- .../src/System/IO/Path.Windows.cs | 2 +- .../src/System/IO/Path.cs | 64 +++ .../src/System/IO/PathInternal.cs | 65 +-- .../System.Runtime.Extensions.Tests.csproj | 1 + .../System/IO/Path.RemoveRedundantSegments.cs | 503 ++++++++++++++++++ 7 files changed, 596 insertions(+), 381 deletions(-) create mode 100644 src/libraries/System.Runtime.Extensions/tests/System/IO/Path.RemoveRedundantSegments.cs diff --git a/src/libraries/Common/tests/Tests/System/IO/PathInternal.Tests.cs b/src/libraries/Common/tests/Tests/System/IO/PathInternal.Tests.cs index 7bfa141a5c58c6..d31d6906fc9c65 100644 --- a/src/libraries/Common/tests/Tests/System/IO/PathInternal.Tests.cs +++ b/src/libraries/Common/tests/Tests/System/IO/PathInternal.Tests.cs @@ -64,345 +64,5 @@ public void GetCommonPathLength(string first, string second, bool ignoreCase, in { Assert.Equal(expected, PathInternal.GetCommonPathLength(first, second, ignoreCase)); } - - public static TheoryData RemoveRelativeSegmentsData => new TheoryData - { - { @"C:\git\runtime", 2, @"C:\git\runtime"}, - { @"C:\\git\runtime", 2, @"C:\git\runtime"}, - { @"C:\git\\runtime", 2, @"C:\git\runtime"}, - { @"C:\git\.\runtime\.\\", 2, @"C:\git\runtime\"}, - { @"C:\git\runtime", 2, @"C:\git\runtime"}, - { @"C:\git\..\runtime", 2, @"C:\runtime"}, - { @"C:\git\runtime\..\", 2, @"C:\git\"}, - { @"C:\git\runtime\..\..\..\", 2, @"C:\"}, - { @"C:\git\runtime\..\..\.\", 2, @"C:\"}, - { @"C:\git\..\.\runtime\temp\..", 2, @"C:\runtime"}, - { @"C:\git\..\\\.\..\runtime", 2, @"C:\runtime"}, - { @"C:\git\runtime\", 2, @"C:\git\runtime\"}, - { @"C:\git\temp\..\runtime\", 2, @"C:\git\runtime\"}, - - { @"C:\.", 3, @"C:\"}, - { @"C:\..", 3, @"C:\"}, - { @"C:\..\..", 3, @"C:\"}, - { @"C:\.", 2, @"C:"}, - { @"C:\..", 2, @"C:"}, - { @"C:\..\..", 2, @"C:"}, - { @"C:A\.", 2, @"C:A"}, - { @"C:A\..", 2, @"C:"}, - { @"C:A\..\..", 2, @"C:"}, - { @"C:A\..\..\..", 2, @"C:"}, - - { @"C:\tmp\home", 3, @"C:\tmp\home" }, - { @"C:\tmp\..", 3, @"C:\" }, - { @"C:\tmp\home\..\.\.\", 3, @"C:\tmp\" }, - { @"C:\tmp\..\..\..\", 3, @"C:\" }, - { @"C:\tmp\\home", 3, @"C:\tmp\home" }, - { @"C:\.\tmp\\home", 3, @"C:\tmp\home" }, - { @"C:\..\tmp\home", 3, @"C:\tmp\home" }, - { @"C:\..\..\..\tmp\.\home", 3, @"C:\tmp\home" }, - { @"C:\\tmp\\\home", 3, @"C:\tmp\home" }, - { @"C:\tmp\home\git\.\..\.\git\runtime\..\", 3, @"C:\tmp\home\git\" }, - { @"C:\.\tmp\home", 3, @"C:\tmp\home" }, - - { @"C:\tmp\home", 6, @"C:\tmp\home" }, - { @"C:\tmp\..", 6, @"C:\tmp" }, - { @"C:\tmp\home\..\.\.\", 5, @"C:\tmp\" }, - { @"C:\tmp\..\..\..\", 6, @"C:\tmp\" }, - { @"C:\tmp\\home", 5, @"C:\tmp\home" }, - { @"C:\.\tmp\\home", 4, @"C:\.\tmp\home" }, - { @"C:\..\tmp\home", 5, @"C:\..\tmp\home" }, - { @"C:\..\..\..\tmp\.\home", 6, @"C:\..\tmp\home" }, - { @"C:\\tmp\\\home", 7, @"C:\\tmp\home" }, - { @"C:\tmp\home\git\.\..\.\git\runtime\..\", 7, @"C:\tmp\home\git\" }, - { @"C:\.\tmp\home", 5, @"C:\.\tmp\home" }, - - { @"C:\tmp\..", 2, @"C:\" }, - { @"C:\tmp\home\..\..\.\", 2, @"C:\" }, - { @"C:\tmp\..\..\..\", 2, @"C:\" }, - { @"C:\tmp\\home", 2, @"C:\tmp\home" }, - { @"C:\.\tmp\\home", 2, @"C:\tmp\home" }, - { @"C:\..\tmp\home", 2, @"C:\tmp\home" }, - { @"C:\..\..\..\tmp\.\home", 2, @"C:\tmp\home" }, - { @"C:\\tmp\\\home", 2, @"C:\tmp\home" }, - { @"C:\tmp\home\git\.\..\.\git\runtime\..\", 2, @"C:\tmp\home\git\" }, - { @"C:\.\tmp\home", 2, @"C:\tmp\home" }, - - { @"C:\tmp\..\..\", 10, @"C:\tmp\..\" }, - { @"C:\tmp\home\..\.\.\", 12, @"C:\tmp\home\" }, - { @"C:\tmp\..\..\..\", 10, @"C:\tmp\..\" }, - { @"C:\tmp\\home\..\.\\", 13, @"C:\tmp\\home\" }, - { @"C:\.\tmp\\home\git\git", 9, @"C:\.\tmp\home\git\git" }, - { @"C:\..\tmp\.\home", 10, @"C:\..\tmp\home" }, - { @"C:\..\..\..\tmp\.\home", 10, @"C:\..\..\..\tmp\home" }, - { @"C:\\tmp\\\home\..", 7, @"C:\\tmp\" }, - { @"C:\tmp\home\git\.\..\.\git\runtime\..\", 18, @"C:\tmp\home\git\.\git\" }, - { @"C:\.\tmp\home\.\.\", 9, @"C:\.\tmp\home\" }, - }; - - public static TheoryData RemoveRelativeSegmentsFirstRelativeSegment => new TheoryData - { - { @"C:\\git\runtime", 2, @"C:\git\runtime"}, - { @"C:\.\git\runtime", 2, @"C:\git\runtime"}, - { @"C:\\.\git\.\runtime", 2, @"C:\git\runtime"}, - { @"C:\..\git\runtime", 2, @"C:\git\runtime"}, - { @"C:\.\git\..\runtime", 2, @"C:\runtime"}, - { @"C:\.\git\runtime\..\", 2, @"C:\git\"}, - { @"C:\.\git\runtime\..\..\..\", 2, @"C:\"}, - { @"C:\.\git\runtime\..\..\.\", 2, @"C:\"}, - { @"C:\.\git\..\.\runtime\temp\..", 2, @"C:\runtime"}, - { @"C:\.\git\..\\\.\..\runtime", 2, @"C:\runtime"}, - { @"C:\.\git\runtime\", 2, @"C:\git\runtime\"}, - { @"C:\.\git\temp\..\runtime\", 2, @"C:\git\runtime\"}, - { @"C:\\..\..", 3, @"C:\"} - }; - - public static TheoryData RemoveRelativeSegmentsSkipAboveRoot => new TheoryData - { - { @"C:\temp\..\" , 7, @"C:\temp\" }, - { @"C:\temp\..\git" , 7, @"C:\temp\git" }, - { @"C:\temp\..\git" , 8, @"C:\temp\git" }, - { @"C:\temp\..\.\" , 8, @"C:\temp\" }, - { @"C:\temp\..\" , 9, @"C:\temp\..\" }, - { @"C:\temp\..\git" , 9, @"C:\temp\..\git" }, - { @"C:\git\..\temp\..\" , 15, @"C:\git\..\temp\" }, - { @"C:\\\.\..\..\temp\..\" , 17, @"C:\\\.\..\..\temp\" }, - }; - - public static TheoryData RemoveRelativeSegmentsFirstRelativeSegmentRoot => new TheoryData - { - { @"C:\\git\runtime", 3, @"C:\git\runtime"}, - { @"C:\.\git\runtime", 3, @"C:\git\runtime"}, - { @"C:\\.\git\.\runtime", 3, @"C:\git\runtime"}, - { @"C:\..\git\runtime", 3, @"C:\git\runtime"}, - { @"C:\.\git\..\runtime", 3, @"C:\runtime"}, - { @"C:\.\git\runtime\..\", 3, @"C:\git\"}, - { @"C:\.\git\runtime\..\..\..\", 3, @"C:\"}, - { @"C:\.\git\runtime\..\..\.\", 3, @"C:\"}, - { @"C:\.\git\..\.\runtime\temp\..", 3, @"C:\runtime"}, - { @"C:\.\git\..\\\.\..\runtime", 3, @"C:\runtime"}, - { @"C:\.\git\runtime\", 3, @"C:\git\runtime\"}, - { @"C:\.\git\temp\..\runtime\", 3, @"C:\git\runtime\"}, - }; - - [Theory, - MemberData(nameof(RemoveRelativeSegmentsData)), - MemberData(nameof(RemoveRelativeSegmentsFirstRelativeSegment)), - MemberData(nameof(RemoveRelativeSegmentsFirstRelativeSegmentRoot)), - MemberData(nameof(RemoveRelativeSegmentsSkipAboveRoot))] - [PlatformSpecific(TestPlatforms.Windows)] - public void RemoveRelativeSegmentsTest(string path, int skip, string expected) - { - Assert.Equal(expected, PathInternal.RemoveRelativeSegments(path, skip)); - Assert.Equal(@"\\.\" + expected, PathInternal.RemoveRelativeSegments(@"\\.\" + path, skip + 4)); - Assert.Equal(@"\\?\" + expected, PathInternal.RemoveRelativeSegments(@"\\?\" + path, skip + 4)); - } - - public static TheoryData RemoveRelativeSegmentsUncData => new TheoryData - { - { @"Server\Share\git\runtime", 12, @"Server\Share\git\runtime"}, - { @"Server\Share\\git\runtime", 12, @"Server\Share\git\runtime"}, - { @"Server\Share\git\\runtime", 12, @"Server\Share\git\runtime"}, - { @"Server\Share\git\.\runtime\.\\", 12, @"Server\Share\git\runtime\"}, - { @"Server\Share\git\runtime", 12, @"Server\Share\git\runtime"}, - { @"Server\Share\git\..\runtime", 12, @"Server\Share\runtime"}, - { @"Server\Share\git\runtime\..\", 12, @"Server\Share\git\"}, - { @"Server\Share\git\runtime\..\..\..\", 12, @"Server\Share\"}, - { @"Server\Share\git\runtime\..\..\.\", 12, @"Server\Share\"}, - { @"Server\Share\git\..\.\runtime\temp\..", 12, @"Server\Share\runtime"}, - { @"Server\Share\git\..\\\.\..\runtime", 12, @"Server\Share\runtime"}, - { @"Server\Share\git\runtime\", 12, @"Server\Share\git\runtime\"}, - { @"Server\Share\git\temp\..\runtime\", 12, @"Server\Share\git\runtime\"}, - }; - - [Theory, - MemberData(nameof(RemoveRelativeSegmentsUncData))] - [PlatformSpecific(TestPlatforms.Windows)] - public void RemoveRelativeSegmentsUncTest(string path, int skip, string expected) - { - Assert.Equal(@"\\" + expected, PathInternal.RemoveRelativeSegments(@"\\" + path, skip + 2)); - Assert.Equal(@"\\.\UNC\" + expected, PathInternal.RemoveRelativeSegments(@"\\.\UNC\" + path, skip + 8)); - Assert.Equal(@"\\?\UNC\" + expected, PathInternal.RemoveRelativeSegments(@"\\?\UNC\" + path, skip + 8)); - } - - public static TheoryData RemoveRelativeSegmentsDeviceData => new TheoryData - { - { @"\\.\git\runtime", 7, @"\\.\git\runtime"}, - { @"\\.\git\runtime", 7, @"\\.\git\runtime"}, - { @"\\.\git\\runtime", 7, @"\\.\git\runtime"}, - { @"\\.\git\.\runtime\.\\", 7, @"\\.\git\runtime\"}, - { @"\\.\git\runtime", 7, @"\\.\git\runtime"}, - { @"\\.\git\..\runtime", 7, @"\\.\git\runtime"}, - { @"\\.\git\runtime\..\", 7, @"\\.\git\"}, - { @"\\.\git\runtime\..\..\..\", 7, @"\\.\git\"}, - { @"\\.\git\runtime\..\..\.\", 7, @"\\.\git\"}, - { @"\\.\git\..\.\runtime\temp\..", 7, @"\\.\git\runtime"}, - { @"\\.\git\..\\\.\..\runtime", 7, @"\\.\git\runtime"}, - { @"\\.\git\runtime\", 7, @"\\.\git\runtime\"}, - { @"\\.\git\temp\..\runtime\", 7, @"\\.\git\runtime\"}, - - { @"\\.\.\runtime", 5, @"\\.\.\runtime"}, - { @"\\.\.\runtime", 5, @"\\.\.\runtime"}, - { @"\\.\.\\runtime", 5, @"\\.\.\runtime"}, - { @"\\.\.\.\runtime\.\\", 5, @"\\.\.\runtime\"}, - { @"\\.\.\runtime", 5, @"\\.\.\runtime"}, - { @"\\.\.\..\runtime", 5, @"\\.\.\runtime"}, - { @"\\.\.\runtime\..\", 5, @"\\.\.\"}, - { @"\\.\.\runtime\..\..\..\", 5, @"\\.\.\"}, - { @"\\.\.\runtime\..\..\.\", 5, @"\\.\.\"}, - { @"\\.\.\..\.\runtime\temp\..", 5, @"\\.\.\runtime"}, - { @"\\.\.\..\\\.\..\runtime", 5, @"\\.\.\runtime"}, - { @"\\.\.\runtime\", 5, @"\\.\.\runtime\"}, - { @"\\.\.\temp\..\runtime\", 5, @"\\.\.\runtime\"}, - - { @"\\.\..\runtime", 6, @"\\.\..\runtime"}, - { @"\\.\..\runtime", 6, @"\\.\..\runtime"}, - { @"\\.\..\\runtime", 6, @"\\.\..\runtime"}, - { @"\\.\..\.\runtime\.\\", 6, @"\\.\..\runtime\"}, - { @"\\.\..\runtime", 6, @"\\.\..\runtime"}, - { @"\\.\..\..\runtime", 6, @"\\.\..\runtime"}, - { @"\\.\..\runtime\..\", 6, @"\\.\..\"}, - { @"\\.\..\runtime\..\..\..\", 6, @"\\.\..\"}, - { @"\\.\..\runtime\..\..\.\", 6, @"\\.\..\"}, - { @"\\.\..\..\.\runtime\temp\..", 6, @"\\.\..\runtime"}, - { @"\\.\..\..\\\.\..\runtime", 6, @"\\.\..\runtime"}, - { @"\\.\..\runtime\", 6, @"\\.\..\runtime\"}, - { @"\\.\..\temp\..\runtime\", 6, @"\\.\..\runtime\"}, - - { @"\\.\\runtime", 4, @"\\.\runtime"}, - { @"\\.\\runtime", 4, @"\\.\runtime"}, - { @"\\.\\\runtime", 4, @"\\.\runtime"}, - { @"\\.\\.\runtime\.\\", 4, @"\\.\runtime\"}, - { @"\\.\\runtime", 4, @"\\.\runtime"}, - { @"\\.\\..\runtime", 4, @"\\.\runtime"}, - { @"\\.\\runtime\..\", 4, @"\\.\"}, - { @"\\.\\runtime\..\..\..\", 4, @"\\.\"}, - { @"\\.\\runtime\..\..\.\", 4, @"\\.\"}, - { @"\\.\\..\.\runtime\temp\..", 4, @"\\.\runtime"}, - { @"\\.\\..\\\.\..\runtime", 4, @"\\.\runtime"}, - { @"\\.\\runtime\", 4, @"\\.\runtime\"}, - { @"\\.\\temp\..\runtime\", 4, @"\\.\runtime\"}, - }; - - public static TheoryData RemoveRelativeSegmentsDeviceRootData => new TheoryData - { - { @"\\.\git\runtime", 8, @"\\.\git\runtime"}, - { @"\\.\git\runtime", 8, @"\\.\git\runtime"}, - { @"\\.\git\\runtime", 8, @"\\.\git\runtime"}, - { @"\\.\git\.\runtime\.\\", 8, @"\\.\git\runtime\"}, - { @"\\.\git\runtime", 8, @"\\.\git\runtime"}, - { @"\\.\git\..\runtime", 8, @"\\.\git\runtime"}, - { @"\\.\git\runtime\..\", 8, @"\\.\git\"}, - { @"\\.\git\runtime\..\..\..\", 8, @"\\.\git\"}, - { @"\\.\git\runtime\..\..\.\", 8, @"\\.\git\"}, - { @"\\.\git\..\.\runtime\temp\..", 8, @"\\.\git\runtime"}, - { @"\\.\git\..\\\.\..\runtime", 8, @"\\.\git\runtime"}, - { @"\\.\git\runtime\", 8, @"\\.\git\runtime\"}, - { @"\\.\git\temp\..\runtime\", 8, @"\\.\git\runtime\"}, - - { @"\\.\.\runtime", 6, @"\\.\.\runtime"}, - { @"\\.\.\runtime", 6, @"\\.\.\runtime"}, - { @"\\.\.\\runtime", 6, @"\\.\.\runtime"}, - { @"\\.\.\.\runtime\.\\", 6, @"\\.\.\runtime\"}, - { @"\\.\.\runtime", 6, @"\\.\.\runtime"}, - { @"\\.\.\..\runtime", 6, @"\\.\.\runtime"}, - { @"\\.\.\runtime\..\", 6, @"\\.\.\"}, - { @"\\.\.\runtime\..\..\..\", 6, @"\\.\.\"}, - { @"\\.\.\runtime\..\..\.\", 6, @"\\.\.\"}, - { @"\\.\.\..\.\runtime\temp\..", 6, @"\\.\.\runtime"}, - { @"\\.\.\..\\\.\..\runtime", 6, @"\\.\.\runtime"}, - { @"\\.\.\runtime\", 6, @"\\.\.\runtime\"}, - { @"\\.\.\temp\..\runtime\", 6, @"\\.\.\runtime\"}, - - { @"\\.\..\runtime", 7, @"\\.\..\runtime"}, - { @"\\.\..\runtime", 7, @"\\.\..\runtime"}, - { @"\\.\..\\runtime", 7, @"\\.\..\runtime"}, - { @"\\.\..\.\runtime\.\\", 7, @"\\.\..\runtime\"}, - { @"\\.\..\runtime", 7, @"\\.\..\runtime"}, - { @"\\.\..\..\runtime", 7, @"\\.\..\runtime"}, - { @"\\.\..\runtime\..\", 7, @"\\.\..\"}, - { @"\\.\..\runtime\..\..\..\", 7, @"\\.\..\"}, - { @"\\.\..\runtime\..\..\.\", 7, @"\\.\..\"}, - { @"\\.\..\..\.\runtime\temp\..", 7, @"\\.\..\runtime"}, - { @"\\.\..\..\\\.\..\runtime", 7, @"\\.\..\runtime"}, - { @"\\.\..\runtime\", 7, @"\\.\..\runtime\"}, - { @"\\.\..\temp\..\runtime\", 7, @"\\.\..\runtime\"}, - - { @"\\.\\runtime", 5, @"\\.\\runtime"}, - { @"\\.\\runtime", 5, @"\\.\\runtime"}, - { @"\\.\\\runtime", 5, @"\\.\\runtime"}, - { @"\\.\\.\runtime\.\\", 5, @"\\.\\runtime\"}, - { @"\\.\\runtime", 5, @"\\.\\runtime"}, - { @"\\.\\..\runtime", 5, @"\\.\\runtime"}, - { @"\\.\\runtime\..\", 5, @"\\.\\"}, - { @"\\.\\runtime\..\..\..\", 5, @"\\.\\"}, - { @"\\.\\runtime\..\..\.\", 5, @"\\.\\"}, - { @"\\.\\..\.\runtime\temp\..", 5, @"\\.\\runtime"}, - { @"\\.\\..\\\.\..\runtime", 5, @"\\.\\runtime"}, - { @"\\.\\runtime\", 5, @"\\.\\runtime\"}, - { @"\\.\\temp\..\runtime\", 5, @"\\.\\runtime\"}, - }; - - [Theory, - MemberData(nameof(RemoveRelativeSegmentsDeviceData)), - MemberData(nameof(RemoveRelativeSegmentsDeviceRootData))] - [PlatformSpecific(TestPlatforms.Windows)] - public void RemoveRelativeSegmentsDeviceTest(string path, int skip, string expected) - { - Assert.Equal(expected, PathInternal.RemoveRelativeSegments(path, skip)); - StringBuilder sb = new StringBuilder(expected); - sb.Replace('.', '?', 0, 4); - expected = sb.ToString(); - - sb = new StringBuilder(path); - sb.Replace('.', '?', 0, 4); - path = sb.ToString(); - Assert.Equal(expected, PathInternal.RemoveRelativeSegments(path, skip)); - } - - public static TheoryData RemoveRelativeSegmentUnixData => new TheoryData - { - { "/tmp/home", 1, "/tmp/home" }, - { "/tmp/..", 1, "/" }, - { "/tmp/home/../././", 1, "/tmp/" }, - { "/tmp/../../../", 1, "/" }, - { "/tmp//home", 1, "/tmp/home" }, - { "/./tmp//home", 1, "/tmp/home" }, - { "/../tmp/home", 1, "/tmp/home" }, - { "/../../../tmp/./home", 1, "/tmp/home" }, - { "//tmp///home", 1, "/tmp/home" }, - { "/tmp/home/git/./.././git/runtime/../", 1, "/tmp/home/git/" }, - { "/./tmp/home", 1, "/tmp/home" }, - - { "/tmp/home", 4, "/tmp/home" }, - { "/tmp/..", 4, "/tmp" }, - { "/tmp/home/../././", 4, "/tmp/" }, - { "/tmp/../../../", 4, "/tmp/" }, - { "/tmp//home", 4, "/tmp/home" }, - { "/./tmp//home", 2, "/./tmp/home" }, - { "/../tmp/home", 3, "/../tmp/home" }, - { "/../../../tmp/./home", 4, "/../tmp/home" }, - { "//tmp///home", 5, "//tmp/home" }, - { "/tmp/home/git/./.././git/runtime/../", 5, "/tmp/home/git/" }, - { "/./tmp/home", 3, "/./tmp/home" }, - - { "/tmp/../../", 8, "/tmp/../" }, - { "/tmp/home/../././", 10, "/tmp/home/" }, - { "/tmp/../../../", 8, "/tmp/../" }, - { "/tmp//home/.././/", 11, "/tmp//home/" }, - { "/./tmp//home/git/git", 7, "/./tmp/home/git/git" }, - { "/../tmp/./home", 8, "/../tmp/home" }, - { "/../../../tmp/./home", 8, "/../../../tmp/home" }, - { "//tmp///home/..", 5, "//tmp/" }, - { "/tmp/home/git/./.././git/runtime/../", 16, "/tmp/home/git/./git/" }, - { "/./tmp/home/././", 7, "/./tmp/home/" }, - }; - - [Theory, - MemberData(nameof(RemoveRelativeSegmentUnixData))] - [PlatformSpecific(TestPlatforms.AnyUnix)] - public void RemoveRelativeSegmentsUnix(string path, int skip, string expected) - { - Assert.Equal(expected, PathInternal.RemoveRelativeSegments(path, skip)); - } } } diff --git a/src/libraries/System.Private.CoreLib/src/System/IO/Path.Unix.cs b/src/libraries/System.Private.CoreLib/src/System/IO/Path.Unix.cs index 25cec9463f3f20..ba358de086a754 100644 --- a/src/libraries/System.Private.CoreLib/src/System/IO/Path.Unix.cs +++ b/src/libraries/System.Private.CoreLib/src/System/IO/Path.Unix.cs @@ -34,7 +34,7 @@ public static string GetFullPath(string path) // We would ideally use realpath to do this, but it resolves symlinks, requires that the file actually exist, // and turns it into a full path, which we only want if fullCheck is true. - string collapsedString = PathInternal.RemoveRelativeSegments(path, PathInternal.GetRootLength(path)); + string collapsedString = Path.RemoveRedundantSegments(path); Debug.Assert(collapsedString.Length < path.Length || collapsedString.ToString() == path, "Either we've removed characters, or the string should be unmodified from the input path."); diff --git a/src/libraries/System.Private.CoreLib/src/System/IO/Path.Windows.cs b/src/libraries/System.Private.CoreLib/src/System/IO/Path.Windows.cs index ce27a28f0e783c..28ce0847cd057b 100644 --- a/src/libraries/System.Private.CoreLib/src/System/IO/Path.Windows.cs +++ b/src/libraries/System.Private.CoreLib/src/System/IO/Path.Windows.cs @@ -130,7 +130,7 @@ public static string GetFullPath(string path, string basePath) // them properly. As such we need to manually remove segments and not use GetFullPath(). return PathInternal.IsDevice(combinedPath.AsSpan()) - ? PathInternal.RemoveRelativeSegments(combinedPath, PathInternal.GetRootLength(combinedPath.AsSpan())) + ? RemoveRedundantSegments(combinedPath.AsSpan()) : GetFullPath(combinedPath); } diff --git a/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs b/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs index b0cdad93bc7a8d..558480dddcf67e 100644 --- a/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs +++ b/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs @@ -926,5 +926,69 @@ private static string GetRelativePath(string relativeTo, string path, StringComp /// Returns true if the path ends in a directory separator. /// public static bool EndsInDirectorySeparator(string path) => PathInternal.EndsInDirectorySeparator(path); + + /// + /// Removes any redundant segments found in the specified path. + /// + /// A string containing an absolute or relative path that may or may not contain redundant segments. + /// without any redundant segments. + [return: NotNullIfNotNull("path")] + public static string? RemoveRedundantSegments(string? path) + { + if (path == null) + return null; + + return RemoveRedundantSegments(path.AsSpan()); + } + + /// + /// Removes any redundant segments found in the specified path. + /// + /// A read-only span containing an absolute or relative path that may or may not contain redundant segments. + /// without any redundant segments. + public static string RemoveRedundantSegments(ReadOnlySpan path) + { + if (PathInternal.IsEffectivelyEmpty(path)) + return string.Empty; + + ValueStringBuilder sb = new ValueStringBuilder(path.Length); + ReadOnlySpan pathRoot = GetPathRoot(path); + + if (!PathInternal.TryRemoveRedundantSegments(path, pathRoot.Length, ref sb)) + return path.ToString(); + + return sb.ToString(); + } + + /// + /// Tries to remove any redundant segments found in the specified path. + /// + /// A read-only span containing an absolute or relative path that may or may not contain redundant segments. + /// When the method returns , a span containing without any redundant segments. When the method returns , the operation failed. + /// When the method returns , contains the total number of characters written in ; when the method returns , the value is zero. + /// if the operation succeeds; if the operation fails. + /// The method returns `false` when the path is empty or when the destination span's length is less than the resulting path's length. + public static bool TryRemoveRedundantSegments(ReadOnlySpan path, Span destination, out int charsWritten) + { + charsWritten = 0; + + if (PathInternal.IsEffectivelyEmpty(path)) + return false; + + ValueStringBuilder sb = new ValueStringBuilder(path.Length); + ReadOnlySpan pathRoot = GetPathRoot(path); + + if (PathInternal.TryRemoveRedundantSegments(path, pathRoot.Length, ref sb)) + { + return sb.TryCopyTo(destination, out charsWritten); + } + else if (path.TryCopyTo(destination)) + { + charsWritten = path.Length; + return true; + } + + return false; + } } } diff --git a/src/libraries/System.Private.CoreLib/src/System/IO/PathInternal.cs b/src/libraries/System.Private.CoreLib/src/System/IO/PathInternal.cs index fd3529067f71c4..95812193132954 100644 --- a/src/libraries/System.Private.CoreLib/src/System/IO/PathInternal.cs +++ b/src/libraries/System.Private.CoreLib/src/System/IO/PathInternal.cs @@ -102,47 +102,34 @@ internal static bool AreRootsEqual(string? first, string? second, StringComparis } /// - /// Try to remove relative segments from the given path (without combining with a root). + /// Tries to remove relative segments from the given path, starting the analysis at the specified location. /// - /// Input path - /// The length of the root of the given path - internal static string RemoveRelativeSegments(string path, int rootLength) + /// The input path. + /// The number of characters to ignore from the beginning of the path string. Usually its the length of the path's root. + /// A reference to a value string builder that will store the result. + /// if the path was modified; otherwise. + internal static bool TryRemoveRedundantSegments(ReadOnlySpan path, int charactersToSkip, ref ValueStringBuilder sb) { - var sb = new ValueStringBuilder(stackalloc char[260 /* PathInternal.MaxShortPath */]); + Debug.Assert(path.Length > 0); + Debug.Assert(charactersToSkip >= 0); - if (RemoveRelativeSegments(path.AsSpan(), rootLength, ref sb)) - { - path = sb.ToString(); - } - - sb.Dispose(); - return path; - } - - /// - /// Try to remove relative segments from the given path (without combining with a root). - /// - /// Input path - /// The length of the root of the given path - /// String builder that will store the result - /// "true" if the path was modified - internal static bool RemoveRelativeSegments(ReadOnlySpan path, int rootLength, ref ValueStringBuilder sb) - { - Debug.Assert(rootLength > 0); bool flippedSeparator = false; - int skip = rootLength; - // We treat "\.." , "\." and "\\" as a relative segment. We want to collapse the first separator past the root presuming - // the root actually ends in a separator. Otherwise the first segment for RemoveRelativeSegments - // in cases like "\\?\C:\.\" and "\\?\C:\..\", the first segment after the root will be ".\" and "..\" which is not considered as a relative segment and hence not be removed. - if (PathInternal.IsDirectorySeparator(path[skip - 1])) - skip--; + int skip = charactersToSkip; // Remove "//", "/./", and "/../" from the path by copying each character to the output, // except the ones we're removing, such that the builder contains the normalized path // at the end. if (skip > 0) { + // We treat "\.." , "\." and "\\" as a relative segment. We want to collapse the first separator past the root presuming + // the root actually ends in a separator. Otherwise the first segment for TryRemoveRedundantSegments + // in cases like "\\?\C:\.\" and "\\?\C:\..\", the first segment after the root will be ".\" and "..\" which is not considered as a relative segment and hence not be removed. + if (IsDirectorySeparator(path[skip - 1])) + { + skip--; + } + sb.Append(path.Slice(0, skip)); } @@ -150,18 +137,18 @@ internal static bool RemoveRelativeSegments(ReadOnlySpan path, int rootLen { char c = path[i]; - if (PathInternal.IsDirectorySeparator(c) && i + 1 < path.Length) + if (IsDirectorySeparator(c) && i + 1 < path.Length) { // Skip this character if it's a directory separator and if the next character is, too, // e.g. "parent//child" => "parent/child" - if (PathInternal.IsDirectorySeparator(path[i + 1])) + if (IsDirectorySeparator(path[i + 1])) { continue; } // Skip this character and the next if it's referring to the current directory, // e.g. "parent/./child" => "parent/child" - if ((i + 2 == path.Length || PathInternal.IsDirectorySeparator(path[i + 2])) && + if ((i + 2 == path.Length || IsDirectorySeparator(path[i + 2])) && path[i + 1] == '.') { i++; @@ -171,14 +158,14 @@ internal static bool RemoveRelativeSegments(ReadOnlySpan path, int rootLen // Skip this character and the next two if it's referring to the parent directory, // e.g. "parent/child/../grandchild" => "parent/grandchild" if (i + 2 < path.Length && - (i + 3 == path.Length || PathInternal.IsDirectorySeparator(path[i + 3])) && + (i + 3 == path.Length || IsDirectorySeparator(path[i + 3])) && path[i + 1] == '.' && path[i + 2] == '.') { // Unwind back to the last slash (and if there isn't one, clear out everything). int s; for (s = sb.Length - 1; s >= skip; s--) { - if (PathInternal.IsDirectorySeparator(sb[s])) + if (IsDirectorySeparator(sb[s])) { sb.Length = (i + 3 >= path.Length && s == skip) ? s + 1 : s; // to avoid removing the complete "\tmp\" segment in cases like \\?\C:\tmp\..\, C:\tmp\.. break; @@ -195,9 +182,9 @@ internal static bool RemoveRelativeSegments(ReadOnlySpan path, int rootLen } // Normalize the directory separator if needed - if (c != PathInternal.DirectorySeparatorChar && c == PathInternal.AltDirectorySeparatorChar) + if (c != DirectorySeparatorChar && c == AltDirectorySeparatorChar) { - c = PathInternal.DirectorySeparatorChar; + c = DirectorySeparatorChar; flippedSeparator = true; } @@ -211,9 +198,9 @@ internal static bool RemoveRelativeSegments(ReadOnlySpan path, int rootLen } // We may have eaten the trailing separator from the root when we started and not replaced it - if (skip != rootLength && sb.Length < rootLength) + if (skip != charactersToSkip && sb.Length < charactersToSkip) { - sb.Append(path[rootLength - 1]); + sb.Append(path[charactersToSkip - 1]); } return true; diff --git a/src/libraries/System.Runtime.Extensions/tests/System.Runtime.Extensions.Tests.csproj b/src/libraries/System.Runtime.Extensions/tests/System.Runtime.Extensions.Tests.csproj index fb959a4a8fadc8..69ceb606d6f962 100644 --- a/src/libraries/System.Runtime.Extensions/tests/System.Runtime.Extensions.Tests.csproj +++ b/src/libraries/System.Runtime.Extensions/tests/System.Runtime.Extensions.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/src/libraries/System.Runtime.Extensions/tests/System/IO/Path.RemoveRedundantSegments.cs b/src/libraries/System.Runtime.Extensions/tests/System/IO/Path.RemoveRedundantSegments.cs new file mode 100644 index 00000000000000..6e4364e0a59ccf --- /dev/null +++ b/src/libraries/System.Runtime.Extensions/tests/System/IO/Path.RemoveRedundantSegments.cs @@ -0,0 +1,503 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics; +using System.Runtime.Serialization; +using System.Tests; +using System.Text; +using Xunit; + +namespace System.IO.Tests +{ + public static class RemoveRedundantSegmentsTests + { + private static readonly string[] s_Prefixes = new string[] { "", @"\\?\", @"\\.\" }; + private static readonly string[] s_UncPrefixes = new string[] { @"\\", @"\\?\UNC\", @"\\.\UNC\" }; + + // Null or empty tests + public static TheoryData NullOrEmptyData => new TheoryData + { + { null, null }, + { "", "" }, + { " ", "" }, + { " ", "" } + }; + + // Normal paths + public static TheoryData WindowsNormalData => new TheoryData + { + // A '\' inside a string that is prefixed with @ is actually passed as an escaped backward slash "\\" + { @"C:", @"C:" }, + { @"C:\", @"C:\" }, + { @"C:\Users", @"C:\Users" }, + { @"C:\Users\", @"C:\Users\" }, + { @"C:\Users\myuser", @"C:\Users\myuser" }, + { @"C:\Users\myuser\", @"C:\Users\myuser\" }, + }; + public static TheoryData UnixNormalData => new TheoryData + { + // AltDirectorySeparatorChar gets normalized to DirectorySeparatorChar + { @"/", @"\" }, + { @"/home", @"\home" }, + { @"/home/", @"\home\" }, + { @"/home/myuser", @"\home\myuser" }, + { @"/home/myuser/", @"\home\myuser\" }, + }; + + // Paths with '..' to indicate parent backtracking + public static TheoryData WindowsValidParentBacktrackingData => new TheoryData + { + { @"C:\..", @"C:\" }, + { @"C:\..\", @"C:\" }, + { @"C:\..\Users", @"C:\Users" }, + { @"C:\..\Users\", @"C:\Users\" }, + { @"C:\Users\..", @"C:\" }, + { @"C:\Users\..\", @"C:\" }, + { @"C:\Users\..\..", @"C:\" }, + { @"C:\Users\..\..\", @"C:\" }, + { @"C:\Users\..\myuser", @"C:\myuser" }, + { @"C:\Users\..\myuser\", @"C:\myuser\" }, + { @"C:\Users\..\..\myuser", @"C:\myuser" }, + { @"C:\Users\..\..\myuser\", @"C:\myuser\" }, + { @"C:\Users\myuser\..", @"C:\Users" }, + { @"C:\Users\myuser\..\", @"C:\Users\" }, + { @"C:\Users\myuser\..\..", @"C:\" }, + { @"C:\Users\myuser\..\..\", @"C:\" }, + { @"C:\Users\..\myuser\..", @"C:\" }, + { @"C:\Users\..\myuser\..\", @"C:\" }, + { @"C:\Users\..\myuser\..\..", @"C:\" }, + { @"C:\Users\..\myuser\..\..\", @"C:\" }, + { @"C:\Users\..\myuser\..\..\..", @"C:\" }, + { @"C:\Users\..\myuser\..\..\..\", @"C:\" }, + { @"C:\Users\..\..\myuser\..\..", @"C:\" }, + { @"C:\Users\..\..\myuser\..\..\", @"C:\" }, + }; + public static TheoryData UnixValidParentBacktrackingData => new TheoryData + { + { @"/..", @"/" }, // This case is not normalizing the root AltDirectorySeparatorChar because no separator is found after the root (which was ignored) + { @"/../", @"\" }, + { @"/../home", @"\home" }, + { @"/../home/", @"\home\" }, + { @"/home/..", @"\" }, + { @"/home/../", @"\" }, + { @"/home/../..", @"/" }, + { @"/home/../../", @"\" }, + { @"/home/../myuser", @"\myuser" }, + { @"/home/../myuser/", @"\myuser\" }, + { @"/home/../../myuser", @"\myuser" }, + { @"/home/../../myuser/", @"\myuser\" }, + { @"/home/myuser/..", @"\home" }, + { @"/home/myuser/../", @"\home\" }, + { @"/home/myuser/../..", @"\" }, + { @"/home/myuser/../../", @"\" }, + { @"/home/../myuser/..", @"\" }, + { @"/home/../myuser/../", @"\" }, + { @"/home/../myuser/../..", @"/" }, // This case is not normalizing the root AltDirectorySeparatorChar + { @"/home/../myuser/../../", @"\" }, + { @"/home/../myuser/../../..", @"/" }, // This case is not normalizing the root AltDirectorySeparatorChar + { @"/home/../myuser/../../../", @"\" }, + { @"/home/../../myuser/../..", @"/" }, // This case is not normalizing the root AltDirectorySeparatorChar + { @"/home/../../myuser/../../", @"\" }, + }; + + // Paths with '.' to indicate current directory + public static TheoryData WindowsValidCurrentDirectoryData => new TheoryData + { + { @"C:\.", @"C:\" }, + { @"C:\.\", @"C:\" }, + { @"C:\.\.", @"C:\" }, + { @"C:\.\.\", @"C:\" }, + { @"C:\.\Users", @"C:\Users" }, + { @"C:\.\Users\", @"C:\Users\" }, + { @"C:\.\.\Users", @"C:\Users" }, + { @"C:\.\.\Users\", @"C:\Users\" }, + { @"C:\Users\.", @"C:\Users" }, + { @"C:\Users\.\", @"C:\Users\" }, + { @"C:\Users\.\.", @"C:\Users" }, + { @"C:\Users\.\.\", @"C:\Users\" }, + { @"C:\.\Users\myuser", @"C:\Users\myuser" }, + { @"C:\.\Users\myuser\", @"C:\Users\myuser\" }, + { @"C:\.\Users\.\myuser", @"C:\Users\myuser" }, + { @"C:\.\Users\.\myuser\", @"C:\Users\myuser\" }, + { @"C:\.\Users\.\myuser\.", @"C:\Users\myuser" }, + { @"C:\.\Users\.\myuser\.\", @"C:\Users\myuser\" }, + { @"C:\Users\.\.\myuser\.\.", @"C:\Users\myuser" }, + { @"C:\Users\.\.\myuser\.\.\", @"C:\Users\myuser\" }, + }; + public static TheoryData UnixValidCurrentDirectoryData => new TheoryData + { + { @"/.", @"/" }, + { @"/./", @"\" }, + { @"/./.", @"/" }, + { @"/././", @"\" }, + { @"/./home", @"\home" }, + { @"/./home/", @"\home\" }, + { @"/././home", @"\home" }, + { @"/././home/", @"\home\" }, + { @"/home/.", @"\home" }, + { @"/home/./", @"\home\" }, + { @"/home/./.", @"\home" }, + { @"/home/././", @"\home\" }, + { @"/./home/myuser", @"\home\myuser" }, + { @"/./home/myuser/", @"\home\myuser\" }, + { @"/./home/./myuser", @"\home\myuser" }, + { @"/./home/./myuser/", @"\home\myuser\" }, + { @"/./home/./myuser/.", @"\home\myuser" }, + { @"/./home/./myuser/./", @"\home\myuser\" }, + { @"/home/././myuser/./.", @"\home\myuser" }, + { @"/home/././myuser/././", @"\home\myuser\" }, + }; + + // Combined '.' and '..' + public static TheoryData WindowsCombinedRedundantData => new TheoryData + { + { @"C:\.\..", @"C:\" }, + { @"C:\.\..\", @"C:\" }, + { @"C:\..\.", @"C:\" }, + { @"C:\..\.\", @"C:\" }, + { @"C:\.\..\.", @"C:\" }, + { @"C:\.\..\.\", @"C:\" }, + { @"C:\..\.\.", @"C:\" }, + { @"C:\..\.\.\", @"C:\" }, + { @"C:\.\..\..", @"C:\" }, + { @"C:\.\..\..\", @"C:\" }, + { @"C:\..\.\..", @"C:\" }, + { @"C:\..\.\..\", @"C:\" }, + { @"C:\Users\.\..", @"C:\" }, + { @"C:\Users\.\..\", @"C:\" }, + { @"C:\Users\..\.", @"C:\" }, + { @"C:\Users\..\.\", @"C:\" }, + { @"C:\.\Users\..", @"C:\" }, + { @"C:\.\Users\..\", @"C:\" }, + { @"C:\..\Users\.", @"C:\Users" }, + { @"C:\..\Users\.\", @"C:\Users\" }, + { @"C:\.\..\Users", @"C:\Users" }, + { @"C:\.\..\Users\", @"C:\Users\" }, + { @"C:\..\.\Users", @"C:\Users" }, + { @"C:\..\.\Users\", @"C:\Users\" }, + { @"C:\.\Users\myuser\..", @"C:\Users" }, + { @"C:\.\Users\myuser\..\", @"C:\Users\" }, + { @"C:\..\Users\myuser\.", @"C:\Users\myuser" }, + { @"C:\..\Users\myuser\.\", @"C:\Users\myuser\" }, + { @"C:\.\Users\..\myuser", @"C:\myuser" }, + { @"C:\.\Users\..\myuser\", @"C:\myuser\" }, + { @"C:\..\Users\.\myuser", @"C:\Users\myuser" }, + { @"C:\..\Users\.\myuser\", @"C:\Users\myuser\" }, + }; + public static TheoryData UnixCombinedRedundantData => new TheoryData + { + { @"/./..", @"/" }, + { @"/./../", @"\" }, + { @"/../.", @"/" }, + { @"/.././", @"\" }, + { @"/./../.", @"/" }, + { @"/./.././", @"\" }, + { @"/.././.", @"/" }, + { @"/../././", @"\" }, + { @"/./../..", @"/" }, + { @"/./../../", @"\" }, + { @"/.././..", @"/" }, + { @"/.././../", @"\" }, + { @"/home/./..", @"\" }, + { @"/home/./../", @"\" }, + { @"/home/../.", @"/" }, + { @"/home/.././", @"\" }, + { @"/./home/..", @"\" }, + { @"/./home/../", @"\" }, + { @"/../home/.", @"\home" }, + { @"/../home/./", @"\home\" }, + { @"/./../home", @"\home" }, + { @"/./../home/", @"\home\" }, + { @"/.././home", @"\home" }, + { @"/.././home/", @"\home\" }, + { @"/./home/myuser/..", @"\home" }, + { @"/./home/myuser/../", @"\home\" }, + { @"/../home/myuser/.", @"\home\myuser" }, + { @"/../home/myuser/./", @"\home\myuser\" }, + { @"/./home/../myuser", @"\myuser" }, + { @"/./home/../myuser/", @"\myuser\" }, + { @"/../home/./myuser", @"\home\myuser" }, + { @"/../home/./myuser/", @"\home\myuser\" }, + }; + + // Duplicate separators + public static TheoryData WindowsDuplicateSeparatorsData => new TheoryData + { + { @"C:\\", @"C:\" }, + { @"C:\\\", @"C:\" }, + { @"C://", @"C:\" }, + { @"C:///", @"C:\" }, + { @"C:\/", @"C:\" }, + { @"C:/\", @"C:\" }, + { @"C:\/\", @"C:\" }, + { @"C:/\\", @"C:\" }, + { @"C:\//", @"C:\" }, + { @"C:/\/", @"C:\" }, + { @"C:\\Users", @"C:\Users" }, + { @"C:\\\Users", @"C:\Users" }, + { @"C:\\Users\\\", @"C:\Users\" }, + { @"C:\\\Users\\", @"C:\Users\" }, + { @"C:\\Users\\/\", @"C:\Users\" }, + { @"C:\\\Users\/\", @"C:\Users\" }, + { @"C:\/Users", @"C:\Users" }, + { @"C:\/Users/", @"C:\Users\" }, + { @"C:\/Users/", @"C:\Users\" }, + { @"C:\\Users\\.\", @"C:\Users\" }, + { @"C:\\\Users\..\\", @"C:\" }, + { @"C:\\Users\\./.\", @"C:\Users\" }, + { @"C:\.\\Users\/./\\", @"C:\Users\" }, + { @"C:\\.\Users\/../\\./", @"C:\" }, + }; + public static TheoryData UnixDuplicateSeparatorsData => new TheoryData + { + { @"/home/", @"\home\" }, + { @"/home\\\", @"\home\" }, + { @"/home//", @"\home\" }, + { @"/home///", @"\home\" }, + { @"/home\/", @"\home\" }, + { @"/home/\", @"\home\" }, + { @"/home\/\", @"\home\" }, + { @"/home/\\", @"\home\" }, + { @"/home\//", @"\home\" }, + { @"/home/\/", @"\home\" }, + { @"/home\\myuser", @"\home\myuser" }, + { @"/home\\\myuser", @"\home\myuser" }, + { @"/home\\myuser\\\", @"\home\myuser\" }, + { @"/home\\\myuser\\", @"\home\myuser\" }, + { @"/home\\myuser\\/\", @"\home\myuser\" }, + { @"/home\\\myuser\/\", @"\home\myuser\" }, + { @"/home\/myuser", @"\home\myuser" }, + { @"/home\/myuser/", @"\home\myuser\" }, + { @"/home\/myuser/", @"\home\myuser\" }, + { @"/home\\myuser\\.\", @"\home\myuser\" }, + { @"/home\\\myuser\..\\", @"\home\" }, + { @"/home\\myuser\\./.\", @"\home\myuser\" }, + { @"/home\.\\myuser\/./\\", @"\home\myuser\" }, + { @"/home\\.\myuser\/../\\./", @"\home\" }, + }; + + // Network locations - Prefixes get prepended in the tests + public static TheoryData UncData => new TheoryData + { + { @"Server\Share\git\runtime", @"Server\Share\git\runtime"}, + { @"Server\Share\\git\runtime", @"Server\Share\git\runtime"}, + { @"Server\Share\git\\runtime", @"Server\Share\git\runtime"}, + { @"Server\Share\git\.\runtime\.\\", @"Server\Share\git\runtime\"}, + { @"Server\Share\git\runtime", @"Server\Share\git\runtime"}, + { @"Server\Share\git\..\runtime", @"Server\Share\runtime"}, + { @"Server\Share\git\runtime\..\", @"Server\Share\git\"}, + { @"Server\Share\git\runtime\..\..\..\", @"Server\Share\"}, + { @"Server\Share\git\runtime\..\..\.\", @"Server\Share\"}, + { @"Server\Share\git\..\.\runtime\temp\..", @"Server\Share\runtime"}, + { @"Server\Share\git\..\\\.\..\runtime", @"Server\Share\runtime"}, + { @"Server\Share\git\runtime\", @"Server\Share\git\runtime\"}, + { @"Server\Share\git\temp\..\runtime\", @"Server\Share\git\runtime\"}, + }; + + // Relative paths (no root) + public static TheoryData UnrootedData => new TheoryData + { + { @"Users\myuser\..\", @"Users\" }, + { @"myuser\..\", @"\" }, + { @"myuser", @"myuser" }, + { @".\myuser", @".\myuser" }, + { @".\myuser\", @".\myuser\" }, + { @"..\myuser", @"..\myuser" }, + { @"..\myuser\", @"..\myuser\" }, + { @"..\\myuser\", @"..\myuser\" }, + }; + + public static TheoryData ValidEdgecasesData => new TheoryData + { + { @"C:\Users\myuser\folder.with\one\dot", @"C:\Users\myuser\folder.with\one\dot" }, + { @"C:\Users\myuser\folder..with\two\dots", @"C:\Users\myuser\folder..with\two\dots" }, + { @"C:\Users\.folder\startswithdot", @"C:\Users\.folder\startswithdot" }, + { @"C:\Users\folder.\endswithdot", @"C:\Users\folder.\endswithdot" }, + { @"C:\Users\..folder\startswithtwodots", @"C:\Users\..folder\startswithtwodots" }, + { @"C:\Users\folder..\endswithtwodots", @"C:\Users\folder..\endswithtwodots" }, + { @"C:\Users\myuser\this\is\a\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\long\path\but\it\should\not\matter\extraword\..\", @"C:\Users\myuser\this\is\a\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\long\path\but\it\should\not\matter\" }, + { @"C:\Users\myuser\this_is_a_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_long_foldername\but_it_should_not_matter\extraword\..\", @"C:\Users\myuser\this_is_a_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_long_foldername\but_it_should_not_matter\" }, + }; + + [Theory] + [MemberData(nameof(NullOrEmptyData))] + [MemberData(nameof(UnrootedData))] + [MemberData(nameof(ValidEdgecasesData))] + public static void SpecialCases_String(string path, string expected) => TestRedundantSegments(path, expected); + + [Theory] + [MemberData(nameof(NullOrEmptyData))] + [MemberData(nameof(UnrootedData))] + [MemberData(nameof(ValidEdgecasesData))] + public static void SpecialCases_Span(string path, string expected) => TestRedundantSegments(path.AsSpan(), expected); + + [Theory] + [MemberData(nameof(UnrootedData))] + [MemberData(nameof(ValidEdgecasesData))] + public static void SpecialCases_True_Try(string path, string expected) => TestTryRedundantSegments(path, expected, true, expected.Length); + + [Theory] + [MemberData(nameof(NullOrEmptyData))] + public static void SpecialCases_False_Try(string path, string expected) => TestTryRedundantSegments(path, expected, false, 0); + + [Theory] + [MemberData(nameof(WindowsNormalData))] + [MemberData(nameof(WindowsValidParentBacktrackingData))] + [MemberData(nameof(WindowsValidCurrentDirectoryData))] + [MemberData(nameof(WindowsCombinedRedundantData))] + [MemberData(nameof(WindowsDuplicateSeparatorsData))] + public static void WindowsValid_String(string path, string expected) + { + foreach (string prefix in s_Prefixes) + { + TestRedundantSegments(prefix + path, prefix + expected); + } + } + + [Theory] + [MemberData(nameof(WindowsNormalData))] + [MemberData(nameof(WindowsValidParentBacktrackingData))] + [MemberData(nameof(WindowsValidCurrentDirectoryData))] + [MemberData(nameof(WindowsCombinedRedundantData))] + [MemberData(nameof(WindowsDuplicateSeparatorsData))] + public static void WindowsValid_Span(string path, string expected) + { + foreach (string prefix in s_Prefixes) + { + TestRedundantSegments((prefix + path).AsSpan(), prefix + expected); + } + } + + [Theory] + [MemberData(nameof(WindowsNormalData))] + [MemberData(nameof(WindowsValidParentBacktrackingData))] + [MemberData(nameof(WindowsValidCurrentDirectoryData))] + [MemberData(nameof(WindowsCombinedRedundantData))] + [MemberData(nameof(WindowsDuplicateSeparatorsData))] + public static void WindowsValid_Try(string path, string expected) + { + foreach (string prefix in s_Prefixes) + { + TestTryRedundantSegments(prefix + path, prefix + expected, true, (prefix + expected).Length); + } + } + + + [Theory] + [MemberData(nameof(UncData))] + public static void WindowsUnc_String(string path, string expected) + { + foreach (string prefix in s_UncPrefixes) + { + TestRedundantSegments(prefix + path, prefix + expected); + } + } + + [Theory] + [MemberData(nameof(UncData))] + public static void WindowsUnc_Span(string path, string expected) + { + foreach (string prefix in s_UncPrefixes) + { + TestRedundantSegments((prefix + path).AsSpan(), prefix + expected); + } + } + + [Theory] + [MemberData(nameof(UncData))] + public static void WindowsUnc_Try(string path, string expected) + { + foreach (string prefix in s_UncPrefixes) + { + TestTryRedundantSegments(prefix + path, prefix + expected, true, (prefix + expected).Length); + } + } + + + // The expected string is returned in Unix with '/' as separator + + [Theory] + [MemberData(nameof(UnixNormalData))] + [MemberData(nameof(UnixValidParentBacktrackingData))] + [MemberData(nameof(UnixValidCurrentDirectoryData))] + [MemberData(nameof(UnixCombinedRedundantData))] + [MemberData(nameof(UnixDuplicateSeparatorsData))] + public static void UnixValid_String(string path, string expected) + { + if (!PlatformDetection.IsWindows) + { + expected = expected.Replace('\\', '/'); + } + TestRedundantSegments(path, expected); + } + + [Theory] + [MemberData(nameof(UnixNormalData))] + [MemberData(nameof(UnixValidParentBacktrackingData))] + [MemberData(nameof(UnixValidCurrentDirectoryData))] + [MemberData(nameof(UnixCombinedRedundantData))] + [MemberData(nameof(UnixDuplicateSeparatorsData))] + public static void UnixValid_Span(string path, string expected) + { + if (!PlatformDetection.IsWindows) + { + expected = expected.Replace('\\', '/'); + } + TestRedundantSegments(path.AsSpan(), expected); + } + + [Theory] + [MemberData(nameof(UnixNormalData))] + [MemberData(nameof(UnixValidParentBacktrackingData))] + [MemberData(nameof(UnixValidCurrentDirectoryData))] + [MemberData(nameof(UnixCombinedRedundantData))] + [MemberData(nameof(UnixDuplicateSeparatorsData))] + public static void UnixValid_Try(string path, string expected) + { + if (!PlatformDetection.IsWindows) + { + expected = expected.Replace('\\', '/'); + } + TestTryRedundantSegments(path, expected, true, expected.Length); + } + + + + [Fact] + public static void DestinationTooSmall_Try() + { + Span actualDestination = stackalloc char[1]; + bool actualReturn = Path.TryRemoveRedundantSegments(@"C:\Users\myuser", actualDestination, out int actualCharsWritten); + string stringDestination = actualDestination.Slice(0, actualCharsWritten).ToString(); + Assert.False(actualReturn); + Assert.Equal(0, actualCharsWritten); + Assert.Equal(0, stringDestination.Length); + } + + + // Helper methods + + private static void TestTryRedundantSegments(string path, string expected, bool expectedReturn, int expectedCharsWritten) + { + Span actualDestination = stackalloc char[(path != null) ? path.Length : 1]; + bool actualReturn = Path.TryRemoveRedundantSegments(path.AsSpan(), actualDestination, out int actualCharsWritten); + Assert.Equal(expectedReturn, actualReturn); + Assert.Equal(expected ?? string.Empty, actualDestination.Slice(0, actualCharsWritten).ToString()); + Assert.Equal(expectedCharsWritten, actualCharsWritten); + } + + private static void TestRedundantSegments(ReadOnlySpan path, string expected) + { + string actual = Path.RemoveRedundantSegments(path); + Assert.Equal(expected ?? string.Empty, actual); + } + + private static void TestRedundantSegments(string path, string expected) + { + string actual = Path.RemoveRedundantSegments(path); + Assert.Equal(expected, actual); + } + + } +} From 2086bcc64e36b48511f480ce0f10191814defd62 Mon Sep 17 00:00:00 2001 From: carlossanlop Date: Sat, 22 Feb 2020 13:27:55 -0800 Subject: [PATCH 2/7] Move refs from System.Runtime.Extensions.cs to System.Runtime.cs --- src/libraries/System.Runtime/ref/System.Runtime.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/libraries/System.Runtime/ref/System.Runtime.cs b/src/libraries/System.Runtime/ref/System.Runtime.cs index 3cea060702adb4..80cf8014bbe3ac 100644 --- a/src/libraries/System.Runtime/ref/System.Runtime.cs +++ b/src/libraries/System.Runtime/ref/System.Runtime.cs @@ -6920,10 +6920,13 @@ public static partial class Path public static string Join(string? path1, string? path2, string? path3) { throw null; } public static string Join(string? path1, string? path2, string? path3, string? path4) { throw null; } public static string Join(params string?[] paths) { throw null; } + public static string RemoveRedundantSegments(string path) { throw null; } + public static string RemoveRedundantSegments(System.ReadOnlySpan path) { throw null; } public static System.ReadOnlySpan TrimEndingDirectorySeparator(System.ReadOnlySpan path) { throw null; } public static string TrimEndingDirectorySeparator(string path) { throw null; } public static bool TryJoin(System.ReadOnlySpan path1, System.ReadOnlySpan path2, System.ReadOnlySpan path3, System.Span destination, out int charsWritten) { throw null; } public static bool TryJoin(System.ReadOnlySpan path1, System.ReadOnlySpan path2, System.Span destination, out int charsWritten) { throw null; } + public static bool TryRemoveRedundantSegments(System.ReadOnlySpan path, System.Span destination, out int charsWritten) { throw null; } } public partial class PathTooLongException : System.IO.IOException { From 4f033d993f92a9fe83365fd5c17b7c8997514295 Mon Sep 17 00:00:00 2001 From: carlossanlop Date: Wed, 4 Mar 2020 11:04:51 -0800 Subject: [PATCH 3/7] Address ValueStringBuilder disposing cases --- .../src/System/IO/Path.cs | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs b/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs index 558480dddcf67e..6f3375640aaffd 100644 --- a/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs +++ b/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs @@ -938,7 +938,19 @@ private static string GetRelativePath(string relativeTo, string path, StringComp if (path == null) return null; - return RemoveRedundantSegments(path.AsSpan()); + if (PathInternal.IsEffectivelyEmpty(path)) + return string.Empty; + + ValueStringBuilder sb = new ValueStringBuilder(path.Length); + ReadOnlySpan pathRoot = GetPathRoot(path); + + if (!PathInternal.TryRemoveRedundantSegments(path, pathRoot.Length, ref sb)) + { + sb.Dispose(); + return path; + } + + return sb.ToString(); // Disposes } /// @@ -954,10 +966,18 @@ public static string RemoveRedundantSegments(ReadOnlySpan path) ValueStringBuilder sb = new ValueStringBuilder(path.Length); ReadOnlySpan pathRoot = GetPathRoot(path); + string result; if (!PathInternal.TryRemoveRedundantSegments(path, pathRoot.Length, ref sb)) - return path.ToString(); + { + result = path.ToString(); + sb.Dispose(); + } + else + { + result = sb.ToString(); // Disposes + } - return sb.ToString(); + return result; } /// @@ -978,17 +998,19 @@ public static bool TryRemoveRedundantSegments(ReadOnlySpan path, Span pathRoot = GetPathRoot(path); + bool result = false; if (PathInternal.TryRemoveRedundantSegments(path, pathRoot.Length, ref sb)) { - return sb.TryCopyTo(destination, out charsWritten); + result = sb.TryCopyTo(destination, out charsWritten); // Disposes } else if (path.TryCopyTo(destination)) { charsWritten = path.Length; - return true; + result = true; + sb.Dispose(); } - return false; + return result; } } } From cc0015ef8a5aa20a84055a44f3fdd51272fce63d Mon Sep 17 00:00:00 2001 From: carlossanlop Date: Wed, 4 Mar 2020 11:47:57 -0800 Subject: [PATCH 4/7] Fix Microsoft.IO.Redist build failure due to: Cannot implicitly convert type 'string' to 'System.ReadOnlySpan'. --- src/libraries/System.Private.CoreLib/src/System/IO/Path.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs b/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs index 6f3375640aaffd..5b2c0e7f03793a 100644 --- a/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs +++ b/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs @@ -938,13 +938,13 @@ private static string GetRelativePath(string relativeTo, string path, StringComp if (path == null) return null; - if (PathInternal.IsEffectivelyEmpty(path)) + if (PathInternal.IsEffectivelyEmpty(path.AsSpan())) return string.Empty; ValueStringBuilder sb = new ValueStringBuilder(path.Length); - ReadOnlySpan pathRoot = GetPathRoot(path); + ReadOnlySpan pathRoot = GetPathRoot(path.AsSpan()); - if (!PathInternal.TryRemoveRedundantSegments(path, pathRoot.Length, ref sb)) + if (!PathInternal.TryRemoveRedundantSegments(path.AsSpan(), pathRoot.Length, ref sb)) { sb.Dispose(); return path; From 9dc4ada9706e859de919c55ef8e49d2a5c28673b Mon Sep 17 00:00:00 2001 From: carlossanlop Date: Wed, 11 Mar 2020 18:15:41 -0700 Subject: [PATCH 5/7] Add support for non-fully qualified paths. --- .../src/System/IO/Path.cs | 9 +- .../src/System/IO/PathInternal.cs | 291 +++++++++---- .../System/IO/Path.RemoveRedundantSegments.cs | 386 +++++++++++------- 3 files changed, 455 insertions(+), 231 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs b/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs index 5b2c0e7f03793a..038713d365900a 100644 --- a/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs +++ b/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs @@ -942,9 +942,8 @@ private static string GetRelativePath(string relativeTo, string path, StringComp return string.Empty; ValueStringBuilder sb = new ValueStringBuilder(path.Length); - ReadOnlySpan pathRoot = GetPathRoot(path.AsSpan()); - if (!PathInternal.TryRemoveRedundantSegments(path.AsSpan(), pathRoot.Length, ref sb)) + if (!PathInternal.TryRemoveRedundantSegments(path.AsSpan(), ref sb)) { sb.Dispose(); return path; @@ -964,10 +963,9 @@ public static string RemoveRedundantSegments(ReadOnlySpan path) return string.Empty; ValueStringBuilder sb = new ValueStringBuilder(path.Length); - ReadOnlySpan pathRoot = GetPathRoot(path); string result; - if (!PathInternal.TryRemoveRedundantSegments(path, pathRoot.Length, ref sb)) + if (!PathInternal.TryRemoveRedundantSegments(path, ref sb)) { result = path.ToString(); sb.Dispose(); @@ -996,10 +994,9 @@ public static bool TryRemoveRedundantSegments(ReadOnlySpan path, Span pathRoot = GetPathRoot(path); bool result = false; - if (PathInternal.TryRemoveRedundantSegments(path, pathRoot.Length, ref sb)) + if (PathInternal.TryRemoveRedundantSegments(path, ref sb)) { result = sb.TryCopyTo(destination, out charsWritten); // Disposes } diff --git a/src/libraries/System.Private.CoreLib/src/System/IO/PathInternal.cs b/src/libraries/System.Private.CoreLib/src/System/IO/PathInternal.cs index 95812193132954..544f773e1c2e56 100644 --- a/src/libraries/System.Private.CoreLib/src/System/IO/PathInternal.cs +++ b/src/libraries/System.Private.CoreLib/src/System/IO/PathInternal.cs @@ -101,94 +101,178 @@ internal static bool AreRootsEqual(string? first, string? second, StringComparis comparisonType: comparisonType) == 0; } + /// + /// Trims one trailing directory separator beyond the root of the path. + /// + [return: NotNullIfNotNull("path")] + internal static string? TrimEndingDirectorySeparator(string? path) => + EndsInDirectorySeparator(path) && !IsRoot(path.AsSpan()) ? + path!.Substring(0, path.Length - 1) : + path; + + /// + /// Returns true if the path ends in a directory separator. + /// + internal static bool EndsInDirectorySeparator(string? path) => + !string.IsNullOrEmpty(path) && IsDirectorySeparator(path[path.Length - 1]); + + /// + /// Trims one trailing directory separator beyond the root of the path. + /// + internal static ReadOnlySpan TrimEndingDirectorySeparator(ReadOnlySpan path) => + EndsInDirectorySeparator(path) && !IsRoot(path) ? + path.Slice(0, path.Length - 1) : + path; + + /// + /// Returns true if the path ends in a directory separator. + /// + internal static bool EndsInDirectorySeparator(ReadOnlySpan path) => + path.Length > 0 && IsDirectorySeparator(path[path.Length - 1]); + /// /// Tries to remove relative segments from the given path, starting the analysis at the specified location. /// /// The input path. - /// The number of characters to ignore from the beginning of the path string. Usually its the length of the path's root. /// A reference to a value string builder that will store the result. /// if the path was modified; otherwise. - internal static bool TryRemoveRedundantSegments(ReadOnlySpan path, int charactersToSkip, ref ValueStringBuilder sb) + internal static bool TryRemoveRedundantSegments(ReadOnlySpan path, ref ValueStringBuilder sb) { Debug.Assert(path.Length > 0); - Debug.Assert(charactersToSkip >= 0); + ReadOnlySpan root = Path.GetPathRoot(path); + int charsToSkip = root.Length; + + bool isFullyQualified = Path.IsPathFullyQualified(path); bool flippedSeparator = false; - int skip = charactersToSkip; + char c; - // Remove "//", "/./", and "/../" from the path by copying each character to the output, - // except the ones we're removing, such that the builder contains the normalized path - // at the end. - if (skip > 0) + // Remove "//", "/./", and "/../" from the path by copying each character to the output, except the ones we're removing, + // such that the builder contains the normalized path at the end. + if (charsToSkip > 0) { - // We treat "\.." , "\." and "\\" as a relative segment. We want to collapse the first separator past the root presuming - // the root actually ends in a separator. Otherwise the first segment for TryRemoveRedundantSegments - // in cases like "\\?\C:\.\" and "\\?\C:\..\", the first segment after the root will be ".\" and "..\" which is not considered as a relative segment and hence not be removed. - if (IsDirectorySeparator(path[skip - 1])) + // We treat "\.." , "\." and "\\" as a redundant segment. + // We want to collapse the first separator past the root presuming the root actually ends in a separator. + // In cases like "\\?\C:\.\" and "\\?\C:\..\", the first segment after the root will be ".\" and "..\" which is not + // considered as a redundant segment and hence not be removed. + if (IsDirectorySeparator(path[charsToSkip - 1])) { - skip--; + charsToSkip--; } - sb.Append(path.Slice(0, skip)); + // Append the root, if any. + // Normalize its directory separators if needed + for (int i = 0; i < charsToSkip; i++) + { + c = path[i]; + flippedSeparator |= TryNormalizeSeparatorCharacter(ref c); + sb.Append(c); + } } - for (int i = skip; i < path.Length; i++) + // Iterate the characters after the root, if any. + for (int currPos = charsToSkip; currPos < path.Length; currPos++) { - char c = path[i]; + c = path[currPos]; + + bool isDirectorySeparator = IsDirectorySeparator(c); - if (IsDirectorySeparator(c) && i + 1 < path.Length) + // Normal case: Start analysis of current segment on the separator + if (isDirectorySeparator && currPos + 1 < path.Length) { - // Skip this character if it's a directory separator and if the next character is, too, - // e.g. "parent//child" => "parent/child" - if (IsDirectorySeparator(path[i + 1])) + // Skip repeated separators, take only the last one. + // e.g. "parent//child" => "parent/child", or "parent/////child" => "parent/child" + if (IsDirectorySeparator(path[currPos + 1])) { continue; } - // Skip this character and the next if it's referring to the current directory, - // e.g. "parent/./child" => "parent/child" - if ((i + 2 == path.Length || IsDirectorySeparator(path[i + 2])) && - path[i + 1] == '.') + // Skip the next segment if it's a single dot (current directory). + // Even if we are at the beginning of a path that is not fully qualified, we always remove these. + // e.g. "parent/./child" => "parent/child", or "parent/." => "parent/" or "./other" => "other" + if (IsNextSegmentSingleDot(path, currPos)) { - i++; + currPos++; continue; } - // Skip this character and the next two if it's referring to the parent directory, + // Skip the next segment if it's a double dot (backtrack to parent directory). // e.g. "parent/child/../grandchild" => "parent/grandchild" - if (i + 2 < path.Length && - (i + 3 == path.Length || IsDirectorySeparator(path[i + 3])) && - path[i + 1] == '.' && path[i + 2] == '.') + if (IsNextSegmentDoubleDot(path, currPos)) { - // Unwind back to the last slash (and if there isn't one, clear out everything). - int s; - for (s = sb.Length - 1; s >= skip; s--) + // Double dots can only get removed if we know who the parent is, which is always possible with fully qualified paths. + if (isFullyQualified) { - if (IsDirectorySeparator(sb[s])) - { - sb.Length = (i + 3 >= path.Length && s == skip) ? s + 1 : s; // to avoid removing the complete "\tmp\" segment in cases like \\?\C:\tmp\..\, C:\tmp\.. - break; - } + TryUnwindBackToPreviousSeparator(ref sb, path, charsToSkip, currPos); + currPos += 2; + continue; } - if (s < skip) + // Non fully qualified paths need to check if there is a folder segment before reaching position 0. + else { - sb.Length = skip; + // If the previous segment is double dot, it means it wasn't processed in a previous loop + // on purpose due to the path being unqualified up to the current position. + // Otherwise, it has to be a folder or a single dot (single dots are always backtracked). + // e.g. "../known" or "../../known" or "known" + if (!IsPreviousSegmentDoubleDot(path, currPos)) + { + // No folder was found behind the current position, only single or double dots. Add the double dots. + if (!TryUnwindBackToPreviousSeparator(ref sb, path, charsToSkip, currPos)) + { + // If the buffer contains data, add a directory separator only if the buffer does not have one already. + // e.g. "..\.\.." => "..\.." + if (sb.Length > 0 && !IsDirectorySeparator(sb[sb.Length - 1])) + { + sb.Append(path[currPos]); + flippedSeparator |= TryNormalizeSeparatorCharacter(ref sb[sb.Length - 1]); + } + // Add the double dots + sb.Append(".."); + } + + currPos += 2; + continue; + } } - - i += 2; - continue; } } + // Special case: single dot segments at the beginning of the path must be skipped + else if (charsToSkip == 0 && sb.Length == 0 && IsNextSegmentSingleDot(path, currPos - 1)) + { + currPos++; + continue; + } // Normalize the directory separator if needed - if (c != DirectorySeparatorChar && c == AltDirectorySeparatorChar) + if (isDirectorySeparator) { - c = DirectorySeparatorChar; - flippedSeparator = true; + flippedSeparator |= TryNormalizeSeparatorCharacter(ref c); } - sb.Append(c); + // Always add the character to the buffer if it's not a directory separator. + + // If it's a directory separator, only append it when: + // - The path is fully qualified: + // e.g. In Unix, a rooted path: "/home/.." => "/" + // - Is not fully qualified, but the buffer already has content: + // e.g. "folder/" => "folder/" + // - Is not fully qualified, buffer is empty but the very first segment is a double dot: + // e.g. "/../folder" => "/../folder" + + // If it's a directory separator, do not append when it's the first character of a sequence with these conditions: + // - Is not fully qualified, started with actual folders which got removed by double dot segments (buffer is empty), and + // has more double dot segments than folders, which would make the double dots reach the beginning of the buffer: + // e.g. "folder/../.." => ".." or "folder/folder/../../../" => "../" + // - Is not fully qualified but is rooted, starts with double dots, or started with actual folders which got removed by + // double dot segments (buffer is empty), and has more double dot segments than folders, which would make the double dots + // reach the beginning of the buffer: + // e.g. "C:..\System32" => "C:\System32" or "C:System32\..\..\" => "C:..\" + if (!isDirectorySeparator || isFullyQualified || sb.Length > root.Length || + (IsNextSegmentDoubleDot(path, currPos) && (currPos == 0 || sb.Length > root.Length))) + { + sb.Append(c); + } } // If we haven't changed the source path, return the original @@ -197,42 +281,101 @@ internal static bool TryRemoveRedundantSegments(ReadOnlySpan path, int cha return false; } - // We may have eaten the trailing separator from the root when we started and not replaced it - if (skip != charactersToSkip && sb.Length < charactersToSkip) + // Final adjustments: + // We may have eaten the trailing separator from the root when we started and not replaced it. + // Only append the trailing separator if the buffer contained information. + if (charsToSkip != root.Length && sb.Length > 0) { - sb.Append(path[charactersToSkip - 1]); + if (sb.Length < charsToSkip) + { + sb.Append(path[charsToSkip - 1]); + } + // e.g. "C:\." => "C:\" or "\\?\C:\.." => "\\?\C:\" + else if (sb.Length == charsToSkip && path.Length > charsToSkip && IsDirectorySeparator(path[charsToSkip])) + { + sb.Append(path[charsToSkip]); + } + } + // If the buffer contained information, but the path was not fully qualified and finished with a separator, + // the separator may have been added, but we should never return a single separator for non-fully qualified paths. + // e.g. "folder/../" => "" + else if (!isFullyQualified && sb.Length == 1 && IsDirectorySeparator(sb[0])) + { + sb.Length = 0; } return true; } - /// - /// Trims one trailing directory separator beyond the root of the path. - /// - [return: NotNullIfNotNull("path")] - internal static string? TrimEndingDirectorySeparator(string? path) => - EndsInDirectorySeparator(path) && !IsRoot(path.AsSpan()) ? - path!.Substring(0, path.Length - 1) : - path; + // Adjusts the length of the buffer to the position of the previous directory separator due to a double dot. + private static bool TryUnwindBackToPreviousSeparator(ref ValueStringBuilder sb, ReadOnlySpan path, int charsToSkip, int currPos) + { + bool onlyDotsAndSeparators = true; + for (int pos = currPos; pos >= charsToSkip; pos--) + { + if (path[pos] != '.' && !IsDirectorySeparator(path[pos])) + { + onlyDotsAndSeparators = false; + } + } + if (onlyDotsAndSeparators) + { + return false; + } - /// - /// Returns true if the path ends in a directory separator. - /// - internal static bool EndsInDirectorySeparator(string? path) => - !string.IsNullOrEmpty(path) && IsDirectorySeparator(path[path.Length - 1]); + int unwindPosition; + for (unwindPosition = sb.Length - 1; unwindPosition >= charsToSkip; unwindPosition--) + { + if (IsDirectorySeparator(sb[unwindPosition])) + { + // Avoid removing the root separator. + // e.g. "\\?\C:\tmp\..\" => "\\?\C:\" or "C:\tmp\.." => "C:\" or "C:\.." => "C:\" + sb.Length = (currPos + 3 >= path.Length && unwindPosition == charsToSkip) ? unwindPosition + 1 : unwindPosition; + break; + } + } + // Never go beyond the root. + // Or in the case of an unqualified path, if the initial segment was a folder + // without a separator at the beginning, the resulting string is empty. + if (unwindPosition < charsToSkip) + { + sb.Length = charsToSkip; + } - /// - /// Trims one trailing directory separator beyond the root of the path. - /// - internal static ReadOnlySpan TrimEndingDirectorySeparator(ReadOnlySpan path) => - EndsInDirectorySeparator(path) && !IsRoot(path) ? - path.Slice(0, path.Length - 1) : - path; + return true; + } - /// - /// Returns true if the path ends in a directory separator. - /// - internal static bool EndsInDirectorySeparator(ReadOnlySpan path) => - path.Length > 0 && IsDirectorySeparator(path[path.Length - 1]); + // If the character is a directory separator, ensure it is set to the current operating system's character. + private static bool TryNormalizeSeparatorCharacter(ref char c) + { + if (c != DirectorySeparatorChar && c == AltDirectorySeparatorChar) + { + c = DirectorySeparatorChar; + return true; + } + return false; + } + + // Checks if the segment before the specified position in the path is a "/../" segment. + private static bool IsPreviousSegmentDoubleDot(ReadOnlySpan path, int currPos) + { + return currPos == 0 || + (currPos - 2 >= 0 && path[currPos - 2] == '.' && path[currPos - 1] == '.'); + } + + // CHecks if the segment after the specified position in the path is a "/../" segment. + private static bool IsNextSegmentDoubleDot(ReadOnlySpan path, int currPos) + { + return currPos + 2 < path.Length && + (currPos + 3 == path.Length || IsDirectorySeparator(path[currPos + 3])) && + path[currPos + 1] == '.' && path[currPos + 2] == '.'; + } + + // CHecks if the segment after the specified position in the path is a "/./" segment. + private static bool IsNextSegmentSingleDot(ReadOnlySpan path, int currPos) + { + return (currPos + 2 == path.Length || IsDirectorySeparator(path[currPos + 2])) && + path[currPos + 1] == '.'; + } } } diff --git a/src/libraries/System.Runtime.Extensions/tests/System/IO/Path.RemoveRedundantSegments.cs b/src/libraries/System.Runtime.Extensions/tests/System/IO/Path.RemoveRedundantSegments.cs index 6e4364e0a59ccf..0d7c4885b56511 100644 --- a/src/libraries/System.Runtime.Extensions/tests/System/IO/Path.RemoveRedundantSegments.cs +++ b/src/libraries/System.Runtime.Extensions/tests/System/IO/Path.RemoveRedundantSegments.cs @@ -37,12 +37,12 @@ public static class RemoveRedundantSegmentsTests }; public static TheoryData UnixNormalData => new TheoryData { - // AltDirectorySeparatorChar gets normalized to DirectorySeparatorChar - { @"/", @"\" }, - { @"/home", @"\home" }, - { @"/home/", @"\home\" }, - { @"/home/myuser", @"\home\myuser" }, - { @"/home/myuser/", @"\home\myuser\" }, + // AltDirectorySeparatorChar gets normalized to DirectorySeparatorChar, if they are not the same in the current platform + { @"/", @"/" }, + { @"/home", @"/home" }, + { @"/home/", @"/home/" }, + { @"/home/myuser", @"/home/myuser" }, + { @"/home/myuser/", @"/home/myuser/" }, }; // Paths with '..' to indicate parent backtracking @@ -75,37 +75,23 @@ public static class RemoveRedundantSegmentsTests }; public static TheoryData UnixValidParentBacktrackingData => new TheoryData { - { @"/..", @"/" }, // This case is not normalizing the root AltDirectorySeparatorChar because no separator is found after the root (which was ignored) - { @"/../", @"\" }, - { @"/../home", @"\home" }, - { @"/../home/", @"\home\" }, - { @"/home/..", @"\" }, - { @"/home/../", @"\" }, - { @"/home/../..", @"/" }, - { @"/home/../../", @"\" }, - { @"/home/../myuser", @"\myuser" }, - { @"/home/../myuser/", @"\myuser\" }, - { @"/home/../../myuser", @"\myuser" }, - { @"/home/../../myuser/", @"\myuser\" }, - { @"/home/myuser/..", @"\home" }, - { @"/home/myuser/../", @"\home\" }, - { @"/home/myuser/../..", @"\" }, - { @"/home/myuser/../../", @"\" }, - { @"/home/../myuser/..", @"\" }, - { @"/home/../myuser/../", @"\" }, - { @"/home/../myuser/../..", @"/" }, // This case is not normalizing the root AltDirectorySeparatorChar - { @"/home/../myuser/../../", @"\" }, - { @"/home/../myuser/../../..", @"/" }, // This case is not normalizing the root AltDirectorySeparatorChar - { @"/home/../myuser/../../../", @"\" }, - { @"/home/../../myuser/../..", @"/" }, // This case is not normalizing the root AltDirectorySeparatorChar - { @"/home/../../myuser/../../", @"\" }, + { @"/home/..", @"/" }, + { @"/home/../", @"/" }, + { @"/home/../myuser", @"/myuser" }, + { @"/home/../myuser/", @"/myuser/" }, + { @"/home/myuser/..", @"/home" }, + { @"/home/myuser/../", @"/home/" }, + { @"/home/myuser/../..", @"/" }, + { @"/home/myuser/../../", @"/" }, + { @"/home/../myuser/..", @"/" }, + { @"/home/../myuser/../", @"/" }, }; // Paths with '.' to indicate current directory public static TheoryData WindowsValidCurrentDirectoryData => new TheoryData { - { @"C:\.", @"C:\" }, - { @"C:\.\", @"C:\" }, + { @"C:\.", @"C:\" }, + { @"C:\.\", @"C:\" }, { @"C:\.\.", @"C:\" }, { @"C:\.\.\", @"C:\" }, { @"C:\.\Users", @"C:\Users" }, @@ -125,28 +111,29 @@ public static class RemoveRedundantSegmentsTests { @"C:\Users\.\.\myuser\.\.", @"C:\Users\myuser" }, { @"C:\Users\.\.\myuser\.\.\", @"C:\Users\myuser\" }, }; + public static TheoryData UnixValidCurrentDirectoryData => new TheoryData { - { @"/.", @"/" }, - { @"/./", @"\" }, - { @"/./.", @"/" }, - { @"/././", @"\" }, - { @"/./home", @"\home" }, - { @"/./home/", @"\home\" }, - { @"/././home", @"\home" }, - { @"/././home/", @"\home\" }, - { @"/home/.", @"\home" }, - { @"/home/./", @"\home\" }, - { @"/home/./.", @"\home" }, - { @"/home/././", @"\home\" }, - { @"/./home/myuser", @"\home\myuser" }, - { @"/./home/myuser/", @"\home\myuser\" }, - { @"/./home/./myuser", @"\home\myuser" }, - { @"/./home/./myuser/", @"\home\myuser\" }, - { @"/./home/./myuser/.", @"\home\myuser" }, - { @"/./home/./myuser/./", @"\home\myuser\" }, - { @"/home/././myuser/./.", @"\home\myuser" }, - { @"/home/././myuser/././", @"\home\myuser\" }, + { @"/.", @"/" }, + { @"/./", @"/" }, + { @"/./.", @"/" }, + { @"/././", @"/" }, + { @"/./home", @"/home" }, + { @"/./home/", @"/home/" }, + { @"/././home", @"/home" }, + { @"/././home/", @"/home/" }, + { @"/home/.", @"/home" }, + { @"/home/./", @"/home/" }, + { @"/home/./.", @"/home" }, + { @"/home/././", @"/home/" }, + { @"/./home/myuser", @"/home/myuser" }, + { @"/./home/myuser/", @"/home/myuser/" }, + { @"/./home/./myuser", @"/home/myuser" }, + { @"/./home/./myuser/", @"/home/myuser/" }, + { @"/./home/./myuser/.", @"/home/myuser" }, + { @"/./home/./myuser/./", @"/home/myuser/" }, + { @"/home/././myuser/./.", @"/home/myuser" }, + { @"/home/././myuser/././", @"/home/myuser/" }, }; // Combined '.' and '..' @@ -185,99 +172,145 @@ public static class RemoveRedundantSegmentsTests { @"C:\..\Users\.\myuser", @"C:\Users\myuser" }, { @"C:\..\Users\.\myuser\", @"C:\Users\myuser\" }, }; + public static TheoryData UnixCombinedRedundantData => new TheoryData { - { @"/./..", @"/" }, - { @"/./../", @"\" }, - { @"/../.", @"/" }, - { @"/.././", @"\" }, - { @"/./../.", @"/" }, - { @"/./.././", @"\" }, - { @"/.././.", @"/" }, - { @"/../././", @"\" }, - { @"/./../..", @"/" }, - { @"/./../../", @"\" }, - { @"/.././..", @"/" }, - { @"/.././../", @"\" }, - { @"/home/./..", @"\" }, - { @"/home/./../", @"\" }, + { @"/home/./..", @"/" }, + { @"/home/./../", @"/" }, { @"/home/../.", @"/" }, - { @"/home/.././", @"\" }, - { @"/./home/..", @"\" }, - { @"/./home/../", @"\" }, - { @"/../home/.", @"\home" }, - { @"/../home/./", @"\home\" }, - { @"/./../home", @"\home" }, - { @"/./../home/", @"\home\" }, - { @"/.././home", @"\home" }, - { @"/.././home/", @"\home\" }, - { @"/./home/myuser/..", @"\home" }, - { @"/./home/myuser/../", @"\home\" }, - { @"/../home/myuser/.", @"\home\myuser" }, - { @"/../home/myuser/./", @"\home\myuser\" }, - { @"/./home/../myuser", @"\myuser" }, - { @"/./home/../myuser/", @"\myuser\" }, - { @"/../home/./myuser", @"\home\myuser" }, - { @"/../home/./myuser/", @"\home\myuser\" }, + { @"/home/.././", @"/" }, + { @"/./home/..", @"/" }, + { @"/./home/../", @"/" }, + { @"/./home/myuser/..", @"/home" }, + { @"/./home/myuser/../", @"/home/" }, + { @"/./home/../myuser", @"/myuser" }, + { @"/./home/../myuser/", @"/myuser/" }, + }; + + public static TheoryData DifferentBehaviorCurrentDirectoryData => new TheoryData + { + // Path Unix Windows + { @"/./home", @"/home", @"home" }, + { @"/././home/", @"/home/", @"home\" }, + { @"/home/.", @"/home", @"home" }, + { @"/home/./", @"/home/", @"home\" }, + { @"/home/./.", @"/home", @"home" }, + { @"/home/././", @"/home/", @"home\" }, + { @"/./home/./", @"/home/", @"home\" }, + { @"/./home/./.", @"/home", @"home" }, + { @"/./home/././", @"/home/", @"home\" }, + { @"/./home/././folder", @"/home/folder", @"home\folder" }, + { @"/./home/././folder/", @"/home/folder/", @"home\folder\" }, + }; + + public static TheoryData DifferentBehaviorBacktrackingData => new TheoryData + { + // Path Unix Windows + { @"/home/../myuser/../..", @"/", @".." }, + { @"/home/../myuser/../../", @"/", @"..\" }, + { @"/home/../myuser/../../..", @"/", @"..\.." }, + { @"/home/../myuser/../../../", @"/", @"..\..\" }, + { @"/home/../..", @"/", @".." }, + { @"/home/../../", @"/", @"..\" }, + { @"/home/../../myuser", @"/myuser", @"..\myuser" }, + { @"/home/../../myuser/", @"/myuser/", @"..\myuser\" }, + { @"/home/../../myuser/../..", @"/", @"..\.." }, + { @"/home/../../myuser/../../", @"/", @"..\..\" }, + { @"/..", @"/", @"\.." }, + { @"/../", @"/", @"\..\" }, + { @"/../home", @"/home", @"\..\home" }, + { @"/../home/", @"/home/", @"\..\home\" }, + { @"/../home/./myuser", @"/home/myuser", @"\..\home\myuser" }, + { @"/../home/./myuser/", @"/home/myuser/", @"\..\home\myuser\" }, }; + // Combined '.' and '..' + public static TheoryData DifferentBehaviorCombinedRedundantData => new TheoryData + { + // Path Unix Windows + { @"/./..", @"/", @".." }, + { @"/./../", @"/", @"..\" }, + { @"/../.", @"/", @"\.." }, + { @"/.././", @"/", @"\..\" }, + { @"/./../.", @"/", @".." }, + { @"/./.././", @"/", @"..\" }, + { @"/.././.", @"/", @"\.." }, + { @"/../././", @"/", @"\..\" }, + { @"/./../..", @"/", @"..\.." }, + { @"/./../../", @"/", @"..\..\" }, + { @"/.././..", @"/", @"\..\.." }, + { @"/.././../", @"/", @"\..\..\" }, + { @"/../home/.", @"/home", @"\..\home" }, + { @"/../home/./", @"/home/", @"\..\home\" }, + { @"/./../home", @"/home", @"..\home" }, + { @"/./../home/", @"/home/", @"..\home\" }, + { @"/.././home", @"/home", @"\..\home" }, + { @"/.././home/", @"/home/", @"\..\home\" }, + { @"/../home/myuser/.", @"/home/myuser", @"\..\home\myuser" }, + { @"/../home/myuser/./", @"/home/myuser/", @"\..\home\myuser\" }, + { @"/../home/myuser/./../", @"/home/", @"\..\home\" }, + { @"/../home/myuser/./../folder", @"/home/folder", @"\..\home\folder" }, + }; + + // Duplicate separators public static TheoryData WindowsDuplicateSeparatorsData => new TheoryData { - { @"C:\\", @"C:\" }, - { @"C:\\\", @"C:\" }, - { @"C://", @"C:\" }, - { @"C:///", @"C:\" }, - { @"C:\/", @"C:\" }, - { @"C:/\", @"C:\" }, - { @"C:\/\", @"C:\" }, - { @"C:/\\", @"C:\" }, - { @"C:\//", @"C:\" }, - { @"C:/\/", @"C:\" }, - { @"C:\\Users", @"C:\Users" }, - { @"C:\\\Users", @"C:\Users" }, - { @"C:\\Users\\\", @"C:\Users\" }, - { @"C:\\\Users\\", @"C:\Users\" }, - { @"C:\\Users\\/\", @"C:\Users\" }, - { @"C:\\\Users\/\", @"C:\Users\" }, - { @"C:\/Users", @"C:\Users" }, - { @"C:\/Users/", @"C:\Users\" }, - { @"C:\/Users/", @"C:\Users\" }, - { @"C:\\Users\\.\", @"C:\Users\" }, - { @"C:\\\Users\..\\", @"C:\" }, - { @"C:\\Users\\./.\", @"C:\Users\" }, - { @"C:\.\\Users\/./\\", @"C:\Users\" }, + { @"C:\\", @"C:\" }, + { @"C:\\\", @"C:\" }, + { @"C://", @"C:\" }, + { @"C:///", @"C:\" }, + { @"C:\/", @"C:\" }, + { @"C:/\", @"C:\" }, + { @"C:\/\", @"C:\" }, + { @"C:/\\", @"C:\" }, + { @"C:\//", @"C:\" }, + { @"C:/\/", @"C:\" }, + { @"C:\\Users", @"C:\Users" }, + { @"C:\\\Users", @"C:\Users" }, + { @"C:\\Users\\\", @"C:\Users\" }, + { @"C:\\\Users\\", @"C:\Users\" }, + { @"C:\\Users\\/\", @"C:\Users\" }, + { @"C:\\\Users\/\", @"C:\Users\" }, + { @"C:\/Users", @"C:\Users" }, + { @"C:\/Users/", @"C:\Users\" }, + { @"C:\/Users/", @"C:\Users\" }, + { @"C:\\Users\\.\", @"C:\Users\" }, + { @"C:\\\Users\..\\", @"C:\" }, + { @"C:\\Users\\./.\", @"C:\Users\" }, + { @"C:\.\\Users\/./\\", @"C:\Users\" }, { @"C:\\.\Users\/../\\./", @"C:\" }, }; + public static TheoryData UnixDuplicateSeparatorsData => new TheoryData { - { @"/home/", @"\home\" }, - { @"/home\\\", @"\home\" }, - { @"/home//", @"\home\" }, - { @"/home///", @"\home\" }, - { @"/home\/", @"\home\" }, - { @"/home/\", @"\home\" }, - { @"/home\/\", @"\home\" }, - { @"/home/\\", @"\home\" }, - { @"/home\//", @"\home\" }, - { @"/home/\/", @"\home\" }, - { @"/home\\myuser", @"\home\myuser" }, - { @"/home\\\myuser", @"\home\myuser" }, - { @"/home\\myuser\\\", @"\home\myuser\" }, - { @"/home\\\myuser\\", @"\home\myuser\" }, - { @"/home\\myuser\\/\", @"\home\myuser\" }, - { @"/home\\\myuser\/\", @"\home\myuser\" }, - { @"/home\/myuser", @"\home\myuser" }, - { @"/home\/myuser/", @"\home\myuser\" }, - { @"/home\/myuser/", @"\home\myuser\" }, - { @"/home\\myuser\\.\", @"\home\myuser\" }, - { @"/home\\\myuser\..\\", @"\home\" }, - { @"/home\\myuser\\./.\", @"\home\myuser\" }, - { @"/home\.\\myuser\/./\\", @"\home\myuser\" }, - { @"/home\\.\myuser\/../\\./", @"\home\" }, + { @"/home/", @"/home/" }, + { @"/home\\\", @"/home/" }, + { @"/home//", @"/home/" }, + { @"/home///", @"/home/" }, + { @"/home\/", @"/home/" }, + { @"/home/\", @"/home/" }, + { @"/home\/\", @"/home/" }, + { @"/home/\\", @"/home/" }, + { @"/home\//", @"/home/" }, + { @"/home/\/", @"/home/" }, + { @"/home\\myuser", @"/home/myuser" }, + { @"/home\\\myuser", @"/home/myuser" }, + { @"/home\\myuser\\\", @"/home/myuser/" }, + { @"/home\\\myuser\\", @"/home/myuser/" }, + { @"/home\\myuser\\/\", @"/home/myuser/" }, + { @"/home\\\myuser\/\", @"/home/myuser/" }, + { @"/home\/myuser", @"/home/myuser" }, + { @"/home\/myuser/", @"/home/myuser/" }, + { @"/home\/myuser/", @"/home/myuser/" }, + { @"/home\\myuser\\.\", @"/home/myuser/" }, + { @"/home\\\myuser\..\\", @"/home/" }, + { @"/home\\myuser\\./.\", @"/home/myuser/" }, + { @"/home\.\\myuser\/./\\", @"/home/myuser/" }, + { @"/home\\.\myuser\/../\\./", @"/home/" }, }; - // Network locations - Prefixes get prepended in the tests + // Network locations - "Server\Share" always stays. UNC prefixes get prepended in the tests. public static TheoryData UncData => new TheoryData { { @"Server\Share\git\runtime", @"Server\Share\git\runtime"}, @@ -295,45 +328,57 @@ public static class RemoveRedundantSegmentsTests { @"Server\Share\git\temp\..\runtime\", @"Server\Share\git\runtime\"}, }; - // Relative paths (no root) - public static TheoryData UnrootedData => new TheoryData - { - { @"Users\myuser\..\", @"Users\" }, - { @"myuser\..\", @"\" }, - { @"myuser", @"myuser" }, - { @".\myuser", @".\myuser" }, - { @".\myuser\", @".\myuser\" }, - { @"..\myuser", @"..\myuser" }, - { @"..\myuser\", @"..\myuser\" }, - { @"..\\myuser\", @"..\myuser\" }, + // Paths that are not rooted + public static TheoryData UnqualifiedPathsData => new TheoryData + { + { @"Users\myuser\..\", @"Users\" }, + { @"Users\myuser\..", @"Users" }, + { @"Users\..\..", @".." }, + { @"Users\..\..\", @"..\" }, + { @"myuser\..\", @"" }, + { @"myuser", @"myuser" }, + { @".\myuser", @"myuser" }, + { @".\myuser\", @"myuser\" }, + { @".\.\myuser", @"myuser" }, + { @".\.\myuser\", @"myuser\" }, + { @"..\myuser", @"..\myuser" }, + { @"..\myuser\", @"..\myuser\" }, + { @"..\\myuser\", @"..\myuser\" }, + { @"..\myuser\..", @".." }, + { @"..\first\..\second", @"..\second" }, + { @"..\first\..\..\second\..", @"..\.." }, + { @"..\first\..\..\second\..\", @"..\..\" }, + { @"..\first\..\..\second\..", @"..\.." }, + { @"..\first\..\..\second\..\..", @"..\..\.." }, + { @"..\first\..\..\second\..\..\", @"..\..\..\" }, }; public static TheoryData ValidEdgecasesData => new TheoryData { - { @"C:\Users\myuser\folder.with\one\dot", @"C:\Users\myuser\folder.with\one\dot" }, + { @"C:\Users\myuser\folder.with\one\dot", @"C:\Users\myuser\folder.with\one\dot" }, { @"C:\Users\myuser\folder..with\two\dots", @"C:\Users\myuser\folder..with\two\dots" }, - { @"C:\Users\.folder\startswithdot", @"C:\Users\.folder\startswithdot" }, - { @"C:\Users\folder.\endswithdot", @"C:\Users\folder.\endswithdot" }, - { @"C:\Users\..folder\startswithtwodots", @"C:\Users\..folder\startswithtwodots" }, - { @"C:\Users\folder..\endswithtwodots", @"C:\Users\folder..\endswithtwodots" }, + { @"C:\Users\.folder\startswithdot", @"C:\Users\.folder\startswithdot" }, + { @"C:\Users\folder.\endswithdot", @"C:\Users\folder.\endswithdot" }, + { @"C:\Users\..folder\startswithtwodots", @"C:\Users\..folder\startswithtwodots" }, + { @"C:\Users\folder..\endswithtwodots", @"C:\Users\folder..\endswithtwodots" }, { @"C:\Users\myuser\this\is\a\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\long\path\but\it\should\not\matter\extraword\..\", @"C:\Users\myuser\this\is\a\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\really\long\path\but\it\should\not\matter\" }, { @"C:\Users\myuser\this_is_a_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_long_foldername\but_it_should_not_matter\extraword\..\", @"C:\Users\myuser\this_is_a_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_really_long_foldername\but_it_should_not_matter\" }, }; [Theory] [MemberData(nameof(NullOrEmptyData))] - [MemberData(nameof(UnrootedData))] + [MemberData(nameof(UnqualifiedPathsData))] [MemberData(nameof(ValidEdgecasesData))] public static void SpecialCases_String(string path, string expected) => TestRedundantSegments(path, expected); [Theory] [MemberData(nameof(NullOrEmptyData))] - [MemberData(nameof(UnrootedData))] + [MemberData(nameof(UnqualifiedPathsData))] [MemberData(nameof(ValidEdgecasesData))] public static void SpecialCases_Span(string path, string expected) => TestRedundantSegments(path.AsSpan(), expected); [Theory] - [MemberData(nameof(UnrootedData))] + [MemberData(nameof(UnqualifiedPathsData))] [MemberData(nameof(ValidEdgecasesData))] public static void SpecialCases_True_Try(string path, string expected) => TestTryRedundantSegments(path, expected, true, expected.Length); @@ -341,12 +386,43 @@ public static class RemoveRedundantSegmentsTests [MemberData(nameof(NullOrEmptyData))] public static void SpecialCases_False_Try(string path, string expected) => TestTryRedundantSegments(path, expected, false, 0); + [Theory] + [MemberData(nameof(DifferentBehaviorCurrentDirectoryData))] + [MemberData(nameof(DifferentBehaviorBacktrackingData))] + [MemberData(nameof(DifferentBehaviorCombinedRedundantData))] + public static void DifferentBehavior_String(string path, string expectedUnix, string expectedWindows) + { + string expected = (PlatformDetection.IsWindows) ? expectedWindows : expectedUnix; + TestRedundantSegments(path, expected); + } + + [Theory] + [MemberData(nameof(DifferentBehaviorCurrentDirectoryData))] + [MemberData(nameof(DifferentBehaviorBacktrackingData))] + [MemberData(nameof(DifferentBehaviorCombinedRedundantData))] + public static void DifferentBehavior_Span(string path, string expectedUnix, string expectedWindows) + { + string expected = (PlatformDetection.IsWindows) ? expectedWindows : expectedUnix; + TestRedundantSegments(path.AsSpan(), expected); + } + + [Theory] + [MemberData(nameof(DifferentBehaviorCurrentDirectoryData))] + [MemberData(nameof(DifferentBehaviorBacktrackingData))] + [MemberData(nameof(DifferentBehaviorCombinedRedundantData))] + public static void DifferentBehavior_Try(string path, string expectedUnix, string expectedWindows) + { + string expected = (PlatformDetection.IsWindows) ? expectedWindows : expectedUnix; + TestTryRedundantSegments(path, expected, true, expected.Length); + } + [Theory] [MemberData(nameof(WindowsNormalData))] [MemberData(nameof(WindowsValidParentBacktrackingData))] [MemberData(nameof(WindowsValidCurrentDirectoryData))] [MemberData(nameof(WindowsCombinedRedundantData))] [MemberData(nameof(WindowsDuplicateSeparatorsData))] + [PlatformSpecific(TestPlatforms.Windows)] public static void WindowsValid_String(string path, string expected) { foreach (string prefix in s_Prefixes) @@ -375,6 +451,7 @@ public static void WindowsValid_Span(string path, string expected) [MemberData(nameof(WindowsValidCurrentDirectoryData))] [MemberData(nameof(WindowsCombinedRedundantData))] [MemberData(nameof(WindowsDuplicateSeparatorsData))] + [PlatformSpecific(TestPlatforms.Windows)] public static void WindowsValid_Try(string path, string expected) { foreach (string prefix in s_Prefixes) @@ -386,6 +463,7 @@ public static void WindowsValid_Try(string path, string expected) [Theory] [MemberData(nameof(UncData))] + [PlatformSpecific(TestPlatforms.Windows)] public static void WindowsUnc_String(string path, string expected) { foreach (string prefix in s_UncPrefixes) @@ -396,6 +474,7 @@ public static void WindowsUnc_String(string path, string expected) [Theory] [MemberData(nameof(UncData))] + [PlatformSpecific(TestPlatforms.Windows)] public static void WindowsUnc_Span(string path, string expected) { foreach (string prefix in s_UncPrefixes) @@ -406,6 +485,7 @@ public static void WindowsUnc_Span(string path, string expected) [Theory] [MemberData(nameof(UncData))] + [PlatformSpecific(TestPlatforms.Windows)] public static void WindowsUnc_Try(string path, string expected) { foreach (string prefix in s_UncPrefixes) @@ -415,7 +495,8 @@ public static void WindowsUnc_Try(string path, string expected) } - // The expected string is returned in Unix with '/' as separator + // The expected string is returned in Unix with '/' as separator. + // The trailing '/' is considered the root, hence it's a fully qualified path. [Theory] [MemberData(nameof(UnixNormalData))] @@ -423,6 +504,7 @@ public static void WindowsUnc_Try(string path, string expected) [MemberData(nameof(UnixValidCurrentDirectoryData))] [MemberData(nameof(UnixCombinedRedundantData))] [MemberData(nameof(UnixDuplicateSeparatorsData))] + [PlatformSpecific(TestPlatforms.AnyUnix)] public static void UnixValid_String(string path, string expected) { if (!PlatformDetection.IsWindows) @@ -438,6 +520,7 @@ public static void UnixValid_String(string path, string expected) [MemberData(nameof(UnixValidCurrentDirectoryData))] [MemberData(nameof(UnixCombinedRedundantData))] [MemberData(nameof(UnixDuplicateSeparatorsData))] + [PlatformSpecific(TestPlatforms.AnyUnix)] public static void UnixValid_Span(string path, string expected) { if (!PlatformDetection.IsWindows) @@ -453,6 +536,7 @@ public static void UnixValid_Span(string path, string expected) [MemberData(nameof(UnixValidCurrentDirectoryData))] [MemberData(nameof(UnixCombinedRedundantData))] [MemberData(nameof(UnixDuplicateSeparatorsData))] + [PlatformSpecific(TestPlatforms.AnyUnix)] public static void UnixValid_Try(string path, string expected) { if (!PlatformDetection.IsWindows) From 87c031daca77549710676870cc9fd99d22aa6ef7 Mon Sep 17 00:00:00 2001 From: carlossanlop Date: Thu, 12 Mar 2020 11:27:30 -0700 Subject: [PATCH 6/7] Fix build break in net472 for Microsoft.IO.Redist by avoiding using the span overloads of GetPathRoot and IsPathFullyQualified inside PathInternal. --- .../src/System/IO/Path.cs | 17 ++++++++++++++--- .../src/System/IO/PathInternal.cs | 16 +++++++--------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs b/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs index 038713d365900a..a57ef28d1fec78 100644 --- a/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs +++ b/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs @@ -943,7 +943,10 @@ private static string GetRelativePath(string relativeTo, string path, StringComp ValueStringBuilder sb = new ValueStringBuilder(path.Length); - if (!PathInternal.TryRemoveRedundantSegments(path.AsSpan(), ref sb)) + ReadOnlySpan root = GetPathRoot(path.AsSpan()); + bool isFullyQualified = IsPathFullyQualified(path); + + if (!PathInternal.TryRemoveRedundantSegments(path.AsSpan(), root.Length, isFullyQualified, ref sb)) { sb.Dispose(); return path; @@ -965,7 +968,11 @@ public static string RemoveRedundantSegments(ReadOnlySpan path) ValueStringBuilder sb = new ValueStringBuilder(path.Length); string result; - if (!PathInternal.TryRemoveRedundantSegments(path, ref sb)) + + ReadOnlySpan root = GetPathRoot(path); + bool isFullyQualified = IsPathFullyQualified(path); + + if (!PathInternal.TryRemoveRedundantSegments(path, root.Length, isFullyQualified, ref sb)) { result = path.ToString(); sb.Dispose(); @@ -996,7 +1003,11 @@ public static bool TryRemoveRedundantSegments(ReadOnlySpan path, Span root = GetPathRoot(path); + bool isFullyQualified = IsPathFullyQualified(path); + + if (PathInternal.TryRemoveRedundantSegments(path, root.Length, isFullyQualified, ref sb)) { result = sb.TryCopyTo(destination, out charsWritten); // Disposes } diff --git a/src/libraries/System.Private.CoreLib/src/System/IO/PathInternal.cs b/src/libraries/System.Private.CoreLib/src/System/IO/PathInternal.cs index 544f773e1c2e56..08762a09568385 100644 --- a/src/libraries/System.Private.CoreLib/src/System/IO/PathInternal.cs +++ b/src/libraries/System.Private.CoreLib/src/System/IO/PathInternal.cs @@ -134,18 +134,16 @@ internal static bool EndsInDirectorySeparator(ReadOnlySpan path) => /// Tries to remove relative segments from the given path, starting the analysis at the specified location. /// /// The input path. + /// The length of the root prefix, if any. + /// Whether the path is fully qualified or not. /// A reference to a value string builder that will store the result. /// if the path was modified; otherwise. - internal static bool TryRemoveRedundantSegments(ReadOnlySpan path, ref ValueStringBuilder sb) + internal static bool TryRemoveRedundantSegments(ReadOnlySpan path, int rootLength, bool isFullyQualified, ref ValueStringBuilder sb) { Debug.Assert(path.Length > 0); - ReadOnlySpan root = Path.GetPathRoot(path); - int charsToSkip = root.Length; - - bool isFullyQualified = Path.IsPathFullyQualified(path); + int charsToSkip = rootLength; bool flippedSeparator = false; - char c; // Remove "//", "/./", and "/../" from the path by copying each character to the output, except the ones we're removing, @@ -268,8 +266,8 @@ internal static bool TryRemoveRedundantSegments(ReadOnlySpan path, ref Val // double dot segments (buffer is empty), and has more double dot segments than folders, which would make the double dots // reach the beginning of the buffer: // e.g. "C:..\System32" => "C:\System32" or "C:System32\..\..\" => "C:..\" - if (!isDirectorySeparator || isFullyQualified || sb.Length > root.Length || - (IsNextSegmentDoubleDot(path, currPos) && (currPos == 0 || sb.Length > root.Length))) + if (!isDirectorySeparator || isFullyQualified || sb.Length > rootLength || + (IsNextSegmentDoubleDot(path, currPos) && (currPos == 0 || sb.Length > rootLength))) { sb.Append(c); } @@ -284,7 +282,7 @@ internal static bool TryRemoveRedundantSegments(ReadOnlySpan path, ref Val // Final adjustments: // We may have eaten the trailing separator from the root when we started and not replaced it. // Only append the trailing separator if the buffer contained information. - if (charsToSkip != root.Length && sb.Length > 0) + if (charsToSkip != rootLength && sb.Length > 0) { if (sb.Length < charsToSkip) { From 3cfbe4576c8606ffb556b271ed74b7773e826779 Mon Sep 17 00:00:00 2001 From: carlossanlop Date: Wed, 22 Apr 2020 14:38:09 -0700 Subject: [PATCH 7/7] Made some methods span based. Added a missing PlatformSpecific attribute. --- .../src/System/IO/Path.Windows.cs | 19 ++++++++++----- .../src/System/IO/Path.cs | 13 ++++++++++- .../src/System/IO/PathInternal.Windows.cs | 23 ++++++++++++++++--- .../System/IO/Path.RemoveRedundantSegments.cs | 1 + 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/IO/Path.Windows.cs b/src/libraries/System.Private.CoreLib/src/System/IO/Path.Windows.cs index 28ce0847cd057b..6d36c443dc2e73 100644 --- a/src/libraries/System.Private.CoreLib/src/System/IO/Path.Windows.cs +++ b/src/libraries/System.Private.CoreLib/src/System/IO/Path.Windows.cs @@ -213,11 +213,18 @@ public static bool IsPathRooted(ReadOnlySpan path) if (PathInternal.IsEffectivelyEmpty(path.AsSpan())) return null; - ReadOnlySpan result = GetPathRoot(path.AsSpan()); - if (path!.Length == result.Length) - return PathInternal.NormalizeDirectorySeparators(path); + int rootLength = PathInternal.GetRootLength(path.AsSpan()); + if (rootLength <= 0) + return null; - return PathInternal.NormalizeDirectorySeparators(result.ToString()); + Span destination = stackalloc char[rootLength]; + // Fails when path already normalized (or empty, but won't happen here) + if (PathInternal.TryNormalizeDirectorySeparators(path.AsSpan(0, rootLength), destination, out int charsWritten)) + { + return destination.Slice(0, charsWritten).ToString(); + } + // Reaching here means the path was already normalized + return path!.Substring(0, rootLength); } /// @@ -228,8 +235,8 @@ public static ReadOnlySpan GetPathRoot(ReadOnlySpan path) if (PathInternal.IsEffectivelyEmpty(path)) return ReadOnlySpan.Empty; - int pathRoot = PathInternal.GetRootLength(path); - return pathRoot <= 0 ? ReadOnlySpan.Empty : path.Slice(0, pathRoot); + int rootLength = PathInternal.GetRootLength(path); + return rootLength <= 0 ? ReadOnlySpan.Empty : path.Slice(0, rootLength); } /// Gets whether the system is case-sensitive. diff --git a/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs b/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs index a57ef28d1fec78..a1e82512980dce 100644 --- a/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs +++ b/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs @@ -107,7 +107,18 @@ public static partial class Path return null; int end = GetDirectoryNameOffset(path.AsSpan()); - return end >= 0 ? PathInternal.NormalizeDirectorySeparators(path.Substring(0, end)) : null; + + if (end >= 0) + { + Span destination = stackalloc char[end]; + // Fails when empty or already normalized + if (PathInternal.TryNormalizeDirectorySeparators(path.AsSpan(0, end), destination, out int charsWritten)) + { + return destination.Slice(0, charsWritten).ToString(); + } + return path.Substring(0, end); + } + return null; } /// diff --git a/src/libraries/System.Private.CoreLib/src/System/IO/PathInternal.Windows.cs b/src/libraries/System.Private.CoreLib/src/System/IO/PathInternal.Windows.cs index b30f690e0ea58a..97805f944a6b16 100644 --- a/src/libraries/System.Private.CoreLib/src/System/IO/PathInternal.Windows.cs +++ b/src/libraries/System.Private.CoreLib/src/System/IO/PathInternal.Windows.cs @@ -314,7 +314,7 @@ internal static bool IsDirectorySeparator(char c) /// Like the current NormalizePath this will not try and analyze periods/spaces within directory segments. /// /// - /// The only callers that used to use Path.Normalize(fullCheck=false) were Path.GetDirectoryName() and Path.GetPathRoot(). Both usages do + /// The only callers that used to use Path.Normalize(fullCheck=false) were Path.GetDirectoryName() and Path.GetPathRoot(string). Both usages do /// not need trimming of trailing whitespace here. /// /// GetPathRoot() could technically skip normalizing separators after the second segment- consider as a future optimization. @@ -341,6 +341,22 @@ internal static bool IsDirectorySeparator(char c) if (string.IsNullOrEmpty(path)) return path; + Span destination = stackalloc char[path.Length]; + if (!TryNormalizeDirectorySeparators(path.AsSpan(), destination, out int charsWritten)) + { + return path; + } + + return destination.Slice(0, charsWritten).ToString(); + } + + internal static bool TryNormalizeDirectorySeparators(ReadOnlySpan path, Span destination, out int charsWritten) + { + charsWritten = 0; + + if (IsEffectivelyEmpty(path)) + return false; + char current; // Make a pass to see if we need to normalize so we can potentially skip allocating @@ -360,7 +376,7 @@ internal static bool IsDirectorySeparator(char c) } if (normalized) - return path; + return false; var builder = new ValueStringBuilder(stackalloc char[MaxShortPath]); @@ -391,7 +407,7 @@ internal static bool IsDirectorySeparator(char c) builder.Append(current); } - return builder.ToString(); + return builder.TryCopyTo(destination, out charsWritten); } /// @@ -411,5 +427,6 @@ internal static bool IsEffectivelyEmpty(ReadOnlySpan path) } return true; } + } } diff --git a/src/libraries/System.Runtime.Extensions/tests/System/IO/Path.RemoveRedundantSegments.cs b/src/libraries/System.Runtime.Extensions/tests/System/IO/Path.RemoveRedundantSegments.cs index 0d7c4885b56511..6cc87b541eff3b 100644 --- a/src/libraries/System.Runtime.Extensions/tests/System/IO/Path.RemoveRedundantSegments.cs +++ b/src/libraries/System.Runtime.Extensions/tests/System/IO/Path.RemoveRedundantSegments.cs @@ -437,6 +437,7 @@ public static void WindowsValid_String(string path, string expected) [MemberData(nameof(WindowsValidCurrentDirectoryData))] [MemberData(nameof(WindowsCombinedRedundantData))] [MemberData(nameof(WindowsDuplicateSeparatorsData))] + [PlatformSpecific(TestPlatforms.Windows)] public static void WindowsValid_Span(string path, string expected) { foreach (string prefix in s_Prefixes)