// 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.Globalization;
using System.Net;
using System.Security.Cryptography;
using System.Text.Json;
using Aspire.Cli.Configuration;
using Aspire.Cli.Interaction;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace Aspire.Cli.Agents.AspireSkills;
/// <summary>
/// Resolves, verifies, and caches Aspire workflow skills from the external Aspire skills package.
/// </summary>
internal sealed class AspireSkillsInstaller(
IGitHubArtifactAttestationVerifier githubArtifactAttestationVerifier,
IHttpClientFactory httpClientFactory,
IAspireSkillsBundleProvider bundleProvider,
IEmbeddedAspireSkillsBundleProvider embeddedBundleProvider,
IInteractionService interactionService,
CliExecutionContext executionContext,
IConfiguration configuration,
IFeatures features,
AspireCliTelemetry telemetry,
ILogger<AspireSkillsInstaller> logger) : IAspireSkillsInstaller
{
internal const string Version = "0.0.1";
internal const string GitHubRepository = "microsoft/aspire-skills";
internal const string ExpectedSourceRepository = $"https://github.com/{GitHubRepository}";
internal const string ExpectedWorkflowPath = ".github/workflows/publish.yml";
internal const string ExpectedBuildType = "https://actions.github.io/buildtypes/workflow/v1";
internal const string DisablePackageValidationKey = "disableAspireSkillsPackageValidation";
internal const string VersionOverrideKey = "aspireSkillsVersion";
internal const string MaxCacheAgeKey = "AspireSkillsMaxCacheAgeSeconds";
private const string GitHubApiBaseUrl = "https://api.github.com";
internal const string ArchiveSha512FileName = ".archive-sha512";
internal const string GitHubArchiveSha256FileName = ".github-archive-sha256";
internal const string GitHubAttestationVerifiedFileName = ".github-attestation-verified";
private const string LastUsedFileName = ".lastused";
private const int CacheLockMaxAttempts = 4;
private const int WindowsSharingViolationHResult = unchecked((int)0x80070020);
private const int WindowsLockViolationHResult = unchecked((int)0x80070021);
private const int LinuxWouldBlockHResult = 11;
private const int MacOsWouldBlockHResult = 35;
private static readonly TimeSpan s_cacheLockInitialRetryDelay = TimeSpan.FromMilliseconds(100);
private static readonly TimeSpan s_cacheLockMaxRetryDelay = TimeSpan.FromMilliseconds(400);
private static readonly TimeSpan s_defaultMaxCacheAge = TimeSpan.FromDays(7);
public Task<AspireSkillsInstallResult> InstallAsync(CancellationToken cancellationToken)
{
return interactionService.ShowStatusAsync(
AgentCommandStrings.AspireSkillsInstaller_InstallingStatus,
() => InstallCoreAsync(cancellationToken));
}
private async Task<AspireSkillsInstallResult> InstallCoreAsync(CancellationToken cancellationToken)
{
using var activity = telemetry.StartReportedActivity("AspireSkillsInstaller.Install");
var effectiveVersion = configuration[VersionOverrideKey];
if (string.IsNullOrWhiteSpace(effectiveVersion))
{
effectiveVersion = Version;
}
activity?.SetTag("aspire.skills.version", effectiveVersion);
var cacheRoot = GetCacheRoot();
Directory.CreateDirectory(cacheRoot);
var validationDisabled = string.Equals(configuration[DisablePackageValidationKey], "true", StringComparison.OrdinalIgnoreCase);
var embeddedMetadata = embeddedBundleProvider.Metadata;
async Task<AspireSkillsInstallResult> CompleteInstallationAsync(AspireSkillsBundle bundle, string archiveSha512)
{
await CleanupStaleCacheEntriesAsync(
cacheRoot,
effectiveVersion,
archiveSha512,
cancellationToken).ConfigureAwait(false);
return AspireSkillsInstallResult.Installed(bundle);
}
// The remote fetch path is opt-in. Ship 13.4 with this disabled so users only
// get the embedded snapshot (no unattended network call out to GitHub on every
// `aspire agent init`). Toggle the feature on to opt in to the GitHub release path,
// which still falls back to the embedded snapshot if the network call fails.
var remoteFetchEnabled = features.IsFeatureEnabled(
KnownFeatures.AspireSkillsRemoteFetchEnabled,
KnownFeatures.GetFeatureMetadata(KnownFeatures.AspireSkillsRemoteFetchEnabled)!.DefaultValue);
activity?.SetTag("aspire.skills.remote_fetch_enabled", remoteFetchEnabled);
AcquisitionResult? githubResult = null;
if (remoteFetchEnabled)
{
githubResult = await InstallFromGitHubAsync(cacheRoot, effectiveVersion, validationDisabled, activity, cancellationToken).ConfigureAwait(false);
if (githubResult.Status == AcquisitionStatus.Installed)
{
return await CompleteInstallationAsync(githubResult.Bundle!, githubResult.ArchiveSha512!).ConfigureAwait(false);
}
if (githubResult.Status == AcquisitionStatus.Failed)
{
logger.LogDebug("Aspire skills GitHub acquisition failed for version {Version}; falling back to embedded snapshot. Failure: {Failure}", effectiveVersion, githubResult.Message);
}
else if (!githubResult.GitHubReleaseMetadataAvailable ||
githubResult.KnownGitHubArchiveSha256 is not null)
{
// A digest from current release metadata can select an exact cache leaf. Only
// use an unpinned verified leaf when release metadata itself was unavailable;
// otherwise an unidentifiable current asset could revive stale same-version content.
var offlineCachedResult = await TryLoadCachedBundleAsync(
cacheRoot,
effectiveVersion,
expectedArchiveSha512: null,
expectedGitHubArchiveSha256: githubResult.KnownGitHubArchiveSha256,
requireVerifiedGitHubSource: true,
skipCompatibilityCheck: false,
activity,
cancellationToken).ConfigureAwait(false);
if (offlineCachedResult is not null)
{
logger.LogDebug(
"Using a previously verified GitHub Aspire skills bundle for version {Version} because GitHub is unavailable.",
effectiveVersion);
return await CompleteInstallationAsync(
offlineCachedResult.Bundle!,
offlineCachedResult.ArchiveSha512!).ConfigureAwait(false);
}
}
}
else
{
logger.LogDebug("Aspire skills remote fetch feature '{Feature}' is disabled; using the embedded snapshot.", KnownFeatures.AspireSkillsRemoteFetchEnabled);
}
var embeddedResult = await InstallFromEmbeddedAsync(cacheRoot, effectiveVersion, embeddedMetadata, activity, cancellationToken).ConfigureAwait(false);
if (embeddedResult.Status == AcquisitionStatus.Installed)
{
return await CompleteInstallationAsync(embeddedResult.Bundle!, embeddedResult.ArchiveSha512!).ConfigureAwait(false);
}
var failureMessage = embeddedResult.Status == AcquisitionStatus.Failed
? embeddedResult.Message ?? AgentCommandStrings.AspireSkillsInstaller_GitHubUnavailable
: githubResult is { Status: AcquisitionStatus.Failed, Message: { } githubMessage }
? githubMessage
: AgentCommandStrings.AspireSkillsInstaller_GitHubUnavailable;
activity?.SetStatus(ActivityStatusCode.Error, failureMessage);
return AspireSkillsInstallResult.Failed(failureMessage);
}
private async Task<AcquisitionResult> InstallFromGitHubAsync(
string cacheRoot,
string version,
bool validationDisabled,
Activity? activity,
CancellationToken cancellationToken)
{
using var tempDirectory = CreateTemporaryCacheDirectory(cacheRoot, "github");
string? knownGitHubArchiveSha256 = null;
var githubReleaseMetadataAvailable = false;
try
{
var httpClient = httpClientFactory.CreateClient();
var release = await TryGetGitHubReleaseAsync(httpClient, version, cancellationToken).ConfigureAwait(false);
if (release is null)
{
logger.LogDebug("Aspire skills GitHub release was unavailable for version {Version}.", version);
return AcquisitionResult.Unavailable();
}
githubReleaseMetadataAvailable = true;
var asset = FindGitHubReleaseAsset(release, version);
if (asset is null)
{
logger.LogDebug("Aspire skills GitHub release {TagName} does not contain a supported bundle asset for version {Version}.", release.TagName, version);
return AcquisitionResult.Unavailable(githubReleaseMetadataAvailable: true);
}
knownGitHubArchiveSha256 = TryNormalizeArchiveSha256(asset.Digest);
if (knownGitHubArchiveSha256 is not null)
{
var cachedResult = await TryLoadCachedBundleAsync(
cacheRoot,
version,
expectedArchiveSha512: null,
expectedGitHubArchiveSha256: knownGitHubArchiveSha256,
requireVerifiedGitHubSource: !validationDisabled,
skipCompatibilityCheck: false,
activity,
cancellationToken).ConfigureAwait(false);
if (cachedResult is not null)
{
return cachedResult;
}
}
var archivePath = Path.Combine(tempDirectory.FullName, GetSafeFileName(asset.Name));
if (!await TryDownloadGitHubAssetAsync(httpClient, asset.DownloadUrl, archivePath, cancellationToken).ConfigureAwait(false))
{
logger.LogDebug("Aspire skills GitHub release asset {AssetName} was unavailable for version {Version}.", asset.Name, version);
return AcquisitionResult.Unavailable(knownGitHubArchiveSha256, githubReleaseMetadataAvailable);
}
try
{
var githubArchiveSha256 = ComputeArchiveSha256(archivePath);
if (knownGitHubArchiveSha256 is not null &&
!string.Equals(githubArchiveSha256, knownGitHubArchiveSha256, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException(string.Format(
CultureInfo.InvariantCulture,
"Aspire skills GitHub release asset '{0}' failed SHA-256 verification.",
asset.Name));
}
if (!validationDisabled)
{
var provenanceResult = await githubArtifactAttestationVerifier.VerifyAsync(
GitHubRepository,
archivePath,
ExpectedSourceRepository,
ExpectedWorkflowPath,
ExpectedBuildType,
version,
cancellationToken).ConfigureAwait(false);
if (!provenanceResult.IsVerified)
{
return AcquisitionResult.Failed(string.Format(
CultureInfo.CurrentCulture,
AgentCommandStrings.PlaywrightCliInstaller_ProvenanceVerificationFailed,
$"GitHub release asset '{asset.Name}'",
provenanceResult.Outcome));
}
}
var archiveSha512 = ComputeArchiveSha512(archivePath);
var bundle = await CacheArchiveAsync(
cacheRoot,
archivePath,
version,
archiveSha512,
githubArchiveSha256,
validationDisabled ? BundleArchiveSource.UnverifiedGitHub : BundleArchiveSource.VerifiedGitHub,
cancellationToken).ConfigureAwait(false);
activity?.SetTag("aspire.skills.source", "github");
activity?.SetTag("aspire.skills.cache_hit", false);
return AcquisitionResult.Installed(bundle, archiveSha512, githubArchiveSha256);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException)
{
// Includes version-mismatch failures from ValidateCompatibility, which fall back to the embedded snapshot.
logger.LogDebug(ex, "Downloaded Aspire skills GitHub release asset {AssetName} is invalid.", asset.Name);
return AcquisitionResult.Failed(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.AspireSkillsInstaller_InvalidBundle, ex.Message));
}
}
// HttpClient.Timeout uses an internal cancellation token, so distinguish it from caller
// cancellation before treating the remote source as unavailable.
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogDebug(ex, "Aspire skills GitHub release acquisition timed out for version {Version}.", version);
return AcquisitionResult.Unavailable(knownGitHubArchiveSha256, githubReleaseMetadataAvailable);
}
// A truncated response body throws HttpIOException rather than HttpRequestException.
// Catch it explicitly so local cache and archive I/O failures still propagate.
catch (Exception ex) when (ex is HttpRequestException or HttpIOException or JsonException)
{
logger.LogDebug(ex, "Aspire skills GitHub release acquisition failed for version {Version}.", version);
return AcquisitionResult.Unavailable(knownGitHubArchiveSha256, githubReleaseMetadataAvailable);
}
}
private async Task<AcquisitionResult> InstallFromEmbeddedAsync(
string cacheRoot,
string version,
EmbeddedAspireSkillsBundleMetadata? metadata,
Activity? activity,
CancellationToken cancellationToken)
{
if (metadata is null)
{
logger.LogDebug("No embedded Aspire skills bundle metadata is available.");
return AcquisitionResult.Unavailable();
}
if (ValidateEmbeddedMetadata(metadata) is { } metadataError)
{
return AcquisitionResult.Failed(string.Format(
CultureInfo.CurrentCulture,
AgentCommandStrings.AspireSkillsInstaller_InvalidMetadata,
metadataError));
}
if (!string.Equals(metadata.Version, version, StringComparison.OrdinalIgnoreCase))
{
logger.LogDebug(
"Embedded Aspire skills bundle version {EmbeddedVersion} does not match requested version {Version}.",
metadata.Version,
version);
return AcquisitionResult.Unavailable();
}
var expectedArchiveSha512 = AspireSkillsBundleProvider.NormalizeSha512(metadata.Sha512!);
var cachedResult = await TryLoadCachedBundleAsync(
cacheRoot,
version,
expectedArchiveSha512,
expectedGitHubArchiveSha256: null,
requireVerifiedGitHubSource: false,
skipCompatibilityCheck: true,
activity,
cancellationToken).ConfigureAwait(false);
if (cachedResult is not null)
{
return cachedResult;
}
using var stageDirectory = CreateTemporaryCacheDirectory(cacheRoot, "stage");
try
{
// The embedded snapshot ships inside the CLI binary as the trusted last-resort
// fallback. Its `supports` range is stamped at the time the snapshot was built,
// which can lag the actual CLI version (especially for prerelease/dogfood builds)
// and would otherwise reject a perfectly usable local copy.
var bundle = await embeddedBundleProvider.CreateBundleAsync(
new DirectoryInfo(stageDirectory.FullName),
cancellationToken).ConfigureAwait(false);
if (bundle is null)
{
logger.LogDebug("Embedded Aspire skills archive is unavailable for version {Version}.", version);
return AcquisitionResult.Unavailable();
}
bundle = await CacheStagedBundleAsync(
cacheRoot,
stageDirectory,
bundle,
expectedArchiveSha512,
githubArchiveSha256: null,
version: version,
source: BundleArchiveSource.Embedded,
cancellationToken: cancellationToken).ConfigureAwait(false);
activity?.SetTag("aspire.skills.source", "embedded");
activity?.SetTag("aspire.skills.cache_hit", false);
return AcquisitionResult.Installed(bundle, expectedArchiveSha512);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException)
{
logger.LogDebug(ex, "Embedded Aspire skills bundle {AssetName} is invalid.", metadata.AssetName);
return AcquisitionResult.Failed(string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.AspireSkillsInstaller_InvalidBundle, ex.Message));
}
}
private static string? ValidateEmbeddedMetadata(EmbeddedAspireSkillsBundleMetadata metadata)
{
if (string.IsNullOrWhiteSpace(metadata.Version))
{
return AgentCommandStrings.AspireSkillsInstaller_MissingMetadataVersion;
}
if (!string.Equals(metadata.Repository, GitHubRepository, StringComparison.OrdinalIgnoreCase))
{
return string.Format(
CultureInfo.CurrentCulture,
AgentCommandStrings.AspireSkillsInstaller_MetadataRepositoryMismatch,
metadata.Repository,
GitHubRepository);
}
if (string.IsNullOrWhiteSpace(metadata.Tag))
{
return AgentCommandStrings.AspireSkillsInstaller_MissingMetadataTag;
}
if (string.IsNullOrWhiteSpace(metadata.AssetName))
{
return AgentCommandStrings.AspireSkillsInstaller_MissingMetadataAssetName;
}
if (string.IsNullOrWhiteSpace(metadata.Sha512))
{
return AgentCommandStrings.AspireSkillsInstaller_MissingMetadataSha512;
}
return null;
}
private static string? TryNormalizeArchiveSha256(string? digest)
{
if (string.IsNullOrWhiteSpace(digest))
{
return null;
}
var sha256 = AspireSkillsBundleProvider.NormalizeSha256(digest);
return sha256.Length == 64 && sha256.All(Uri.IsHexDigit)
? sha256.ToLowerInvariant()
: null;
}
private static string? TryNormalizeArchiveSha512(string? digest)
{
if (string.IsNullOrWhiteSpace(digest))
{
return null;
}
var sha512 = AspireSkillsBundleProvider.NormalizeSha512(digest);
return sha512.Length == 128 && sha512.All(Uri.IsHexDigit)
? sha512.ToLowerInvariant()
: null;
}
private async Task<GitHubReleaseInfo?> TryGetGitHubReleaseAsync(HttpClient httpClient, string version, CancellationToken cancellationToken)
{
foreach (var tag in GetGitHubTagCandidates(version))
{
var releaseUrl = $"{GitHubApiBaseUrl}/repos/{GitHubRepository}/releases/tags/{Uri.EscapeDataString(tag)}";
using var request = CreateGitHubRequest(releaseUrl);
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
continue;
}
if (!response.IsSuccessStatusCode)
{
logger.LogDebug("Failed to fetch Aspire skills GitHub release {Tag}: HTTP {StatusCode}.", tag, response.StatusCode);
return null;
}
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return ParseGitHubReleaseInfo(json);
}
return null;
}
private static GitHubReleaseInfo ParseGitHubReleaseInfo(string json)
{
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
var tagName = root.TryGetProperty("tag_name", out var tagNameElement) && tagNameElement.ValueKind == JsonValueKind.String
? tagNameElement.GetString() ?? string.Empty
: string.Empty;
List<GitHubReleaseAsset> assets = [];
if (root.TryGetProperty("assets", out var assetsElement) && assetsElement.ValueKind == JsonValueKind.Array)
{
foreach (var assetElement in assetsElement.EnumerateArray())
{
if (!assetElement.TryGetProperty("name", out var nameElement) ||
nameElement.ValueKind != JsonValueKind.String ||
!assetElement.TryGetProperty("browser_download_url", out var downloadUrlElement) ||
downloadUrlElement.ValueKind != JsonValueKind.String)
{
continue;
}
var name = nameElement.GetString();
var downloadUrl = downloadUrlElement.GetString();
if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(downloadUrl))
{
var digest = assetElement.TryGetProperty("digest", out var digestElement) && digestElement.ValueKind == JsonValueKind.String
? digestElement.GetString()
: null;
assets.Add(new GitHubReleaseAsset(name, downloadUrl, digest));
}
}
}
return new GitHubReleaseInfo(tagName, assets);
}
private static GitHubReleaseAsset? FindGitHubReleaseAsset(GitHubReleaseInfo release, string version)
{
foreach (var assetName in GetGitHubReleaseAssetNameCandidates(version))
{
var asset = release.Assets.FirstOrDefault(asset => string.Equals(asset.Name, assetName, StringComparison.OrdinalIgnoreCase));
if (asset is not null)
{
return asset;
}
}
return null;
}
private static IEnumerable<string> GetGitHubTagCandidates(string version)
{
if (version.StartsWith('v') || version.StartsWith('V'))
{
yield return version;
yield return version[1..];
yield break;
}
yield return $"v{version}";
yield return version;
}
private static IEnumerable<string> GetGitHubReleaseAssetNameCandidates(string version)
{
var unprefixedVersion = version.StartsWith('v') || version.StartsWith('V') ? version[1..] : version;
var prefixedVersion = $"v{unprefixedVersion}";
foreach (var archiveExtension in new[] { ".zip", ".tar.gz", ".tgz" })
{
yield return $"aspire-skills-{prefixedVersion}{archiveExtension}";
yield return $"aspire-skills-{unprefixedVersion}{archiveExtension}";
}
}
private static async Task<bool> TryDownloadGitHubAssetAsync(HttpClient httpClient, string downloadUrl, string archivePath, CancellationToken cancellationToken)
{
using var request = CreateGitHubRequest(downloadUrl);
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return false;
}
await using var fileStream = File.Create(archivePath);
await response.Content.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
return true;
}
private static HttpRequestMessage CreateGitHubRequest(string url)
{
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.UserAgent.ParseAdd("aspire-cli");
request.Headers.Accept.ParseAdd("application/vnd.github+json");
request.Headers.TryAddWithoutValidation("X-GitHub-Api-Version", "2022-11-28");
return request;
}
private async Task<AcquisitionResult?> TryLoadCachedBundleAsync(
string cacheRoot,
string version,
string? expectedArchiveSha512,
string? expectedGitHubArchiveSha256,
bool requireVerifiedGitHubSource,
bool skipCompatibilityCheck,
Activity? activity,
CancellationToken cancellationToken)
{
activity?.SetTag("aspire.skills.cache_hit", false);
if (expectedArchiveSha512 is null &&
expectedGitHubArchiveSha256 is null &&
!requireVerifiedGitHubSource)
{
return null;
}
await using var cacheLock = await AcquireCacheLockAsync(cacheRoot, version, cancellationToken).ConfigureAwait(false);
return await TryLoadCachedBundleCoreAsync(
cacheRoot,
version,
expectedArchiveSha512,
expectedGitHubArchiveSha256,
requireVerifiedGitHubSource,
skipCompatibilityCheck,
activity,
cancellationToken).ConfigureAwait(false);
}
private async Task<AcquisitionResult?> TryLoadCachedBundleCoreAsync(
string cacheRoot,
string version,
string? expectedArchiveSha512,
string? expectedGitHubArchiveSha256,
bool requireVerifiedGitHubSource,
bool skipCompatibilityCheck,
Activity? activity,
CancellationToken cancellationToken)
{
var versionCacheDirectory = GetVersionCacheDirectory(cacheRoot, version);
if (!Directory.Exists(versionCacheDirectory))
{
return null;
}
if (expectedArchiveSha512 is not null)
{
return await TryLoadCachedBundleDirectoryAsync(
GetBundleCacheDirectory(versionCacheDirectory, expectedArchiveSha512),
version,
expectedArchiveSha512,
expectedGitHubArchiveSha256,
requireVerifiedGitHubSource,
skipCompatibilityCheck,
activity,
cancellationToken).ConfigureAwait(false);
}
// GitHub release metadata exposes the attestation subject's SHA-256, while cache leaves
// are keyed by the bundle-integrity SHA-512. Enumerate SHA-512 leaves so the persisted
// GitHub SHA-256 mapping can select the current asset without downloading it again.
// When release metadata is unavailable, prefer the most recently used bundle whose
// GitHub provenance was previously verified.
List<(string Directory, string ArchiveSha512, DateTimeOffset LastUsed)> candidates = [];
try
{
foreach (var directory in Directory.GetDirectories(versionCacheDirectory))
{
var archiveSha512 = TryNormalizeArchiveSha512(Path.GetFileName(directory));
if (archiveSha512 is not null)
{
candidates.Add((directory, archiveSha512, GetLastUsed(directory)));
}
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogDebug(ex, "Failed to enumerate cached Aspire skills bundles for version {Version}.", version);
return null;
}
foreach (var candidate in candidates.OrderByDescending(static candidate => candidate.LastUsed))
{
var cachedResult = await TryLoadCachedBundleDirectoryAsync(
candidate.Directory,
version,
candidate.ArchiveSha512,
expectedGitHubArchiveSha256,
requireVerifiedGitHubSource,
skipCompatibilityCheck,
activity,
cancellationToken).ConfigureAwait(false);
if (cachedResult is not null)
{
return cachedResult;
}
}
return null;
}
private async Task<AcquisitionResult?> TryLoadCachedBundleDirectoryAsync(
string cacheDirectory,
string version,
string expectedArchiveSha512,
string? expectedGitHubArchiveSha256,
bool requireVerifiedGitHubSource,
bool skipCompatibilityCheck,
Activity? activity,
CancellationToken cancellationToken)
{
if (!Directory.Exists(cacheDirectory))
{
return null;
}
if (requireVerifiedGitHubSource &&
!File.Exists(Path.Combine(cacheDirectory, GitHubAttestationVerifiedFileName)))
{
logger.LogDebug(
"Ignoring cached Aspire skills bundle at {CacheDirectory} because GitHub attestation verification was not recorded.",
cacheDirectory);
return null;
}
try
{
var cachedArchiveSha512Path = Path.Combine(cacheDirectory, ArchiveSha512FileName);
var cachedArchiveSha512 = File.Exists(cachedArchiveSha512Path)
? TryNormalizeArchiveSha512(File.ReadAllText(cachedArchiveSha512Path).Trim())
: null;
if (cachedArchiveSha512 is null ||
!string.Equals(cachedArchiveSha512, expectedArchiveSha512, StringComparison.OrdinalIgnoreCase))
{
logger.LogDebug(
"Ignoring cached Aspire skills bundle at {CacheDirectory} because its archive SHA-512 does not match its cache identity.",
cacheDirectory);
return null;
}
var cachedGitHubArchiveSha256Path = Path.Combine(cacheDirectory, GitHubArchiveSha256FileName);
var cachedGitHubArchiveSha256 = File.Exists(cachedGitHubArchiveSha256Path)
? TryNormalizeArchiveSha256(File.ReadAllText(cachedGitHubArchiveSha256Path).Trim())
: null;
if (expectedGitHubArchiveSha256 is not null &&
!string.Equals(cachedGitHubArchiveSha256, expectedGitHubArchiveSha256, StringComparison.OrdinalIgnoreCase))
{
logger.LogDebug(
"Ignoring cached Aspire skills bundle at {CacheDirectory} because its GitHub archive SHA-256 does not match the current release asset.",
cacheDirectory);
return null;
}
var bundle = await bundleProvider.LoadAsync(
new DirectoryInfo(cacheDirectory),
cancellationToken,
skipCompatibilityCheck).ConfigureAwait(false);
ValidateBundleVersion(bundle, version);
TouchLastUsed(cacheDirectory);
activity?.SetTag("aspire.skills.cache_hit", true);
logger.LogDebug("Using cached Aspire skills bundle from {CacheDirectory}.", cacheDirectory);
return AcquisitionResult.Installed(bundle, cachedArchiveSha512, cachedGitHubArchiveSha256);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException)
{
logger.LogDebug(ex, "Ignoring invalid cached Aspire skills bundle at {CacheDirectory}.", cacheDirectory);
return null;
}
}
private async Task<AspireSkillsBundle> CacheArchiveAsync(
string cacheRoot,
string archivePath,
string version,
string archiveSha512,
string? githubArchiveSha256,
BundleArchiveSource source,
CancellationToken cancellationToken)
{
using var stageDirectory = CreateTemporaryCacheDirectory(cacheRoot, "stage");
var stagedBundle = await bundleProvider.CreateAsync(
new FileInfo(archivePath),
new DirectoryInfo(stageDirectory.FullName),
archiveSha512,
cancellationToken).ConfigureAwait(false);
return await CacheStagedBundleAsync(
cacheRoot,
stageDirectory,
stagedBundle,
archiveSha512,
githubArchiveSha256,
version,
source,
cancellationToken).ConfigureAwait(false);
}
private async Task<AspireSkillsBundle> CacheStagedBundleAsync(
string cacheRoot,
TemporaryCacheDirectory stageDirectory,
AspireSkillsBundle stagedBundle,
string archiveSha512,
string? githubArchiveSha256,
string version,
BundleArchiveSource source,
CancellationToken cancellationToken)
{
RemoveInstallerMetadata(stageDirectory.FullName);
ValidateBundleVersion(stagedBundle, version);
// The archive is discarded after extraction. Retain its digest so same-version
// archives can be distinguished without keeping or downloading the archive again.
await File.WriteAllTextAsync(
Path.Combine(stageDirectory.FullName, ArchiveSha512FileName),
AspireSkillsBundleProvider.NormalizeSha512(archiveSha512).ToLowerInvariant(),
cancellationToken).ConfigureAwait(false);
if (githubArchiveSha256 is not null)
{
// GitHub release metadata and artifact attestations currently identify subjects by
// SHA-256. Persist that secondary identity so the next release lookup can find this
// SHA-512-keyed leaf without downloading the archive first.
await File.WriteAllTextAsync(
Path.Combine(stageDirectory.FullName, GitHubArchiveSha256FileName),
AspireSkillsBundleProvider.NormalizeSha256(githubArchiveSha256).ToLowerInvariant(),
cancellationToken).ConfigureAwait(false);
}
if (source == BundleArchiveSource.VerifiedGitHub)
{
await File.WriteAllTextAsync(
Path.Combine(stageDirectory.FullName, GitHubAttestationVerifiedFileName),
string.Empty,
cancellationToken).ConfigureAwait(false);
}
await using var cacheLock = await AcquireCacheLockAsync(cacheRoot, version, cancellationToken).ConfigureAwait(false);
var versionCacheDirectory = GetVersionCacheDirectory(cacheRoot, version);
var targetDir = GetBundleCacheDirectory(versionCacheDirectory, archiveSha512);
var cachedResult = await TryLoadCachedBundleCoreAsync(
cacheRoot,
version,
archiveSha512,
githubArchiveSha256,
requireVerifiedGitHubSource: source == BundleArchiveSource.VerifiedGitHub,
skipCompatibilityCheck: source == BundleArchiveSource.Embedded,
activity: null,
cancellationToken).ConfigureAwait(false);
if (cachedResult is not null)
{
return cachedResult.Bundle!;
}
if (Directory.Exists(targetDir))
{
logger.LogDebug("Replacing Aspire skills cache directory {CacheDirectory}.", targetDir);
TryDeleteDirectory(targetDir);
if (Directory.Exists(targetDir))
{
throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Could not replace Aspire skills cache directory '{0}'.", targetDir));
}
}
Directory.CreateDirectory(versionCacheDirectory);
RemoveLegacyCacheLayout(versionCacheDirectory);
stageDirectory.MoveTo(targetDir);
TouchLastUsed(targetDir);
return stagedBundle;
}
private static void RemoveInstallerMetadata(string bundleDirectory)
{
// These files describe local installer state, not bundle content. Always recreate them
// from the acquisition path so an archive cannot claim freshness or GitHub provenance.
File.Delete(Path.Combine(bundleDirectory, ArchiveSha512FileName));
File.Delete(Path.Combine(bundleDirectory, GitHubArchiveSha256FileName));
File.Delete(Path.Combine(bundleDirectory, GitHubAttestationVerifiedFileName));
File.Delete(Path.Combine(bundleDirectory, LastUsedFileName));
}
private TemporaryCacheDirectory CreateTemporaryCacheDirectory(string cacheRoot, string prefix)
{
return TemporaryCacheDirectory.Create(
cacheRoot,
prefix,
TryDeleteDirectory,
TryDeleteFile);
}
private Task<FileStream> AcquireCacheLockAsync(string cacheRoot, string version, CancellationToken cancellationToken)
{
return AcquireCacheLockCoreAsync(cacheRoot, version, maxAttempts: null, cancellationToken);
}
private Task<FileStream> AcquireCacheLockForCleanupAsync(string cacheRoot, string version, CancellationToken cancellationToken)
{
return AcquireCacheLockCoreAsync(cacheRoot, version, CacheLockMaxAttempts, cancellationToken);
}
private async Task<FileStream> AcquireCacheLockCoreAsync(
string cacheRoot,
string version,
int? maxAttempts,
CancellationToken cancellationToken)
{
var lockPath = Path.Combine(cacheRoot, $".{GetSafeFileName(version)}.lock");
var retryDelay = s_cacheLockInitialRetryDelay;
for (var attempt = 1; ; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
// Keep the path visible for the lifetime of the cache. Unlinking a held lock on Unix
// would let another process create a different inode and enter the same critical section.
return new FileStream(
lockPath,
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.None,
bufferSize: 1,
FileOptions.Asynchronous);
}
catch (IOException ex) when (
IsCacheLockContention(ex, OperatingSystem.IsWindows()) &&
(maxAttempts is null || attempt < maxAttempts.Value))
{
if (maxAttempts is { } boundedAttempts)
{
logger.LogDebug(
"Acquiring the Aspire skills cache lock for version {Version} failed with HRESULT {HResult}; retrying in {DelayMilliseconds} ms (retry {RetryCount} of {MaxRetries}).",
version,
ex.HResult,
retryDelay.TotalMilliseconds,
attempt,
boundedAttempts - 1);
}
else
{
logger.LogDebug(
"Acquiring the Aspire skills cache lock for version {Version} failed with HRESULT {HResult}; retrying in {DelayMilliseconds} ms.",
version,
ex.HResult,
retryDelay.TotalMilliseconds);
}
await Task.Delay(retryDelay, cancellationToken).ConfigureAwait(false);
if (retryDelay < s_cacheLockMaxRetryDelay)
{
retryDelay *= 2;
}
}
}
}
internal static bool IsCacheLockContention(IOException exception, bool isWindows)
{
if (isWindows)
{
return exception.HResult is WindowsSharingViolationHResult or WindowsLockViolationHResult;
}
// On Unix, FileStream implements FileShare.None with a non-blocking flock and exposes
// EWOULDBLOCK as the raw errno in IOException.HResult: 11 on Linux and 35 on macOS.
// See https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/Microsoft/Win32/SafeHandles/SafeFileHandle.Unix.cs.
return exception.HResult is LinuxWouldBlockHResult or MacOsWouldBlockHResult;
}
private static void ValidateBundleVersion(AspireSkillsBundle bundle, string expectedVersion)
{
if (!string.Equals(bundle.Version, expectedVersion, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(string.Format(
CultureInfo.InvariantCulture,
"Aspire skills bundle version '{0}' does not match expected version '{1}'.",
bundle.Version,
expectedVersion));
}
}
private static string ComputeArchiveSha256(string archivePath)
{
using var stream = File.OpenRead(archivePath);
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}
private static string ComputeArchiveSha512(string archivePath)
{
using var stream = File.OpenRead(archivePath);
return Convert.ToHexString(SHA512.HashData(stream)).ToLowerInvariant();
}
private string GetCacheRoot()
{
return Path.Combine(executionContext.CacheDirectory.FullName, "aspire-skills");
}
private static string GetVersionCacheDirectory(string cacheRoot, string version)
{
return Path.Combine(cacheRoot, version);
}
private static string GetBundleCacheDirectory(string versionCacheDirectory, string archiveSha512)
{
return Path.Combine(
versionCacheDirectory,
AspireSkillsBundleProvider.NormalizeSha512(archiveSha512).ToLowerInvariant());
}
private async Task CleanupStaleCacheEntriesAsync(
string cacheRoot,
string currentVersion,
string currentArchiveSha512,
CancellationToken cancellationToken)
{
if (!Directory.Exists(cacheRoot))
{
return;
}
var maxAge = ReadWindow(configuration, MaxCacheAgeKey, s_defaultMaxCacheAge);
string[] cacheDirectories;
try
{
cacheDirectories = Directory.GetDirectories(cacheRoot);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogDebug(ex, "Failed to enumerate Aspire skills cache directories for cleanup.");
return;
}
foreach (var directory in cacheDirectories)
{
var version = Path.GetFileName(directory);
if (version.StartsWith(".", StringComparison.Ordinal))
{
try
{
if (IsTemporaryCacheDirectory(version) &&
DateTime.UtcNow - Directory.GetLastWriteTimeUtc(directory) > maxAge)
{
var leasePath = TemporaryCacheDirectory.GetLeasePath(directory);
using (TemporaryCacheDirectory.OpenLease(directory))
{
TryDeleteDirectory(directory);
}
TryDeleteFile(leasePath);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogDebug(ex, "Failed to evaluate temporary Aspire skills cache directory {Directory} for cleanup.", directory);
}
continue;
}
try
{
await using var cacheLock = await AcquireCacheLockForCleanupAsync(
cacheRoot,
version,
cancellationToken).ConfigureAwait(false);
if (!Directory.Exists(directory))
{
continue;
}
var isCurrentVersion = string.Equals(version, currentVersion, StringComparison.Ordinal);
if (HasLegacyCacheLayout(directory) &&
(isCurrentVersion || DateTimeOffset.UtcNow - GetLastUsed(directory) > maxAge))
{
RemoveLegacyCacheLayout(directory);
}
foreach (var bundleDirectory in Directory.GetDirectories(directory))
{
var directoryName = Path.GetFileName(bundleDirectory);
var archiveSha512 = TryNormalizeArchiveSha512(directoryName);
if (archiveSha512 is null)
{
continue;
}
if ((isCurrentVersion &&
string.Equals(archiveSha512, currentArchiveSha512, StringComparison.OrdinalIgnoreCase)) ||
DateTimeOffset.UtcNow - GetLastUsed(bundleDirectory) <= maxAge)
{
continue;
}
TryDeleteDirectory(bundleDirectory);
}
if (!Directory.EnumerateFileSystemEntries(directory).Any())
{
TryDeleteDirectory(directory);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Cleanup is optional. Leave an unavailable cache for a later pass after
// lock acquisition or filesystem access exhausts its retry/error handling.
logger.LogDebug(
ex,
"Skipping cleanup of Aspire skills cache version {Version} because it could not be evaluated.",
version);
}
}
}
private static bool HasLegacyCacheLayout(string versionCacheDirectory)
{
return Directory.Exists(Path.Combine(versionCacheDirectory, "skills")) ||
File.Exists(Path.Combine(versionCacheDirectory, "skill-manifest.json")) ||
File.Exists(Path.Combine(versionCacheDirectory, ArchiveSha512FileName)) ||
File.Exists(Path.Combine(versionCacheDirectory, GitHubArchiveSha256FileName)) ||
File.Exists(Path.Combine(versionCacheDirectory, GitHubAttestationVerifiedFileName)) ||
File.Exists(Path.Combine(versionCacheDirectory, LastUsedFileName));
}
private void RemoveLegacyCacheLayout(string versionCacheDirectory)
{
// Older CLIs stored extracted files directly in the version directory. Remove only
// those known entries so digest-addressed children created by newer CLIs remain intact.
TryDeleteDirectory(Path.Combine(versionCacheDirectory, "skills"));
TryDeleteFile(Path.Combine(versionCacheDirectory, "skill-manifest.json"));
TryDeleteFile(Path.Combine(versionCacheDirectory, ArchiveSha512FileName));
TryDeleteFile(Path.Combine(versionCacheDirectory, GitHubArchiveSha256FileName));
TryDeleteFile(Path.Combine(versionCacheDirectory, GitHubAttestationVerifiedFileName));
TryDeleteFile(Path.Combine(versionCacheDirectory, LastUsedFileName));
}
private static bool IsTemporaryCacheDirectory(string name)
{
return name.StartsWith(".github-", StringComparison.Ordinal) ||
name.StartsWith(".embedded-", StringComparison.Ordinal) ||
name.StartsWith(".extract-", StringComparison.Ordinal) ||
name.StartsWith(".stage-", StringComparison.Ordinal);
}
private static TimeSpan ReadWindow(IConfiguration configuration, string key, TimeSpan fallback)
{
if (configuration[key] is string secondsString && double.TryParse(secondsString, CultureInfo.InvariantCulture, out var seconds) && seconds > 0)
{
return TimeSpan.FromSeconds(seconds);
}
return fallback;
}
private void TouchLastUsed(string directory)
{
try
{
File.WriteAllText(Path.Combine(directory, LastUsedFileName), DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogDebug(ex, "Failed to update Aspire skills cache last-used marker for {Directory}.", directory);
}
}
private static DateTimeOffset GetLastUsed(string directory)
{
var markerPath = Path.Combine(directory, LastUsedFileName);
if (File.Exists(markerPath) &&
long.TryParse(File.ReadAllText(markerPath), NumberStyles.Integer, CultureInfo.InvariantCulture, out var unixTime) &&
unixTime >= DateTimeOffset.MinValue.ToUnixTimeSeconds() &&
unixTime <= DateTimeOffset.MaxValue.ToUnixTimeSeconds())
{
return DateTimeOffset.FromUnixTimeSeconds(unixTime);
}
return Directory.GetLastWriteTimeUtc(directory);
}
private static string GetSafeFileName(string fileName)
{
var safeName = Path.GetFileName(fileName);
foreach (var invalidCharacter in Path.GetInvalidFileNameChars())
{
safeName = safeName.Replace(invalidCharacter, '_');
}
return string.IsNullOrWhiteSpace(safeName) ? $"aspire-skills-{Guid.NewGuid():N}.archive" : safeName;
}
private void TryDeleteDirectory(string directory)
{
try
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogDebug(ex, "Failed to delete Aspire skills cache directory {Directory}.", directory);
}
}
private void TryDeleteFile(string path)
{
try
{
File.Delete(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogDebug(ex, "Failed to delete Aspire skills cache file {Path}.", path);
}
}
private enum BundleArchiveSource
{
Embedded,
VerifiedGitHub,
UnverifiedGitHub
}
private enum AcquisitionStatus
{
Installed,
Unavailable,
Failed
}
private sealed record AcquisitionResult(
AcquisitionStatus Status,
AspireSkillsBundle? Bundle,
string? Message,
string? ArchiveSha512,
string? KnownGitHubArchiveSha256,
bool GitHubReleaseMetadataAvailable)
{
public static AcquisitionResult Installed(
AspireSkillsBundle bundle,
string archiveSha512,
string? knownGitHubArchiveSha256 = null)
{
return new AcquisitionResult(
AcquisitionStatus.Installed,
bundle,
null,
archiveSha512,
knownGitHubArchiveSha256,
GitHubReleaseMetadataAvailable: false);
}
public static AcquisitionResult Unavailable(
string? knownGitHubArchiveSha256 = null,
bool githubReleaseMetadataAvailable = false)
{
return new AcquisitionResult(
AcquisitionStatus.Unavailable,
null,
null,
null,
knownGitHubArchiveSha256,
githubReleaseMetadataAvailable);
}
public static AcquisitionResult Failed(string message)
{
return new AcquisitionResult(
AcquisitionStatus.Failed,
null,
message,
null,
null,
GitHubReleaseMetadataAvailable: false);
}
}
private sealed record GitHubReleaseInfo(string TagName, IReadOnlyList<GitHubReleaseAsset> Assets);
private sealed record GitHubReleaseAsset(string Name, string DownloadUrl, string? Digest);
}