File: Bundles\BundleService.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.Tool.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.Diagnostics;
using System.Formats.Tar;
using System.IO.Compression;
using Aspire.Cli.Layout;
using Aspire.Cli.Utils;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Bundles;
 
/// <summary>
/// Manages extraction of the embedded bundle payload from self-extracting CLI binaries.
/// </summary>
internal sealed class BundleService(ILayoutDiscovery layoutDiscovery, ILogger<BundleService> logger) : IBundleService
{
    private const string PayloadResourceName = "bundle.tar.gz";
 
    /// <summary>
    /// Name of the marker file written after successful extraction.
    /// </summary>
    internal const string VersionMarkerFileName = ".aspire-bundle-version";
 
    private static readonly bool s_isBundle =
        typeof(BundleService).Assembly.GetManifestResourceInfo(PayloadResourceName) is not null;
 
    /// <inheritdoc/>
    public bool IsBundle => s_isBundle;
 
    /// <summary>
    /// Opens a read-only stream over the embedded bundle payload.
    /// Returns <see langword="null"/> if no payload is embedded.
    /// </summary>
    public static Stream? OpenPayload() =>
        typeof(BundleService).Assembly.GetManifestResourceStream(PayloadResourceName);
 
    /// <summary>
    /// Well-known layout subdirectories that are cleaned before re-extraction.
    /// The bin/ directory is intentionally excluded since it contains the running CLI binary.
    /// </summary>
    internal static readonly string[] s_layoutDirectories = [
        BundleDiscovery.RuntimeDirectoryName,
        BundleDiscovery.DashboardDirectoryName,
        BundleDiscovery.DcpDirectoryName,
        BundleDiscovery.AppHostServerDirectoryName,
        "tools"
    ];
 
    /// <inheritdoc/>
    public async Task EnsureExtractedAsync(CancellationToken cancellationToken = default)
    {
        if (!IsBundle)
        {
            logger.LogDebug("No embedded bundle payload, skipping extraction.");
            return;
        }
 
        var processPath = Environment.ProcessPath;
        if (string.IsNullOrEmpty(processPath))
        {
            logger.LogDebug("ProcessPath is null or empty, skipping bundle extraction.");
            return;
        }
 
        var extractDir = GetDefaultExtractDir(processPath);
        if (extractDir is null)
        {
            logger.LogDebug("Could not determine extraction directory from {ProcessPath}, skipping.", processPath);
            return;
        }
 
        logger.LogDebug("Ensuring bundle is extracted to {ExtractDir}.", extractDir);
        var result = await ExtractAsync(extractDir, force: false, cancellationToken);
 
        if (result is BundleExtractResult.ExtractionFailed)
        {
            throw new InvalidOperationException(
                "Bundle extraction failed. Run 'aspire setup --force' to retry, or reinstall the Aspire CLI.");
        }
    }
 
    /// <inheritdoc/>
    public async Task<LayoutConfiguration?> EnsureExtractedAndGetLayoutAsync(CancellationToken cancellationToken = default)
    {
        await EnsureExtractedAsync(cancellationToken).ConfigureAwait(false);
        return layoutDiscovery.DiscoverLayout();
    }
 
