save checkpoint

This commit is contained in:
master
2026-02-11 01:32:14 +02:00
parent 5593212b41
commit cf5b72974f
2316 changed files with 68799 additions and 3808 deletions

View File

@@ -31950,10 +31950,18 @@ stella policy test {policyName}.stella
AnsiConsole.MarkupLine($" Entries: {result.Entries}");
AnsiConsole.MarkupLine($" Created: {result.CreatedAt?.ToString("O", CultureInfo.InvariantCulture) ?? "unknown"}");
AnsiConsole.MarkupLine($" Portable: {(result.Portable ? "yes" : "no")}");
if (!string.IsNullOrWhiteSpace(result.Profile))
{
AnsiConsole.MarkupLine($" Profile: {Markup.Escape(result.Profile)}");
}
}
else
{
AnsiConsole.MarkupLine($"[red]Bundle verification failed:[/] {Markup.Escape(result.ErrorMessage ?? "Unknown error")}");
if (!string.IsNullOrWhiteSpace(result.ErrorCode))
{
AnsiConsole.MarkupLine($" [grey]Code: {Markup.Escape(result.ErrorCode)}[/]");
}
if (!string.IsNullOrEmpty(result.ErrorDetail))
{
AnsiConsole.MarkupLine($" [grey]{Markup.Escape(result.ErrorDetail)}[/]");

View File

@@ -26,6 +26,7 @@ internal static class VerifyCommandGroup
verify.Add(BuildVerifyOfflineCommand(services, verboseOption, cancellationToken));
verify.Add(BuildVerifyImageCommand(services, verboseOption, cancellationToken));
verify.Add(BuildVerifyBundleCommand(services, verboseOption, cancellationToken));
verify.Add(BuildVerifyReleaseCommand(services, verboseOption, cancellationToken));
// Sprint: SPRINT_20260118_012_CLI_verification_consolidation (CLI-V-002)
// stella verify attestation - moved from stella attest verify
@@ -225,6 +226,101 @@ internal static class VerifyCommandGroup
return command;
}
private static Command BuildVerifyReleaseCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var bundleArg = new Argument<string>("bundle")
{
Description = "Path to the promotion DSSE bundle file."
};
var sbomOption = new Option<string?>("--sbom")
{
Description = "Path to SBOM file for material verification."
};
var vexOption = new Option<string?>("--vex")
{
Description = "Path to VEX file for material verification."
};
var trustRootOption = new Option<string?>("--trust-root")
{
Description = "Path to trusted certificate chain."
};
var checkpointOption = new Option<string?>("--checkpoint")
{
Description = "Path to Rekor checkpoint for verification."
};
var skipSignatureOption = new Option<bool>("--skip-signature")
{
Description = "Skip signature verification."
};
var skipRekorOption = new Option<bool>("--skip-rekor")
{
Description = "Skip Rekor inclusion proof verification."
};
var jsonOption = new Option<bool>("--json")
{
Description = "Output as JSON for CI integration."
};
var tenantOption = new Option<string?>("--tenant", "-t")
{
Description = "Tenant identifier."
};
var command = new Command("release", "Verify a release promotion bundle chain (source, build, signature, and transparency evidence).")
{
bundleArg,
sbomOption,
vexOption,
trustRootOption,
checkpointOption,
skipSignatureOption,
skipRekorOption,
jsonOption,
tenantOption,
verboseOption
};
command.SetAction((parseResult, _) =>
{
var bundlePath = parseResult.GetValue(bundleArg) ?? string.Empty;
var sbom = parseResult.GetValue(sbomOption);
var vex = parseResult.GetValue(vexOption);
var trustRoot = parseResult.GetValue(trustRootOption);
var checkpoint = parseResult.GetValue(checkpointOption);
var skipSignature = parseResult.GetValue(skipSignatureOption);
var skipRekor = parseResult.GetValue(skipRekorOption);
var emitJson = parseResult.GetValue(jsonOption);
var tenant = parseResult.GetValue(tenantOption);
var verbose = parseResult.GetValue(verboseOption);
return CommandHandlers.HandlePromotionVerifyAsync(
services,
bundlePath,
sbom,
vex,
trustRoot,
checkpoint,
skipSignature,
skipRekor,
emitJson,
tenant,
verbose,
cancellationToken);
});
return command;
}
#region Sprint: SPRINT_20260118_012_CLI_verification_consolidation
/// <summary>

