File: Acquisition\InstallationCandidateSources.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 Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Acquisition;
 
/// <summary>
/// Finds raw Aspire CLI install candidates for <see cref="InstallationDiscovery"/>.
/// </summary>
internal interface IInstallationCandidateSource
{
    IEnumerable<InstallationDiscoveryCandidate> GetCandidates(InstallationCandidateContext context);
}
 
internal sealed record InstallationCandidateContext(
    string AspireBinaryName,
    DirectoryInfo HomeDirectory,
    DirectoryInfo AspireHomeDirectory,
    IReadOnlyList<InstallationPathHit> PathHits,
    ILogger Logger,
    CancellationToken CancellationToken)
{
    public string AspireHome => AspireHomeDirectory.FullName;
}
 
internal sealed record InstallationPathHit(string OriginalPath, string CanonicalPath);
 
// CanonicalPath is an optional pre-resolved hint from a candidate source that
// already had to resolve the symlink chain itself (currently only $PATH hits;
// FindAllAspireOnPath calls CliPathHelper.ResolveSymlinkToFullPath while
// enumerating). When provided, DiscoverAllAsync uses it directly instead of
// re-resolving, which avoids a redundant syscall and closes a small TOCTOU
// window between the two resolves where a symlink swap could produce
// divergent dedup keys. Other sources (release prefix, dogfood, dotnet-tool
// store) leave it null and rely on DiscoverAllAsync to resolve.
internal sealed record InstallationDiscoveryCandidate(string BinaryPath, string Origin, string? CanonicalPath = null);
 
internal static class InstallationDiscoveryLayout
{
    public const string DogfoodDirectoryName = "dogfood";
}
 
internal sealed class PathInstallationCandidateSource : IInstallationCandidateSource
{
    public IEnumerable<InstallationDiscoveryCandidate> GetCandidates(InstallationCandidateContext context)
    {
        foreach (var pathHit in context.PathHits)
        {
            yield return new InstallationDiscoveryCandidate(pathHit.OriginalPath, "$PATH", pathHit.CanonicalPath);
        }
    }
}
 
internal sealed class ReleasePrefixInstallationCandidateSource : IInstallationCandidateSource
{
    public IEnumerable<InstallationDiscoveryCandidate> GetCandidates(InstallationCandidateContext context)
    {
        var releaseDir = Path.Combine(context.AspireHome, "bin");
        var releaseBinary = Path.Combine(releaseDir, context.AspireBinaryName);
        if (File.Exists(releaseBinary))
        {
            context.Logger.LogDebug("Discovery: release prefix walk yielded '{Binary}'.", releaseBinary);
            yield return new InstallationDiscoveryCandidate(releaseBinary, "well-known release prefix");
        }
        else if (Directory.Exists(releaseDir))
        {
            // Bin dir exists but no `aspire` inside it: likely a partially removed install
            // or a third-party `~/.aspire/bin` use. Log so users can correlate with expectations.
            context.Logger.LogDebug(
                "Discovery: release prefix directory '{ReleaseDir}' exists but does not contain an 'aspire' binary — not classifying as a real install.",
                releaseDir);
        }
        else
        {
            context.Logger.LogDebug("Discovery: release prefix '{ReleaseDir}' does not exist; skipping.", releaseDir);
        }
    }
}
 
internal sealed class DogfoodInstallationCandidateSource : IInstallationCandidateSource
{
    public IEnumerable<InstallationDiscoveryCandidate> GetCandidates(InstallationCandidateContext context)
    {
        var dogfoodRoot = Path.Combine(context.AspireHome, InstallationDiscoveryLayout.DogfoodDirectoryName);
        if (Directory.Exists(dogfoodRoot))
        {
            var subdirCount = 0;
            foreach (var prDir in InstallationCandidateSourceHelpers.EnumerateDirectoriesSafe(dogfoodRoot, context.Logger, context.CancellationToken))
            {
                subdirCount++;
                var binDir = Path.Combine(prDir, "bin");
                var binary = Path.Combine(binDir, context.AspireBinaryName);
                if (File.Exists(binary))
                {
                    context.Logger.LogDebug("Discovery: dogfood walk yielded '{Binary}'.", binary);
                    yield return new InstallationDiscoveryCandidate(binary, "dogfood prefix");
                }
                else
                {
                    // A dogfood pr-N directory without bin/aspire is most commonly a stale
                    // leftover from a failed install or partial uninstall.
                    context.Logger.LogDebug(
                        "Discovery: dogfood directory '{PrDir}' exists but does not contain a '{Bin}/aspire' binary — not classifying as a real install.",
                        prDir, "bin");
                }
            }
 
            if (subdirCount == 0)
            {
                context.Logger.LogDebug("Discovery: dogfood root '{DogfoodRoot}' exists but contains no subdirectories.", dogfoodRoot);
            }
        }
        else
        {
            context.Logger.LogDebug("Discovery: dogfood root '{DogfoodRoot}' does not exist; skipping.", dogfoodRoot);
        }
    }
}
 
