File: Utils\CliPathHelper.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.IO.Hashing;
using System.Text;
using Aspire.Cli.Acquisition;
using Aspire.Hosting.Backchannel;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Utils;
 
internal static class CliPathHelper
{
    internal const string AspireHomeEnvironmentVariable = "ASPIRE_HOME";
 
    /// <summary>
    /// Name of the directory under <c>ASPIRE_HOME</c> that holds NuGet package caches keyed by
    /// a stable hash of the resolved staging feed URL. Two staging builds of the same release
    /// branch share the same stable-shaped semver (e.g. <c>13.4.0</c>) but ship from different
    /// darc feeds; an <c>overrideStagingFeed</c> setting can also point the same CLI at any
    /// arbitrary feed. Each distinct feed URL therefore gets its own feed-hash subdirectory
    /// here to avoid <c>(id, version)</c> cache collisions in NuGet. <c>aspire cache clear</c>
    /// wipes the feed-hash subdirectories so users can recover wedged staging restores without
    /// manual filesystem surgery.
    /// </summary>
    internal const string StagingNuGetPackagesFolderName = ".nugetpackages";
 
    /// <summary>
    /// Default number of hex characters used for staging feed cache keys. 8 keeps deep
    /// integration cache paths well under Windows <c>MAX_PATH</c> while still giving roughly
    /// 4 billion buckets — orders of magnitude more than the handful of staging feeds any one
    /// user ever sees, so collisions are negligible in practice.
    /// </summary>
    internal const int DefaultStagingFeedCacheKeyLength = 8;
 
    // The maximum age before a leftover CLI socket file in the runtime sockets directory is
    // pruned. 24 hours is comfortably longer than any legitimate Aspire CLI run and short enough
    // that stale entries don't pile up indefinitely after crashes (see issue #16709).
    internal static readonly TimeSpan s_staleSocketThreshold = TimeSpan.FromHours(24);
 
    private static int s_socketDirectorySwept;
 
    internal static string GetAspireHomeDirectory(string? processPath = null, ILogger? logger = null)
    {
        var effectiveProcessPath = processPath ?? Environment.ProcessPath;
 
        return TryGetAspireHomeDirectoryFromInstallRoute(effectiveProcessPath, logger)
            ?? GetDefaultAspireHomeDirectory();
    }
 
    internal static string GetDefaultAspireHomeDirectory()
        => GetDefaultAspireHomeDirectory(
            Environment.GetEnvironmentVariable(AspireHomeEnvironmentVariable),
            GetUserProfileDirectory());
 
    internal static string GetDefaultAspireHomeDirectory(string? configuredAspireHome, string userProfileDirectory)
    {
        return string.IsNullOrWhiteSpace(configuredAspireHome)
            ? Path.Combine(userProfileDirectory, ".aspire")
            : configuredAspireHome;
    }
 
    /// <summary>
    /// Returns the absolute path to the staging NuGet package cache root
    /// (<c>&lt;ASPIRE_HOME&gt;/.nugetpackages</c>). Producers (the
    /// <c>PrebuiltAppHostServer</c> temp nuget.config) write feed-hash-keyed
    /// subdirectories under this root; the <c>aspire cache clear</c> command wipes those
    /// subdirectories. Centralized here so both call sites agree on the location.
    /// </summary>
    internal static string GetStagingNuGetPackagesDirectory(DirectoryInfo aspireHomeDirectory)
    {
        ArgumentNullException.ThrowIfNull(aspireHomeDirectory);
        return Path.Combine(aspireHomeDirectory.FullName, StagingNuGetPackagesFolderName);
    }
 
