Restrictions sur les arguments de PathRelativePathTo dans un environnement «long path aware»

12

Pour un processus prenant en charge les longs chemins sur Windows 10, j'essaie de comprendre quelles sont les restrictions d'argument lors de l'utilisation de la méthode shell Windows PathRelativePathTo .

Dans mon exemple ci-dessous, j'utilise C # via pinvoke pour appeler la méthode.
J'ai donné plusieurs exemples ci-dessous et leur sortie. Remarque:

  • Tous les exemples donnent des chemins de répertoire pour "de" et des chemins de fichier pour "vers" (aucun de ces chemins n'existe réellement sur le disque)
  • Mes observations sont que
    • Les chemins sous la longueur "courte" MAX_PATH (260) renvoient le succès avec le résultat attendu.
    • Certains chemins sur le "court" MAX_PATH renvoient le succès avec le résultat correct.
    • Certains chemins sur le "court" MAX_PATH retournent le succès avec la mauvaise réponse (ouais!)
    • Certains chemins beaucoup plus longs renvoient une erreur. Cependant, il n'est pas à une certaine longueur maximale fixe.

La source:

    class Program
    {
        static class Native
        {
            // https://www.pinvoke.net/default.aspx/shlwapi.pathrelativepathto
            // https://docs.microsoft.com/en-us/windows/win32/api/shlwapi/nf-shlwapi-pathrelativepathtoa
            [DllImport("shlwapi.dll", SetLastError = true, CharSet = CharSet.Auto)]
            [return: MarshalAs(UnmanagedType.Bool)]
            internal static extern bool PathRelativePathTo([Out] StringBuilder pszPath, [In] string pszFrom, [In] int dwAttrFrom, [In] string pszTo, [In] int dwAttrTo);
        }

        static void Main(string[] args)
        {
            string pszFrom, pszTo;
            int i = 0;

            // #1 At "short" max path (259)
            // Succeeds with right answer
            pszFrom = @"c:\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCD123456789";
            pszTo = @"c:\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCD123456789\abcdefghijklmnop.txt";
            TestPathRelativePathTo(++i, pszFrom, pszTo);

            // #2 One over "short" max path
            // Succeeds with right answer
            pszFrom = @"c:\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCD1234567890";
            pszTo = @"c:\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCD1234567890\abcdefghijklmnop.txt";
            TestPathRelativePathTo(++i, pszFrom, pszTo);

            // #3 Shortest path (by experiment) that returned the wrong answer
            pszFrom = @"c:\ABCDEFGHIJKLMNOPQRS\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCD1234567890";
            pszTo = @"c:\ABCDEFGHIJKLMNOPQRS\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCD1234567890\b.txt";
            TestPathRelativePathTo(++i, pszFrom, pszTo);

            // #4: Long path that errors out
            // Errors out
            pszFrom = @"c:\ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGH\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
            pszTo = @"c:\ABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGH\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\b.txt";
            TestPathRelativePathTo(++i, pszFrom, pszTo);

            // #5: Same as previous except one character removed from beginning of first folder
            // Succeeds, but wrong return result
            pszFrom = @"c:\BCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGH\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
            pszTo = @"c:\BCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGH\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\b.txt";
            TestPathRelativePathTo(++i, pszFrom, pszTo);

            // #6: Same as previous except 3 characters added to filename. 
            // Succeeds, but wrong return result
            pszFrom = @"c:\BCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGH\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
            pszTo = @"c:\BCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGH\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\b123.txt";
            TestPathRelativePathTo(++i, pszFrom, pszTo);
        }

        static void TestPathRelativePathTo(int i, string pszFromDir, string pszToFile)
        {
            int maxResult = 10000;
            StringBuilder result = new StringBuilder(maxResult);
            Console.WriteLine($"#{i}: Calling PathRelativePathTo(...): pszFrom.Length: {pszFromDir.Length}; pszTo.Length {pszToFile.Length} ");
            bool bRet = Native.PathRelativePathTo(result, pszFromDir, (int)FileAttributes.Directory, pszToFile, (int)FileAttributes.Normal);
            if (!bRet)
            {
                // *Edit*: As pointed out in the comments, PathRelativePathTo does not set last error, so this part of the code is incorrect, it should really just print out that the method returned false.
                // https://blogs.msdn.microsoft.com/shawnfa/2004/09/10/formatmessage-shortcut-for-win32-error-codes/
                int currentError = Marshal.GetLastWin32Error();
                var errorMessage = new Win32Exception(currentError).Message;
                Console.WriteLine($"  Error: {errorMessage}");
            }
            else
            {
                Console.WriteLine($"  Result: {result}");
            }
        }
    }

Production:

#1: Calling PathRelativePathTo(...): pszFrom.Length: 238; pszTo.Length 259
  Result: .\abcdefghijklmnop.txt
#2: Calling PathRelativePathTo(...): pszFrom.Length: 239; pszTo.Length 260
  Result: .\abcdefghijklmnop.txt
#3: Calling PathRelativePathTo(...): pszFrom.Length: 259; pszTo.Length 265
  Result: ..\ABCD1234567890\b.txt
#4: Calling PathRelativePathTo(...): pszFrom.Length: 481; pszTo.Length 487
  Error: The system cannot find the file specified
#5: Calling PathRelativePathTo(...): pszFrom.Length: 480; pszTo.Length 486
  Result: .\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\b.txt
#6: Calling PathRelativePathTo(...): pszFrom.Length: 480; pszTo.Length 489
  Result: .\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890\b123.txt

Des questions:

  • Quel est le comportement attendu de PathRelativePathToce qui précède?
  • Est-il uniquement censé fonctionner correctement avec des chemins sous la limite "courte" MAX_PATH (et le reste du comportement n'est pas défini)?
  • Y a-t-il autre chose dans le framework .net que je peux utiliser à la place (Remarque: je vois que .NET Core a Path.GetRelativePath , mais je ne peux pas (encore) l'utiliser)?
Matt Smith
la source
Les commentaires ne sont pas pour une discussion approfondie; cette conversation a été déplacée vers le chat .
Samuel Liew
Oubliez PathRelativePathTo, ce n'est pas destiné aux longs chemins. Il n'est en fait pas sûr de l'utiliser, car vous ne pouvez pas indiquer la taille du tampon de destination, la documentation indique seulement qu'il "doit avoir au moins MAX_PATH caractères."
acelent
le document officiel est assez clair sur les limites de MAX_PATH. En ce qui concerne un remplacement, il est facile de se tromper, vous pouvez réutiliser la source principale .NET ou l'utiliser comme point de départ: github.com/dotnet/corefx/blob/…
Simon Mourier
Qu'utilisez-vous jusqu'à présent? Classic .NET ou .NET Core, quelle version?
Pavel Anikhouski
Framework .net. Une fois que je pourrai passer à .net core 3.0, je serai prêt car ils ont la méthode intégrée que j'ai mentionnée.
Matt Smith,

Réponses:

4

À première vue, il semble que l'API PathRelativePathTo soit sûre uniquement pour les chemins jusqu'à MAX_LENGTH. Au moins de la documentation de Wine, nous voyons que l'API a été problématique dans l'implémentation de Win32.

La version Win32 de cette fonction contient un bogue dans lequel la chaîne lpszTo peut être référencée 1 octet au-delà de la fin de la chaîne. En conséquence, des déchets aléatoires peuvent être écrits dans le chemin de sortie, selon ce qui se trouve au-delà du dernier octet de la chaîne. Ce bogue se produit en raison du comportement de PathCommonPrefix () (voir les notes de cette fonction), et aucune solution de contournement ne semble possible avec Win32. Ce bogue a été corrigé ici, par exemple, le chemin relatif de "\" à "\" est correctement déterminé comme "." dans cette mise en œuvre.

Et à partir de la documentation PathCommonPrefix,

Un préfixe commun de 2 est toujours renvoyé comme 3. Il est donc possible que la longueur renvoyée ne soit pas valide (c'est-à-dire plus longue que l'une ou les deux des chaînes données comme paramètres). Ce comportement Win32 a été implémenté ici et ne peut pas être modifié (corrigé?) Sans interrompre les autres appels SHLWAPI. Pour contourner ce problème lors de l'utilisation de cette fonction, vérifiez toujours que l'octet de [common_prefix_len-1] n'est pas un NUL. Si c'est le cas, déduisez 1 du préfixe.

Ces informations et en supposant que l'implémentation shlwapi fonctionne avec des tampons de longueur MAX_SIZE et sont similaires à ce qu'elles sont dans Wine ou ReactOS ( https://doxygen.reactos.org/de/dff/dll_2win32_2shlwapi_2path_8c_source.html ) semblent en quelque sorte expliquer le non défini comportement que vous voyez dans les tests.

En ce qui concerne une solution .NET, la façon la plus simple (peut-être pas la meilleure) à laquelle je pense est d'utiliser System.Uri

Uri path1 = new Uri(@"c:\lvl1\lvl2\");
Uri path2 = new Uri(@"c:\lvl1\lvl3\file1.txt");
Uri diff = path1.MakeRelativeUri(path2);
// Uri will switch to forward slashes, so to fix that...
string relPath = 
Uri.UnescapeDataString(diff.OriginalString).Replace("/",@"\");

Ou bien sûr, vous pouvez implémenter quelque chose basé sur la source .NET Core de Path.GetRelativePath

fou du roi
la source
3

Solution .NET 4.6.2

Utilisez la \\?\C:\Verrrrrrrrrrrry long pathsyntaxe décrite ici .

Il y a aussi un excellent article sur ce blog

En général, le plus gros problème que j'ai est avec les dossiers partagés sur le Web. Le reste va bien.

Versions .NET plus anciennes

Si vous utilisez une ancienne version de .NET, vous pouvez consulter cette fonction API Win32 , vous en aurez besoin P/Invoke.

L'API Windows possède de nombreuses fonctions qui ont également des versions Unicode pour autoriser un chemin de longueur étendue pour une longueur de chemin totale maximale de 32 767 caractères

Vous pouvez également consulter cette question SO, qui est très similaire à la vôtre.
Comment gérer les fichiers avec un nom de plus de 259 caractères?

Gilad
la source
4
mais tout cela PathRelativePathTo
n'a
2
Comment cela répond-il à la question?
bouffon
C'est exactement la même idée derrière toutes les fonctionnalités de Path.
Gilad
1
aucune de toutes les fonctionnalités de Path concrète PathRelativePathToaffectée par aucun préfixe. il s'agit d'une API d'analyse lexicale pure, codée en dur à 260 caractères maximum. aussi même \\ vs / different - cassez-le
RbMm
Il y a même un commentaire indiquant que cela ne fonctionne pas: chat.stackoverflow.com/transcript/message/47826723#47826723
user1781290
2

à Comment obtenir un chemin de fichier absolu ou normalisé dans .NET? je vois

public static string NormalizePath(string path)
{
    return Path.GetFullPath(new Uri(path).LocalPath)
           .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
           .ToUpperInvariant();
}

Je commencerais donc par cela pour normaliser les deux chemins (voir également https://blogs.msdn.microsoft.com/jeremykuhne/2016/04/21/path-normalization/ dans le cas qui couvre plus de cas)

alors je les diviserais en tableaux / listes de sous-chemins (disons avec l'une des méthodes de Comment extraire chaque nom de dossier d'un chemin? )

à partir de là, je trouverais les N premières parties max qui sont communes.

alors je soustrais N du nombre de parties C du premier chemin, alias CN pour obtenir combien .. \ Je dois ajouter au premier chemin afin de revenir au chemin commun.

enfin j'ajouterais le reste du toPath après en avoir supprimé les N premiers éléments et retourner le chemin résultant

Je suppose que vous pouvez également le faire (pour éviter un stockage supplémentaire) avec l'analyse de chaîne (sans fractionner dans les listes) une fois que vous avez trouvé les chemins normalisés. L'idée serait que vous trouviez le préfixe de chaîne commun, puis que vous en coupiez la dernière partie si la partie commune ne se retrouvait pas avec le séparateur de chemin (car ce serait une partie commune supplémentaire fortuite, par exemple c: \ a \ test1 et c: \ a \ test2 ont un chemin commun c: \ a \ et non c: \ a \ test comme vous obtiendriez une simple extraction de chaîne de préfixe commune).

Alternativement, vous pouvez utiliser un algorithme qui renvoie des index de caractères pour chacun \ travaillant les deux chemins normalisés en même temps dans une boucle (une étape sur chacun) afin que vous n'ayez pas besoin de stocker quelque chose de plus. La logique serait similaire à celle décrite ci-dessus.

George Birbilis
la source
1

J'ai décidé d'utiliser un portage de la méthode.dotnet/corefx Path.GetRelativePath

Le code suivant a été adapté à partir des sources suivantes. Lisez les commentaires dans le code où je répertorie tous les ajustements ou solutions de contournement que j'ai utilisés:

Mon objectif en adaptant le code était de

  • apporter le moins de modifications possible (noté dans les commentaires du code toute modification apportée)
  • Gardez la structure de classe la même que dans la source d'origine
  • N'inclure que les méthodes / propriétés qui étaient nécessaires pour implémenter la méthode GetRelativePath

Code

using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text;
using static System.IO.Path;

static class PathExtension
{
    // Port of .net 3.0 Path.GetRelativePath (Windows version)
    // https://docs.microsoft.com/en-us/dotnet/api/system.io.path.getrelativepath?view=netcore-3.0
    // 
    // Adapted from:
    // https://github.com/dotnet/corefx/blob/b123ba4b9107c73cbc02010dc1ee78eb8ffccb93/src/Common/src/CoreLib/System/IO/Path.cs
    // https://github.com/dotnet/corefx/blob/4a7075f188b5777ccb519f2af9b8a284f4383357/src/Common/src/CoreLib/System/IO/Path.Windows.cs
    //
    // Notes:
    // * I didn't have access to ReadOnlySpan<T> nor .AsSpan(), so I removed them.  I just used regular string instead.
    // * I hard coded some resource strings (from exceptions)
    // * Replaced ValueStringBuild with StringBuilder

    /// <summary>
    /// Create a relative path from one path to another. Paths will be resolved before calculating the difference.
    /// Default path comparison for the active platform will be used (OrdinalIgnoreCase for Windows or Mac, Ordinal for Unix).
    /// </summary>
    /// <param name="relativeTo">The source path the output should be relative to. This path is always considered to be a directory.</param>
    /// <param name="path">The destination path.</param>
    /// <returns>The relative path or <paramref name="path"/> if the paths don't share the same root.</returns>
    /// <exception cref="ArgumentNullException">Thrown if <paramref name="relativeTo"/> or <paramref name="path"/> is <c>null</c> or an empty string.</exception>
    public static string GetRelativePath(string relativeTo, string path)
    {
        return GetRelativePath(relativeTo, path, StringComparison);
    }

    private static string GetRelativePath(string relativeTo, string path, StringComparison comparisonType)
    {
        if (relativeTo == null)
            throw new ArgumentNullException(nameof(relativeTo));

        if (PathInternal.IsEffectivelyEmpty(relativeTo.AsSpan()))
            throw new ArgumentException(SR.Arg_PathEmpty, nameof(relativeTo));

        if (path == null)
            throw new ArgumentNullException(nameof(path));

        if (PathInternal.IsEffectivelyEmpty(path.AsSpan()))
            throw new ArgumentException(SR.Arg_PathEmpty, nameof(path));

        Debug.Assert(comparisonType == StringComparison.Ordinal || comparisonType == StringComparison.OrdinalIgnoreCase);

        relativeTo = GetFullPath(relativeTo);
        path = GetFullPath(path);

        // Need to check if the roots are different- if they are we need to return the "to" path.
        if (!PathInternal.AreRootsEqual(relativeTo, path, comparisonType))
            return path;

        int commonLength = PathInternal.GetCommonPathLength(relativeTo, path, ignoreCase: comparisonType == StringComparison.OrdinalIgnoreCase);

        // If there is nothing in common they can't share the same root, return the "to" path as is.
        if (commonLength == 0)
            return path;

        // Trailing separators aren't significant for comparison
        int relativeToLength = relativeTo.Length;
        if (EndsInDirectorySeparator(relativeTo.AsSpan()))
            relativeToLength--;

        bool pathEndsInSeparator = EndsInDirectorySeparator(path.AsSpan());
        int pathLength = path.Length;
        if (pathEndsInSeparator)
            pathLength--;

        // If we have effectively the same path, return "."
        if (relativeToLength == pathLength && commonLength >= relativeToLength) return ".";

        // We have the same root, we need to calculate the difference now using the
        // common Length and Segment count past the length.
        //
        // Some examples:
        //
        //  C:\Foo C:\Bar L3, S1 -> ..\Bar
        //  C:\Foo C:\Foo\Bar L6, S0 -> Bar
        //  C:\Foo\Bar C:\Bar\Bar L3, S2 -> ..\..\Bar\Bar
        //  C:\Foo\Foo C:\Foo\Bar L7, S1 -> ..\Bar

        // Original: var sb = new ValueStringBuilder(stackalloc char[260]);
        var sb = new StringBuilder(260);
        sb.EnsureCapacity(Math.Max(relativeTo.Length, path.Length));

        // Add parent segments for segments past the common on the "from" path
        if (commonLength < relativeToLength)
        {
            sb.Append("..");

            for (int i = commonLength + 1; i < relativeToLength; i++)
            {
                if (PathInternal.IsDirectorySeparator(relativeTo[i]))
                {
                    sb.Append(DirectorySeparatorChar);
                    sb.Append("..");
                }
            }
        }
        else if (PathInternal.IsDirectorySeparator(path[commonLength]))
        {
            // No parent segments and we need to eat the initial separator
            //  (C:\Foo C:\Foo\Bar case)
            commonLength++;
        }

        // Now add the rest of the "to" path, adding back the trailing separator
        int differenceLength = pathLength - commonLength;
        if (pathEndsInSeparator)
            differenceLength++;

        if (differenceLength > 0)
        {
            if (sb.Length > 0)
            {
                sb.Append(DirectorySeparatorChar);
            }

            sb.Append(path.AsSpan(commonLength, differenceLength));
        }

        return sb.ToString();
    }

    /// <summary>Returns a comparison that can be used to compare file and directory names for equality.</summary>
    internal static StringComparison StringComparison =>
        IsCaseSensitive ?
            StringComparison.Ordinal :
            StringComparison.OrdinalIgnoreCase;

    /// <summary>
    /// Returns true if the path ends in a directory separator.
    /// </summary>
    public static bool EndsInDirectorySeparator(string path) // Originally was public static bool EndsInDirectorySeparator(ReadOnlySpan<char> path)
        => path.Length > 0 && PathInternal.IsDirectorySeparator(path[path.Length - 1]);

    #region Resources
    // From https://github.com/dotnet/corefx/blob/c390ce7df50252e11f5d322276e9d19e046d1332/src/Microsoft.IO.Redist/src/Resources/Strings.resx

    static class SR
    {
        public static string Arg_PathEmpty => "The path is empty.";
    }
    #endregion Resources

    #region Path.Windows 
    // Code from 
    // https://github.com/dotnet/corefx/blob/4a7075f188b5777ccb519f2af9b8a284f4383357/src/Common/src/CoreLib/System/IO/Path.Windows.cs

    // https://github.com/dotnet/corefx/blob/4a7075f188b5777ccb519f2af9b8a284f4383357/src/Common/src/CoreLib/System/IO/Path.Windows.cs#L235
    /// <summary>Gets whether the system is case-sensitive.</summary>
    internal static bool IsCaseSensitive => false;

    #endregion Path.Windows

    #region Workarounds

    // Note, this is here just to cause all .AsSpan() calls to return a string since I don't have access to ReadOnlySpan<char>
    // https://docs.microsoft.com/en-us/dotnet/api/microsoft.extensions.primitives.stringsegment.asspan?view=dotnet-plat-ext-3.0
    static string AsSpan(this string s)
    {
        return s;
    }

    // Note, this is here just to cause all .AsSpan() calls to return a string since I don't have access to ReadOnlySpan<char>
    // https://docs.microsoft.com/en-us/dotnet/api/system.memoryextensions.asspan?view=netcore-3.0#System_MemoryExtensions_AsSpan_System_String_System_Int32_System_Int32_
    static string AsSpan(this string s, int startIndex, int length)
    {
        return s.Substring(startIndex, length);
    }


    #endregion Workarounds

    // Code from 
    // https://github.com/dotnet/corefx/blob/b123ba4b9107c73cbc02010dc1ee78eb8ffccb93/src/Common/src/CoreLib/System/IO/PathInternal.cs
    // https://github.com/dotnet/corefx/blob/b123ba4b9107c73cbc02010dc1ee78eb8ffccb93/src/Common/src/CoreLib/System/IO/PathInternal.Windows.cs
    static class PathInternal
    {
        /// <summary>
        /// Returns true if the two paths have the same root
        /// </summary>
        internal static bool AreRootsEqual(string first, string second, StringComparison comparisonType)
        {
            int firstRootLength = GetRootLength(first.AsSpan());
            int secondRootLength = GetRootLength(second.AsSpan());

            return firstRootLength == secondRootLength
                && string.Compare(
                    strA: first,
                    indexA: 0,
                    strB: second,
                    indexB: 0,
                    length: firstRootLength,
                    comparisonType: comparisonType) == 0;
        }

        #region PathInternal.Windows
        // Code from https://github.com/dotnet/corefx/blob/b123ba4b9107c73cbc02010dc1ee78eb8ffccb93/src/Common/src/CoreLib/System/IO/PathInternal.Windows.cs

        // \\?\, \\.\, \??\
        internal const int DevicePrefixLength = 4;

        // \\
        internal const int UncPrefixLength = 2;

        // \\?\UNC\, \\.\UNC\
        internal const int UncExtendedPrefixLength = 8;

        /// <summary>
        /// Returns true if the given character is a valid drive letter
        /// </summary>
        internal static bool IsValidDriveChar(char value)
        {
            return (value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z');
        }

        /// <summary>
        /// True if the given character is a directory separator.
        /// </summary>
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        internal static bool IsDirectorySeparator(char c)
        {
            return c == DirectorySeparatorChar || c == AltDirectorySeparatorChar;
        }

        /// <summary>
        /// Returns true if the path uses the canonical form of extended syntax ("\\?\" or "\??\"). If the
        /// path matches exactly (cannot use alternate directory separators) Windows will skip normalization
        /// and path length checks.
        /// </summary>
        internal static bool IsExtended(string path) // Original was internal static bool IsExtended(ReadOnlySpan<char> path)
        {
            // While paths like "//?/C:/" will work, they're treated the same as "\\.\" paths.
            // Skipping of normalization will *only* occur if back slashes ('\') are used.
            return path.Length >= DevicePrefixLength
                && path[0] == '\\'
                && (path[1] == '\\' || path[1] == '?')
                && path[2] == '?'
                && path[3] == '\\';
        }

        /// <summary>
        /// Returns true if the path uses any of the DOS device path syntaxes. ("\\.\", "\\?\", or "\??\")
        /// </summary>
        internal static bool IsDevice(string path) // Original was: internal static bool IsDevice(ReadOnlySpan<char> path)
        {
            // If the path begins with any two separators is will be recognized and normalized and prepped with
            // "\??\" for internal usage correctly. "\??\" is recognized and handled, "/??/" is not.
            return IsExtended(path)
                ||
                (
                    path.Length >= DevicePrefixLength
                    && IsDirectorySeparator(path[0])
                    && IsDirectorySeparator(path[1])
                    && (path[2] == '.' || path[2] == '?')
                    && IsDirectorySeparator(path[3])
                );
        }

        /// <summary>
        /// Returns true if the path is a device UNC (\\?\UNC\, \\.\UNC\)
        /// </summary>
        internal static bool IsDeviceUNC(string path) // Original was: internal static bool IsDeviceUNC(ReadOnlySpan<char> path) 
        {
            return path.Length >= UncExtendedPrefixLength
                && IsDevice(path)
                && IsDirectorySeparator(path[7])
                && path[4] == 'U'
                && path[5] == 'N'
                && path[6] == 'C';
        }

        /// <summary>
        /// Gets the length of the root of the path (drive, share, etc.).
        /// </summary>
        internal static int GetRootLength(string path) // Note: original was internal static int GetRootLength(ReadOnlySpan<char> path)

        {
            int pathLength = path.Length;
            int i = 0;

            bool deviceSyntax = IsDevice(path);
            bool deviceUnc = deviceSyntax && IsDeviceUNC(path);

            if ((!deviceSyntax || deviceUnc) && pathLength > 0 && IsDirectorySeparator(path[0]))
            {
                // UNC or simple rooted path (e.g. "\foo", NOT "\\?\C:\foo")
                if (deviceUnc || (pathLength > 1 && IsDirectorySeparator(path[1])))
                {
                    // UNC (\\?\UNC\ or \\), scan past server\share

                    // Start past the prefix ("\\" or "\\?\UNC\")
                    i = deviceUnc ? UncExtendedPrefixLength : UncPrefixLength;

                    // Skip two separators at most
                    int n = 2;
                    while (i < pathLength && (!IsDirectorySeparator(path[i]) || --n > 0))
                        i++;
                }
                else
                {
                    // Current drive rooted (e.g. "\foo")
                    i = 1;
                }
            }
            else if (deviceSyntax)
            {
                // Device path (e.g. "\\?\.", "\\.\")
                // Skip any characters following the prefix that aren't a separator
                i = DevicePrefixLength;
                while (i < pathLength && !IsDirectorySeparator(path[i]))
                    i++;

                // If there is another separator take it, as long as we have had at least one
                // non-separator after the prefix (e.g. don't take "\\?\\", but take "\\?\a\")
                if (i < pathLength && i > DevicePrefixLength && IsDirectorySeparator(path[i]))
                    i++;
            }
            else if (pathLength >= 2
                && path[1] == VolumeSeparatorChar
                && IsValidDriveChar(path[0]))
            {
                // Valid drive specified path ("C:", "D:", etc.)
                i = 2;

                // If the colon is followed by a directory separator, move past it (e.g "C:\")
                if (pathLength > 2 && IsDirectorySeparator(path[2]))
                    i++;
            }

            return i;
        }

        /// <summary>
        /// Gets the count of common characters from the left optionally ignoring case
        /// </summary>
        internal static unsafe int EqualStartingCharacterCount(string first, string second, bool ignoreCase)
        {
            if (string.IsNullOrEmpty(first) || string.IsNullOrEmpty(second)) return 0;

            int commonChars = 0;

            fixed (char* f = first)
            fixed (char* s = second)
            {
                char* l = f;
                char* r = s;
                char* leftEnd = l + first.Length;
                char* rightEnd = r + second.Length;

                while (l != leftEnd && r != rightEnd
                    && (*l == *r || (ignoreCase && char.ToUpperInvariant(*l) == char.ToUpperInvariant(*r))))
                {
                    commonChars++;
                    l++;
                    r++;
                }
            }

            return commonChars;
        }

        /// <summary>
        /// Get the common path length from the start of the string.
        /// </summary>
        internal static int GetCommonPathLength(string first, string second, bool ignoreCase)
        {
            int commonChars = EqualStartingCharacterCount(first, second, ignoreCase: ignoreCase);

            // If nothing matches
            if (commonChars == 0)
                return commonChars;

            // Or we're a full string and equal length or match to a separator
            if (commonChars == first.Length
                && (commonChars == second.Length || IsDirectorySeparator(second[commonChars])))
                return commonChars;

            if (commonChars == second.Length && IsDirectorySeparator(first[commonChars]))
                return commonChars;

            // It's possible we matched somewhere in the middle of a segment e.g. C:\Foodie and C:\Foobar.
            while (commonChars > 0 && !IsDirectorySeparator(first[commonChars - 1]))
                commonChars--;

            return commonChars;
        }

        /// <summary>
        /// Returns true if the path is effectively empty for the current OS.
        /// For unix, this is empty or null. For Windows, this is empty, null, or
        /// just spaces ((char)32).
        /// </summary>
        /// 
        internal static bool IsEffectivelyEmpty(string path)
        {
            // Note, see the original version below
            return string.IsNullOrWhiteSpace(path);
        }

        // Note: here's the original version.  I've replaced it with the version above that just uses string
        // 
        //internal static bool IsEffectivelyEmpty(ReadOnlySpan<char> path)
        //{
        //    if (path.IsEmpty)
        //        return true;

        //    foreach (char c in path)
        //    {
        //        if (c != ' ')
        //            return false;
        //    }
        //    return true;
        //}

        #endregion PathInternal.Windows
    }
}
Matt Smith
la source