// 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.<tick></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><sanitized-version>-<64-bit-xxhash-hex></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;
}
}
}
}