diff --git a/Duplicati/CommandLine/BackendTool/Program.cs b/Duplicati/CommandLine/BackendTool/Program.cs index 0b30706cd..c72cee92d 100644 --- a/Duplicati/CommandLine/BackendTool/Program.cs +++ b/Duplicati/CommandLine/BackendTool/Program.cs @@ -25,7 +25,7 @@ using System.Threading; namespace Duplicati.CommandLine.BackendTool { - class Program + public class Program { /// /// The main entry point for the application. diff --git a/Duplicati/Library/Common/Duplicati.Library.Common.csproj b/Duplicati/Library/Common/Duplicati.Library.Common.csproj index 6fc16df6a..abd423d19 100644 --- a/Duplicati/Library/Common/Duplicati.Library.Common.csproj +++ b/Duplicati/Library/Common/Duplicati.Library.Common.csproj @@ -62,6 +62,8 @@ + + diff --git a/Duplicati/Library/Common/IO/Path.cs b/Duplicati/Library/Common/IO/Path.cs new file mode 100755 index 000000000..6a830a955 --- /dev/null +++ b/Duplicati/Library/Common/IO/Path.cs @@ -0,0 +1,59 @@ +// Adapted from https://raw.githubusercontent.com/dotnet/runtime/v5.0.12/src/libraries/System.Private.CoreLib/src/System/IO/Path.cs + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// The MIT License (MIT) +// +// Copyright(c).NET Foundation and Contributors +// +// All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System; + +namespace Duplicati.Library.Common.IO +{ + public static class DotNetRuntimePathWindows + { + /// + /// Returns true if the path is fixed to a specific drive or UNC path. This method does no + /// validation of the path (URIs will be returned as relative as a result). + /// Returns false if the path specified is relative to the current drive or working directory. + /// + /// + /// Handles paths that use the alternate directory separator. It is a frequent mistake to + /// assume that rooted paths are not relative. This isn't the case. + /// "C:a" is drive relative- meaning that it will be resolved against the current directory + /// for C: (rooted, but relative). "C:\a" is rooted and not relative (the current directory + /// will not be used to modify the path). + /// + /// + /// Thrown if is null. + /// + public static bool IsPathFullyQualified(string path) + { + if (path == null) + throw new ArgumentNullException(nameof(path)); + + return !PathInternalWindows.IsPartiallyQualified(path); + } + } +} diff --git a/Duplicati/Library/Common/IO/PathInternal.Windows.cs b/Duplicati/Library/Common/IO/PathInternal.Windows.cs new file mode 100755 index 000000000..75a117392 --- /dev/null +++ b/Duplicati/Library/Common/IO/PathInternal.Windows.cs @@ -0,0 +1,133 @@ +// Adapted from https://github.com/dotnet/runtime/blob/v5.0.12/src/libraries/Common/src/System/IO/PathInternal.Windows.cs + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// The MIT License (MIT) +// +// Copyright(c).NET Foundation and Contributors +// +// All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +using System.Runtime.CompilerServices; +using System.IO; + +namespace Duplicati.Library.Common.IO +{ + /// Contains internal path helpers that are shared between many projects. + internal static class PathInternalWindows + { + // All paths in Win32 ultimately end up becoming a path to a File object in the Windows object manager. Passed in paths get mapped through + // DosDevice symbolic links in the object tree to actual File objects under \Devices. To illustrate, this is what happens with a typical + // path "Foo" passed as a filename to any Win32 API: + // + // 1. "Foo" is recognized as a relative path and is appended to the current directory (say, "C:\" in our example) + // 2. "C:\Foo" is prepended with the DosDevice namespace "\??\" + // 3. CreateFile tries to create an object handle to the requested file "\??\C:\Foo" + // 4. The Object Manager recognizes the DosDevices prefix and looks + // a. First in the current session DosDevices ("\Sessions\1\DosDevices\" for example, mapped network drives go here) + // b. If not found in the session, it looks in the Global DosDevices ("\GLOBAL??\") + // 5. "C:" is found in DosDevices (in our case "\GLOBAL??\C:", which is a symbolic link to "\Device\HarddiskVolume6") + // 6. The full path is now "\Device\HarddiskVolume6\Foo", "\Device\HarddiskVolume6" is a File object and parsing is handed off + // to the registered parsing method for Files + // 7. The registered open method for File objects is invoked to create the file handle which is then returned + // + // There are multiple ways to directly specify a DosDevices path. The final format of "\??\" is one way. It can also be specified + // as "\\.\" (the most commonly documented way) and "\\?\". If the question mark syntax is used the path will skip normalization + // (essentially GetFullPathName()) and path length checks. + + // Windows Kernel-Mode Object Manager + // https://msdn.microsoft.com/en-us/library/windows/hardware/ff565763.aspx + // https://channel9.msdn.com/Shows/Going+Deep/Windows-NT-Object-Manager + // + // Introduction to MS-DOS Device Names + // https://msdn.microsoft.com/en-us/library/windows/hardware/ff548088.aspx + // + // Local and Global MS-DOS Device Names + // https://msdn.microsoft.com/en-us/library/windows/hardware/ff554302.aspx + + internal const string ExtendedDevicePathPrefix = @"\\?\"; + internal const string UncPathPrefix = @"\\"; + internal const string UncDevicePrefixToInsert = @"?\UNC\"; + internal const string UncExtendedPathPrefix = @"\\?\UNC\"; + internal const string DevicePathPrefix = @"\\.\"; + + internal const int MaxShortPath = 260; + + // \\?\, \\.\, \??\ + internal const int DevicePrefixLength = 4; + + /// + /// Returns true if the given character is a valid drive letter + /// + internal static bool IsValidDriveChar(char value) + { + return ((value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z')); + } + + /// + /// Returns true if the path specified is relative to the current drive or working directory. + /// Returns false if the path is fixed to a specific drive or UNC path. This method does no + /// validation of the path (URIs will be returned as relative as a result). + /// + /// + /// Handles paths that use the alternate directory separator. It is a frequent mistake to + /// assume that rooted paths (Path.IsPathRooted) are not relative. This isn't the case. + /// "C:a" is drive relative- meaning that it will be resolved against the current directory + /// for C: (rooted, but relative). "C:\a" is rooted and not relative (the current directory + /// will not be used to modify the path). + /// + internal static bool IsPartiallyQualified(string path) + { + if (path.Length < 2) + { + // It isn't fixed, it must be relative. There is no way to specify a fixed + // path with one character (or less). + return true; + } + + if (IsDirectorySeparator(path[0])) + { + // There is no valid way to specify a relative path with two initial slashes or + // \? as ? isn't valid for drive relative paths and \??\ is equivalent to \\?\ + return !(path[1] == '?' || IsDirectorySeparator(path[1])); + } + + // The only way to specify a fixed path that doesn't begin with two slashes + // is the drive, colon, slash format- i.e. C:\ + return !((path.Length >= 3) + && (path[1] == Path.VolumeSeparatorChar) + && IsDirectorySeparator(path[2]) + // To match old behavior we'll check the drive character for validity as the path is technically + // not qualified if you don't have a valid drive. "=:\" is the "=" file's default data stream. + && IsValidDriveChar(path[0])); + } + + /// + /// True if the given character is a directory separator. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool IsDirectorySeparator(char c) + { + return c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar; + } + } +} diff --git a/Duplicati/Library/Common/IO/SystemIOWindows.cs b/Duplicati/Library/Common/IO/SystemIOWindows.cs index b29a34331..52f316576 100644 --- a/Duplicati/Library/Common/IO/SystemIOWindows.cs +++ b/Duplicati/Library/Common/IO/SystemIOWindows.cs @@ -29,20 +29,51 @@ namespace Duplicati.Library.Common.IO { public struct SystemIOWindows : ISystemIO { - private const string UNCPREFIX = @"\\?\"; - private const string UNCPREFIX_SERVER = @"\\?\UNC\"; - private const string PATHPREFIX_SERVER = @"\\"; + private const string UNCPREFIX = PathInternalWindows.ExtendedDevicePathPrefix; + private const string UNCPREFIX_SERVER = PathInternalWindows.UncExtendedPathPrefix; + private const string PATHPREFIX_SERVER = PathInternalWindows.UncPathPrefix; + private const string PATHPREFIX_SERVER_ALT = @"//"; private static readonly string DIRSEP = Util.DirectorySeparatorString; + /// + /// Prefix path with one of the UNC prefixes, but only if it's a fully + /// qualified path with no relative components (i.e., with no "." or + /// ".." as part of the path). + /// public static string PrefixWithUNC(string path) { if (IsPrefixedWithUNC(path)) { + // For example: \\?\C:\Temp\foo.txt or \\?\UNC\example.com\share\foo.txt return path; } - return path.StartsWith(PATHPREFIX_SERVER, StringComparison.Ordinal) - ? UNCPREFIX_SERVER + path.Substring(PATHPREFIX_SERVER.Length) - : UNCPREFIX + path; + else + { + var hasRelativePathComponents = HasRelativePathComponents(path); + if (IsPrefixedWithBasicUNC(path) && !hasRelativePathComponents) + { + // For example: \\example.com\share\foo.txt or //example.com/share/foo.txt + return UNCPREFIX_SERVER + ConvertSlashes(path.Substring(PATHPREFIX_SERVER.Length)); + } + else if (DotNetRuntimePathWindows.IsPathFullyQualified(path) && !hasRelativePathComponents) + { + // For example: C:\Temp\foo.txt or C:/Temp/foo.txt + return UNCPREFIX + ConvertSlashes(path); + } + else + { + // A relative path or a fully qualified path with relative + // path components so the UNC prefix cannot be applied. + // For example: foo.txt or C:\Temp\..\foo.txt + return path; + } + } + } + + private static bool IsPrefixedWithBasicUNC(string path) + { + return path.StartsWith(PATHPREFIX_SERVER, StringComparison.Ordinal) || + path.StartsWith(PATHPREFIX_SERVER_ALT, StringComparison.Ordinal); } private static bool IsPrefixedWithUNC(string path) @@ -51,6 +82,32 @@ namespace Duplicati.Library.Common.IO path.StartsWith(UNCPREFIX, StringComparison.Ordinal); } + private static string[] relativePathComponents = new[] { ".", ".." }; + + /// + /// Returns true if contains relative path components; i.e., "." or "..". + /// + private static bool HasRelativePathComponents(string path) + { + return GetPathComponents(path).Any(pathComponent => relativePathComponents.Contains(pathComponent)); + } + + /// + /// Returns a sequence representing the files and directories in . + /// + private static IEnumerable GetPathComponents(string path) + { + while (!String.IsNullOrEmpty(path)) + { + var pathComponent = Path.GetFileName(path); + if (!String.IsNullOrEmpty(pathComponent)) + { + yield return pathComponent; + } + path = Path.GetDirectoryName(path); + } + } + public static string StripUNCPrefix(string path) { if (path.StartsWith(UNCPREFIX_SERVER, StringComparison.Ordinal)) @@ -407,13 +464,18 @@ namespace Duplicati.Library.Common.IO public string PathGetFullPath(string path) { + // Desired behavior: + // 1. If path is already prefixed with \\?\, it should be left untouched + // 2. If path is not already prefixed with \\?\, the return value should also not be prefixed + // 3. If path is relative or has relative components, that should be resolved by calling Path.GetFullPath() + // 4. If path is not relative and has no relative components, prefix with \\?\ to prevent normalization from munging "problematic Windows paths" if (IsPrefixedWithUNC(path)) { - return System.IO.Path.GetFullPath(ConvertSlashes(path)); + return path; } else { - return StripUNCPrefix(System.IO.Path.GetFullPath(PrefixWithUNC(ConvertSlashes(path)))); + return StripUNCPrefix(Path.GetFullPath(PrefixWithUNC(path))); } } diff --git a/Duplicati/UnitTest/BackendToolTests.cs b/Duplicati/UnitTest/BackendToolTests.cs new file mode 100755 index 000000000..6c4b17122 --- /dev/null +++ b/Duplicati/UnitTest/BackendToolTests.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Duplicati.Library.Interface; +using Duplicati.Library.Main; +using NUnit.Framework; + +namespace Duplicati.UnitTest +{ + [TestFixture] + public class BackendToolTests : BasicSetupHelper + { + [Test] + [Category("BackendTool")] + public void Get() + { + // Files to create in MB. + int[] fileSizes = {10, 20, 30}; + foreach (int size in fileSizes) + { + var data = new byte[size * 1024 * 1024]; + var rng = new Random(); + rng.NextBytes(data); + File.WriteAllBytes(Path.Combine(DATAFOLDER, size + "MB"), data); + } + + // Run a backup. + var options = new Dictionary(TestOptions); + var backendURL = "file://" + this.TARGETFOLDER; + using (Controller c = new Controller(backendURL, options, null)) + { + var backupResults = c.Backup(new[] {DATAFOLDER}); + Assert.AreEqual(0, backupResults.Errors.Count()); + Assert.AreEqual(0, backupResults.Warnings.Count()); + } + + // Get the backend files using absolute paths + var absoluteDownloadFolder = Path.Combine(RESTOREFOLDER, "target-files-absolute"); + Directory.CreateDirectory(absoluteDownloadFolder); + foreach (var targetFile in Directory.GetFiles(TARGETFOLDER)) + { + // Absolute path + var downloadFileName = Path.Combine(absoluteDownloadFolder, Path.GetFileName(targetFile)); + var status = CommandLine.BackendTool.Program.RealMain(new[] { "GET", $"{backendURL}", $"{downloadFileName}" }); + Assert.AreEqual(0, status); + Assert.IsTrue(File.Exists(downloadFileName)); + TestUtils.AssertFilesAreEqual(targetFile, downloadFileName, false, downloadFileName); + } + + // Get the backend files using relative paths + var relativeDownloadFolder = Path.Combine(RESTOREFOLDER, "target-files-relative"); + Directory.CreateDirectory(relativeDownloadFolder); + var originalCurrentDirectory = Directory.GetCurrentDirectory(); + Directory.SetCurrentDirectory(relativeDownloadFolder); + try + { + foreach (var targetFile in Directory.GetFiles(TARGETFOLDER)) + { + // Relative path + var downloadFileName = Path.GetFileName(targetFile); + var status = CommandLine.BackendTool.Program.RealMain(new[] { "GET", $"{backendURL}", $"{downloadFileName}" }); + Assert.AreEqual(0, status); + Assert.IsTrue(File.Exists(downloadFileName)); + TestUtils.AssertFilesAreEqual(targetFile, downloadFileName, false, downloadFileName); + } + } + finally + { + Directory.SetCurrentDirectory(originalCurrentDirectory); + } + } + } +} \ No newline at end of file diff --git a/Duplicati/UnitTest/Duplicati.UnitTest.csproj b/Duplicati/UnitTest/Duplicati.UnitTest.csproj index 5851a78d2..e0459ce24 100644 --- a/Duplicati/UnitTest/Duplicati.UnitTest.csproj +++ b/Duplicati/UnitTest/Duplicati.UnitTest.csproj @@ -46,6 +46,7 @@ + diff --git a/Duplicati/UnitTest/IOTests.cs b/Duplicati/UnitTest/IOTests.cs index 48b4ef5ca..6c18f27f6 100644 --- a/Duplicati/UnitTest/IOTests.cs +++ b/Duplicati/UnitTest/IOTests.cs @@ -21,7 +21,8 @@ using NUnit.Framework; using Duplicati.Library.Common.IO; using Duplicati.Library.Utility; using Duplicati.Library.Common; - +using System.Collections.Generic; + namespace Duplicati.UnitTest { [Category("IO")] @@ -95,5 +96,222 @@ namespace Duplicati.UnitTest //In particular don't throw PathTooLongException Assert.Throws(() => SystemIO.IO_OS.GetDirectories(longPath)); } - } -} + + [Test] + public void TestPrefixWithUNCInWindowsClient() + { + if (!Platform.IsClientWindows) + { + return; + } + + var testCasesLeavingPathUnchanged = + new[] + { + // Normalization of basic relative paths, with both kinds of slashes + @".", + @"temp", + @"temp\file.txt", + @"\", + @"/", + @"\temp", + @"/temp", + @"/temp/file.txt", + + // Normalization of full qualified paths, but with relative components, with both kinds of slashes + @"C:\temp\.", + @"C:/temp/.", + @"C:\temp\..", + @"C:/temp/..", + @"C:\temp\..\folder", + @"C:/temp/../folder", + @"C:\temp\.\file.txt", + @"C:/temp/./file.txt", + @"\\example.com\share\.", + @"//example.com/share/.", + @"\\example.com\share\..", + @"//example.com/share/..", + @"\\example.com\share\..\folder", + @"//example.com/share/../folder", + @"\\example.com\share\.\file.txt", + @"//example.com/share/./file.txt", + + // Normalization disabled for paths with @"\\?\" prefix + @"\\?\C:\", + @"\\?\C:\temp", + @"\\?\C:\temp\file.txt", + @"\\?\C:\temp.", + @"\\?\C:\temp.\file.txt", + @"\\?\C:\temp.\file.txt.", + @"\\?\C:\temp ", + @"\\?\C:\temp\file.txt ", + @"\\?\C:\", + @"\\?\UNC\example.com\share", + @"\\?\UNC\example.com\share\file.txt", + @"\\?\UNC\example.com\share.", + @"\\?\UNC\example.com\share.\file.txt", + @"\\?\UNC\example.com\share.\file.txt.", + @"\\?\UNC\example.com\share ", + @"\\?\UNC\example.com\share\file.txt " + }; + foreach (var path in testCasesLeavingPathUnchanged) + { + var actual = SystemIOWindows.PrefixWithUNC(path); + var expected = path; + Assert.AreEqual(expected, actual, $"Path: {path}"); + } + + var testCasesWherePrefixIsApplied = + new Dictionary() + { + // Fully qualified paths with no relative components, with both kinds of slashes + { @"C:\", @"\\?\C:\" }, + { @"C:/", @"\\?\C:\" }, + { @"C:\temp", @"\\?\C:\temp" }, + { @"C:/temp", @"\\?\C:\temp" }, + { @"C:\temp\file.txt", @"\\?\C:\temp\file.txt" }, + { @"C:/temp/file.txt", @"\\?\C:\temp\file.txt" }, + { @"\\example.com\share", @"\\?\UNC\example.com\share" }, + { @"//example.com/share", @"\\?\UNC\example.com\share" }, + { @"\\example.com\share\file.txt", @"\\?\UNC\example.com\share\file.txt" }, + { @"//example.com/share/file.txt", @"\\?\UNC\example.com\share\file.txt" }, + + // Fully qualified paths with no relative components, but with problematic names, with both kinds of slashes + { @"C:\temp.", @"\\?\C:\temp." }, + { @"C:/temp.", @"\\?\C:\temp." }, + { @"C:\temp.\file.txt", @"\\?\C:\temp.\file.txt" }, + { @"C:/temp./file.txt", @"\\?\C:\temp.\file.txt" }, + { @"C:\temp.\file.txt.", @"\\?\C:\temp.\file.txt." }, + { @"C:/temp./file.txt.", @"\\?\C:\temp.\file.txt." }, + { @"C:\temp ", @"\\?\C:\temp " }, + { @"C:/temp ", @"\\?\C:\temp " }, + { @"C:\temp\file.txt ", @"\\?\C:\temp\file.txt " }, + { @"C:/temp/file.txt ", @"\\?\C:\temp\file.txt " }, + { @"\\example.com\share.", @"\\?\UNC\example.com\share." }, + { @"//example.com/share.", @"\\?\UNC\example.com\share." }, + { @"\\example.com\share\file.txt.", @"\\?\UNC\example.com\share\file.txt." }, + { @"//example.com/share./file.txt.", @"\\?\UNC\example.com\share.\file.txt." }, + { @"\\example.com\share ", @"\\?\UNC\example.com\share " }, + { @"//example.com/share ", @"\\?\UNC\example.com\share " }, + { @"\\example.com\share\file.txt ", @"\\?\UNC\example.com\share\file.txt " }, + { @"//example.com/share/file.txt ", @"\\?\UNC\example.com\share\file.txt " } + }; + foreach (var keyValuePair in testCasesWherePrefixIsApplied) + { + var path = keyValuePair.Key; + var actual = SystemIOWindows.PrefixWithUNC(path); + var expected = keyValuePair.Value; + Assert.AreEqual(expected, actual, $"Path: {path}"); + } + } + + [Test] + public void TestPathGetFullPathInWindowsClient() + { + if (!Platform.IsClientWindows) + { + return; + } + + var testCasesWherePathGetFullGivesSameResultsAsDotNet = + new[] + { + // Normalization of basic relative paths, with both kinds of slashes + @".", + @"temp", + @"temp\file.txt", + @"\", + @"/", + @"\temp", + @"/temp", + @"/temp/file.txt", + @"/temp/file.txt", + + // Normalization of full qualified paths, but with relative components, with both kinds of slashes + @"C:\temp\.", + @"C:/temp/.", + @"C:\temp\..", + @"C:/temp/..", + @"C:\temp\..\folder", + @"C:/temp/../folder", + @"C:\temp\.\file.txt", + @"C:/temp/./file.txt", + @"\\example.com\share\.", + @"//example.com/share/.", + @"\\example.com\share\..", + @"//example.com/share/..", + @"\\example.com\share\..\folder", + @"//example.com/share/../folder", + @"\\example.com\share\.\file.txt", + @"//example.com/share/./file.txt", + + // Fully qualified paths with no relative components, with both kinds of slashes + @"C:\", + @"C:/", + @"C:\temp", + @"C:/temp", + @"C:\temp\file.txt", + @"C:/temp/file.txt", + @"\\example.com\share", + @"//example.com/share", + @"\\example.com\share\file.txt", + @"//example.com/share/file.txt", + + // Normalization disabled for paths with @"\\?\" prefix + @"\\?\C:\", + @"\\?\C:\temp", + @"\\?\C:\temp\file.txt", + @"\\?\C:\temp.", + @"\\?\C:\temp.\file.txt", + @"\\?\C:\temp.\file.txt.", + @"\\?\C:\temp ", + @"\\?\C:\temp\file.txt ", + @"\\?\C:\", + @"\\?\UNC\example.com\share", + @"\\?\UNC\example.com\share\file.txt", + @"\\?\UNC\example.com\share.", + @"\\?\UNC\example.com\share.\file.txt", + @"\\?\UNC\example.com\share.\file.txt.", + @"\\?\UNC\example.com\share ", + @"\\?\UNC\example.com\share\file.txt ", + }; + foreach (var path in testCasesWherePathGetFullGivesSameResultsAsDotNet) + { + var actual = SystemIO.IO_WIN.PathGetFullPath(path); + var expected = System.IO.Path.GetFullPath(path); + Assert.AreEqual(expected, actual, $"Path: {path}"); + } + + var testCasesWherePathGetFullGivesDifferentResultsThanDotNet = + new Dictionary() + { + // Fully qualified paths with no relative components, but with problematic names, with both kinds of slashes + { @"C:\temp.", @"C:\temp." }, + { @"C:/temp.", @"C:\temp." }, + { @"C:\temp.\file.txt", @"C:\temp.\file.txt" }, + { @"C:/temp./file.txt", @"C:\temp.\file.txt" }, + { @"C:\temp.\file.txt.", @"C:\temp.\file.txt." }, + { @"C:/temp./file.txt.", @"C:\temp.\file.txt." }, + { @"C:\temp ", @"C:\temp " }, + { @"C:/temp ", @"C:\temp " }, + { @"C:\temp\file.txt ", @"C:\temp\file.txt " }, + { @"C:/temp/file.txt ", @"C:\temp\file.txt " }, + { @"\\example.com\share.", @"\\example.com\share." }, + { @"//example.com/share.", @"\\example.com\share." }, + { @"\\example.com\share\file.txt.", @"\\example.com\share\file.txt." }, + { @"//example.com/share./file.txt.", @"\\example.com\share.\file.txt." }, + { @"\\example.com\share ", @"\\example.com\share " }, + { @"//example.com/share ", @"\\example.com\share " }, + { @"\\example.com\share\file.txt ", @"\\example.com\share\file.txt " }, + { @"//example.com/share/file.txt ", @"\\example.com\share\file.txt " }, + }; + foreach (var keyValuePair in testCasesWherePathGetFullGivesDifferentResultsThanDotNet) + { + var path = keyValuePair.Key; + var actual = SystemIO.IO_WIN.PathGetFullPath(path); + var expected = keyValuePair.Value; + Assert.AreEqual(expected, actual, $"Path: {path}"); + } + } + } +} \ No newline at end of file