    /// <summary>
    /// Returns a stable lowercase hex cache key derived from <paramref name="feedUrl"/>,
    /// truncated to <paramref name="length"/> characters. Returns <see langword="null"/> when
    /// the URL is null, empty, or whitespace-only.
    /// </summary>
    /// <remarks>
    /// Used by <c>PrebuiltAppHostServer</c> to compute the per-feed
    /// <c>globalPackagesFolder</c> subdirectory under
    /// <c>&lt;ASPIRE_HOME&gt;/.nugetpackages</c>. Keying on the feed URL (rather than the CLI
    /// commit SHA) means that the same CLI talking to two different override staging feeds
    /// gets two distinct caches, which is important because staging packages from different
    /// feeds share the same stable-shaped <c>(id, version)</c> tuple and would otherwise
    /// collide in NuGet's cache.
    ///
    /// The URL is trimmed and lower-cased before hashing so harmless variations (trailing
    /// whitespace from a config file, hostname casing) don't fragment the cache. Hashing the
    /// URL with <see cref="XxHash3"/> (non-cryptographic but very high quality) keeps any
    /// embedded credentials out of the on-disk directory name even when the feed URL itself
    /// contains them.
    /// </remarks>
    internal static string? ComputeStagingFeedCacheKey(string? feedUrl, int length = DefaultStagingFeedCacheKeyLength)
    {
        if (string.IsNullOrWhiteSpace(feedUrl) || length <= 0)
        {
            return null;
        }
 
        var normalized = feedUrl.Trim().ToLowerInvariant();
        var bytes = Encoding.UTF8.GetBytes(normalized);
        // XxHash3 emits 8 bytes (64 bits) -> 16 hex chars; truncate to the requested length.
        var hex = Convert.ToHexString(XxHash3.Hash(bytes)).ToLowerInvariant();
        return length >= hex.Length ? hex : hex[..length];
    }
 
    internal static string? TryGetAspireHomeDirectoryFromInstallRoute(string? processPath, ILogger? logger = null)
    {
        if (string.IsNullOrEmpty(processPath))
        {
            return null;
        }
 
        var realBinaryPath = ResolveSymlinkOrOriginalPath(processPath, logger);
        var binaryDir = Path.GetDirectoryName(realBinaryPath);
        if (string.IsNullOrEmpty(binaryDir))
        {
            return null;
        }
 
        var sidecarPath = Path.Combine(binaryDir, InstallSidecarReader.SidecarFileName);
        var source = InstallSidecarReader.ReadSourceField(sidecarPath);
 
        return source switch
        {
            InstallSourceExtensions.ScriptWire
                or InstallSourceExtensions.LocalHiveWire => Path.GetDirectoryName(binaryDir) ?? binaryDir,
            InstallSourceExtensions.PrWire => TryGetPrInstallPrefix(binaryDir),
            _ => null
        };
    }
 
    private static string? TryGetPrInstallPrefix(string binaryDir)
    {
        var prDir = Path.GetDirectoryName(binaryDir);
        if (string.IsNullOrEmpty(prDir))
        {
            return null;
        }
 
        var dogfoodDir = Path.GetDirectoryName(prDir);
        if (string.IsNullOrEmpty(dogfoodDir) ||
            !string.Equals(Path.GetFileName(dogfoodDir), InstallationDiscoveryLayout.DogfoodDirectoryName, StringComparison.Ordinal))
        {
            return null;
        }
 
        return Path.GetDirectoryName(dogfoodDir);
    }
 
    internal static string GetUserProfileDirectory()
        => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
 
    /// <summary>
    /// Normalizes the casing of a filesystem <paramref name="path"/> so that paths which differ only
    /// by case collapse to a single value when used as a comparison or hash key.
    /// </summary>
    /// <param name="path">The path to normalize.</param>
    /// <param name="environment">
    /// Environment abstraction used to detect the host OS. Supply a test double to exercise the
    /// Windows behavior on another OS.
    /// </param>
    /// <remarks>
    /// On Windows the filesystem is case-insensitive, so the same physical project can be reached
    /// through paths that differ only by case — for example <c>c:\repo\App.csproj</c> when the CLI is
    /// launched from VS Code versus <c>C:\Repo\app.csproj</c> from a terminal. Lowercasing the whole
    /// path collapses those to one key. On case-sensitive platforms (Linux, and case-sensitive macOS
    /// volumes) casing is significant, so the path is returned unchanged; macOS is intentionally left
    /// alone because its volumes may be case-sensitive.
    /// </remarks>
    internal static string NormalizePathCasing(string path, IEnvironment environment)
    {
        return environment.IsWindows() ? path.ToLowerInvariant() : path;
    }
 
    internal static string ResolveSymlinkOrOriginalPath(string path, ILogger? logger = null)
    {
        if (string.IsNullOrEmpty(path))
        {
            return path;
        }
 
        return MaybeStripMacOSFirmlinkPrefix(TryResolveSymlinkTarget(path, logger, "using the raw path") ?? path);
    }
 
