File: Bundles\BundleService.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.Diagnostics;
using System.Formats.Tar;
using System.IO.Compression;
using System.IO.Hashing;
using System.Text;
using Aspire.Cli.Acquisition;
using Aspire.Cli.Layout;
using Aspire.Cli.Utils;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
 
namespace Aspire.Cli.Bundles;
 
/// <summary>
/// Manages extraction of the embedded bundle payload from self-extracting CLI binaries.
/// </summary>
internal sealed class BundleService(
    IBundlePayloadProvider payloadProvider,
    ILayoutDiscovery layoutDiscovery,
    IEnvironment environment,
    ILogger<BundleService> logger,
    WingetFirstRunProbe? wingetFirstRunProbe = null) : IBundleService
{
    /// <summary>
    /// Name of the marker file written after successful extraction.
    /// </summary>
    internal const string VersionMarkerFileName = ".aspire-bundle-version";
 
    /// <summary>
    /// Directory under the layout root containing per-version bundle installations.
    /// </summary>
    internal const string VersionsDirectoryName = "versions";
 
    /// <summary>
    /// Suffix appended to an in-progress extraction directory so it is ignored by
    /// layout discovery and can be atomically renamed to its final name only after
    /// extraction completes.
    /// </summary>
    internal const string TempSuffixPrefix = ".tmp.";
 
    /// <summary>
    /// Suffix appended to a versioned directory that failed verification. Retained
    /// on disk (with the version-id fingerprint) so the fingerprint-match
    /// short-circuit cannot accidentally promote a known-bad payload on a later run.
    /// </summary>
    internal const string BadSuffixPrefix = ".bad.";
 
    // Windows scanners can briefly open freshly extracted files without delete sharing, causing
    // Directory.Move to fail with one of these HRESULTs. Unix permits renames while files are open,
    // and the exact error list avoids delaying deterministic failures such as ERROR_DISK_FULL.
    // See https://learn.microsoft.com/windows/win32/debug/system-error-codes--0-499-
    private const int AccessDeniedHResult = unchecked((int)0x80070005);
    private const int SharingViolationHResult = unchecked((int)0x80070020);
    private const int LockViolationHResult = unchecked((int)0x80070021);
 
    private static readonly TimeSpan s_directoryMoveMaxRetryElapsed = TimeSpan.FromSeconds(3);
    private static readonly TimeSpan s_directoryMoveMaxRetryDelay = TimeSpan.FromSeconds(1);
 
    /// <inheritdoc/>
    public bool IsBundle => payloadProvider.HasPayload;
 
    /// <summary>
    /// Overrides <see cref="Environment.ProcessPath"/> for version fingerprinting.
    /// Used in tests to simulate different CLI binaries.
    /// </summary>
    internal string? ProcessPathOverride { get; init; }
 
    /// <summary>
    /// Well-known layout subdirectory that is exposed as a reparse point pointing
    /// at the active versioned bundle directory. Components (<c>managed/</c> and
    /// <c>dcp/</c>) are resolved as subdirectories of this link target.
    /// </summary>
    internal static readonly string[] s_linkedLayoutDirectories = [
        BundleDiscovery.BundleDirectoryName,
    ];
 
    /// <inheritdoc/>
    public async Task EnsureExtractedAsync(CancellationToken cancellationToken = default)
    {
        var extractDir = GetBundleExtractDirForCurrentProcess();
        if (string.IsNullOrEmpty(extractDir))
        {
            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<BundleLayoutLease?> EnsureExtractedAndAcquireLayoutAsync(string holderKind, string? commandName = null, CancellationToken cancellationToken = default)
    {
        var extractDir = GetBundleExtractDirForCurrentProcess();
        if (string.IsNullOrEmpty(extractDir))
        {
            var fallbackLayout = layoutDiscovery.DiscoverLayout();
            return fallbackLayout is null
                ? null
                : new BundleLayoutLease(fallbackLayout, lease: null);
        }
 
        var lockPath = Path.Combine(extractDir, ".aspire-bundle-lock");
        using var fileLock = await FileLock.AcquireAsync(lockPath, cancellationToken).ConfigureAwait(false);
 
        // Extraction cleanup and lease acquisition must share the same critical section;
        // otherwise a concurrent upgrade can delete the just-resolved active version
        // before this process protects it with a lease.
        var result = await ExtractAsyncCore(extractDir, force: false, cancellationToken).ConfigureAwait(false);
        if (result is BundleExtractResult.ExtractionFailed)
        {
            throw new InvalidOperationException(
                "Bundle extraction failed. Run 'aspire setup --force' to retry, or reinstall the Aspire CLI.");
        }
 
        var activeVersion = ResolveActiveVersionDirectory(extractDir);
        if (activeVersion is null)
        {
            logger.LogDebug("Could not resolve an active bundle version under {ExtractDir}.", extractDir);
            return null;
        }
 
        BundleVersionLease? lease = null;
        try
        {
            lease = BundleVersionLease.Acquire(activeVersion.Value.VersionDirectory, holderKind, commandName);
            return new BundleLayoutLease(
                CreateVersionRootedLayout(activeVersion.Value.VersionDirectory),
                lease);
        }
        catch
        {
            lease?.Dispose();
            throw;
        }
    }
 
    /// <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.");
 
        return await ExtractAsyncCore(destinationPath, force, cancellationToken).ConfigureAwait(false);
    }
 
    private async Task<BundleExtractResult> ExtractAsyncCore(string destinationPath, bool force, CancellationToken cancellationToken)
    {
        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(ProcessPathOverride);
                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 string? GetBundleExtractDirForCurrentProcess()
    {
        if (!IsBundle)
        {
            logger.LogDebug("No embedded bundle payload, skipping extraction.");
            return null;
        }
 
        var processPath = ProcessPathOverride ?? Environment.ProcessPath;
        if (string.IsNullOrEmpty(processPath))
        {
            logger.LogDebug("ProcessPath is null or empty, skipping bundle extraction.");
            return null;
        }
 
        // The winget portable installer has no post-install hook, so the CLI
        // self-stamps the install-route sidecar on first run. No-op on
        // non-Windows and once the sidecar already exists.
        if (wingetFirstRunProbe is not null && environment.IsWindows())
        {
            var realBinaryPath = CliPathHelper.ResolveSymlinkOrOriginalPath(processPath, logger);
            var binaryDir = Path.GetDirectoryName(realBinaryPath);
            if (!string.IsNullOrEmpty(binaryDir))
            {
                wingetFirstRunProbe.Run(binaryDir);
            }
        }
 
        var extractDir = GetDefaultExtractDir(processPath);
        if (string.IsNullOrEmpty(extractDir))
        {
            logger.LogDebug("Could not determine extraction directory from {ProcessPath}, skipping.", processPath);
            return null;
        }
 
        return extractDir;
    }
 
    private async Task<BundleExtractResult> ExtractCoreAsync(string destinationPath, CancellationToken cancellationToken)
    {
        logger.LogInformation("Extracting embedded bundle to {Path}...", destinationPath);
 
        Directory.CreateDirectory(destinationPath);
        var versionsRoot = Path.Combine(destinationPath, VersionsDirectoryName);
        Directory.CreateDirectory(versionsRoot);
 
        var currentVersion = GetCurrentVersion(ProcessPathOverride);
        var versionId = ComputeVersionId(currentVersion);
        var activeVersionDir = Path.Combine(versionsRoot, versionId);
 
        // Reuse an already-extracted versioned directory if it passes validation.
        // This handles the case where the marker / links were deleted but the
        // payload is still intact on disk.
        if (!IsVersionedLayoutValid(activeVersionDir))
        {
            logger.LogDebug("Versioned layout {Path} not valid or missing; extracting fresh.", activeVersionDir);
            if (!await ExtractVersionedLayoutAsync(versionsRoot, versionId, activeVersionDir, cancellationToken).ConfigureAwait(false))
            {
                return BundleExtractResult.ExtractionFailed;
            }
        }
        else
        {
            logger.LogDebug("Reusing existing versioned layout at {Path}.", activeVersionDir);
        }
 
        // Capture prior link targets before flipping so we can roll back if the
        // post-flip sanity check fails.
        var priorTargets = CaptureLinkTargets(destinationPath);
 
        // Migrate any legacy real directories (managed/, dcp/) and flip the public
        // reparse points to point at the new versioned directory.
        if (!TryFlipLinks(destinationPath, activeVersionDir))
        {
            logger.LogError("Failed to flip bundle links to {VersionDir}.", activeVersionDir);
            return BundleExtractResult.ExtractionFailed;
        }
 
        // Post-flip sanity check: confirm layout discovery resolves through the
        // new reparse points. Roll back to the prior targets on failure.
        if (layoutDiscovery.DiscoverLayout() is null)
        {
            logger.LogError("Post-flip layout validation failed; attempting rollback.");
            if (!TryRestoreLinks(destinationPath, priorTargets))
            {
                logger.LogError("Rollback of bundle links failed; layout is in an inconsistent state.");
            }
            return BundleExtractResult.ExtractionFailed;
        }
 
        // Write version marker so subsequent runs can short-circuit.
        WriteVersionMarker(destinationPath, currentVersion);
        logger.LogDebug("Version marker written (version: {Version}).", currentVersion);
 
        // Best-effort cleanup of non-active versioned directories and any stale
        // .tmp.*, .bad.*, .old.* siblings.
        TryCleanupStaleVersions(versionsRoot, versionId);
 
        // Best-effort cleanup of .old legacy directories created during this
        // migration. These are safe to remove now that post-flip validation passed.
        foreach (var dir in s_linkedLayoutDirectories)
        {
            FileDeleteHelper.TryCleanupOldItems(destinationPath, dir);
        }
 
        // Best-effort cleanup of legacy top-level managed/ and dcp/ paths from
        // the old layout (before the single bundle/ link was introduced). These
        // are no longer needed now that layout discovery resolves through bundle/.
        TryCleanupLegacyLayoutPaths(destinationPath);
 
        logger.LogDebug("Bundle extraction verified successfully.");
        return BundleExtractResult.Extracted;
    }
 
    /// <summary>
    /// Extracts the payload into a <c>.tmp.*</c> sibling of the target versioned
    /// directory, validates the result, and atomically renames it to
    /// <paramref name="activeVersionDir"/>. Returns <see langword="false"/> if
    /// verification fails (in which case the failed directory has been renamed
    /// to <c>.bad.&lt;tick&gt;</c> and logged).
    /// </summary>
    private async Task<bool> ExtractVersionedLayoutAsync(
        string versionsRoot,
        string versionId,
        string activeVersionDir,
        CancellationToken cancellationToken)
    {
        var tempDir = Path.Combine(versionsRoot, $"{versionId}{TempSuffixPrefix}{Guid.NewGuid():N}");
 
        // Clean up if a previous attempt left a dir with this exact name.
        FileDeleteHelper.TryDeleteDirectory(tempDir);
 
        var sw = Stopwatch.StartNew();
        try
        {
            await ExtractPayloadAsync(tempDir, cancellationToken).ConfigureAwait(false);
        }
        catch
        {
            FileDeleteHelper.TryDeleteDirectory(tempDir);
            throw;
        }
        sw.Stop();
        logger.LogDebug("Payload extraction into {Path} completed in {ElapsedMs}ms.", tempDir, sw.ElapsedMilliseconds);
 
        // Pre-flip verification: validate the freshly-unpacked bundle before it
        // can become the active version.
        if (!IsVersionedLayoutValid(tempDir))
        {
            var badPath = $"{activeVersionDir}{BadSuffixPrefix}{Environment.TickCount64}";
            logger.LogError("Extracted bundle at {Path} failed verification; renaming to {BadPath}.", tempDir, badPath);
            try
            {
                Directory.Move(tempDir, badPath);
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
            {
                logger.LogWarning(ex, "Unable to preserve failed bundle at {BadPath}; deleting instead.", badPath);
                FileDeleteHelper.TryDeleteDirectory(tempDir);
            }
            return false;
        }
 
        // If a stale activeVersionDir exists (partial prior install), move it aside.
        if (Directory.Exists(activeVersionDir))
        {
            FileDeleteHelper.TryDeleteDirectory(activeVersionDir);
        }
 
        try
        {
            await MoveDirectoryWithRetryAsync(tempDir, activeVersionDir, cancellationToken).ConfigureAwait(false);
        }
        catch (OperationCanceledException)
        {
            FileDeleteHelper.TryDeleteDirectory(tempDir);
            throw;
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            logger.LogError(ex, "Failed to promote {TempDir} to {ActiveDir}.", tempDir, activeVersionDir);
            FileDeleteHelper.TryDeleteDirectory(tempDir);
            return false;
        }
 
        // Re-validate after rename.
        if (!IsVersionedLayoutValid(activeVersionDir))
        {
            var badPath = $"{activeVersionDir}{BadSuffixPrefix}{Environment.TickCount64}";
            logger.LogError("Post-rename validation failed for {Path}; renaming to {BadPath}.", activeVersionDir, badPath);
            try
            {
                Directory.Move(activeVersionDir, badPath);
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
            {
                logger.LogWarning(ex, "Unable to preserve failed bundle at {BadPath}.", badPath);
            }
            return false;
        }
 
        return true;
    }
 
    /// <summary>
    /// Moves a directory, retrying transient Windows file-lock failures with bounded backoff.
    /// </summary>
    internal async Task MoveDirectoryWithRetryAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken)
    {
        var delay = TimeSpan.FromMilliseconds(100);
        var retryCount = 0;
        var stopwatch = Stopwatch.StartNew();
 
        while (true)
        {
            try
            {
                Directory.Move(sourcePath, destinationPath);
                return;
            }
            catch (Exception ex) when (IsRetryableDirectoryMoveException(ex, environment.IsWindows()) && stopwatch.Elapsed < s_directoryMoveMaxRetryElapsed)
            {
                retryCount++;
                logger.LogDebug(
                    "Directory move from {SourcePath} to {DestinationPath} failed with HRESULT {HResult}; retrying in {DelayMs}ms (retry {RetryCount}).",
                    sourcePath,
                    destinationPath,
                    ex.HResult,
                    delay.TotalMilliseconds,
                    retryCount);
                await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
                delay = TimeSpan.FromMilliseconds(Math.Min(
                    delay.TotalMilliseconds * 2,
                    s_directoryMoveMaxRetryDelay.TotalMilliseconds));
            }
        }
    }
 
    /// <summary>
    /// Determines whether a directory move exception represents a transient Windows file lock.
    /// </summary>
    internal static bool IsRetryableDirectoryMoveException(Exception exception, bool isWindows)
    {
        return isWindows &&
            exception is IOException or UnauthorizedAccessException &&
            exception.HResult is AccessDeniedHResult or SharingViolationHResult or LockViolationHResult;
    }
 
    /// <inheritdoc/>
    public string? GetDefaultExtractDir(string processPath)
        => ComputeDefaultExtractDir(processPath, logger);
 
    /// <summary>
    /// Computes the bundle extract directory from the sidecar source value.
    /// See <c>docs/specs/install-routes.md</c> for the contract.
    /// </summary>
    internal static string? ComputeDefaultExtractDir(string processPath)
        => ComputeDefaultExtractDir(processPath, logger: null);
 
    private static string? ComputeDefaultExtractDir(string processPath, ILogger? logger)
    {
        logger ??= NullLogger.Instance;
 
        if (string.IsNullOrEmpty(processPath))
        {
            return null;
        }
 
        var realBinaryPath = CliPathHelper.ResolveSymlinkOrOriginalPath(processPath, logger);
        var binaryDir = Path.GetDirectoryName(realBinaryPath);
        if (string.IsNullOrEmpty(binaryDir))
        {
            return null;
        }
 
        // Sidecar parsing is shared with InstallSidecarReader; the layout
        // mapping below intentionally uses the raw wire string so the
        // mapping remains a static, dependency-free function callable from
        // any context (including code paths that run before DI is wired).
        var sidecarPath = Path.Combine(binaryDir, InstallSidecarReader.SidecarFileName);
        var source = InstallSidecarReader.ReadSourceField(sidecarPath);
 
        return source switch
        {
            InstallSourceExtensions.WingetWire
                or InstallSourceExtensions.BrewWire
                or InstallSourceExtensions.DotnetToolWire => binaryDir,
            InstallSourceExtensions.ScriptWire
                or InstallSourceExtensions.PrWire
                or InstallSourceExtensions.LocalHiveWire => Path.GetDirectoryName(binaryDir) ?? binaryDir,
            // Sidecar-less binaries can be installed in arbitrary locations, including
            // read-only package stores. Default to user-owned Aspire home unless a
            // route-specific sidecar explicitly opts in to colocated extraction.
            _ => CliPathHelper.GetDefaultAspireHomeDirectory(),
        };
    }
 
    /// <summary>
    /// Captures the current reparse-point targets for the public link paths so
    /// they can be restored if the post-flip sanity check fails.
    /// A non-reparse-point path (or missing path) is captured as <see langword="null"/>
    /// meaning "no link to restore".
    /// </summary>
    internal static IReadOnlyDictionary<string, string?> CaptureLinkTargets(string layoutPath)
    {
        var targets = new Dictionary<string, string?>(s_linkedLayoutDirectories.Length, StringComparer.Ordinal);
        foreach (var dir in s_linkedLayoutDirectories)
        {
            var linkPath = Path.Combine(layoutPath, dir);
            targets[dir] = ReparsePoint.IsReparsePoint(linkPath) ? ReparsePoint.GetTarget(linkPath) : null;
        }
        return targets;
    }
 
    /// <summary>
    /// Points the public <c>bundle/</c> link at the active versioned directory.
    /// Migrates any legacy real directory sitting at the link path by renaming it
    /// to a <c>.old</c> sibling (preserved until post-flip validation succeeds).
    /// </summary>
    private bool TryFlipLinks(string layoutPath, string activeVersionDir)
    {
        foreach (var dir in s_linkedLayoutDirectories)
        {
            var linkPath = Path.Combine(layoutPath, dir);
 
            // The bundle link points directly at the active version directory —
            // components (managed/, dcp/) are subdirectories of the target.
            var target = activeVersionDir;
 
            // Clear out legacy stale siblings from prior runs first.
            FileDeleteHelper.TryCleanupOldItems(layoutPath, dir);
 
            // If a legacy real directory is sitting at the public path, rename it
            // to a .old sibling so a reparse point can be created. The .old sibling
            // is preserved until after post-flip validation succeeds.
            if (Directory.Exists(linkPath) && !ReparsePoint.IsReparsePoint(linkPath))
            {
                var renamedPath = $"{linkPath}.old.{Environment.TickCount64}";
                logger.LogDebug("Migrating legacy directory at {Path} to {Renamed}.", linkPath, renamedPath);
                try
                {
                    Directory.Move(linkPath, renamedPath);
                }
                catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
                {
                    logger.LogError(ex, "Failed to rename legacy directory {Path}.", linkPath);
                    return false;
                }
            }
 
            try
            {
                ReparsePoint.CreateOrReplace(linkPath, target);
                logger.LogDebug("Linked {Link} -> {Target}", linkPath, target);
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
            {
                logger.LogError(ex, "Failed to create reparse point at {Path} -> {Target}.", linkPath, target);
                return false;
            }
        }
 
        return true;
    }
 
    /// <summary>
    /// Best-effort restore of link targets captured before a failed flip. Entries
    /// whose prior value was <see langword="null"/> (no previous link) are removed.
    /// </summary>
    private bool TryRestoreLinks(string layoutPath, IReadOnlyDictionary<string, string?> priorTargets)
    {
        var allOk = true;
        foreach (var (dir, priorTarget) in priorTargets)
        {
            var linkPath = Path.Combine(layoutPath, dir);
            try
            {
                if (priorTarget is null)
                {
                    ReparsePoint.RemoveIfExists(linkPath);
                }
                else
                {
                    ReparsePoint.CreateOrReplace(linkPath, priorTarget);
                }
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
            {
                logger.LogError(ex, "Rollback failed for link {Path}.", linkPath);
                allOk = false;
            }
        }
        return allOk;
    }
 
    /// <summary>
    /// Returns <see langword="true"/> if <paramref name="versionDir"/> contains the
    /// essential bundle components (<c>managed/aspire-managed</c> and the DCP executable).
    /// </summary>
    internal static bool IsVersionedLayoutValid(string versionDir)
    {
        if (!Directory.Exists(versionDir))
        {
            return false;
        }
 
        var managedDir = Path.Combine(versionDir, BundleDiscovery.ManagedDirectoryName);
        var managedExe = Path.Combine(managedDir, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName));
 
        if (!Directory.Exists(managedDir) || !File.Exists(managedExe))
        {
            return false;
        }
 
        try
        {
            var info = new FileInfo(managedExe);
            if (info.Length == 0)
            {
                return false;
            }
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
        {
            return false;
        }
 
        var dcpDir = Path.Combine(versionDir, BundleDiscovery.DcpDirectoryName);
        var dcpExe = BundleDiscovery.GetDcpExecutablePath(dcpDir);
        if (!Directory.Exists(dcpDir) || !File.Exists(dcpExe))
        {
            return false;
        }
 
        return true;
    }
 
    /// <summary>
    /// Best-effort sweep of the <c>versions/</c> directory, removing anything other
    /// than the active version. Directories with active leases are left untouched
    /// and retried by a later extraction.
    /// </summary>
    internal static void TryCleanupStaleVersions(string versionsRoot, string activeVersionId)
    {
        if (!Directory.Exists(versionsRoot))
        {
            return;
        }
 
        foreach (var entry in Directory.EnumerateDirectories(versionsRoot))
        {
            var name = Path.GetFileName(entry);
 
            // Keep the active version.
            if (string.Equals(name, activeVersionId, StringComparison.Ordinal))
            {
                continue;
            }
 
            if (BundleVersionLease.HasActiveLease(entry))
            {
                continue;
            }
 
            try
            {
                Directory.Delete(entry, recursive: true);
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
            {
                // If deletion fails after lease probing, leave the directory untouched.
                // A later setup/update can retry without invalidating a potential reader.
            }
        }
    }
 
    private static (string VersionId, string VersionDirectory)? ResolveActiveVersionDirectory(string extractDir)
    {
        var bundlePath = Path.Combine(extractDir, BundleDiscovery.BundleDirectoryName);
        if (ResolveReparsePointTarget(bundlePath, extractDir) is { } linkTarget &&
            IsVersionedLayoutValid(linkTarget))
        {
            return (GetDirectoryName(linkTarget), linkTarget);
        }
 
        var existingVersion = ReadVersionMarker(extractDir);
        if (!string.IsNullOrEmpty(existingVersion))
        {
            var versionId = ComputeVersionId(existingVersion);
            var markerVersionDir = Path.Combine(extractDir, VersionsDirectoryName, versionId);
            if (IsVersionedLayoutValid(markerVersionDir))
            {
                return (versionId, markerVersionDir);
            }
        }
 
        return null;
    }
 
    private static string? ResolveReparsePointTarget(string linkPath, string layoutPath)
    {
        if (!ReparsePoint.IsReparsePoint(linkPath))
        {
            return null;
        }
 
        var target = ReparsePoint.GetTarget(linkPath);
        if (string.IsNullOrEmpty(target))
        {
            return null;
        }
 
        return Path.GetFullPath(Path.IsPathRooted(target)
            ? target
            : Path.Combine(layoutPath, target));
    }
 
    private static string GetDirectoryName(string path)
    {
        return Path.GetFileName(path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
    }
 
    private static LayoutConfiguration CreateVersionRootedLayout(string versionDirectory)
    {
        return new LayoutConfiguration
        {
            LayoutPath = versionDirectory,
            Components = new LayoutComponents
            {
                Dcp = BundleDiscovery.DcpDirectoryName,
                Managed = BundleDiscovery.ManagedDirectoryName,
            }
        };
    }
 
    /// <summary>
    /// Best-effort removal of legacy top-level <c>managed/</c> and <c>dcp/</c>
    /// directories from the old layout shape (before the single <c>bundle/</c> link
    /// was introduced). Failures are silently ignored since the new layout via
    /// <c>bundle/</c> is already functional.
    /// </summary>
    private void TryCleanupLegacyLayoutPaths(string layoutPath)
    {
        string[] legacyDirs = [BundleDiscovery.ManagedDirectoryName, BundleDiscovery.DcpDirectoryName];
 
        foreach (var dir in legacyDirs)
        {
            var legacyPath = Path.Combine(layoutPath, dir);
            if (!Directory.Exists(legacyPath))
            {
                continue;
            }
 
            try
            {
                FileDeleteHelper.TryDeleteDirectory(legacyPath);
                logger.LogDebug("Removed legacy directory at {Path}.", legacyPath);
            }
            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
            {
                logger.LogDebug(ex, "Could not remove legacy path {Path}; will retry next run.", legacyPath);
            }
        }
    }
 
    /// <summary>
    /// Computes a deterministic, filesystem-safe directory name for a given
    /// current-version fingerprint. The fingerprint already captures the CLI
    /// binary's size and timestamp, so the resulting id changes whenever the
    /// payload would change.
    /// </summary>
    /// <remarks>
    /// Format: <c>&lt;sanitized-version&gt;-&lt;64-bit-xxhash-hex&gt;</c>. Version
    /// characters outside <c>[A-Za-z0-9._-]</c> are replaced with <c>_</c>.
    /// </remarks>
    internal static string ComputeVersionId(string currentVersion)
    {
        var hashBytes = XxHash3.Hash(Encoding.UTF8.GetBytes(currentVersion));
        var hash = Convert.ToHexString(hashBytes).ToLowerInvariant();
 
        // Extract the human-readable prefix (everything before the first '|') for
        // readability in the on-disk layout, and sanitize for filesystem safety.
        var separatorIndex = currentVersion.IndexOf('|');
        var versionPart = separatorIndex >= 0 ? currentVersion[..separatorIndex] : currentVersion;
 
        var sb = new StringBuilder(versionPart.Length);
        foreach (var ch in versionPart)
        {
            sb.Append(ch is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z') or (>= '0' and <= '9') or '.' or '-' or '_'
                ? ch
                : '_');
        }
 
        var prefix = sb.Length == 0 ? "bundle" : sb.ToString();
        return $"{prefix}-{hash}";
    }
 
    /// <summary>
    /// Gets a fingerprint for the current CLI bundle.
    /// Used as the version marker to detect when re-extraction is needed.
    /// </summary>
    internal static string GetCurrentVersion(string? processPath = null)
    {
        // physical-binary-version-by-design (see docs/specs/cli-identity-sidecar.md):
        // this fingerprints the single-file bundle's OWN binary so re-extraction is triggered
        // when the installed bundle changes. It describes the file on disk, not the emulated
        // ASPIRE_CLI_VERSION identity, so it must read the assembly version directly.
        var version = VersionHelper.GetDefaultTemplateVersion();
        processPath ??= Environment.ProcessPath;
 
        if (string.IsNullOrEmpty(processPath))
        {
            return version;
        }
 
        try
        {
            var fileInfo = new FileInfo(processPath);
            if (!fileInfo.Exists)
            {
                return version;
            }
 
            return $"{version}|{fileInfo.Length}|{fileInfo.LastWriteTimeUtc.Ticks}";
        }
        catch (IOException)
        {
            return version;
        }
        catch (UnauthorizedAccessException)
        {
            return version;
        }
        catch (NotSupportedException)
        {
            return version;
        }
    }
 
    /// <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 async Task ExtractPayloadAsync(string destinationPath, CancellationToken cancellationToken)
    {
        using var payloadStream = payloadProvider.OpenPayload() ?? throw new InvalidOperationException("No bundle payload available.");
        await ExtractPayloadAsync(payloadStream, destinationPath, environment, cancellationToken).ConfigureAwait(false);
    }
 
    /// <summary>
    /// Extracts a tar.gz payload stream to the specified directory.
    /// </summary>
    internal static async Task ExtractPayloadAsync(Stream payloadStream, string destinationPath, IEnvironment environment, CancellationToken cancellationToken)
    {
        Directory.CreateDirectory(destinationPath);
 
        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 (!environment.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;
            }
        }
    }
}