diff --git a/eng/scripts/aspire-skills-bundle.common.ps1 b/eng/scripts/aspire-skills-bundle.common.ps1 index 84320e64c2d..52ba3e8b1d5 100644 --- a/eng/scripts/aspire-skills-bundle.common.ps1 +++ b/eng/scripts/aspire-skills-bundle.common.ps1 @@ -58,10 +58,10 @@ function ConvertTo-LfUtf8Bytes { return [System.Text.UTF8Encoding]::new($false).GetBytes($text) } -function Get-AspireSkillsSha256Hex { +function Get-AspireSkillsSha512Hex { param([Parameter(Mandatory = $true)][AllowEmptyCollection()][byte[]]$Bytes) - $sha = [System.Security.Cryptography.SHA256]::Create() + $sha = [System.Security.Cryptography.SHA512]::Create() try { return [System.BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-', '').ToLowerInvariant() } diff --git a/eng/scripts/update-aspire-skills-bundle.ps1 b/eng/scripts/update-aspire-skills-bundle.ps1 index 796c4301df3..5758eee5b24 100644 --- a/eng/scripts/update-aspire-skills-bundle.ps1 +++ b/eng/scripts/update-aspire-skills-bundle.ps1 @@ -126,7 +126,7 @@ try { Write-Host "Verifying GitHub artifact attestation for '$($asset.name)'..." Invoke-GitHubCli attestation verify $archivePath --repo $Repository --cert-identity $certIdentity --cert-oidc-issuer 'https://token.actions.githubusercontent.com' - $hash = (Get-FileHash -Algorithm SHA256 $archivePath).Hash.ToLowerInvariant() + $hash = (Get-FileHash -Algorithm SHA512 $archivePath).Hash.ToLowerInvariant() $targetArchivePath = Join-Path $embeddedDir $asset.name Get-ChildItem -Path $embeddedDir -File -Force | @@ -156,7 +156,7 @@ try { Write-Host "Syncing hook script '$hookFileName' from '$Repository' at commit '$hookCommitSha'..." $hookBytes = Get-AspireSkillsHookContent -Repository $Repository -CommitSha $hookCommitSha -FileName $hookFileName $hookContents[$hookFileName] = $hookBytes - $hookHashes[$hookFileName] = Get-AspireSkillsSha256Hex -Bytes $hookBytes + $hookHashes[$hookFileName] = Get-AspireSkillsSha512Hex -Bytes $hookBytes } foreach ($hookFileName in $hookContents.Keys) { @@ -185,7 +185,7 @@ try { repository = $Repository tag = $release.tagName assetName = $asset.name - sha256 = $hash + sha512 = $hash } if ($null -ne $hookMetadata) { $metadata['hooks'] = $hookMetadata @@ -206,7 +206,7 @@ try { "Agents\AspireSkills\Embedded\$($asset.name)") Set-TextFile -Path $cliProjectPath -Content $cliProjectContent - Write-Host "Embedded Aspire skills bundle updated to '$($asset.name)' with SHA-256 '$hash'." + Write-Host "Embedded Aspire skills bundle updated to '$($asset.name)' with SHA-512 '$hash'." } finally { if (Test-Path $tempDir) { diff --git a/eng/scripts/verify-aspire-skills-bundle.ps1 b/eng/scripts/verify-aspire-skills-bundle.ps1 index 5b2486ace44..8b65ff8c740 100644 --- a/eng/scripts/verify-aspire-skills-bundle.ps1 +++ b/eng/scripts/verify-aspire-skills-bundle.ps1 @@ -43,8 +43,8 @@ if ($metadata.assetName -ne [System.IO.Path]::GetFileName($metadata.assetName)) throw "Embedded Aspire skills asset name '$($metadata.assetName)' must not contain path separators." } -if ([string]::IsNullOrWhiteSpace($metadata.sha256)) { - throw "Embedded Aspire skills metadata must specify the release asset SHA-256 hash." +if ([string]::IsNullOrWhiteSpace($metadata.sha512)) { + throw "Embedded Aspire skills metadata must specify the release asset SHA-512 hash." } $archivePath = Join-Path $embeddedDir $metadata.assetName @@ -52,9 +52,9 @@ if (-not (Test-Path $archivePath)) { throw "Embedded Aspire skills archive was not found at '$archivePath'." } -$actualHash = (Get-FileHash -Algorithm SHA256 $archivePath).Hash.ToLowerInvariant() -if ($actualHash -ne $metadata.sha256) { - throw "Embedded bundle SHA-256 mismatch. Expected '$($metadata.sha256)', got '$actualHash'." +$actualHash = (Get-FileHash -Algorithm SHA512 $archivePath).Hash.ToLowerInvariant() +if ($actualHash -ne $metadata.sha512) { + throw "Embedded bundle SHA-512 mismatch. Expected '$($metadata.sha512)', got '$actualHash'." } $certIdentity = "https://github.com/$($metadata.repository)/.github/workflows/publish.yml@refs/tags/$($metadata.tag)" @@ -100,12 +100,12 @@ if ($metadata.PSObject.Properties.Name -contains 'hooks') { } # Hash over LF-normalized bytes so .ps1 (text=auto) checked out with CRLF on Windows matches. - $embeddedHash = Get-AspireSkillsSha256Hex -Bytes (ConvertTo-LfUtf8Bytes -Bytes ([System.IO.File]::ReadAllBytes($embeddedHookPath))) + $embeddedHash = Get-AspireSkillsSha512Hex -Bytes (ConvertTo-LfUtf8Bytes -Bytes ([System.IO.File]::ReadAllBytes($embeddedHookPath))) if ($embeddedHash -ne $recordedHash) { - throw "Embedded telemetry hook '$hookFileName' SHA-256 mismatch. Expected '$recordedHash', got '$embeddedHash'. Re-run update-aspire-skills-bundle.ps1." + throw "Embedded telemetry hook '$hookFileName' SHA-512 mismatch. Expected '$recordedHash', got '$embeddedHash'. Re-run update-aspire-skills-bundle.ps1." } - $sourceHash = Get-AspireSkillsSha256Hex -Bytes (Get-AspireSkillsHookContent -Repository $metadata.repository -CommitSha $hooks.commitSha -FileName $hookFileName) + $sourceHash = Get-AspireSkillsSha512Hex -Bytes (Get-AspireSkillsHookContent -Repository $metadata.repository -CommitSha $hooks.commitSha -FileName $hookFileName) if ($sourceHash -ne $recordedHash) { throw "Telemetry hook '$hookFileName' does not match '$($metadata.repository)' at commit '$($hooks.commitSha)'. Expected '$recordedHash', got '$sourceHash'." } diff --git a/extension/schemas/aspire-config.schema.json b/extension/schemas/aspire-config.schema.json index cbe318ba863..8932d114932 100644 --- a/extension/schemas/aspire-config.schema.json +++ b/extension/schemas/aspire-config.schema.json @@ -50,22 +50,6 @@ "description": "Feature flags for enabling/disabling experimental or optional features. Key is feature name, value is enabled (true) or disabled (false).", "type": "object", "properties": { - "aspireSkillsRemoteFetchEnabled": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": [ - "true", - "false" - ] - } - ], - "description": "(Preview) Allow the Aspire CLI to download the aspire-skills bundle from GitHub. When disabled (the 13.4 default), the CLI only uses the cached bundle and the embedded snapshot baked into the CLI; toggle on to opt in to the remote fetch path.", - "default": false - }, "defaultWatchEnabled": { "anyOf": [ { @@ -162,6 +146,22 @@ "description": "Enable or disable defaulting the DOTNET_NUGET_SIGNATURE_VERIFICATION environment variable for spawned processes", "default": true }, + "polyglotIntegrationFilterEnabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "description": "(Experimental) Restrict 'aspire add', 'aspire integration list', and 'aspire integration search' in non-C# AppHosts to integrations carrying the 'polyglot' NuGet tag. Disabled by default because no remote feed resolves the tag usefully today: Azure DevOps Artifacts feeds ignore 'tags:' query scoping, and nuget.org returns no first-party integrations for it. The filter fails closed, so enabling it against a remote feed hides every integration. Enable it only against a local package source or hive, where the tag is read from the nuspec.", + "default": false + }, "showAllTemplates": { "anyOf": [ { diff --git a/extension/schemas/aspire-global-settings.schema.json b/extension/schemas/aspire-global-settings.schema.json index 077fdcaff16..640eaa9cd62 100644 --- a/extension/schemas/aspire-global-settings.schema.json +++ b/extension/schemas/aspire-global-settings.schema.json @@ -13,22 +13,6 @@ "description": "Feature flags for enabling/disabling experimental or optional features. Key is feature name, value is enabled (true) or disabled (false).", "type": "object", "properties": { - "aspireSkillsRemoteFetchEnabled": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": [ - "true", - "false" - ] - } - ], - "description": "(Preview) Allow the Aspire CLI to download the aspire-skills bundle from GitHub. When disabled (the 13.4 default), the CLI only uses the cached bundle and the embedded snapshot baked into the CLI; toggle on to opt in to the remote fetch path.", - "default": false - }, "defaultWatchEnabled": { "anyOf": [ { @@ -125,6 +109,22 @@ "description": "Enable or disable defaulting the DOTNET_NUGET_SIGNATURE_VERIFICATION environment variable for spawned processes", "default": true }, + "polyglotIntegrationFilterEnabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "description": "(Experimental) Restrict 'aspire add', 'aspire integration list', and 'aspire integration search' in non-C# AppHosts to integrations carrying the 'polyglot' NuGet tag. Disabled by default because no remote feed resolves the tag usefully today: Azure DevOps Artifacts feeds ignore 'tags:' query scoping, and nuget.org returns no first-party integrations for it. The filter fails closed, so enabling it against a remote feed hides every integration. Enable it only against a local package source or hive, where the tag is read from the nuspec.", + "default": false + }, "showAllTemplates": { "anyOf": [ { diff --git a/extension/schemas/aspire-settings.schema.json b/extension/schemas/aspire-settings.schema.json index f7d3d9dc235..09fbc30c1cd 100644 --- a/extension/schemas/aspire-settings.schema.json +++ b/extension/schemas/aspire-settings.schema.json @@ -13,22 +13,6 @@ "description": "Feature flags for enabling/disabling experimental or optional features. Key is feature name, value is enabled (true) or disabled (false).", "type": "object", "properties": { - "aspireSkillsRemoteFetchEnabled": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": [ - "true", - "false" - ] - } - ], - "description": "(Preview) Allow the Aspire CLI to download the aspire-skills bundle from GitHub. When disabled (the 13.4 default), the CLI only uses the cached bundle and the embedded snapshot baked into the CLI; toggle on to opt in to the remote fetch path.", - "default": false - }, "defaultWatchEnabled": { "anyOf": [ { @@ -125,6 +109,22 @@ "description": "Enable or disable defaulting the DOTNET_NUGET_SIGNATURE_VERIFICATION environment variable for spawned processes", "default": true }, + "polyglotIntegrationFilterEnabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "true", + "false" + ] + } + ], + "description": "(Experimental) Restrict 'aspire add', 'aspire integration list', and 'aspire integration search' in non-C# AppHosts to integrations carrying the 'polyglot' NuGet tag. Disabled by default because no remote feed resolves the tag usefully today: Azure DevOps Artifacts feeds ignore 'tags:' query scoping, and nuget.org returns no first-party integrations for it. The filter fails closed, so enabling it against a remote feed hides every integration. Enable it only against a local package source or hive, where the tag is read from the nuspec.", + "default": false + }, "showAllTemplates": { "anyOf": [ { diff --git a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs index 9013105a6ed..a1ff3fd636d 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsBundle.cs @@ -204,10 +204,6 @@ private static void ValidateManifest( private static void ValidateFile(DirectoryInfo bundleDirectory, string skillName, SkillBundleFile file) { var relativePath = NormalizeRelativePath(file.RelativePath); - if (string.IsNullOrWhiteSpace(file.Sha256)) - { - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle file '{0}' in skill '{1}' does not specify a SHA-256 hash.", relativePath, skillName)); - } var fullPath = Path.Combine(bundleDirectory.FullName, SkillsDirectoryName, skillName, relativePath); if (!File.Exists(fullPath)) @@ -215,16 +211,22 @@ private static void ValidateFile(DirectoryInfo bundleDirectory, string skillName throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle file '{0}' in skill '{1}' was not found.", relativePath, skillName)); } - var expectedHash = NormalizeSha256(file.Sha256); - string actualHash; - using (var stream = File.OpenRead(fullPath)) + // Prefer SHA-512, which current microsoft/aspire-skills builds emit. SHA-256 remains accepted for + // bundles published before the switch — notably the attestation-verified v0.0.1 snapshot embedded in + // the CLI, whose exact bytes (and therefore its SHA-256 per-file manifest) cannot change without + // invalidating its published attestation. Once a signed SHA-512 release is re-embedded, that bundle + // carries `sha512` instead and this fallback is no longer exercised. + if (!string.IsNullOrWhiteSpace(file.Sha512)) { - actualHash = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + VerifyFileHash(fullPath, skillName, relativePath, NormalizeSha512(file.Sha512), static stream => SHA512.HashData(stream), "SHA-512"); } - - if (!string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase)) + else if (!string.IsNullOrWhiteSpace(file.Sha256)) { - throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle file '{0}' in skill '{1}' failed SHA-256 verification.", relativePath, skillName)); + VerifyFileHash(fullPath, skillName, relativePath, NormalizeSha256(file.Sha256), static stream => SHA256.HashData(stream), "SHA-256"); + } + else + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle file '{0}' in skill '{1}' does not specify a SHA-512 or SHA-256 hash.", relativePath, skillName)); } if (string.Equals(relativePath, SkillFileName, StringComparison.Ordinal)) @@ -233,6 +235,20 @@ private static void ValidateFile(DirectoryInfo bundleDirectory, string skillName } } + private static void VerifyFileHash(string fullPath, string skillName, string relativePath, string expectedHash, Func computeHash, string algorithmName) + { + string actualHash; + using (var stream = File.OpenRead(fullPath)) + { + actualHash = Convert.ToHexString(computeHash(stream)).ToLowerInvariant(); + } + + if (!string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "Aspire skills bundle file '{0}' in skill '{1}' failed {2} verification.", relativePath, skillName, algorithmName)); + } + } + private static void ValidateSkillFileFrontmatter(string skillName, string skillFilePath) { var content = File.ReadAllText(skillFilePath); @@ -347,6 +363,14 @@ internal static string NormalizeRelativePath(string? relativePath) return Path.Combine(segments); } + internal static string NormalizeSha512(string sha512) + { + const string prefix = "sha512-"; + return sha512.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ? sha512[prefix.Length..] + : sha512; + } + internal static string NormalizeSha256(string sha256) { const string prefix = "sha256-"; diff --git a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs index 388fa3c9e70..1f1d64d7246 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/AspireSkillsInstaller.cs @@ -249,7 +249,7 @@ private async Task InstallFromEmbeddedAsync( await archiveStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); } - ValidateArchiveSha256(archivePath, metadata.Sha256!); + ValidateArchiveSha512(archivePath, metadata.Sha512!); try { @@ -307,21 +307,21 @@ private async Task InstallFromEmbeddedAsync( return AgentCommandStrings.AspireSkillsInstaller_MissingMetadataAssetName; } - if (string.IsNullOrWhiteSpace(metadata.Sha256)) + if (string.IsNullOrWhiteSpace(metadata.Sha512)) { - return AgentCommandStrings.AspireSkillsInstaller_MissingMetadataSha256; + return AgentCommandStrings.AspireSkillsInstaller_MissingMetadataSha512; } return null; } - private static void ValidateArchiveSha256(string archivePath, string expectedSha256) + private static void ValidateArchiveSha512(string archivePath, string expectedSha512) { - var expectedHash = AspireSkillsBundle.NormalizeSha256(expectedSha256); + var expectedHash = AspireSkillsBundle.NormalizeSha512(expectedSha512); string actualHash; using (var stream = File.OpenRead(archivePath)) { - actualHash = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + actualHash = Convert.ToHexString(SHA512.HashData(stream)).ToLowerInvariant(); } if (!string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase)) diff --git a/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json index faa05313f0a..d8ca4828c8d 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json +++ b/src/Aspire.Cli/Agents/AspireSkills/Embedded/aspire-skills.metadata.json @@ -3,5 +3,5 @@ "repository": "microsoft/aspire-skills", "tag": "v0.0.1", "assetName": "aspire-skills-v0.0.1.tgz", - "sha256": "8a8022a250818c730c78a5054f485e0a09ec80ac60da63910cf5091684ceec9a" + "sha512": "d968e7c9268d92c5964490ee5f96182c06a1d48879fe3f56e496544c2ec172596307a82cb4d8d1ee5f680db33570c3f12aae8768f186a5deac973d2ca06ca8bf" } diff --git a/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs b/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs index 6f5a469deae..822c56dc11f 100644 --- a/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs +++ b/src/Aspire.Cli/Agents/AspireSkills/SkillBundleManifest.cs @@ -51,6 +51,15 @@ internal sealed class SkillBundleFile { public string? RelativePath { get; init; } + // Lowercase hex SHA-512 of the file contents (preferred), read from `skill-manifest.json` inside the + // bundle archive (an optional `sha512-` SRI-style prefix is tolerated). Emitted per-file by current + // microsoft/aspire-skills' build-aspire-bundles.mjs and verified by AspireSkillsBundle.ValidateFile. + public string? Sha512 { get; init; } + + // Lowercase hex SHA-256 of the file contents, accepted only for bundles published before the SHA-512 + // switch — notably the attestation-verified v0.0.1 snapshot currently embedded in the CLI, whose bytes + // cannot be re-hashed without breaking their published attestation. When both are present SHA-512 wins; + // an optional `sha256-` SRI-style prefix is tolerated. New/remote bundles emit `Sha512` and this is null. public string? Sha256 { get; init; } } @@ -67,7 +76,8 @@ internal sealed class EmbeddedAspireSkillsBundleMetadata public string? AssetName { get; init; } - public string? Sha256 { get; init; } + // Lowercase hex SHA-512 of the embedded `.tgz` archive; verified by AspireSkillsInstaller before extraction. + public string? Sha512 { get; init; } } /// diff --git a/src/Aspire.Cli/Commands/ConfigCommand.cs b/src/Aspire.Cli/Commands/ConfigCommand.cs index e28ea147473..9ed5d3b7ec6 100644 --- a/src/Aspire.Cli/Commands/ConfigCommand.cs +++ b/src/Aspire.Cli/Commands/ConfigCommand.cs @@ -283,6 +283,7 @@ private async Task ExecuteAsync(bool showAll, CancellationToken cancellatio .ToHashSet(StringComparer.Ordinal); var unconfiguredFeatures = KnownFeatures.GetAllFeatureMetadata() + .Where(f => !f.Hidden) .Where(f => !allConfiguredFeatures.Contains(f.Name)) .ToList(); @@ -464,6 +465,7 @@ private Task ExecuteAsync(bool useJson) var localPath = ConfigurationService.GetSettingsFilePath(isGlobal: false); var globalPath = ConfigurationService.GetSettingsFilePath(isGlobal: true); var availableFeatures = KnownFeatures.GetAllFeatureMetadata() + .Where(m => !m.Hidden) .Select(m => new FeatureInfo(m.Name, m.Description, m.DefaultValue)) .ToList(); var localSchema = SettingsSchemaBuilder.BuildSchema(excludeLocalOnly: false); diff --git a/src/Aspire.Cli/KnownFeatures.cs b/src/Aspire.Cli/KnownFeatures.cs index b8e7986d036..6ff9ae19190 100644 --- a/src/Aspire.Cli/KnownFeatures.cs +++ b/src/Aspire.Cli/KnownFeatures.cs @@ -13,7 +13,12 @@ namespace Aspire.Cli; /// The feature flag name (without the "features." prefix). /// A description of what the feature does. /// The default value if not explicitly configured. -internal sealed record FeatureMetadata(string Name, string Description, bool DefaultValue); +/// +/// When true, the feature is omitted from user-facing surfaces (the aspire config +/// available-features listing and aspire config info / its generated VS Code schemas). The +/// flag still works if set directly in configuration; hiding only removes it from discovery. +/// +internal sealed record FeatureMetadata(string Name, string Description, bool DefaultValue, bool Hidden = false); // this is a copy of Shared/KnownResourceNames.cs internal static class KnownFeatures @@ -88,7 +93,11 @@ internal static class KnownFeatures [AspireSkillsRemoteFetchEnabled] = new( AspireSkillsRemoteFetchEnabled, "(Preview) Allow the Aspire CLI to download the aspire-skills bundle from GitHub. When disabled (the 13.4 default), the CLI only uses the cached bundle and the embedded snapshot baked into the CLI; toggle on to opt in to the remote fetch path.", - DefaultValue: false), + DefaultValue: false, + // Hidden from discovery while the remote-fetch path is preview-only: the CLI ships with a + // trusted SHA-512 embedded snapshot, and the remote path stays off by default. The flag is + // still honored if set directly in config. + Hidden: true), [TerminalCommandsEnabled] = new( TerminalCommandsEnabled, diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs index 93a713c4f7c..ebc8af6b91c 100644 --- a/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs +++ b/src/Aspire.Cli/Resources/AgentCommandStrings.Designer.cs @@ -475,16 +475,16 @@ internal static string AspireSkillsInstaller_MissingMetadataAssetName { } /// - /// Looks up a localized string similar to Embedded Aspire skills metadata must specify the release asset SHA-256 hash.. + /// Looks up a localized string similar to Embedded Aspire skills metadata must specify the release asset SHA-512 hash.. /// - internal static string AspireSkillsInstaller_MissingMetadataSha256 { + internal static string AspireSkillsInstaller_MissingMetadataSha512 { get { - return ResourceManager.GetString("AspireSkillsInstaller_MissingMetadataSha256", resourceCulture); + return ResourceManager.GetString("AspireSkillsInstaller_MissingMetadataSha512", resourceCulture); } } /// - /// Looks up a localized string similar to Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'.. + /// Looks up a localized string similar to Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'.. /// internal static string AspireSkillsInstaller_ArchiveHashVerificationFailed { get { diff --git a/src/Aspire.Cli/Resources/AgentCommandStrings.resx b/src/Aspire.Cli/Resources/AgentCommandStrings.resx index 28d79bb169a..3247c1f420b 100644 --- a/src/Aspire.Cli/Resources/AgentCommandStrings.resx +++ b/src/Aspire.Cli/Resources/AgentCommandStrings.resx @@ -198,11 +198,11 @@ Embedded Aspire skills metadata must specify a release asset name. - - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. + + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. Installing Playwright CLI... diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf index 38cf04c3710..983ed504626 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.cs.xlf @@ -43,8 +43,8 @@ - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. @@ -67,9 +67,9 @@ Embedded Aspire skills metadata must specify a release asset name. - - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. + + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf index 77dd5971357..1b0095eabd1 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.de.xlf @@ -43,8 +43,8 @@ - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. @@ -67,9 +67,9 @@ Embedded Aspire skills metadata must specify a release asset name. - - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. + + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf index 2faaf5c0219..c8f6f6571e3 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.es.xlf @@ -43,8 +43,8 @@ - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. @@ -67,9 +67,9 @@ Embedded Aspire skills metadata must specify a release asset name. - - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. + + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf index 1cdcdd1b1a2..ddf183d8749 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.fr.xlf @@ -43,8 +43,8 @@ - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. @@ -67,9 +67,9 @@ Embedded Aspire skills metadata must specify a release asset name. - - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. + + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf index c6805c1e0b5..9223e8d05d6 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.it.xlf @@ -43,8 +43,8 @@ - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. @@ -67,9 +67,9 @@ Embedded Aspire skills metadata must specify a release asset name. - - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. + + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf index e707385fba4..5ae6f97ae0c 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ja.xlf @@ -43,8 +43,8 @@ - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. @@ -67,9 +67,9 @@ Embedded Aspire skills metadata must specify a release asset name. - - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. + + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf index 58ee31598cd..6564327f761 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ko.xlf @@ -43,8 +43,8 @@ - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. @@ -67,9 +67,9 @@ Embedded Aspire skills metadata must specify a release asset name. - - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. + + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf index 04f94ce7fe8..a9eadaadb56 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pl.xlf @@ -43,8 +43,8 @@ - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. @@ -67,9 +67,9 @@ Embedded Aspire skills metadata must specify a release asset name. - - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. + + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf index bb10c874bb7..2a5900803f0 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.pt-BR.xlf @@ -43,8 +43,8 @@ - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. @@ -67,9 +67,9 @@ Embedded Aspire skills metadata must specify a release asset name. - - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. + + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf index 5e29d63e2cc..6dacac1fc8e 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.ru.xlf @@ -43,8 +43,8 @@ - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. @@ -67,9 +67,9 @@ Embedded Aspire skills metadata must specify a release asset name. - - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. + + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf index 99ca1d49b24..64960b70db8 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.tr.xlf @@ -43,8 +43,8 @@ - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. @@ -67,9 +67,9 @@ Embedded Aspire skills metadata must specify a release asset name. - - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. + + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf index 5d30b13b291..9b72a032e04 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hans.xlf @@ -43,8 +43,8 @@ - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. @@ -67,9 +67,9 @@ Embedded Aspire skills metadata must specify a release asset name. - - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. + + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. diff --git a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf index 4086330cc91..e9078eab781 100644 --- a/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf +++ b/src/Aspire.Cli/Resources/xlf/AgentCommandStrings.zh-Hant.xlf @@ -43,8 +43,8 @@ - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. - Embedded Aspire skills archive failed SHA-256 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. + Embedded Aspire skills archive failed SHA-512 verification. Expected '{0}', got '{1}'. @@ -67,9 +67,9 @@ Embedded Aspire skills metadata must specify a release asset name. - - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. - Embedded Aspire skills metadata must specify the release asset SHA-256 hash. + + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. + Embedded Aspire skills metadata must specify the release asset SHA-512 hash. diff --git a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs index 420aa39808c..2d9ed07054a 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/AgentCommandTests.cs @@ -357,15 +357,15 @@ private static async Task SeedAspireSkillsBundleCacheAsync(Hex1bTerminalAutomato # Aspire Orchestration SKILL - aspire_skill_hash="$(sha256sum "$cache/skills/aspire/SKILL.md" | awk '{print $1}')" - aspire_commands_hash="$(sha256sum "$cache/skills/aspire/references/app-commands.md" | awk '{print $1}')" - aspire_evals_hash="$(sha256sum "$cache/skills/aspire/evals/evals.json" | awk '{print $1}')" - aspireify_skill_hash="$(sha256sum "$cache/skills/aspireify/SKILL.md" | awk '{print $1}')" - deployment_skill_hash="$(sha256sum "$cache/skills/aspire-deployment/SKILL.md" | awk '{print $1}')" - deployment_preflight_hash="$(sha256sum "$cache/skills/aspire-deployment/references/preflight.md" | awk '{print $1}')" - init_skill_hash="$(sha256sum "$cache/skills/aspire-init/SKILL.md" | awk '{print $1}')" - monitoring_skill_hash="$(sha256sum "$cache/skills/aspire-monitoring/SKILL.md" | awk '{print $1}')" - orchestration_skill_hash="$(sha256sum "$cache/skills/aspire-orchestration/SKILL.md" | awk '{print $1}')" + aspire_skill_hash="$(sha512sum "$cache/skills/aspire/SKILL.md" | awk '{print $1}')" + aspire_commands_hash="$(sha512sum "$cache/skills/aspire/references/app-commands.md" | awk '{print $1}')" + aspire_evals_hash="$(sha512sum "$cache/skills/aspire/evals/evals.json" | awk '{print $1}')" + aspireify_skill_hash="$(sha512sum "$cache/skills/aspireify/SKILL.md" | awk '{print $1}')" + deployment_skill_hash="$(sha512sum "$cache/skills/aspire-deployment/SKILL.md" | awk '{print $1}')" + deployment_preflight_hash="$(sha512sum "$cache/skills/aspire-deployment/references/preflight.md" | awk '{print $1}')" + init_skill_hash="$(sha512sum "$cache/skills/aspire-init/SKILL.md" | awk '{print $1}')" + monitoring_skill_hash="$(sha512sum "$cache/skills/aspire-monitoring/SKILL.md" | awk '{print $1}')" + orchestration_skill_hash="$(sha512sum "$cache/skills/aspire-orchestration/SKILL.md" | awk '{print $1}')" cat > "$cache/skill-manifest.json" < { ["SKILL.md"] = CreateSkillFileContent() - }, hashOverride: "0000000000000000000000000000000000000000000000000000000000000000"); + }, hashOverride: "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"); var exception = await Assert.ThrowsAsync(() => AspireSkillsBundle.LoadAsync(new DirectoryInfo(bundleDirectory), CancellationToken.None)); - Assert.Contains("failed SHA-256 verification", exception.Message); + Assert.Contains("failed SHA-512 verification", exception.Message); + } + finally + { + Directory.Delete(bundleDirectory, recursive: true); + } + } + + [Fact] + public async Task LoadAsync_ValidatesLegacySha256PerFileHashes() + { + // The attestation-verified v0.0.1 bundle embedded in the CLI predates the SHA-512 switch and carries + // SHA-256 per-file hashes. Its exact bytes can't be re-hashed without invalidating the published + // attestation, so ValidateFile must keep accepting SHA-256 for such bundles until a SHA-512 release + // is re-embedded. + var bundleDirectory = CreateTempDirectory(); + var skillDirectory = Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName); + Directory.CreateDirectory(skillDirectory); + var skillPath = Path.Combine(skillDirectory, "SKILL.md"); + await File.WriteAllTextAsync(skillPath, CreateSkillFileContent()); + + try + { + var manifest = new SkillBundleManifest + { + Version = AspireSkillsInstaller.Version, + Supports = CreateSupports(), + Skills = + [ + new SkillBundleSkill + { + Name = CommonAgentApplicators.AspireSkillName, + Description = AspireSkillDescription, + Files = + [ + new SkillBundleFile + { + RelativePath = "SKILL.md", + Sha256 = ComputeSha256(skillPath) + } + ] + } + ] + }; + + await WriteManifestAsync(bundleDirectory, manifest); + + var bundle = await AspireSkillsBundle.LoadAsync(new DirectoryInfo(bundleDirectory), CancellationToken.None); + var skill = Assert.Single(bundle.GetSkillDefinitions()); + Assert.Equal(CommonAgentApplicators.AspireSkillName, skill.Name); + } + finally + { + Directory.Delete(bundleDirectory, recursive: true); + } + } + + [Fact] + public async Task LoadAsync_ThrowsWhenNoPerFileHashSpecified() + { + var bundleDirectory = CreateTempDirectory(); + var skillDirectory = Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName); + Directory.CreateDirectory(skillDirectory); + await File.WriteAllTextAsync(Path.Combine(skillDirectory, "SKILL.md"), CreateSkillFileContent()); + + try + { + var manifest = new SkillBundleManifest + { + Version = AspireSkillsInstaller.Version, + Supports = CreateSupports(), + Skills = + [ + new SkillBundleSkill + { + Name = CommonAgentApplicators.AspireSkillName, + Description = AspireSkillDescription, + Files = + [ + new SkillBundleFile + { + RelativePath = "SKILL.md" + } + ] + } + ] + }; + + await WriteManifestAsync(bundleDirectory, manifest); + + var exception = await Assert.ThrowsAsync(() => AspireSkillsBundle.LoadAsync(new DirectoryInfo(bundleDirectory), CancellationToken.None)); + + Assert.Contains("SHA-512 or SHA-256", exception.Message, StringComparison.Ordinal); } finally { @@ -181,7 +273,7 @@ public async Task LoadAsync_ThrowsWhenFilePathEscapesSkillRoot() new SkillBundleFile { RelativePath = "../SKILL.md", - Sha256 = "0000000000000000000000000000000000000000000000000000000000000000" + Sha512 = "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" } ] } @@ -225,7 +317,7 @@ public async Task GetSkillFilesAsync_TreatsMissingOptionalPathArraysAsEmpty() "name": "{{CommonAgentApplicators.AspireifySkillName}}", "description": "{{AspireifySkillDescription}}", "files": [ - { "relativePath": "SKILL.md", "sha256": "{{ComputeSha256(skillPath)}}" } + { "relativePath": "SKILL.md", "sha512": "{{ComputeSha512(skillPath)}}" } ] } ] @@ -271,7 +363,7 @@ public async Task LoadAsync_ThrowsWhenSupportsAreMissing() new SkillBundleFile { RelativePath = "SKILL.md", - Sha256 = ComputeSha256(skillPath) + Sha512 = ComputeSha512(skillPath) } ] } @@ -380,7 +472,7 @@ await CreateBundleAsync( bundleDirectory, new Dictionary { ["SKILL.md"] = CreateSkillFileContent() }); - // Truncate the bundled SKILL.md so the SHA-256 in the manifest no longer matches. + // Truncate the bundled SKILL.md so the SHA-512 in the manifest no longer matches. // The compatibility skip must not bypass content verification. var skillPath = Path.Combine(bundleDirectory, "skills", CommonAgentApplicators.AspireSkillName, "SKILL.md"); await File.WriteAllTextAsync(skillPath, "tampered"); @@ -429,7 +521,7 @@ private static async Task CreateBundleAsync( .Select(file => new SkillBundleFile { RelativePath = file.Key, - Sha256 = hashOverride ?? ComputeSha256(Path.Combine(skillDirectory, AspireSkillsBundle.NormalizeRelativePath(file.Key))) + Sha512 = hashOverride ?? ComputeSha512(Path.Combine(skillDirectory, AspireSkillsBundle.NormalizeRelativePath(file.Key))) }) .ToArray() } @@ -466,7 +558,7 @@ private static SkillBundleSkill CreateManifestSkill(string bundleDirectory, stri new SkillBundleFile { RelativePath = "SKILL.md", - Sha256 = ComputeSha256(Path.Combine(bundleDirectory, "skills", skillName, "SKILL.md")) + Sha512 = ComputeSha512(Path.Combine(bundleDirectory, "skills", skillName, "SKILL.md")) } ] }; @@ -478,6 +570,12 @@ private static Task WriteManifestAsync(string bundleDirectory, SkillBundleManife return File.WriteAllTextAsync(Path.Combine(bundleDirectory, "skill-manifest.json"), manifestJson); } + private static string ComputeSha512(string path) + { + using var stream = File.OpenRead(path); + return Convert.ToHexString(SHA512.HashData(stream)).ToLowerInvariant(); + } + private static string ComputeSha256(string path) { using var stream = File.OpenRead(path); diff --git a/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs b/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs index 95b1f0da049..0a9da2c45bd 100644 --- a/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs +++ b/tests/Aspire.Cli.Tests/Agents/AspireSkillsInstallerTests.cs @@ -191,12 +191,110 @@ public void EmbeddedAspireSkillsBundleProvider_OpensSnapshotResource() Assert.Equal(AspireSkillsInstaller.Version, metadata.Version); Assert.Equal(AspireSkillsInstaller.GitHubRepository, metadata.Repository); - Assert.Equal(metadata.Sha256, ComputeSha256(archiveStream)); + Assert.Equal(metadata.Sha512, ComputeSha512(archiveStream)); } - private static string ComputeSha256(Stream stream) + private static string ComputeSha512(Stream stream) { - return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + return Convert.ToHexString(SHA512.HashData(stream)).ToLowerInvariant(); + } + + [Fact] + public async Task EmbeddedAspireSkillsBundle_ArchiveIsSha512_AndPerFileHashesVerify() + { + var provider = new EmbeddedAspireSkillsBundleProvider(NullLogger.Instance); + var metadata = Assert.IsType(provider.Metadata); + Assert.NotNull(metadata.Version); + Assert.NotNull(metadata.AssetName); + Assert.NotNull(metadata.Sha512); + + var extractRoot = CreateTempDirectory(); + try + { + // Stage the embedded archive to disk and confirm its full-file SHA-512 matches the metadata + // the installer trusts before extraction (mirrors AspireSkillsInstaller.ValidateArchiveSha512). + // The full-file archive checksum is always SHA-512, independent of the per-file manifest below. + var archivePath = Path.Combine(extractRoot, metadata.AssetName!); + await using (var archiveStream = Assert.IsAssignableFrom(provider.OpenArchive())) + await using (var fileStream = File.Create(archivePath)) + { + await archiveStream.CopyToAsync(fileStream); + } + + Assert.Equal(128, AspireSkillsBundle.NormalizeSha512(metadata.Sha512!).Length); + Assert.Equal(AspireSkillsBundle.NormalizeSha512(metadata.Sha512!), ComputeSha512(archivePath)); + + // Extract the .tgz and independently recompute every per-file hash from the internal + // skill-manifest.json. The embedded snapshot is the exact attestation-verified release asset, + // so its per-file manifest uses whatever digest that release shipped (SHA-256 for v0.0.1; + // SHA-512 once a signed SHA-512 release is re-embedded). This self-computes from whatever is + // embedded, so it stays valid across that transition. + var contentDir = Path.Combine(extractRoot, "content"); + Directory.CreateDirectory(contentDir); + await using (var fileStream = File.OpenRead(archivePath)) + await using (var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress)) + { + await TarFile.ExtractToDirectoryAsync(gzipStream, contentDir, overwriteFiles: true, CancellationToken.None); + } + + var manifestPath = Directory.EnumerateFiles(contentDir, "skill-manifest.json", SearchOption.AllDirectories).Single(); + var bundleRoot = Path.GetDirectoryName(manifestPath)!; + + SkillBundleManifest? manifest; + await using (var manifestStream = File.OpenRead(manifestPath)) + { + manifest = await JsonSerializer.DeserializeAsync(manifestStream, AspireSkillsJsonSerializerContext.Default.SkillBundleManifest); + } + + Assert.NotNull(manifest); + Assert.NotEmpty(manifest!.Skills); + + var validatedFileCount = 0; + foreach (var skill in manifest.Skills) + { + Assert.NotEmpty(skill.Files); + foreach (var file in skill.Files) + { + var filePath = Path.Combine(bundleRoot, "skills", skill.Name!, AspireSkillsBundle.NormalizeRelativePath(file.RelativePath)); + + // Verify against the digest the manifest actually carries. SHA-512 is preferred and is + // what new builds emit; SHA-256 is the accepted fallback for the pre-switch attested bundle. + if (!string.IsNullOrWhiteSpace(file.Sha512)) + { + var normalizedHash = AspireSkillsBundle.NormalizeSha512(file.Sha512); + Assert.Equal(128, normalizedHash.Length); + Assert.Equal(normalizedHash, ComputeSha512(filePath)); + } + else + { + var normalizedHash = AspireSkillsBundle.NormalizeSha256(file.Sha256!); + Assert.Equal(64, normalizedHash.Length); + Assert.Equal(normalizedHash, ComputeSha256(filePath)); + } + + validatedFileCount++; + } + } + + Assert.True(validatedFileCount > 0); + + // Run the production loader end-to-end over the extracted bundle. Per-file verification lives in + // AspireSkillsBundle.ValidateFile, so a clean load is the same check the runtime performs before + // caching the embedded snapshot. + var bundle = await AspireSkillsBundle.LoadAsync( + new DirectoryInfo(bundleRoot), + metadata.Version!, + metadata.Version!, + skipCompatibilityCheck: true, + CancellationToken.None); + + Assert.Equal(metadata.Version, bundle.Version); + Assert.NotEmpty(bundle.GetSkillDefinitions()); + } + finally + { + Directory.Delete(extractRoot, recursive: true); + } } [Fact] @@ -344,7 +442,7 @@ public async Task InstallAsync_WhenEmbeddedArchiveHashDoesNotMatch_ReturnsFailur Repository = AspireSkillsInstaller.GitHubRepository, Tag = $"v{AspireSkillsInstaller.Version}", AssetName = $"aspire-skills-v{AspireSkillsInstaller.Version}.tgz", - Sha256 = "0000000000000000000000000000000000000000000000000000000000000000" + Sha512 = "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" }; var executionContext = TestExecutionContextHelper.CreateExecutionContext(new DirectoryInfo(rootDirectory)); var installer = CreateInstaller( @@ -355,8 +453,8 @@ public async Task InstallAsync_WhenEmbeddedArchiveHashDoesNotMatch_ReturnsFailur Assert.Equal(AspireSkillsInstallStatus.Failed, result.Status); Assert.NotNull(result.Message); - Assert.Contains("SHA-256", result.Message, StringComparison.Ordinal); - Assert.Contains("0000000000000000000000000000000000000000000000000000000000000000", result.Message, StringComparison.Ordinal); + Assert.Contains("SHA-512", result.Message, StringComparison.Ordinal); + Assert.Contains("00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", result.Message, StringComparison.Ordinal); Assert.True(embeddedBundleProvider.OpenArchiveCalled); } finally @@ -488,7 +586,7 @@ await File.WriteAllTextAsync(skillPath, new SkillBundleFile { RelativePath = "SKILL.md", - Sha256 = ComputeSha256(skillPath) + Sha512 = ComputeSha512(skillPath) } ] } @@ -531,15 +629,21 @@ private static async Task CreateBundleArchiveBytesAsync(SkillBundleSuppo } } + private static string ComputeSha512(string path) + { + using var stream = File.OpenRead(path); + return Convert.ToHexString(SHA512.HashData(stream)).ToLowerInvariant(); + } + private static string ComputeSha256(string path) { using var stream = File.OpenRead(path); return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); } - private static string ComputeSha256(byte[] bytes) + private static string ComputeSha512(byte[] bytes) { - return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + return Convert.ToHexString(SHA512.HashData(bytes)).ToLowerInvariant(); } private static async Task CreateEmbeddedBundleProviderAsync(SkillBundleSupports? supports = null) @@ -553,7 +657,7 @@ private static async Task CreateEmbedded Repository = AspireSkillsInstaller.GitHubRepository, Tag = $"v{AspireSkillsInstaller.Version}", AssetName = $"aspire-skills-v{AspireSkillsInstaller.Version}.tgz", - Sha256 = ComputeSha256(archiveBytes) + Sha512 = ComputeSha512(archiveBytes) }, ArchiveBytes = archiveBytes }; diff --git a/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs index 35eda1d78c0..09cc1eb448a 100644 --- a/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/AgentInitCommandTests.cs @@ -865,7 +865,7 @@ await File.WriteAllTextAsync(skillPath, $$""" new SkillBundleFile { RelativePath = "SKILL.md", - Sha256 = ComputeSha256(skillPath) + Sha512 = ComputeSha512(skillPath) } ] }); @@ -887,10 +887,10 @@ await File.WriteAllTextAsync(skillPath, $$""" return await AspireSkillsBundle.LoadAsync(bundleDirectory, CancellationToken.None); } - private static string ComputeSha256(string path) + private static string ComputeSha512(string path) { using var stream = File.OpenRead(path); - return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + return Convert.ToHexString(SHA512.HashData(stream)).ToLowerInvariant(); } [Fact] diff --git a/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs index 37c80641b30..5254acba1e3 100644 --- a/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/ConfigCommandTests.cs @@ -473,6 +473,75 @@ public async Task ConfigListCommand_WithAllFlag_NoConfig_ShowsAvailableFeatures( Assert.Contains("default:", output); } + [Fact] + public async Task ConfigListCommand_WithAllFlag_DoesNotListHiddenFeature() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = outputWriter; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + + var listResult = command.Parse("config list --all"); + var listExitCode = await listResult.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, listExitCode); + + var output = string.Join("\n", outputWriter.Logs); + // A non-hidden feature is listed, proving the available-features section rendered... + Assert.Contains(KnownFeatures.ShowAllTemplates, output); + // ...but the hidden aspireSkillsRemoteFetchEnabled feature must not be advertised. + Assert.DoesNotContain(KnownFeatures.AspireSkillsRemoteFetchEnabled, output); + } + + [Fact] + public async Task ConfigInfoJson_DoesNotAdvertiseHiddenFeature() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var outputWriter = new TestOutputTextWriter(outputHelper); + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.OutputTextWriter = outputWriter; + }); + using var provider = services.BuildServiceProvider(); + + var command = provider.GetRequiredService(); + + var infoResult = command.Parse("config info --json"); + var infoExitCode = await infoResult.InvokeAsync().DefaultTimeout(); + Assert.Equal(0, infoExitCode); + + // The only place a feature name reaches the JSON is availableFeatures (SettingsSchemaBuilder + // does not enumerate feature flags), so asserting on the name token reliably proves exclusion. + var output = string.Join("\n", outputWriter.Logs); + // A non-hidden feature is present, proving availableFeatures was emitted... + Assert.Contains(KnownFeatures.ShowAllTemplates, output); + // ...but the hidden aspireSkillsRemoteFetchEnabled feature must not appear. + Assert.DoesNotContain(KnownFeatures.AspireSkillsRemoteFetchEnabled, output); + } + + [Fact] + public void HiddenFeature_WhenSetExplicitly_IsStillHonored() + { + using var workspace = TemporaryWorkspace.CreateForCli(outputHelper); + var services = CliTestHelper.CreateServiceCollection( + workspace, + outputHelper, + // Hiding a feature only removes it from discovery; a value set directly in + // configuration must still be honored by IFeatures. + options => options.ConfigurationCallback += config => + { + config[$"{KnownFeatures.FeaturePrefix}:{KnownFeatures.AspireSkillsRemoteFetchEnabled}"] = "true"; + }); + using var provider = services.BuildServiceProvider(); + + var featureFlags = provider.GetRequiredService(); + Assert.True(featureFlags.IsFeatureEnabled(KnownFeatures.AspireSkillsRemoteFetchEnabled, defaultValue: false)); + } + [Fact] public async Task FeatureFlags_WhenSetToTrue_ReturnsTrue() { diff --git a/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs b/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs index c12743ff1d5..6588fdab6a4 100644 --- a/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs +++ b/tests/Aspire.Cli.Tests/TestServices/FakePlaywrightServices.cs @@ -186,16 +186,16 @@ private SkillBundleSkill CreateSkill(string skillName, string[] installExcludedR .Select(entry => new SkillBundleFile { RelativePath = entry.Key.RelativePath, - Sha256 = ComputeSha256(Path.Combine(_bundleDirectory.FullName, "skills", skillName, entry.Key.RelativePath)) + Sha512 = ComputeSha512(Path.Combine(_bundleDirectory.FullName, "skills", skillName, entry.Key.RelativePath)) }) .ToArray() }; } - private static string ComputeSha256(string path) + private static string ComputeSha512(string path) { using var stream = File.OpenRead(path); - return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + return Convert.ToHexString(SHA512.HashData(stream)).ToLowerInvariant(); } } diff --git a/tests/Infrastructure.Tests/PowerShellScripts/AspireSkillsBundleHashTests.cs b/tests/Infrastructure.Tests/PowerShellScripts/AspireSkillsBundleHashTests.cs index 968881e33b0..2db3bf8f780 100644 --- a/tests/Infrastructure.Tests/PowerShellScripts/AspireSkillsBundleHashTests.cs +++ b/tests/Infrastructure.Tests/PowerShellScripts/AspireSkillsBundleHashTests.cs @@ -22,10 +22,10 @@ namespace Infrastructure.Tests; /// public sealed class AspireSkillsBundleHashTests : IDisposable { - // SHA-256 of the LF, UTF-8 (no BOM) bytes of CanonicalText, computed independently of the script + // SHA-512 of the LF, UTF-8 (no BOM) bytes of CanonicalText, computed independently of the script // under test (so this is a real oracle, not a tautology). Every line-ending variant of the same // logical content must normalize to this one hash. - private const string ExpectedSha256 = "83fd2d53ae2f0c5f2326321934026cf6f0c3397f17aa1ba0887178155c220931"; + private const string ExpectedSha512 = "00957ac0d67fb6cb43c6dc38038de8dc96f75375d278c907484686d38faf812eeaec1153a0e523507b4afb49cf8f39f9075afb20ab13e71e753a455d3abb78b0"; // Canonical hook-like content using LF placeholders; each test rewrites the newlines per style. private const string CanonicalText = "#!/usr/bin/env bash\necho 'aspire'\n"; @@ -56,7 +56,7 @@ public async Task NormalizesEveryLineEndingVariantToTheSameHash(LineEndings line var hash = await RunHashDriverAsync(inputPath); - Assert.Equal(ExpectedSha256, hash); + Assert.Equal(ExpectedSha512, hash); } [Fact] @@ -97,7 +97,7 @@ private async Task RunHashDriverAsync(string inputPath) $bytes = [System.IO.File]::ReadAllBytes($InputFile) $normalized = ConvertTo-LfUtf8Bytes -Bytes $bytes - Write-Output (Get-AspireSkillsSha256Hex -Bytes $normalized) + Write-Output (Get-AspireSkillsSha512Hex -Bytes $normalized) """); var result = await RunDriverAsync( @@ -106,9 +106,9 @@ private async Task RunHashDriverAsync(string inputPath) "-InputFile", $"\"{inputPath}\""); var hash = ReadLines(result.Output) - .FirstOrDefault(static l => l.Length == 64 && l.All(static c => char.IsAsciiHexDigitLower(c))); + .FirstOrDefault(static l => l.Length == 128 && l.All(static c => char.IsAsciiHexDigitLower(c))); - Assert.True(hash is not null, $"Expected a SHA-256 line in driver output:{Environment.NewLine}{result.Output}"); + Assert.True(hash is not null, $"Expected a SHA-512 line in driver output:{Environment.NewLine}{result.Output}"); return hash!; }