    internal static string? ResolveSymlinkToFullPath(string? path, ILogger? logger = null)
    {
        if (string.IsNullOrEmpty(path))
        {
            return null;
        }
 
        var resolved = TryResolveSymlinkTarget(path, logger, "trying the normalized path");
        if (resolved is not null)
        {
            return MaybeStripMacOSFirmlinkPrefix(resolved);
        }
 
        try
        {
            return MaybeStripMacOSFirmlinkPrefix(Path.GetFullPath(path));
        }
        catch (Exception ex) when (IsPathResolutionException(ex))
        {
            logger?.LogDebug(ex, "Could not normalize path {Path}; skipping it.", path);
            return null;
        }
    }
 
    /// <summary>
    /// Returns <paramref name="path"/> with a leading macOS firmlink prefix
    /// (<c>/private/var</c>, <c>/private/tmp</c>, <c>/private/etc</c>)
    /// rewritten back to the user-facing logical form (<c>/var</c>,
    /// <c>/tmp</c>, <c>/etc</c>). Returns the input unchanged when no
    /// firmlink prefix matches.
    /// </summary>
    /// <remarks>
    /// macOS Catalina (10.15) and later use APFS firmlinks to transparently
    /// redirect <c>/var</c><c>/private/var</c>, <c>/tmp</c>
    /// <c>/private/tmp</c>, and <c>/etc</c><c>/private/etc</c> at the
    /// filesystem layer. Firmlinks are not symlinks — <c>lstat</c> reports
    /// the directory directly and <see cref="File.ResolveLinkTarget(string, bool)"/>
    /// returns <see langword="null"/>. Meanwhile, <see cref="Environment.ProcessPath"/>
    /// and libc <c>realpath(3)</c> return the <c>/private/*</c> form, while
    /// <see cref="Path.GetFullPath(string)"/>, <c>$PATH</c> walks via
    /// <see cref="PathLookupHelper"/>, NuGet's <c>packageSourceMapping</c>
    /// lookup, and user-typed paths use the un-prefixed form.
    /// 
    /// This asymmetry breaks every cross-surface path-string comparison
    /// when the CLI is installed under a firmlinked prefix
    /// (e.g. <c>/var/folders/...</c> from <c>mktemp</c>): the same
    /// physical binary shows up as two distinct strings, which breaks the
    /// dedup in <see cref="Acquisition.InstallationDiscovery"/> and causes
    /// NuGet to silently drop <c>&lt;packageSource&gt;</c> mappings whose
    /// key is in the <c>/private/*</c> form (NuGet canonicalizes path-named
    /// sources by stripping <c>/private/</c> when registering the source,
    /// but the <c>packageSourceMapping</c> key is matched against the
    /// stored name as-written — so any mapping authored with the
    /// <c>/private/*</c> key is unreachable and <c>Aspire*</c> patterns
    /// fall through to the catch-all source).
    /// 
    /// Normalizing all canonical paths back to the un-prefixed form keeps
    /// every comparison site consistent. Do not "fix" the resolve helpers
    /// above to return the realpath / <c>/private/*</c> form without also
    /// updating every downstream consumer (dedup, nuget.config writer,
    /// path-status check): the un-prefixed form is the one that crosses
    /// tool boundaries correctly.
    /// 
    /// The match is <see cref="StringComparison.Ordinal"/> because
    /// case-sensitive APFS volumes distinguish <c>/Private/Var/...</c>
    /// (a real user-created path) from <c>/private/var/...</c> (the
    /// firmlink). The match is also boundary-aware: <c>/private/varlog</c>
    /// is preserved because <c>varlog</c> is not the <c>var</c> path
    /// component followed by a separator.
    /// 
    /// See https://support.apple.com/guide/security/firmlinks-secf3a9d2014/web
    /// for Apple's firmlink reference.
    /// </remarks>
    internal static string StripMacOSFirmlinkPrefix(string path)
    {
        if (string.IsNullOrEmpty(path) || !path.StartsWith("/private/", StringComparison.Ordinal))
        {
            return path;
        }
 
        foreach (var firmlink in s_macosFirmlinkPrefixes)
        {
            if (path.Length >= firmlink.Length &&
                path.StartsWith(firmlink, StringComparison.Ordinal) &&
                (path.Length == firmlink.Length || path[firmlink.Length] == '/'))
            {
                return path[PrivateSegmentLength..];
            }
        }
 
        return path;
    }
 