View File

@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging;
using System.Buffers;
using System.Formats.Tar;
using System.Globalization;
using System.IO.Compression;
@@ -14,6 +15,25 @@ namespace StellaOps.Cli.Services;
/// </summary>
internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
{
private const string PortableProfileVersion = "1.0";
private const string PortableCanonicalBomPath = "canonical_bom.json";
private const string PortableDsseEnvelopePath = "dsse_envelope.json";
private const string PortableManifestSigPath = "manifest.sig";
private const string PortableComponentsParquetPath = "components.parquet";
private const string ErrManifestMissing = "ERR_MANIFEST_MISSING";
private const string ErrManifestSchema = "ERR_MANIFEST_SCHEMA";
private const string ErrManifestSignatureMissing = "ERR_MANIFEST_SIGNATURE_MISSING";
private const string ErrManifestSignatureInvalid = "ERR_MANIFEST_SIGNATURE_INVALID";
private const string ErrFileMissing = "ERR_FILE_MISSING";
private const string ErrFileSizeMismatch = "ERR_FILE_SIZE_MISMATCH";
private const string ErrFileDigestMismatch = "ERR_FILE_DIGEST_MISMATCH";
private const string ErrDssePayloadDigest = "ERR_DSSE_PAYLOAD_DIGEST";
private const string ErrRekorTileMissing = "ERR_REKOR_TILE_MISSING";
private const string ErrRekorReferenceUncovered = "ERR_REKOR_REFERENCE_UNCOVERED";
private const string ErrRekorRootMismatch = "ERR_REKOR_ROOT_MISMATCH";
private const string ErrParquetFingerprint = "ERR_PARQUET_FINGERPRINT_MISMATCH";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
@@ -80,17 +100,22 @@ internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
ex.Message);
}
// Step 4: Verify DSSE signature
if (IsPortableManifest(contents.ManifestJson))
{
return VerifyPortableBundle(contents, offline);
}
// Legacy verification path
var signatureValid = VerifyDsseSignature(contents, offline, out var signatureError);
if (!signatureValid && !string.IsNullOrEmpty(signatureError))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"DSSE signature verification failed",
signatureError);
signatureError,
ErrManifestSignatureInvalid);
}
// Step 5: Verify TSA (only if not offline)
if (!offline && contents.Signature is not null)
{
if (string.IsNullOrEmpty(contents.Signature.TimestampAuthority) ||
@@ -103,7 +128,6 @@ internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
}
}
// Step 6: Build success result
return new DevPortalBundleVerificationResult
{
Status = "verified",
@@ -114,6 +138,7 @@ internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
Entries = contents.Manifest?.Entries?.Count ?? 0,
CreatedAt = contents.Manifest?.CreatedAt ?? contents.BundleMetadata?.CreatedAt,
Portable = contents.BundleMetadata?.PortableGeneratedAt is not null,
Profile = "legacy",
ExitCode = DevPortalVerifyExitCode.Success
};
}
@@ -160,24 +185,25 @@ internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
using var memoryStream = new MemoryStream();
await entry.DataStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false);
var json = System.Text.Encoding.UTF8.GetString(memoryStream.ToArray());
var bytes = memoryStream.ToArray();
contents.Files[entry.Name] = bytes;
switch (entry.Name)
{
case "manifest.json":
contents.ManifestJson = json;
contents.Manifest = JsonSerializer.Deserialize<BundleManifest>(json, SerializerOptions);
contents.ManifestJson = System.Text.Encoding.UTF8.GetString(bytes);
contents.Manifest = JsonSerializer.Deserialize<BundleManifest>(bytes, SerializerOptions);
break;
case "signature.json":
contents.SignatureJson = json;
contents.Signature = JsonSerializer.Deserialize<BundleSignature>(json, SerializerOptions);
contents.SignatureJson = System.Text.Encoding.UTF8.GetString(bytes);
contents.Signature = JsonSerializer.Deserialize<BundleSignature>(bytes, SerializerOptions);
break;
case "bundle.json":
contents.BundleMetadataJson = json;
contents.BundleMetadata = JsonSerializer.Deserialize<BundleMetadataDocument>(json, SerializerOptions);
contents.BundleMetadataJson = System.Text.Encoding.UTF8.GetString(bytes);
contents.BundleMetadata = JsonSerializer.Deserialize<BundleMetadataDocument>(bytes, SerializerOptions);
break;
case "checksums.txt":
contents.ChecksumsText = json;
contents.ChecksumsText = System.Text.Encoding.UTF8.GetString(bytes);
break;
}
}
@@ -245,8 +271,431 @@ internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
return true;
}
private DevPortalBundleVerificationResult VerifyPortableBundle(BundleContents contents, bool offline)
{
if (string.IsNullOrWhiteSpace(contents.ManifestJson))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"Portable manifest is missing",
null,
ErrManifestMissing);
}
PortableManifest? manifest;
try
{
manifest = JsonSerializer.Deserialize<PortableManifest>(contents.ManifestJson!, SerializerOptions);
}
catch (JsonException ex)
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"Portable manifest is invalid JSON",
ex.Message,
ErrManifestSchema);
}
if (!ValidatePortableManifestSchema(manifest, out var schemaError))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"Portable manifest schema validation failed",
schemaError,
ErrManifestSchema);
}
if (!contents.Files.TryGetValue(PortableManifestSigPath, out var manifestSigBytes))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"Detached manifest signature is missing",
PortableManifestSigPath,
ErrManifestSignatureMissing);
}
ManifestSignatureEnvelope? manifestSig;
try
{
manifestSig = JsonSerializer.Deserialize<ManifestSignatureEnvelope>(manifestSigBytes, SerializerOptions);
}
catch (JsonException ex)
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"Detached manifest signature is invalid JSON",
ex.Message,
ErrManifestSignatureInvalid);
}
if (manifestSig is null || string.IsNullOrWhiteSpace(manifestSig.Payload) || manifestSig.Signatures.Count == 0)
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"Detached manifest signature is incomplete",
null,
ErrManifestSignatureInvalid);
}
byte[] detachedPayload;
try
{
detachedPayload = Convert.FromBase64String(manifestSig.Payload);
}
catch (FormatException ex)
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"Detached manifest payload encoding is invalid",
ex.Message,
ErrManifestSignatureInvalid);
}
var canonicalManifest = CanonicalizeJson(contents.ManifestJson!);
if (!detachedPayload.AsSpan().SequenceEqual(canonicalManifest))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"Detached manifest payload does not match canonical manifest",
null,
ErrManifestSignatureInvalid);
}
foreach (var file in manifest!.Files.OrderBy(kvp => kvp.Key, StringComparer.Ordinal))
{
if (!contents.Files.TryGetValue(file.Key, out var bytes))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"Manifest references missing file",
file.Key,
ErrFileMissing);
}
if (bytes.LongLength != file.Value.Size)
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.ChecksumMismatch,
"File size does not match manifest",
file.Key,
ErrFileSizeMismatch);
}
var digest = ComputeSha256Hex(bytes);
if (!string.Equals(digest, file.Value.Sha256, StringComparison.OrdinalIgnoreCase))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.ChecksumMismatch,
"File digest does not match manifest",
file.Key,
ErrFileDigestMismatch);
}
}
if (!contents.Files.TryGetValue(PortableDsseEnvelopePath, out var dsseEnvelopeBytes))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"DSSE envelope file is missing",
PortableDsseEnvelopePath,
ErrFileMissing);
}
DsseEnvelope? dsseEnvelope;
try
{
dsseEnvelope = JsonSerializer.Deserialize<DsseEnvelope>(dsseEnvelopeBytes, SerializerOptions);
}
catch (JsonException ex)
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"DSSE envelope JSON is invalid",
ex.Message,
ErrDssePayloadDigest);
}
if (dsseEnvelope is null || string.IsNullOrWhiteSpace(dsseEnvelope.Payload))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"DSSE envelope payload is missing",
null,
ErrDssePayloadDigest);
}
byte[] dssePayloadBytes;
try
{
dssePayloadBytes = Convert.FromBase64String(dsseEnvelope.Payload);
}
catch (FormatException ex)
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"DSSE payload encoding is invalid",
ex.Message,
ErrDssePayloadDigest);
}
var payloadDigest = ComputeSha256Hex(dssePayloadBytes);
if (!string.Equals(payloadDigest, manifest.Digests.DssePayloadDigest.Sha256, StringComparison.OrdinalIgnoreCase))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"DSSE payload digest does not match manifest",
null,
ErrDssePayloadDigest);
}
if (!contents.Files.TryGetValue(PortableCanonicalBomPath, out var canonicalBomBytes))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"Canonical BOM file is missing",
PortableCanonicalBomPath,
ErrFileMissing);
}
var canonicalBomDigest = ComputeSha256Hex(canonicalBomBytes);
if (!string.Equals(canonicalBomDigest, manifest.Digests.CanonicalBomSha256, StringComparison.OrdinalIgnoreCase))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.ChecksumMismatch,
"Canonical BOM digest does not match manifest",
null,
ErrFileDigestMismatch);
}
if (!IsHex64(manifest.Rekor.RootHash))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"Rekor root hash is invalid",
manifest.Rekor.RootHash,
ErrRekorRootMismatch);
}
foreach (var tileRef in manifest.Rekor.TileRefs)
{
if (!manifest.Files.ContainsKey(tileRef.Path) || !contents.Files.ContainsKey(tileRef.Path))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"Rekor tile reference is missing from bundle",
tileRef.Path,
ErrRekorTileMissing);
}
var requiredCovers = new[]
{
$"SHA256:{manifest.Artifact.Digest.Sha256.ToUpperInvariant()}",
$"SHA256:{manifest.Digests.CanonicalBomSha256.ToUpperInvariant()}"
};
foreach (var requiredCover in requiredCovers)
{
if (!tileRef.Covers.Contains(requiredCover, StringComparer.Ordinal))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"Rekor tile cover set is incomplete",
requiredCover,
ErrRekorReferenceUncovered);
}
}
}
if (manifest.Files.TryGetValue(PortableComponentsParquetPath, out var parquetFile))
{
if (string.IsNullOrWhiteSpace(parquetFile.SchemaFingerprint))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"Parquet schema fingerprint is missing",
PortableComponentsParquetPath,
ErrParquetFingerprint);
}
}
if (!offline)
{
if (manifest.Verifiers.RekorPub is null || string.IsNullOrWhiteSpace(manifest.Verifiers.RekorPub.KeyMaterial))
{
return DevPortalBundleVerificationResult.Failed(
DevPortalVerifyExitCode.SignatureFailure,
"Rekor verifier key material is missing for online verification",
null,
ErrRekorRootMismatch);
}
}
DateTimeOffset? createdAt = null;
if (DateTimeOffset.TryParse(manifest.CreatedUtc, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsedCreatedAt))
{
createdAt = parsedCreatedAt;
}
return new DevPortalBundleVerificationResult
{
Status = "verified",
BundleId = manifest.Compatibility?.LegacyBundleId ?? manifest.Artifact.Name,
RootHash = $"sha256:{manifest.Rekor.RootHash}",
Entries = manifest.Files.Count,
CreatedAt = createdAt,
Portable = true,
Profile = "portable-v1",
ExitCode = DevPortalVerifyExitCode.Success
};
}
private static bool IsPortableManifest(string? manifestJson)
{
if (string.IsNullOrWhiteSpace(manifestJson))
{
return false;
}
try
{
using var doc = JsonDocument.Parse(manifestJson);
return doc.RootElement.TryGetProperty("spec_version", out var specVersion)
&& string.Equals(specVersion.GetString(), PortableProfileVersion, StringComparison.Ordinal);
}
catch (JsonException)
{
return false;
}
}
private static bool ValidatePortableManifestSchema(PortableManifest? manifest, out string error)
{
if (manifest is null)
{
error = "Manifest is null.";
return false;
}
if (!string.Equals(manifest.SpecVersion, PortableProfileVersion, StringComparison.Ordinal))
{
error = "spec_version must be 1.0.";
return false;
}
if (manifest.Artifact is null || manifest.Files is null || manifest.Digests is null || manifest.Rekor is null || manifest.Verifiers is null)
{
error = "Required top-level fields are missing.";
return false;
}
if (manifest.Files.Count == 0
|| !manifest.Files.ContainsKey(PortableCanonicalBomPath)
|| !manifest.Files.ContainsKey(PortableDsseEnvelopePath))
{
error = "Required portable files are missing.";
return false;
}
if (!IsHex64(manifest.Artifact.Digest.Sha256)
|| !IsHex64(manifest.Digests.CanonicalBomSha256)
|| !IsHex64(manifest.Digests.DssePayloadDigest.Sha256))
{
error = "Digest format is invalid.";
return false;
}
if (manifest.Rekor.TileRefs.Count == 0)
{
error = "Rekor tile_refs are required.";
return false;
}
error = string.Empty;
return true;
}
private static byte[] CanonicalizeJson(string json)
{
using var document = JsonDocument.Parse(json);
var buffer = new ArrayBufferWriter<byte>();
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = false });
WriteCanonicalElement(writer, document.RootElement);
writer.Flush();
return buffer.WrittenSpan.ToArray();
}
private static void WriteCanonicalElement(Utf8JsonWriter writer, JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
writer.WriteStartObject();
foreach (var property in element.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal))
{
writer.WritePropertyName(property.Name);
WriteCanonicalElement(writer, property.Value);
}
writer.WriteEndObject();
break;
case JsonValueKind.Array:
writer.WriteStartArray();
foreach (var item in element.EnumerateArray())
{
WriteCanonicalElement(writer, item);
}
writer.WriteEndArray();
break;
case JsonValueKind.String:
writer.WriteStringValue(element.GetString());
break;
case JsonValueKind.Number:
writer.WriteRawValue(element.GetRawText(), skipInputValidation: true);
break;
case JsonValueKind.True:
case JsonValueKind.False:
writer.WriteBooleanValue(element.GetBoolean());
break;
case JsonValueKind.Null:
writer.WriteNullValue();
break;
default:
throw new InvalidOperationException($"Unsupported JSON token kind '{element.ValueKind}'.");
}
}
private static string ComputeSha256Hex(byte[] bytes)
=> Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
private static bool IsHex64(string? value)
{
if (string.IsNullOrWhiteSpace(value) || value.Length != 64)
{
return false;
}
foreach (var ch in value)
{
var isHex = ch is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F';
if (!isHex)
{
return false;
}
}
return true;
}
private sealed class BundleContents
{
public Dictionary<string, byte[]> Files { get; } = new(StringComparer.Ordinal);
public string? ManifestJson { get; set; }
public BundleManifest? Manifest { get; set; }
public string? SignatureJson { get; set; }
@@ -256,6 +705,87 @@ internal sealed class DevPortalBundleVerifier : IDevPortalBundleVerifier
public string? ChecksumsText { get; set; }
}
private sealed class PortableManifest
{
public string SpecVersion { get; set; } = string.Empty;
public string CreatedUtc { get; set; } = string.Empty;
public PortableArtifact Artifact { get; set; } = new();
public Dictionary<string, PortableManifestFile> Files { get; set; } = new(StringComparer.Ordinal);
public PortableDigests Digests { get; set; } = new();
public PortableRekor Rekor { get; set; } = new();
public PortableVerifiers Verifiers { get; set; } = new();
public PortableCompatibility? Compatibility { get; set; }
}
private sealed class PortableArtifact
{
public string Name { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
public PortableShaDigest Digest { get; set; } = new();
}
private sealed class PortableManifestFile
{
public string Sha256 { get; set; } = string.Empty;
public long Size { get; set; }
public string? SchemaFingerprint { get; set; }
}
private sealed class PortableDigests
{
public string CanonicalBomSha256 { get; set; } = string.Empty;
public PortableShaDigest DssePayloadDigest { get; set; } = new();
}
private sealed class PortableShaDigest
{
public string Sha256 { get; set; } = string.Empty;
}
private sealed class PortableRekor
{
public string RootHash { get; set; } = string.Empty;
public List<PortableTileReference> TileRefs { get; set; } = [];
}
private sealed class PortableTileReference
{
public string Path { get; set; } = string.Empty;
public List<string> Covers { get; set; } = [];
}
private sealed class PortableVerifiers
{
public PortableRekorVerifier? RekorPub { get; set; }
}
private sealed class PortableRekorVerifier
{
public string? KeyMaterial { get; set; }
}
private sealed class PortableCompatibility
{
public string? LegacyBundleId { get; set; }
}
private sealed class ManifestSignatureEnvelope
{
public string Payload { get; set; } = string.Empty;
public List<ManifestSignature> Signatures { get; set; } = [];
}
private sealed class ManifestSignature
{
public string Keyid { get; set; } = string.Empty;
public string Sig { get; set; } = string.Empty;
}
private sealed class DsseEnvelope
{
public string Payload { get; set; } = string.Empty;
}
private sealed class BundleManifest
{
public string? BundleId { get; set; }
@@ -335,20 +865,24 @@ public sealed class DevPortalBundleVerificationResult
public int Entries { get; set; }
public DateTimeOffset? CreatedAt { get; set; }
public bool Portable { get; set; }
public string? Profile { get; set; }
public DevPortalVerifyExitCode ExitCode { get; set; } = DevPortalVerifyExitCode.Unexpected;
public string? ErrorMessage { get; set; }
public string? ErrorDetail { get; set; }
public string? ErrorCode { get; set; }
public static DevPortalBundleVerificationResult Failed(
DevPortalVerifyExitCode exitCode,
string message,
string? detail = null)
string? detail = null,
string? errorCode = null)
=> new()
{
Status = "failed",
ExitCode = exitCode,
ErrorMessage = message,
ErrorDetail = detail
ErrorDetail = detail,
ErrorCode = errorCode
};
public string ToJson()
@@ -368,11 +902,15 @@ public sealed class DevPortalBundleVerificationResult
if (CreatedAt.HasValue)
output["createdAt"] = CreatedAt.Value.ToString("O", CultureInfo.InvariantCulture);
output["entries"] = Entries;
if (ErrorCode is not null)
output["errorCode"] = ErrorCode;
if (ErrorDetail is not null)
output["errorDetail"] = ErrorDetail;
if (ErrorMessage is not null)
output["errorMessage"] = ErrorMessage;
output["portable"] = Portable;
if (Profile is not null)
output["profile"] = Profile;
if (RootHash is not null)
output["rootHash"] = RootHash;
output["status"] = Status;

View File

@@ -59,7 +59,9 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT_20260208_030-CORE | DONE | Added `stella advise ask --file` batch processing and `stella advise export` conversation history command surfaces (2026-02-08). |
| SPRINT_20260208_033-CORE | DONE | Unknowns export schema/versioning envelope and CLI option integration completed (2026-02-08). |
| STS-004 | DONE | SPRINT_20260210_004 - Added `stella verify release` command that maps to promotion bundle verification flow. |
| SPRINT_20260208_031-CORE | DONE | Compare verification overlay options, builder, and output/model integration completed (2026-02-08).
| PAPI-005 | DONE | SPRINT_20260210_005 - DevPortal portable-v1 verify parity and deterministic error-code output completed; CLI verifier paths validated in suite run (1173 passed) on 2026-02-10. |