From 5d1c77884f6686a59f121639dc177b7095e8c477 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 5 Sep 2020 13:37:40 -0400 Subject: add unit tests for PathUtilities, fix some edge cases --- src/SMAPI.Toolkit/Utilities/PathUtilities.cs | 43 ++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) (limited to 'src/SMAPI.Toolkit/Utilities/PathUtilities.cs') diff --git a/src/SMAPI.Toolkit/Utilities/PathUtilities.cs b/src/SMAPI.Toolkit/Utilities/PathUtilities.cs index e9d71747..34940d4f 100644 --- a/src/SMAPI.Toolkit/Utilities/PathUtilities.cs +++ b/src/SMAPI.Toolkit/Utilities/PathUtilities.cs @@ -9,6 +9,13 @@ namespace StardewModdingAPI.Toolkit.Utilities /// Provides utilities for normalizing file paths. public static class PathUtilities { + /********* + ** Fields + *********/ + /// The root prefix for a Windows UNC path. + private const string WindowsUncRoot = @"\\"; + + /********* ** Accessors *********/ @@ -25,6 +32,7 @@ namespace StardewModdingAPI.Toolkit.Utilities /// Get the segments from a path (e.g. /usr/bin/example => usr, bin, and example). /// The path to split. /// The number of segments to match. Any additional segments will be merged into the last returned part. + [Pure] public static string[] GetSegments(string path, int? limit = null) { return limit.HasValue @@ -37,16 +45,28 @@ namespace StardewModdingAPI.Toolkit.Utilities [Pure] public static string NormalizePathSeparators(string path) { - string[] parts = PathUtilities.GetSegments(path); - string normalized = string.Join(PathUtilities.PreferredPathSeparator, parts); - if (path.StartsWith(PathUtilities.PreferredPathSeparator)) - normalized = PathUtilities.PreferredPathSeparator + normalized; // keep root slash + string normalized = string.Join(PathUtilities.PreferredPathSeparator, PathUtilities.GetSegments(path)); + + // keep root +#if SMAPI_FOR_WINDOWS + if (path.StartsWith(PathUtilities.WindowsUncRoot)) + normalized = PathUtilities.WindowsUncRoot + normalized; + else +#endif + if (path.StartsWith(PathUtilities.PreferredPathSeparator) || path.StartsWith(PathUtilities.WindowsUncRoot)) + normalized = PathUtilities.PreferredPathSeparator + normalized; + return normalized; } - /// Get a directory or file path relative to a given source path. + /// Get a directory or file path relative to a given source path. If no relative path is possible (e.g. the paths are on different drives), an absolute path is returned. /// The source folder path. /// The target folder or file path. + /// + /// + /// NOTE: this is a heuristic implementation that works in the cases SMAPI needs it for, but it doesn't handle all edge cases (e.g. case-sensitivity on Linux, or traversing between UNC paths on Windows). This should be replaced with the more comprehensive Path.GetRelativePath if the game ever migrates to .NET Core. + /// + /// [Pure] public static string GetRelativePath(string sourceDir, string targetPath) { @@ -58,13 +78,25 @@ namespace StardewModdingAPI.Toolkit.Utilities // get relative path string relative = PathUtilities.NormalizePathSeparators(Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString())); + + // set empty path to './' if (relative == "") relative = "./"; + + // fix root + if (relative.StartsWith("file:") && !targetPath.Contains("file:")) + { + relative = relative.Substring("file:".Length); + if (targetPath.StartsWith(PathUtilities.WindowsUncRoot) && !relative.StartsWith(PathUtilities.WindowsUncRoot)) + relative = PathUtilities.WindowsUncRoot + relative.TrimStart('\\'); + } + return relative; } /// Get whether a path is relative and doesn't try to climb out of its containing folder (e.g. doesn't contain ../). /// The path to check. + [Pure] public static bool IsSafeRelativePath(string path) { if (string.IsNullOrWhiteSpace(path)) @@ -77,6 +109,7 @@ namespace StardewModdingAPI.Toolkit.Utilities /// Get whether a string is a valid 'slug', containing only basic characters that are safe in all contexts (e.g. filenames, URLs, etc). /// The string to check. + [Pure] public static bool IsSlug(string str) { return !Regex.IsMatch(str, "[^a-z0-9_.-]", RegexOptions.IgnoreCase); -- cgit From be1df8e7050ff5872799f6bee7f8cb419d7a3f38 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 5 Sep 2020 14:51:52 -0400 Subject: simplify path separator normalization It no longer tries to clean up the path (e.g. "path/to///file/" => "path/to/file"), which means it can more intuitively handle cases like this: asset.AssetName.StartsWith(PathUtilities.NormalizePathSeparators("Characters/Dialogue/")) --- src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs | 55 +++++++++++++++++++++++-- src/SMAPI.Toolkit/Utilities/PathUtilities.cs | 32 ++++++-------- 2 files changed, 65 insertions(+), 22 deletions(-) (limited to 'src/SMAPI.Toolkit/Utilities/PathUtilities.cs') diff --git a/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs b/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs index 1d6c371d..ea69a9ea 100644 --- a/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs +++ b/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs @@ -24,6 +24,18 @@ namespace SMAPI.Tests.Utilities NormalizedOnUnix = @"C:/Program Files (x86)/Steam/steamapps/common/Stardew Valley" }, + // Windows absolute path (with trailing slash) + new SamplePath + { + OriginalPath = @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\", + + Segments = new[] { "C:", "Program Files (x86)", "Steam", "steamapps", "common", "Stardew Valley" }, + SegmentsLimit3 = new [] { "C:", "Program Files (x86)", @"Steam\steamapps\common\Stardew Valley\" }, + + NormalizedOnWindows = @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\", + NormalizedOnUnix = @"C:/Program Files (x86)/Steam/steamapps/common/Stardew Valley/" + }, + // Windows relative path new SamplePath { @@ -68,10 +80,22 @@ namespace SMAPI.Tests.Utilities Segments = new [] { "home", ".steam", "steam", "steamapps", "common", "Stardew Valley" }, SegmentsLimit3 = new [] { "home", ".steam", "steam/steamapps/common/Stardew Valley" }, - NormalizedOnWindows = @"home\.steam\steam\steamapps\common\Stardew Valley", + NormalizedOnWindows = @"\home\.steam\steam\steamapps\common\Stardew Valley", NormalizedOnUnix = @"/home/.steam/steam/steamapps/common/Stardew Valley" }, + // Linux absolute path (with trailing slash) + new SamplePath + { + OriginalPath = @"/home/.steam/steam/steamapps/common/Stardew Valley/", + + Segments = new [] { "home", ".steam", "steam", "steamapps", "common", "Stardew Valley" }, + SegmentsLimit3 = new [] { "home", ".steam", "steam/steamapps/common/Stardew Valley/" }, + + NormalizedOnWindows = @"\home\.steam\steam\steamapps\common\Stardew Valley\", + NormalizedOnUnix = @"/home/.steam/steam/steamapps/common/Stardew Valley/" + }, + // Linux absolute path (with ~) new SamplePath { @@ -198,16 +222,41 @@ namespace SMAPI.Tests.Utilities @"\\adjacent\unc", ExpectedResult = @"\\adjacent\unc" )] + [TestCase( + @"C:\same\path", + @"C:\same\path", + ExpectedResult = @"." + )] [TestCase( @"C:\parent", @"C:\PARENT\child", - ExpectedResult = @"child" // note: incorrect on Linux and sometimes MacOS, but not worth the complexity of detecting whether the filesystem is case-sensitive for SMAPI's purposes + ExpectedResult = @"child" )] #else [TestCase( @"~/.steam/steam/steamapps/common/Stardew Valley", @"~/.steam/steam/steamapps/common/Stardew Valley/Mods/Automate", - ExpectedResult = @"Mods\Automate" + ExpectedResult = @"Mods/Automate" + )] + [TestCase( + @"~/.steam/steam/steamapps/common/Stardew Valley/Mods/Automate", + @"~/.steam/steam/steamapps/common/Stardew Valley/Content", + ExpectedResult = @"../../Content" + )] + [TestCase( + @"~/.steam/steam/steamapps/common/Stardew Valley/Mods/Automate", + @"/mnt/another-drive", + ExpectedResult = @"/mnt/another-drive" + )] + [TestCase( + @"~/same/path", + @"~/same/path", + ExpectedResult = @"." + )] + [TestCase( + @"~/parent", + @"~/PARENT/child", + ExpectedResult = @"child" // note: incorrect on Linux and sometimes MacOS, but not worth the complexity of detecting whether the filesystem is case-sensitive for SMAPI's purposes )] #endif public string GetRelativePath(string sourceDir, string targetPath) diff --git a/src/SMAPI.Toolkit/Utilities/PathUtilities.cs b/src/SMAPI.Toolkit/Utilities/PathUtilities.cs index 34940d4f..bd5fafbc 100644 --- a/src/SMAPI.Toolkit/Utilities/PathUtilities.cs +++ b/src/SMAPI.Toolkit/Utilities/PathUtilities.cs @@ -45,18 +45,10 @@ namespace StardewModdingAPI.Toolkit.Utilities [Pure] public static string NormalizePathSeparators(string path) { - string normalized = string.Join(PathUtilities.PreferredPathSeparator, PathUtilities.GetSegments(path)); - - // keep root -#if SMAPI_FOR_WINDOWS - if (path.StartsWith(PathUtilities.WindowsUncRoot)) - normalized = PathUtilities.WindowsUncRoot + normalized; - else -#endif - if (path.StartsWith(PathUtilities.PreferredPathSeparator) || path.StartsWith(PathUtilities.WindowsUncRoot)) - normalized = PathUtilities.PreferredPathSeparator + normalized; + if (string.IsNullOrWhiteSpace(path)) + return path; - return normalized; + return string.Join(PathUtilities.PreferredPathSeparator, path.Split(PathUtilities.PossiblePathSeparators)); } /// Get a directory or file path relative to a given source path. If no relative path is possible (e.g. the paths are on different drives), an absolute path is returned. @@ -79,16 +71,18 @@ namespace StardewModdingAPI.Toolkit.Utilities // get relative path string relative = PathUtilities.NormalizePathSeparators(Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString())); - // set empty path to './' + // normalize if (relative == "") - relative = "./"; - - // fix root - if (relative.StartsWith("file:") && !targetPath.Contains("file:")) + relative = "."; + else { - relative = relative.Substring("file:".Length); - if (targetPath.StartsWith(PathUtilities.WindowsUncRoot) && !relative.StartsWith(PathUtilities.WindowsUncRoot)) - relative = PathUtilities.WindowsUncRoot + relative.TrimStart('\\'); + // trim trailing slash from URL + if (relative.EndsWith(PathUtilities.PreferredPathSeparator)) + relative = relative.Substring(0, relative.Length - PathUtilities.PreferredPathSeparator.Length); + + // fix root + if (relative.StartsWith("file:") && !targetPath.Contains("file:")) + relative = relative.Substring("file:".Length); } return relative; -- cgit