internal sealed class DotnetToolStoreInstallationCandidateSource : IInstallationCandidateSource
{
    // Streaming-enumeration options used for the dotnet-tool store walk. The tool store
    // is recursive (~/.dotnet/tools/.store/aspire.cli/<version>/aspire.cli.<rid>/<version>/tools/...)
    // so we recurse, but we skip inaccessible subtrees instead of throwing mid-walk.
    // That lets the surrounding foreach observe each yielded entry — and the caller's
    // cancellation token — incrementally, instead of having to materialize the whole
    // tree before the discovery loop can react to Ctrl+C on a slow filesystem.
    //
    // Reparse points (symlinks / junctions) are skipped so a symlink cycle anywhere
    // under the store cannot make `Directory.EnumerateFiles` walk indefinitely. The
    // legitimate tool-store layout contains no symlinks, so this loses nothing real
    // and removes a self-DoS surface from `aspire doctor` discovery.
    private static readonly EnumerationOptions s_enumerationOptions = new()
    {
        RecurseSubdirectories = true,
        IgnoreInaccessible = true,
        AttributesToSkip = FileAttributes.Hidden | FileAttributes.System | FileAttributes.ReparsePoint,
    };
 
    public IEnumerable<InstallationDiscoveryCandidate> GetCandidates(InstallationCandidateContext context)
    {
        var toolStore = Path.Combine(context.HomeDirectory.FullName, ".dotnet", "tools", ".store", "aspire.cli");
        if (!Directory.Exists(toolStore))
        {
            context.Logger.LogDebug("Discovery: dotnet-tool store '{ToolStore}' does not exist; skipping.", toolStore);
            yield break;
        }
 
        var anyMatch = false;
        foreach (var binary in InstallationCandidateSourceHelpers.EnumerateFilesSafe(toolStore, context.AspireBinaryName, s_enumerationOptions, context.Logger, context.CancellationToken))
        {
            anyMatch = true;
            context.Logger.LogDebug("Discovery: dotnet-tool store walk yielded '{Binary}'.", binary);
            yield return new InstallationDiscoveryCandidate(binary, "dotnet-tool store");
        }
 
        if (!anyMatch)
        {
            context.Logger.LogDebug(
                "Discovery: dotnet-tool store '{ToolStore}' exists but contains no '{BinaryName}' binary — not classifying as a real install.",
                toolStore, context.AspireBinaryName);
        }
    }
}
 
internal static class InstallationCandidateSourceHelpers
{
    // Streams filesystem entries under <paramref name="root"/> so the caller can observe each
    // entry — and the surrounding cancellation token — incrementally. Materializing the
    // whole list upfront would defeat the per-step cancellation cadence the discovery
    // walkers rely on. Iterator methods cannot yield inside a catch, so we drive the
    // enumerator manually and swallow IOExceptions raised on initial open or mid-walk.
    public static IEnumerable<string> EnumerateDirectoriesSafe(string root, ILogger logger, CancellationToken cancellationToken = default)
    {
        return EnumerateFileSystemEntriesSafe(root, logger, "directories", () => Directory.EnumerateDirectories(root).GetEnumerator(), cancellationToken);
    }
 
    public static IEnumerable<string> EnumerateFilesSafe(string root, string searchPattern, EnumerationOptions enumerationOptions, ILogger logger, CancellationToken cancellationToken = default)
    {
        return EnumerateFileSystemEntriesSafe(root, logger, "files", () => Directory.EnumerateFiles(root, searchPattern, enumerationOptions).GetEnumerator(), cancellationToken);
    }
 
    private static IEnumerable<string> EnumerateFileSystemEntriesSafe(string root, ILogger logger, string entryKind, Func<IEnumerator<string>> enumeratorFactory, CancellationToken cancellationToken)
    {
        IEnumerator<string> enumerator;
        try
        {
            enumerator = enumeratorFactory();
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException)
        {
            logger.LogDebug(ex, "Discovery: failed to enumerate {EntryKind} under '{Root}'.", entryKind, root);
            yield break;
        }
 
        using (enumerator)
        {
            while (true)
            {
                bool moved;
                try
                {
                    cancellationToken.ThrowIfCancellationRequested();
                    moved = enumerator.MoveNext();
                }
                catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException)
                {
                    logger.LogDebug(ex, "Discovery: failed to enumerate {EntryKind} under '{Root}'.", entryKind, root);
                    yield break;
                }
 
                if (!moved)
                {
                    yield break;
                }
 
                yield return enumerator.Current;
            }
        }
    }
}