    // "/private".Length — the byte we trim off when rewriting a firmlink path.
    private const int PrivateSegmentLength = 8;
 
    // Apple-documented user-visible firmlinks that take a `/private/<dir>` form
    // on macOS Catalina and later. Other macOS firmlinks (under
    // /System/Volumes/Data) do not surface as user paths and are not relevant
    // to install-path comparisons.
    private static readonly string[] s_macosFirmlinkPrefixes = ["/private/var", "/private/tmp", "/private/etc"];
 
    private static string MaybeStripMacOSFirmlinkPrefix(string path)
        => OperatingSystem.IsMacOS() ? StripMacOSFirmlinkPrefix(path) : path;
 
    /// <summary>
    /// Creates a randomized CLI-managed socket path.
    /// </summary>
    /// <param name="socketPrefix">The socket file prefix.</param>
    internal static string CreateUnixDomainSocketPath(string socketPrefix)
        => CreateSocketPath(socketPrefix);
 
    internal static string CreateGuestAppHostSocketPath(string socketPrefix)
        => OperatingSystem.IsWindows()
            ? CreateSocketName(socketPrefix)
            : CreateSocketPath(socketPrefix);
 
    /// <summary>
    /// Prunes leftover CLI socket files from <c>~/.aspire/cli/runtime/sockets/</c> whose last
    /// modified timestamp is older than <paramref name="maxAge"/>. Returns the number of files
    /// that were deleted. Exceptions from individual file deletions are swallowed so a single
    /// permission-denied or locked file can't break startup. Exposed for tests via
    /// <see cref="CleanupStaleCliSockets(string, TimeSpan, TimeProvider)"/>.
    /// </summary>
    /// <remarks>
    /// Unlike <see cref="BackchannelConstants.CleanupOrphanedSockets"/>,
    /// CLI sockets don't encode the process ID in their filename — they're created with a random
    /// GUID-style suffix — so the only reliable signal we have for "this is stale" is the file's
    /// mtime. We pick a generous default threshold so an in-flight long-running run never has its
    /// socket pruned out from under it.
    /// </remarks>
    internal static int CleanupStaleCliSockets(string socketDirectory, TimeSpan maxAge, TimeProvider? timeProvider = null)
    {
        if (!Directory.Exists(socketDirectory))
        {
            return 0;
        }
 
        var now = (timeProvider ?? TimeProvider.System).GetUtcNow();
        var deleted = 0;
 
        var socketFileSearchPattern = BackchannelConstants.ComputeSocketFileSearchPattern("cli.sock");
        foreach (var path in Directory.EnumerateFiles(socketDirectory, socketFileSearchPattern))
        {
            try
            {
                var lastWrite = File.GetLastWriteTimeUtc(path);
                if (now - lastWrite >= maxAge)
                {
                    File.Delete(path);
                    deleted++;
                }
            }
            catch
            {
                // Best-effort cleanup; one bad file should not block CLI startup.
            }
        }
 
        return deleted;
    }
 
    private static string CreateSocketPath(string socketPrefix)
    {
        var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
        var socketPath = BackchannelConstants.ComputeCliSocketPath(homeDirectory, socketPrefix);
        var socketDirectory = Path.GetDirectoryName(socketPath)!;
        Directory.CreateDirectory(socketDirectory);
 
        if (Interlocked.CompareExchange(ref s_socketDirectorySwept, 1, 0) == 0)
        {
            CleanupStaleCliSockets(socketDirectory, s_staleSocketThreshold);
        }
 
        return socketPath;
    }
 
    private static string CreateSocketName(string socketPrefix)
    {
        return BackchannelConstants.ComputeSocketFileName(socketPrefix);
    }
 
    private static string? TryResolveSymlinkTarget(string path, ILogger? logger, string fallbackDescription)
    {
        try
        {
            var resolved = File.ResolveLinkTarget(path, returnFinalTarget: true);
            return resolved?.FullName;
        }
        catch (Exception ex) when (IsPathResolutionException(ex))
        {
            logger?.LogDebug(ex, "Could not resolve symlink target for {Path}; {FallbackDescription}.", path, fallbackDescription);
            return null;
        }
    }
 
    private static bool IsPathResolutionException(Exception ex)
        => ex is IOException
            or UnauthorizedAccessException
            or ArgumentException
            or NotSupportedException
            or PathTooLongException
            or System.Security.SecurityException;
}