    /// <inheritdoc/>
    public async Task<BundleExtractResult> ExtractAsync(string destinationPath, bool force = false, CancellationToken cancellationToken = default)
    {
        if (!IsBundle)
        {
            logger.LogDebug("No embedded bundle payload.");
            return BundleExtractResult.NoPayload;
        }
 
        // Use a file lock for cross-process synchronization
        var lockPath = Path.Combine(destinationPath, ".aspire-bundle-lock");
        logger.LogDebug("Acquiring bundle extraction lock at {LockPath}...", lockPath);
        using var fileLock = await FileLock.AcquireAsync(lockPath, cancellationToken).ConfigureAwait(false);
        logger.LogDebug("Bundle extraction lock acquired.");
 
        try
        {
            // Re-check after acquiring lock — another process may have already extracted
            if (!force && layoutDiscovery.DiscoverLayout() is not null)
            {
                var existingVersion = ReadVersionMarker(destinationPath);
                var currentVersion = GetCurrentVersion();
                if (existingVersion == currentVersion)
                {
                    logger.LogDebug("Bundle already extracted and up to date (version: {Version}).", existingVersion);
                    return BundleExtractResult.AlreadyUpToDate;
                }
 
                logger.LogDebug("Version mismatch: existing={ExistingVersion}, current={CurrentVersion}. Re-extracting.", existingVersion, currentVersion);
            }
 
            return await ExtractCoreAsync(destinationPath, cancellationToken);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Failed to extract bundle to {Path}", destinationPath);
            return BundleExtractResult.ExtractionFailed;
        }
    }
 
    private async Task<BundleExtractResult> ExtractCoreAsync(string destinationPath, CancellationToken cancellationToken)
    {
        logger.LogInformation("Extracting embedded bundle to {Path}...", destinationPath);
 
        // Clean existing layout directories before extraction to avoid file conflicts
        logger.LogDebug("Cleaning existing layout directories in {Path}.", destinationPath);
        CleanLayoutDirectories(destinationPath);
 
        var sw = Stopwatch.StartNew();
        await ExtractPayloadAsync(destinationPath, cancellationToken);
        sw.Stop();
        logger.LogDebug("Payload extraction completed in {ElapsedMs}ms.", sw.ElapsedMilliseconds);
 
        // Write version marker so subsequent runs skip extraction
        var currentVersion = GetCurrentVersion();
        WriteVersionMarker(destinationPath, currentVersion);
        logger.LogDebug("Version marker written (version: {Version}).", currentVersion);
 
        // Verify extraction produced a valid layout
        if (layoutDiscovery.DiscoverLayout() is null)
        {
            logger.LogError("Extraction completed but no valid layout found in {Path}.", destinationPath);
            return BundleExtractResult.ExtractionFailed;
        }
 
        logger.LogDebug("Bundle extraction verified successfully.");
        return BundleExtractResult.Extracted;
    }
 
    /// <summary>
    /// Determines the default extraction directory for the current CLI binary.
    /// If CLI is at ~/.aspire/bin/aspire, returns ~/.aspire/ so layout discovery
    /// finds components via the bin/ layout pattern.
    /// </summary>
    internal static string? GetDefaultExtractDir(string processPath)
    {
        var cliDir = Path.GetDirectoryName(processPath);
        if (string.IsNullOrEmpty(cliDir))
        {
            return null;
        }
 
        return Path.GetDirectoryName(cliDir) ?? cliDir;
    }
 
    /// <summary>
    /// Removes well-known layout subdirectories before re-extraction.
    /// Preserves the bin/ directory (which contains the CLI binary itself).
    /// </summary>
    internal static void CleanLayoutDirectories(string layoutPath)
    {
        foreach (var dir in s_layoutDirectories)
        {
            var fullPath = Path.Combine(layoutPath, dir);
            if (Directory.Exists(fullPath))
            {
                Directory.Delete(fullPath, recursive: true);
            }
        }
 
        // Remove version marker so it's rewritten after extraction
        var markerPath = Path.Combine(layoutPath, VersionMarkerFileName);
        if (File.Exists(markerPath))
        {
            File.Delete(markerPath);
        }
    }
 
    /// <summary>
    /// Gets the assembly informational version of the current CLI binary.
    /// Used as the version marker to detect when re-extraction is needed.
    /// </summary>
    internal static string GetCurrentVersion()
    {
        return VersionHelper.GetDefaultTemplateVersion();
    }
 
    /// <summary>
    /// Writes a version marker file to the extraction directory.
    /// </summary>
    internal static void WriteVersionMarker(string extractDir, string version)
    {
        var markerPath = Path.Combine(extractDir, VersionMarkerFileName);
        File.WriteAllText(markerPath, version);
    }
 
