File: src\Shared\PathNormalizer.cs
Web Access
Project: src\src\Aspire.Hosting.Rust\Aspire.Hosting.Rust.csproj (Aspire.Hosting.Rust)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
namespace Aspire.Hosting.Utils;
 
internal static class PathNormalizer
{
    public static string NormalizePathForCurrentPlatform(string path)
    {
        if (string.IsNullOrEmpty(path))
        {
            return path;
        }
 
        // Fix slashes
        path = path.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar);
 
        return Path.GetFullPath(path);
    }
 
    /// <summary>
    /// Normalizes a path for storage in configuration files by replacing
    /// backslash separators with forward slashes.
    /// </summary>
    public static string NormalizePathForStorage(string path)
    {
        return path.Replace('\\', '/');
    }
 
    /// <summary>
    /// On Windows, resolves a path to its filesystem-canonical form by querying the OS for
    /// the actual casing of each path component. On other platforms this is a no-op because
    /// the file system is case-sensitive and there is no casing ambiguity.
    /// </summary>
    /// <remarks>
    /// Use this when the path needs to match what MSBuild reports for the same file.
    /// MSBuild always uses the true filesystem casing; a user who types
    /// <c>--apphost c:\FOO\bar.csproj</c> will get back <c>C:\foo\bar.csproj</c>
    /// if that is the on-disk casing, making the hash agree with the AppHost side.
    /// </remarks>
    /// <param name="path">An absolute path to a file that exists on disk.</param>
    /// <returns>
    /// The path with OS-canonical casing, or <paramref name="path"/> unchanged if it
    /// cannot be resolved (file does not exist, UNC path, etc.).
    /// </returns>
    public static string ResolveToFilesystemPath(string path)
    {
        if (!OperatingSystem.IsWindows())
        {
            return path;
        }
 
        // Only handle standard drive-letter paths (e.g. C:\...).
        // UNC paths (\\server\share\...) are not common for project files and are left unchanged.
        if (path.Length < 3 || path[1] != ':' || path[2] != Path.DirectorySeparatorChar)
        {
            return path;
        }
 
        // Uppercase the drive letter and use it as the starting root (e.g. "C:\").
        var current = char.ToUpperInvariant(path[0]) + ":" + Path.DirectorySeparatorChar;
 
        // Walk each component after the root ("X:\") to resolve its real casing.
        var parts = path[3..].Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
 
        for (var i = 0; i < parts.Length; i++)
        {
            if (i == parts.Length - 1)
            {
                // Final component: find the file with its real name.
                var files = Directory.GetFiles(current, parts[i]);
                return files.Length == 1 ? files[0] : Path.Combine(current, parts[i]);
            }
 
            // Intermediate component: find the directory with its real name.
            var dirs = Directory.GetDirectories(current, parts[i]);
            current = dirs.Length == 1 ? dirs[0] : Path.Combine(current, parts[i]);
 
            if (!current.EndsWith(Path.DirectorySeparatorChar))
            {
                current += Path.DirectorySeparatorChar;
            }
        }
 
        return path;
    }
 
    /// <summary>
    /// Resolves symbolic links along every segment of <paramref name="path"/> and returns
    /// the filesystem-canonical absolute path. Useful for comparing two user-supplied paths
    /// that may differ only because one of them traverses a symlinked directory
    /// (for example <c>/tmp/x</c> vs <c>/private/tmp/x</c> on macOS, where <c>/tmp</c> is a
    /// symlink to <c>/private/tmp</c>).
    /// </summary>
    /// <remarks>
    /// <para>Walks each segment so that an <em>intermediate</em> directory symlink resolves
    /// correctly — <see cref="Directory.ResolveLinkTarget(string, bool)"/> only reads the
    /// symlink at exactly the path it is given, so a single call on a path like
    /// <c>/tmp/x/y.cs</c> would not unwrap <c>/tmp</c>.</para>
    /// <para>On any IO failure (broken link, permission denied, missing intermediate
    /// segment, circular link), returns the path with as many segments resolved as
    /// possible. This is a best-effort canonicalization for comparison — callers should
    /// not rely on it for security boundaries.</para>
    /// </remarks>
    public static string ResolveSymlinks(string path)
    {
        return ResolveSymlinksCore(path, depth: 0);
    }
 
    /// <summary>
    /// Attempts to resolve symbolic links along every segment of <paramref name="path"/>.
    /// </summary>
    /// <remarks>
    /// Unlike <see cref="ResolveSymlinks"/>, this method reports IO, permission, and circular-link failures
    /// instead of returning a partially canonicalized path. An ordinary missing segment that is not observable
    /// as a symbolic link remains lexical; callers must still account for filesystem changes after validation.
    /// </remarks>
    /// <param name="path">The path to canonicalize.</param>
    /// <param name="resolvedPath">The canonical path when the method returns <see langword="true"/>.</param>
    /// <returns><see langword="true"/> when every observable symbolic link was resolved; otherwise, <see langword="false"/>.</returns>
    public static bool TryResolveSymlinks(string path, out string resolvedPath)
    {
        return TryResolveSymlinksCore(path, depth: 0, out resolvedPath);
    }
 
    // Hard depth limit on recursive canonicalization to defend against pathological
    // symlink chains; well-formed real-world paths resolve in a handful of levels.
    private const int MaxResolveSymlinksDepth = 40;
 
    private static string ResolveSymlinksCore(string path, int depth)
    {
        if (string.IsNullOrEmpty(path))
        {
            return path;
        }
 
        if (depth > MaxResolveSymlinksDepth)
        {
            // Give up rather than risk a stack overflow on circular/pathological links.
            return path;
        }
 
        try
        {
            var fullPath = Path.GetFullPath(path);
            var root = Path.GetPathRoot(fullPath);
            if (string.IsNullOrEmpty(root))
            {
                return fullPath;
            }
 
            // Walk only the part after the root so segment splitting cannot eat a drive
            // letter ("C:") or UNC prefix.
            var relative = fullPath[root.Length..];
            var segments = relative.Split(
                [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
                StringSplitOptions.RemoveEmptyEntries);
 
            var current = root;
            for (var i = 0; i < segments.Length; i++)
            {
                current = Path.Combine(current, segments[i]);
 
                FileSystemInfo? linkTarget = null;
                try
                {
                    // For intermediate segments we know they must be directories — files
                    // cannot have child segments. For the final segment, try file first
                    // then directory, since either is plausible.
                    linkTarget = i < segments.Length - 1
                        ? Directory.ResolveLinkTarget(current, returnFinalTarget: true)
                        : File.ResolveLinkTarget(current, returnFinalTarget: true)
                          ?? Directory.ResolveLinkTarget(current, returnFinalTarget: true);
                }
                catch (IOException)
                {
                    // Broken or circular symlink. Stop unwrapping and return what we have
                    // resolved so far combined with the remaining unresolved segments —
                    // matches the behaviour callers get from FileInfo when the link is bad.
                    return CombineRemaining(current, segments, i + 1);
                }
                catch (UnauthorizedAccessException)
                {
                    return CombineRemaining(current, segments, i + 1);
                }
 
                if (linkTarget?.FullName is { Length: > 0 } resolved)
                {
                    // ResolveLinkTarget returns the symlink target exactly as stored on disk,
                    // which may itself contain unresolved symlinks in intermediate segments
                    // (for example on macOS a link target "/var/.../app" still has
                    // "/var -> /private/var" unresolved). Recurse so the canonical form does
                    // not depend on which side of the comparison reached the file first.
                    current = ResolveSymlinksCore(resolved, depth + 1);
                }
            }
 
            return current;
        }
        catch (Exception)
        {
            // Defensive: any unexpected normalization failure preserves caller-visible
            // behaviour by falling back to the input path.
            return path;
        }
 
        static string CombineRemaining(string current, string[] segments, int startIndex)
        {
            for (var j = startIndex; j < segments.Length; j++)
            {
                current = Path.Combine(current, segments[j]);
            }
 
            return current;
        }
    }
 
    private static bool TryResolveSymlinksCore(string path, int depth, out string resolvedPath)
    {
        resolvedPath = path;
 
        if (string.IsNullOrEmpty(path))
        {
            return true;
        }
 
        if (depth > MaxResolveSymlinksDepth)
        {
            return false;
        }
 
        try
        {
            var fullPath = Path.GetFullPath(path);
            var root = Path.GetPathRoot(fullPath);
            if (string.IsNullOrEmpty(root))
            {
                resolvedPath = fullPath;
                return true;
            }
 
            var relative = fullPath[root.Length..];
            var segments = relative.Split(
                [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
                StringSplitOptions.RemoveEmptyEntries);
 
            var current = root;
            for (var i = 0; i < segments.Length; i++)
            {
                current = Path.Combine(current, segments[i]);
 
                var storedLinkTarget = new FileInfo(current).LinkTarget ?? new DirectoryInfo(current).LinkTarget;
                if (storedLinkTarget is null && !File.Exists(current) && !Directory.Exists(current))
                {
                    try
                    {
                        _ = File.GetAttributes(current);
                    }
                    catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException)
                    {
                        for (var j = i + 1; j < segments.Length; j++)
                        {
                            current = Path.Combine(current, segments[j]);
                        }
 
                        resolvedPath = current;
                        return true;
                    }
                }
 
                var linkTarget = i < segments.Length - 1
                    ? Directory.ResolveLinkTarget(current, returnFinalTarget: true)
                    : File.ResolveLinkTarget(current, returnFinalTarget: true)
                      ?? Directory.ResolveLinkTarget(current, returnFinalTarget: true);
 
                if (linkTarget?.FullName is { Length: > 0 } resolved)
                {
                    if (!TryResolveSymlinksCore(resolved, depth + 1, out current))
                    {
                        return false;
                    }
                }
            }
 
            resolvedPath = current;
            return true;
        }
        catch
        {
            return false;
        }
    }
}