    /// <summary>
    /// Reads the version string from a previously written marker file.
    /// Returns null if the marker doesn't exist or is empty.
    /// </summary>
    internal static string? ReadVersionMarker(string extractDir)
    {
        var markerPath = Path.Combine(extractDir, VersionMarkerFileName);
        if (!File.Exists(markerPath))
        {
            return null;
        }
 
        var content = File.ReadAllText(markerPath).Trim();
        return string.IsNullOrEmpty(content) ? null : content;
    }
 
    /// <summary>
    /// Extracts the embedded tar.gz payload to the specified directory using .NET TarReader.
    /// </summary>
    internal static async Task ExtractPayloadAsync(string destinationPath, CancellationToken cancellationToken)
    {
        Directory.CreateDirectory(destinationPath);
 
        using var payloadStream = OpenPayload() ?? throw new InvalidOperationException("No embedded bundle payload.");
        await using var gzipStream = new GZipStream(payloadStream, CompressionMode.Decompress);
        await using var tarReader = new TarReader(gzipStream);
 
        while (await tarReader.GetNextEntryAsync(cancellationToken: cancellationToken) is { } entry)
        {
            // Strip the top-level directory (equivalent to tar --strip-components=1)
            var name = entry.Name;
            var slashIndex = name.IndexOf('/');
            if (slashIndex < 0)
            {
                continue; // Top-level directory entry itself, skip
            }
 
            var relativePath = name[(slashIndex + 1)..];
            if (string.IsNullOrEmpty(relativePath))
            {
                continue;
            }
 
            var fullPath = Path.GetFullPath(Path.Combine(destinationPath, relativePath));
            var normalizedDestination = Path.GetFullPath(destinationPath);
 
            // Guard against path traversal attacks (e.g., entries containing ".." segments)
            if (!fullPath.StartsWith(normalizedDestination + Path.DirectorySeparatorChar, StringComparison.Ordinal) &&
                !fullPath.Equals(normalizedDestination, StringComparison.Ordinal))
            {
                throw new InvalidOperationException($"Tar entry '{entry.Name}' would extract outside the destination directory.");
            }
 
            switch (entry.EntryType)
            {
                case TarEntryType.Directory:
                    Directory.CreateDirectory(fullPath);
                    break;
 
                case TarEntryType.RegularFile:
                    var dir = Path.GetDirectoryName(fullPath);
                    if (dir is not null)
                    {
                        Directory.CreateDirectory(dir);
                    }
                    await entry.ExtractToFileAsync(fullPath, overwrite: true, cancellationToken);
 
                    // Preserve Unix file permissions from tar entry (e.g., execute bit)
                    if (!OperatingSystem.IsWindows() && entry.Mode != default)
                    {
                        File.SetUnixFileMode(fullPath, (UnixFileMode)entry.Mode);
                    }
                    break;
 
                case TarEntryType.SymbolicLink:
                    if (string.IsNullOrEmpty(entry.LinkName))
                    {
                        continue;
                    }
                    // Validate symlink target stays within the extraction directory
                    var linkTarget = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(fullPath)!, entry.LinkName));
                    if (!linkTarget.StartsWith(normalizedDestination + Path.DirectorySeparatorChar, StringComparison.Ordinal) &&
                        !linkTarget.Equals(normalizedDestination, StringComparison.Ordinal))
                    {
                        throw new InvalidOperationException($"Symlink '{entry.Name}' targets '{entry.LinkName}' which resolves outside the destination directory.");
                    }
                    var linkDir = Path.GetDirectoryName(fullPath);
                    if (linkDir is not null)
                    {
                        Directory.CreateDirectory(linkDir);
                    }
                    if (File.Exists(fullPath))
                    {
                        File.Delete(fullPath);
                    }
                    File.CreateSymbolicLink(fullPath, entry.LinkName);
                    break;
            }
        }
    }
}