partly or unimplemented features - now implemented

This commit is contained in:
master
2026-02-09 08:53:51 +02:00
parent 1bf6bbf395
commit 4bdc298ec1
674 changed files with 90194 additions and 2271 deletions

View File

@@ -7,8 +7,11 @@ using StellaOps.Cli.Configuration;
using StellaOps.Cli.Services.Chat;
using StellaOps.Cli.Services.Models.Chat;
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -28,10 +31,16 @@ internal static class AdviseChatCommandGroup
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var queryArgument = new Argument<string>("query")
var queryArgument = new Argument<string?>("query")
{
Description = "The question or query to ask the advisory assistant."
};
queryArgument.Arity = ArgumentArity.ZeroOrOne;
var fileOption = new Option<string?>("--file")
{
Description = "Read batch queries from newline-delimited JSON (.jsonl)."
};
var imageOption = new Option<string?>("--image", new[] { "-i" })
{
@@ -95,6 +104,7 @@ internal static class AdviseChatCommandGroup
conversationOption,
noActionOption,
evidenceOption,
fileOption,
formatOption,
outputOption,
tenantOption,
@@ -104,13 +114,14 @@ internal static class AdviseChatCommandGroup
ask.SetAction(async (parseResult, ct) =>
{
var query = parseResult.GetValue(queryArgument) ?? string.Empty;
var query = parseResult.GetValue(queryArgument);
var image = parseResult.GetValue(imageOption);
var digest = parseResult.GetValue(digestOption);
var env = parseResult.GetValue(envOption);
var conversationId = parseResult.GetValue(conversationOption);
var noAction = parseResult.GetValue(noActionOption);
var evidence = parseResult.GetValue(evidenceOption);
var filePath = parseResult.GetValue(fileOption);
var format = ParseChatOutputFormat(parseResult.GetValue(formatOption));
var outputPath = parseResult.GetValue(outputOption);
var tenant = parseResult.GetValue(tenantOption);
@@ -127,6 +138,7 @@ internal static class AdviseChatCommandGroup
conversationId,
noAction,
evidence,
filePath,
format,
outputPath,
tenant,
@@ -138,6 +150,85 @@ internal static class AdviseChatCommandGroup
return ask;
}
/// <summary>
/// Build the 'advise export' command for exporting conversation history.
/// </summary>
public static Command BuildExportCommand(
IServiceProvider services,
StellaOpsCliOptions options,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var conversationIdOption = new Option<string?>("--conversation-id", new[] { "-c" })
{
Description = "Export a single conversation id."
};
var tenantOption = new Option<string?>("--tenant")
{
Description = "Tenant context for listing conversations."
};
var userOption = new Option<string?>("--user")
{
Description = "User context for listing conversations."
};
var limitOption = new Option<int?>("--limit")
{
Description = "Maximum number of conversations to export (default: 100)."
};
limitOption.SetDefaultValue(100);
var formatOption = new Option<string>("--format", new[] { "-f" })
{
Description = "Output format: json, table, markdown (default: json)."
};
formatOption.SetDefaultValue("json");
formatOption.FromAmong("json", "table", "markdown");
var outputOption = new Option<string?>("--output", new[] { "-o" })
{
Description = "Write export output to file instead of stdout."
};
var export = new Command("export", "Export advisory conversation history.")
{
conversationIdOption,
tenantOption,
userOption,
limitOption,
formatOption,
outputOption,
verboseOption
};
export.SetAction(async (parseResult, ct) =>
{
var conversationId = parseResult.GetValue(conversationIdOption);
var tenant = parseResult.GetValue(tenantOption);
var user = parseResult.GetValue(userOption);
var limit = parseResult.GetValue(limitOption);
var format = ParseChatOutputFormat(parseResult.GetValue(formatOption));
var outputPath = parseResult.GetValue(outputOption);
var verbose = parseResult.GetValue(verboseOption);
await HandleExportAsync(
services,
options,
conversationId,
tenant,
user,
limit,
format,
outputPath,
verbose,
cancellationToken).ConfigureAwait(false);
});
return export;
}
/// <summary>
/// Build the 'advise doctor' command for chat diagnostics.
/// </summary>
@@ -467,16 +558,25 @@ internal static class AdviseChatCommandGroup
};
}
private sealed record BatchQueryEntry(int LineNumber, string Query);
private sealed record BatchQueryResult(
int LineNumber,
string Query,
ChatQueryResponse? Response,
string? Error);
private static async Task HandleAskAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string query,
string? query,
string? image,
string? digest,
string? environment,
string? conversationId,
bool noAction,
bool includeEvidence,
string? filePath,
ChatOutputFormat format,
string? outputPath,
string? tenant,
@@ -502,9 +602,38 @@ internal static class AdviseChatCommandGroup
return;
}
if (!string.IsNullOrWhiteSpace(filePath))
{
try
{
await HandleAskBatchAsync(
services,
options,
filePath,
image,
digest,
environment,
conversationId,
noAction,
includeEvidence,
format,
outputPath,
tenant,
user,
verbose,
cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or InvalidOperationException)
{
Console.Error.WriteLine($"Batch query failed: {ex.Message}");
}
return;
}
if (string.IsNullOrWhiteSpace(query))
{
Console.Error.WriteLine("Error: Query cannot be empty.");
Console.Error.WriteLine("Error: Query cannot be empty. Provide 'query' or '--file'.");
return;
}
@@ -565,6 +694,235 @@ internal static class AdviseChatCommandGroup
}
}
private static async Task HandleAskBatchAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string filePath,
string? image,
string? digest,
string? environment,
string? conversationId,
bool noAction,
bool includeEvidence,
ChatOutputFormat format,
string? outputPath,
string? tenant,
string? user,
bool verbose,
CancellationToken cancellationToken)
{
var client = CreateChatClient(services, options);
var requests = await ReadBatchRequestsAsync(filePath, cancellationToken).ConfigureAwait(false);
if (requests.Count == 0)
{
Console.Error.WriteLine("Error: Batch file contained no queries.");
return;
}
if (verbose)
{
Console.Error.WriteLine($"Processing {requests.Count} query entries from '{filePath}'.");
}
var results = new List<BatchQueryResult>(requests.Count);
foreach (var entry in requests)
{
var request = new ChatQueryRequest
{
Query = entry.Query,
ImageReference = image,
ArtifactDigest = digest,
Environment = environment,
ConversationId = conversationId,
NoAction = noAction,
IncludeEvidence = includeEvidence
};
try
{
var response = await client.QueryAsync(request, tenant, user, cancellationToken).ConfigureAwait(false);
results.Add(new BatchQueryResult(entry.LineNumber, entry.Query, response, null));
}
catch (ChatException ex)
{
results.Add(new BatchQueryResult(entry.LineNumber, entry.Query, null, ex.Message));
}
}
await using var writer = GetOutputWriter(outputPath);
if (format == ChatOutputFormat.Json)
{
var payload = new
{
file = Path.GetFileName(filePath),
count = results.Count,
results = results.Select(static item => new
{
item.LineNumber,
item.Query,
Error = item.Error,
Response = item.Response
})
};
var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true });
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
return;
}
foreach (var item in results)
{
await writer.WriteLineAsync($"# Batch line {item.LineNumber}".AsMemory(), cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(item.Error))
{
await writer.WriteLineAsync($"Error: {item.Error}".AsMemory(), cancellationToken).ConfigureAwait(false);
await writer.WriteLineAsync(string.Empty.AsMemory(), cancellationToken).ConfigureAwait(false);
continue;
}
await writer.WriteLineAsync($"Query: {item.Query}".AsMemory(), cancellationToken).ConfigureAwait(false);
await writer.WriteLineAsync(string.Empty.AsMemory(), cancellationToken).ConfigureAwait(false);
await ChatRenderer.RenderQueryResponseAsync(item.Response!, format, writer, cancellationToken).ConfigureAwait(false);
await writer.WriteLineAsync(string.Empty.AsMemory(), cancellationToken).ConfigureAwait(false);
}
}
private static async Task<List<BatchQueryEntry>> ReadBatchRequestsAsync(
string filePath,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(filePath))
{
throw new InvalidOperationException("Batch file path must be provided.");
}
if (!File.Exists(filePath))
{
throw new FileNotFoundException("Batch query file was not found.", filePath);
}
var lines = await File.ReadAllLinesAsync(filePath, cancellationToken).ConfigureAwait(false);
var results = new List<BatchQueryEntry>(lines.Length);
for (var i = 0; i < lines.Length; i++)
{
var lineNumber = i + 1;
var line = lines[i]?.Trim();
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
{
continue;
}
try
{
using var doc = JsonDocument.Parse(line);
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.String)
{
var query = root.GetString();
if (string.IsNullOrWhiteSpace(query))
{
throw new InvalidOperationException($"Batch line {lineNumber} contains empty query.");
}
results.Add(new BatchQueryEntry(lineNumber, query));
continue;
}
if (root.ValueKind != JsonValueKind.Object ||
!TryGetStringProperty(root, "query", out var jsonQuery) ||
string.IsNullOrWhiteSpace(jsonQuery))
{
throw new InvalidOperationException($"Batch line {lineNumber} must be a JSON string or object with non-empty 'query'.");
}
results.Add(new BatchQueryEntry(lineNumber, jsonQuery!));
}
catch (JsonException ex)
{
throw new InvalidOperationException($"Batch line {lineNumber} is not valid JSON: {ex.Message}", ex);
}
}
return results;
}
private static bool TryGetStringProperty(JsonElement element, string name, out string? value)
{
value = null;
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase) &&
property.Value.ValueKind == JsonValueKind.String)
{
value = property.Value.GetString();
return true;
}
}
return false;
}
private static async Task HandleExportAsync(
IServiceProvider services,
StellaOpsCliOptions options,
string? conversationId,
string? tenant,
string? user,
int? limit,
ChatOutputFormat format,
string? outputPath,
bool verbose,
CancellationToken cancellationToken)
{
var client = CreateChatClient(services, options);
var conversations = new List<ChatConversationResponse>();
try
{
if (!string.IsNullOrWhiteSpace(conversationId))
{
if (verbose)
{
Console.Error.WriteLine($"Exporting conversation '{conversationId}'.");
}
var single = await client.GetConversationAsync(conversationId, tenant, user, cancellationToken).ConfigureAwait(false);
conversations.Add(single);
}
else
{
if (verbose)
{
Console.Error.WriteLine($"Listing conversations (limit: {limit ?? 100}).");
}
var listed = await client.ListConversationsAsync(tenant, user, limit, cancellationToken).ConfigureAwait(false);
foreach (var summary in listed.Conversations
.OrderBy(static item => item.ConversationId, StringComparer.Ordinal))
{
conversations.Add(await client.GetConversationAsync(summary.ConversationId, tenant, user, cancellationToken).ConfigureAwait(false));
}
}
var export = new ChatConversationExport
{
GeneratedAt = DateTimeOffset.UtcNow,
TenantId = tenant,
UserId = user,
ConversationCount = conversations.Count,
Conversations = conversations
.OrderBy(static item => item.ConversationId, StringComparer.Ordinal)
.ToList()
};
await using var writer = GetOutputWriter(outputPath);
await ChatRenderer.RenderConversationExportAsync(export, format, writer, cancellationToken).ConfigureAwait(false);
}
catch (ChatException ex)
{
Console.Error.WriteLine($"Export failed: {ex.Message}");
}
}
private static async Task HandleDoctorAsync(
IServiceProvider services,
StellaOpsCliOptions options,

View File

@@ -100,6 +100,33 @@ internal static class ChatRenderer
}
}
/// <summary>
/// Render a chat conversation export.
/// </summary>
public static async Task RenderConversationExportAsync(
ChatConversationExport export,
ChatOutputFormat format,
TextWriter writer,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(export);
ArgumentNullException.ThrowIfNull(writer);
switch (format)
{
case ChatOutputFormat.Markdown:
await RenderConversationExportMarkdownAsync(export, writer, cancellationToken).ConfigureAwait(false);
break;
case ChatOutputFormat.Table:
await RenderConversationExportTableAsync(export, writer, cancellationToken).ConfigureAwait(false);
break;
case ChatOutputFormat.Json:
default:
await RenderConversationExportJsonAsync(export, writer, cancellationToken).ConfigureAwait(false);
break;
}
}
private static async Task RenderQueryJsonAsync(ChatQueryResponse response, TextWriter writer, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(response, JsonOptions);
@@ -429,4 +456,78 @@ internal static class ChatRenderer
await writer.WriteAsync(sb.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
}
private static async Task RenderConversationExportJsonAsync(ChatConversationExport export, TextWriter writer, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(export, JsonOptions);
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
}
private static async Task RenderConversationExportTableAsync(ChatConversationExport export, TextWriter writer, CancellationToken cancellationToken)
{
var sb = new StringBuilder();
sb.AppendLine();
sb.AppendLine("=== Advisory Conversation Export ===");
sb.AppendLine();
sb.AppendLine($"Generated: {export.GeneratedAt:yyyy-MM-dd HH:mm:ss} UTC");
sb.AppendLine($"Tenant: {export.TenantId ?? "(not set)"}");
sb.AppendLine($"User: {export.UserId ?? "(not set)"}");
sb.AppendLine($"Conversations: {export.ConversationCount}");
sb.AppendLine();
foreach (var conversation in export.Conversations
.OrderBy(static item => item.ConversationId, StringComparer.Ordinal))
{
sb.AppendLine($"--- Conversation {conversation.ConversationId} ---");
sb.AppendLine($"Created: {conversation.CreatedAt:yyyy-MM-dd HH:mm:ss} UTC");
sb.AppendLine($"Updated: {conversation.UpdatedAt:yyyy-MM-dd HH:mm:ss} UTC");
sb.AppendLine($"Turns: {conversation.Turns.Count}");
foreach (var turn in conversation.Turns
.OrderBy(static item => item.Timestamp)
.ThenBy(static item => item.TurnId, StringComparer.Ordinal))
{
var role = string.IsNullOrWhiteSpace(turn.Role) ? "unknown" : turn.Role;
sb.AppendLine($" [{turn.Timestamp:yyyy-MM-dd HH:mm:ss}] {role}: {turn.Content}");
}
sb.AppendLine();
}
await writer.WriteAsync(sb.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
}
private static async Task RenderConversationExportMarkdownAsync(ChatConversationExport export, TextWriter writer, CancellationToken cancellationToken)
{
var sb = new StringBuilder();
sb.AppendLine("# Advisory Conversation Export");
sb.AppendLine();
sb.AppendLine($"- Generated: {export.GeneratedAt:yyyy-MM-dd HH:mm:ss} UTC");
sb.AppendLine($"- Tenant: {export.TenantId ?? "(not set)"}");
sb.AppendLine($"- User: {export.UserId ?? "(not set)"}");
sb.AppendLine($"- Conversations: {export.ConversationCount}");
sb.AppendLine();
foreach (var conversation in export.Conversations
.OrderBy(static item => item.ConversationId, StringComparer.Ordinal))
{
sb.AppendLine($"## {conversation.ConversationId}");
sb.AppendLine();
sb.AppendLine($"- Created: {conversation.CreatedAt:yyyy-MM-dd HH:mm:ss} UTC");
sb.AppendLine($"- Updated: {conversation.UpdatedAt:yyyy-MM-dd HH:mm:ss} UTC");
sb.AppendLine($"- Turns: {conversation.Turns.Count}");
sb.AppendLine();
foreach (var turn in conversation.Turns
.OrderBy(static item => item.Timestamp)
.ThenBy(static item => item.TurnId, StringComparer.Ordinal))
{
var role = string.IsNullOrWhiteSpace(turn.Role) ? "unknown" : turn.Role;
sb.AppendLine($"- **{role}** ({turn.Timestamp:yyyy-MM-dd HH:mm:ss} UTC): {turn.Content}");
}
sb.AppendLine();
}
await writer.WriteAsync(sb.ToString().AsMemory(), cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -4490,6 +4490,7 @@ flowchart TB
advise.Add(AdviseChatCommandGroup.BuildAskCommand(services, options, verboseOption, cancellationToken));
advise.Add(AdviseChatCommandGroup.BuildDoctorCommand(services, options, verboseOption, cancellationToken));
advise.Add(AdviseChatCommandGroup.BuildSettingsCommand(services, options, verboseOption, cancellationToken));
advise.Add(AdviseChatCommandGroup.BuildExportCommand(services, options, verboseOption, cancellationToken));
return advise;
}

View File

@@ -1,12 +1,14 @@
// -----------------------------------------------------------------------------
// CompareCommandBuilder.cs
// Sprint: SPRINT_4200_0002_0004_cli_compare
// Updated: SPRINT_20260208_029_Cli_baseline_selection_logic
// Description: CLI commands for comparing scan snapshots.
// -----------------------------------------------------------------------------
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cli.Output;
using StellaOps.Cli.Services;
using System.CommandLine;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -16,6 +18,7 @@ namespace StellaOps.Cli.Commands.Compare;
/// <summary>
/// Builds CLI commands for comparing scan snapshots.
/// Per SPRINT_4200_0002_0004.
/// Updated for baseline strategies per SPRINT_20260208_029.
/// </summary>
internal static class CompareCommandBuilder
{
@@ -34,10 +37,11 @@ internal static class CompareCommandBuilder
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var baseDigestOption = new Option<string>("--base", new[] { "-b" })
// Base can now be optional when using baseline strategies
var baseDigestOption = new Option<string?>("--base", new[] { "-b" })
{
Description = "Base snapshot digest (the 'before' state)",
Required = true
Description = "Base snapshot digest (the 'before' state). Optional when using --baseline-strategy.",
Required = false
};
var targetDigestOption = new Option<string>("--target", new[] { "-t" })
@@ -46,6 +50,22 @@ internal static class CompareCommandBuilder
Required = true
};
// SPRINT_20260208_029: Baseline strategy options
var baselineStrategyOption = new Option<string?>("--baseline-strategy")
{
Description = "Strategy for selecting baseline: 'explicit' (default, requires --base), 'last-green' (most recent passing), or 'previous-release' (previous release tag)"
};
var artifactOption = new Option<string?>("--artifact", new[] { "-a" })
{
Description = "Artifact identifier (PURL, OCI reference, or path) for baseline resolution. Required when using non-explicit strategies."
};
var currentVersionOption = new Option<string?>("--current-version")
{
Description = "Current version/tag for context (helps with previous-release strategy)"
};
var outputOption = new Option<string?>("--output", new[] { "-o" })
{
Description = "Output format (table, json, sarif)"
@@ -71,33 +91,70 @@ internal static class CompareCommandBuilder
Description = "Scanner WebService URL override"
};
var verificationReportOption = new Option<string?>("--verification-report")
{
Description = "Path to JSON output from 'stella bundle verify --output json' for compare verification overlay"
};
var reverifyBundleOption = new Option<string?>("--reverify-bundle")
{
Description = "Path to local evidence bundle directory to recompute hash/signature status inline with compare output"
};
var determinismManifestOption = new Option<string?>("--determinism-manifest")
{
Description = "Path to determinism manifest JSON used to attach determinism score context to compare output"
};
// compare diff - Full comparison
var diffCommand = new Command("diff", "Compare two scan snapshots and show detailed diff.");
diffCommand.Add(baseDigestOption);
diffCommand.Add(targetDigestOption);
diffCommand.Add(baselineStrategyOption);
diffCommand.Add(artifactOption);
diffCommand.Add(currentVersionOption);
diffCommand.Add(outputOption);
diffCommand.Add(outputFileOption);
diffCommand.Add(includeUnchangedOption);
diffCommand.Add(severityFilterOption);
diffCommand.Add(backendUrlOption);
diffCommand.Add(verificationReportOption);
diffCommand.Add(reverifyBundleOption);
diffCommand.Add(determinismManifestOption);
diffCommand.SetAction(async parseResult =>
{
var baseDigest = parseResult.GetValue(baseDigestOption)!;
var baseDigest = parseResult.GetValue(baseDigestOption);
var targetDigest = parseResult.GetValue(targetDigestOption)!;
var baselineStrategyRaw = parseResult.GetValue(baselineStrategyOption);
var artifact = parseResult.GetValue(artifactOption);
var currentVersion = parseResult.GetValue(currentVersionOption);
var output = parseResult.GetValue(outputOption) ?? "table";
var outputFile = parseResult.GetValue(outputFileOption);
var includeUnchanged = parseResult.GetValue(includeUnchangedOption);
var severity = parseResult.GetValue(severityFilterOption);
var backendUrl = parseResult.GetValue(backendUrlOption);
var verificationReportPath = parseResult.GetValue(verificationReportOption);
var reverifyBundlePath = parseResult.GetValue(reverifyBundleOption);
var determinismManifestPath = parseResult.GetValue(determinismManifestOption);
var verbose = parseResult.GetValue(verboseOption);
var renderer = services.GetService<IOutputRenderer>() ?? new OutputRenderer();
var client = services.GetService<ICompareClient>()
?? new LocalCompareClient();
var baselineResolver = services.GetService<IBaselineResolver>();
// Resolve baseline using strategy
var resolvedBase = await ResolveBaselineAsync(
baseDigest, baselineStrategyRaw, artifact, currentVersion, backendUrl, baselineResolver, verbose, cancellationToken);
if (resolvedBase is null)
{
return; // Error already printed
}
var request = new CompareRequest
{
BaseDigest = baseDigest,
BaseDigest = resolvedBase,
TargetDigest = targetDigest,
IncludeUnchanged = includeUnchanged,
SeverityFilter = severity,
@@ -105,6 +162,16 @@ internal static class CompareCommandBuilder
};
var result = await client.CompareAsync(request, cancellationToken);
var verification = await CompareVerificationOverlayBuilder.BuildAsync(
verificationReportPath,
reverifyBundlePath,
determinismManifestPath,
cancellationToken);
if (verification is not null)
{
result = result with { Verification = verification };
}
await WriteOutputAsync(result, output, outputFile, renderer, verbose);
});
@@ -113,12 +180,18 @@ internal static class CompareCommandBuilder
var summaryCommand = new Command("summary", "Show quick summary of changes between snapshots.");
summaryCommand.Add(baseDigestOption);
summaryCommand.Add(targetDigestOption);
summaryCommand.Add(baselineStrategyOption);
summaryCommand.Add(artifactOption);
summaryCommand.Add(currentVersionOption);
summaryCommand.Add(outputOption);
summaryCommand.Add(backendUrlOption);
summaryCommand.SetAction(async parseResult =>
{
var baseDigest = parseResult.GetValue(baseDigestOption)!;
var baseDigest = parseResult.GetValue(baseDigestOption);
var targetDigest = parseResult.GetValue(targetDigestOption)!;
var baselineStrategyRaw = parseResult.GetValue(baselineStrategyOption);
var artifact = parseResult.GetValue(artifactOption);
var currentVersion = parseResult.GetValue(currentVersionOption);
var output = parseResult.GetValue(outputOption) ?? "table";
var backendUrl = parseResult.GetValue(backendUrlOption);
var verbose = parseResult.GetValue(verboseOption);
@@ -126,8 +199,18 @@ internal static class CompareCommandBuilder
var renderer = services.GetService<IOutputRenderer>() ?? new OutputRenderer();
var client = services.GetService<ICompareClient>()
?? new LocalCompareClient();
var baselineResolver = services.GetService<IBaselineResolver>();
var result = await client.GetSummaryAsync(baseDigest, targetDigest, backendUrl, cancellationToken);
// Resolve baseline using strategy
var resolvedBase = await ResolveBaselineAsync(
baseDigest, baselineStrategyRaw, artifact, currentVersion, backendUrl, baselineResolver, verbose, cancellationToken);
if (resolvedBase is null)
{
return; // Error already printed
}
var result = await client.GetSummaryAsync(resolvedBase, targetDigest, backendUrl, cancellationToken);
WriteSummary(result, output, renderer, verbose);
});
@@ -136,18 +219,35 @@ internal static class CompareCommandBuilder
var canShipCommand = new Command("can-ship", "Check if target snapshot can ship relative to base.");
canShipCommand.Add(baseDigestOption);
canShipCommand.Add(targetDigestOption);
canShipCommand.Add(baselineStrategyOption);
canShipCommand.Add(artifactOption);
canShipCommand.Add(currentVersionOption);
canShipCommand.Add(backendUrlOption);
canShipCommand.SetAction(async parseResult =>
{
var baseDigest = parseResult.GetValue(baseDigestOption)!;
var baseDigest = parseResult.GetValue(baseDigestOption);
var targetDigest = parseResult.GetValue(targetDigestOption)!;
var baselineStrategyRaw = parseResult.GetValue(baselineStrategyOption);
var artifact = parseResult.GetValue(artifactOption);
var currentVersion = parseResult.GetValue(currentVersionOption);
var backendUrl = parseResult.GetValue(backendUrlOption);
var verbose = parseResult.GetValue(verboseOption);
var client = services.GetService<ICompareClient>()
?? new LocalCompareClient();
var baselineResolver = services.GetService<IBaselineResolver>();
var result = await client.GetSummaryAsync(baseDigest, targetDigest, backendUrl, cancellationToken);
// Resolve baseline using strategy
var resolvedBase = await ResolveBaselineAsync(
baseDigest, baselineStrategyRaw, artifact, currentVersion, backendUrl, baselineResolver, verbose, cancellationToken);
if (resolvedBase is null)
{
Environment.ExitCode = 1;
return;
}
var result = await client.GetSummaryAsync(resolvedBase, targetDigest, backendUrl, cancellationToken);
WriteCanShipResult(result, verbose);
@@ -161,13 +261,19 @@ internal static class CompareCommandBuilder
var vulnsCommand = new Command("vulns", "List vulnerability changes between snapshots.");
vulnsCommand.Add(baseDigestOption);
vulnsCommand.Add(targetDigestOption);
vulnsCommand.Add(baselineStrategyOption);
vulnsCommand.Add(artifactOption);
vulnsCommand.Add(currentVersionOption);
vulnsCommand.Add(outputOption);
vulnsCommand.Add(severityFilterOption);
vulnsCommand.Add(backendUrlOption);
vulnsCommand.SetAction(async parseResult =>
{
var baseDigest = parseResult.GetValue(baseDigestOption)!;
var baseDigest = parseResult.GetValue(baseDigestOption);
var targetDigest = parseResult.GetValue(targetDigestOption)!;
var baselineStrategyRaw = parseResult.GetValue(baselineStrategyOption);
var artifact = parseResult.GetValue(artifactOption);
var currentVersion = parseResult.GetValue(currentVersionOption);
var output = parseResult.GetValue(outputOption) ?? "table";
var severity = parseResult.GetValue(severityFilterOption);
var backendUrl = parseResult.GetValue(backendUrlOption);
@@ -176,10 +282,20 @@ internal static class CompareCommandBuilder
var renderer = services.GetService<IOutputRenderer>() ?? new OutputRenderer();
var client = services.GetService<ICompareClient>()
?? new LocalCompareClient();
var baselineResolver = services.GetService<IBaselineResolver>();
// Resolve baseline using strategy
var resolvedBase = await ResolveBaselineAsync(
baseDigest, baselineStrategyRaw, artifact, currentVersion, backendUrl, baselineResolver, verbose, cancellationToken);
if (resolvedBase is null)
{
return; // Error already printed
}
var request = new CompareRequest
{
BaseDigest = baseDigest,
BaseDigest = resolvedBase,
TargetDigest = targetDigest,
SeverityFilter = severity,
BackendUrl = backendUrl
@@ -263,6 +379,50 @@ internal static class CompareCommandBuilder
{
Console.WriteLine($"Policy Verdict: {result.TargetVerdict} (unchanged)");
}
if (result.Verification is null)
{
return;
}
Console.WriteLine();
Console.WriteLine($"Verification Overlay: {result.Verification.OverallStatus}");
Console.WriteLine($" Source: {result.Verification.Source}");
if (result.Verification.Determinism is not null)
{
var score = result.Verification.Determinism.OverallScore.HasValue
? result.Verification.Determinism.OverallScore.Value.ToString("0.000")
: "n/a";
var threshold = result.Verification.Determinism.Threshold.HasValue
? result.Verification.Determinism.Threshold.Value.ToString("0.000")
: "n/a";
Console.WriteLine($" Determinism: score={score}, threshold={threshold}, status={result.Verification.Determinism.Status}");
}
if (result.Verification.Artifacts.Count == 0)
{
Console.WriteLine(" Artifacts: (none)");
}
else
{
Console.WriteLine(" Artifacts:");
foreach (var artifact in result.Verification.Artifacts)
{
Console.WriteLine(
$" - {artifact.Artifact}: hash={FormatVerificationBadge(artifact.HashStatus)}; signature={FormatVerificationBadge(artifact.SignatureStatus)}");
}
}
if (verbose && result.Verification.Warnings.Count > 0)
{
Console.WriteLine(" Warnings:");
foreach (var warning in result.Verification.Warnings)
{
Console.WriteLine($" - {warning}");
}
}
}
private static void WriteSummary(CompareSummary summary, string format, IOutputRenderer renderer, bool verbose)
@@ -418,6 +578,135 @@ internal static class CompareCommandBuilder
_ => "none"
};
}
private static string FormatVerificationBadge(string state)
{
return state.ToLowerInvariant() switch
{
"pass" => "PASS",
"fail" => "FAIL",
"warning" => "WARN",
_ => "UNKNOWN"
};
}
/// <summary>
/// Resolves the baseline digest using the specified strategy.
/// SPRINT_20260208_029: Baseline selection logic.
/// </summary>
private static async Task<string?> ResolveBaselineAsync(
string? explicitBaseDigest,
string? strategyRaw,
string? artifact,
string? currentVersion,
string? backendUrl,
IBaselineResolver? resolver,
bool verbose,
CancellationToken cancellationToken)
{
// Parse strategy (default to explicit if base is provided, otherwise last-green)
var strategy = ParseStrategy(strategyRaw, explicitBaseDigest);
if (strategy == BaselineStrategy.Explicit)
{
if (string.IsNullOrWhiteSpace(explicitBaseDigest))
{
Console.Error.WriteLine("Error: --base is required when using explicit baseline strategy.");
Console.Error.WriteLine(" Use --baseline-strategy=last-green or --baseline-strategy=previous-release for automatic resolution.");
return null;
}
if (verbose)
{
Console.WriteLine($"Using explicit baseline: {explicitBaseDigest}");
}
return explicitBaseDigest;
}
// For auto-resolution strategies, we need the resolver and artifact
if (resolver is null)
{
Console.Error.WriteLine("Error: Baseline resolution service not available.");
Console.Error.WriteLine(" Use --base with an explicit digest instead.");
return null;
}
if (string.IsNullOrWhiteSpace(artifact))
{
Console.Error.WriteLine($"Error: --artifact is required when using {strategy} baseline strategy.");
return null;
}
var request = new BaselineResolutionRequest
{
ArtifactId = artifact,
Strategy = strategy,
ExplicitDigest = explicitBaseDigest,
CurrentVersion = currentVersion,
BackendUrl = backendUrl
};
if (verbose)
{
Console.WriteLine($"Resolving baseline using {strategy} strategy for artifact: {artifact}");
}
var result = await resolver.ResolveAsync(request, cancellationToken);
if (!result.Success)
{
Console.Error.WriteLine($"Error: Failed to resolve baseline - {result.Error}");
if (!string.IsNullOrWhiteSpace(result.Suggestion))
{
Console.Error.WriteLine($" Suggestion: {result.Suggestion}");
}
// Offer suggestions
var suggestions = await resolver.GetSuggestionsAsync(artifact, cancellationToken);
if (suggestions.Count > 0)
{
Console.Error.WriteLine("\nAvailable baselines:");
foreach (var suggestion in suggestions.Take(5))
{
var status = suggestion.IsPassing ? "[PASS]" : "[FAIL]";
Console.Error.WriteLine($" {status} {suggestion.Digest[..Math.Min(12, suggestion.Digest.Length)]}... - {suggestion.Description}");
}
}
return null;
}
if (verbose)
{
Console.WriteLine($"Resolved baseline: {result.Digest}");
}
return result.Digest;
}
/// <summary>
/// Parses the baseline strategy from the command line.
/// </summary>
private static BaselineStrategy ParseStrategy(string? raw, string? explicitDigest)
{
if (string.IsNullOrWhiteSpace(raw))
{
// Default: explicit if --base provided, otherwise last-green
return string.IsNullOrWhiteSpace(explicitDigest)
? BaselineStrategy.LastGreen
: BaselineStrategy.Explicit;
}
return raw.ToLowerInvariant() switch
{
"explicit" => BaselineStrategy.Explicit,
"last-green" or "lastgreen" => BaselineStrategy.LastGreen,
"previous-release" or "previousrelease" => BaselineStrategy.PreviousRelease,
_ => BaselineStrategy.Explicit
};
}
}
/// <summary>
@@ -445,6 +734,7 @@ public sealed record CompareResult
public string? BaseVerdict { get; init; }
public string? TargetVerdict { get; init; }
public required IReadOnlyList<VulnChange> Vulnerabilities { get; init; }
public CompareVerificationOverlay? Verification { get; init; }
}
/// <summary>

View File

@@ -0,0 +1,582 @@
using System.Security.Cryptography;
using System.Text.Json;
namespace StellaOps.Cli.Commands.Compare;
internal static class CompareVerificationOverlayBuilder
{
public static async Task<CompareVerificationOverlay?> BuildAsync(
string? verificationReportPath,
string? reverifyBundlePath,
string? determinismManifestPath,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(verificationReportPath)
&& string.IsNullOrWhiteSpace(reverifyBundlePath)
&& string.IsNullOrWhiteSpace(determinismManifestPath))
{
return null;
}
var source = new List<string>();
var warnings = new SortedSet<string>(StringComparer.Ordinal);
var artifacts = new Dictionary<string, ArtifactVerificationBuilder>(StringComparer.Ordinal);
var overallFromReport = "UNKNOWN";
var reverified = false;
if (!string.IsNullOrWhiteSpace(verificationReportPath))
{
source.Add("verification-report");
await ApplyVerificationReportAsync(
verificationReportPath,
artifacts,
warnings,
value => overallFromReport = value,
cancellationToken).ConfigureAwait(false);
}
if (!string.IsNullOrWhiteSpace(reverifyBundlePath))
{
source.Add("reverify-bundle");
reverified = true;
await ApplyBundleReverificationAsync(
reverifyBundlePath,
artifacts,
warnings,
cancellationToken).ConfigureAwait(false);
}
CompareDeterminismVerification? determinism = null;
if (!string.IsNullOrWhiteSpace(determinismManifestPath))
{
source.Add("determinism-manifest");
determinism = await ParseDeterminismManifestAsync(
determinismManifestPath,
warnings,
cancellationToken).ConfigureAwait(false);
}
var artifactList = artifacts.Values
.Select(builder => builder.Build())
.OrderBy(static item => item.Artifact, StringComparer.Ordinal)
.ToArray();
var aggregateStatus = ComputeOverallStatus(artifactList, warnings, determinism);
var overallStatus = MergeOverallStatus(overallFromReport, aggregateStatus);
var sourceText = source.Count == 0 ? "none" : string.Join("+", source);
return new CompareVerificationOverlay
{
Source = sourceText,
Reverified = reverified,
OverallStatus = overallStatus,
Artifacts = artifactList,
Determinism = determinism,
Warnings = warnings.ToArray()
};
}
private static async Task ApplyVerificationReportAsync(
string reportPath,
IDictionary<string, ArtifactVerificationBuilder> artifacts,
ISet<string> warnings,
Action<string> setOverallStatus,
CancellationToken cancellationToken)
{
if (!File.Exists(reportPath))
{
warnings.Add($"verification report not found: {reportPath}");
return;
}
try
{
var json = await File.ReadAllTextAsync(reportPath, cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
if (root.TryGetProperty("overallStatus", out var overallNode)
&& overallNode.ValueKind == JsonValueKind.String)
{
setOverallStatus(overallNode.GetString() ?? "UNKNOWN");
}
if (!root.TryGetProperty("checks", out var checksNode)
|| checksNode.ValueKind != JsonValueKind.Array)
{
warnings.Add("verification report missing checks array");
return;
}
foreach (var check in checksNode.EnumerateArray())
{
var name = check.TryGetProperty("name", out var nameNode) && nameNode.ValueKind == JsonValueKind.String
? nameNode.GetString() ?? string.Empty
: string.Empty;
var message = check.TryGetProperty("message", out var messageNode) && messageNode.ValueKind == JsonValueKind.String
? messageNode.GetString() ?? string.Empty
: string.Empty;
var passed = check.TryGetProperty("passed", out var passedNode) && passedNode.ValueKind == JsonValueKind.True;
var severity = check.TryGetProperty("severity", out var severityNode) && severityNode.ValueKind == JsonValueKind.String
? severityNode.GetString() ?? string.Empty
: string.Empty;
var status = ToStatus(passed, severity);
if (name.StartsWith("checksum:", StringComparison.OrdinalIgnoreCase))
{
var artifact = NormalizePath(name["checksum:".Length..]);
if (string.IsNullOrWhiteSpace(artifact))
{
continue;
}
GetArtifactBuilder(artifacts, artifact).SetHash(status, message);
continue;
}
if (name.StartsWith("dsse:", StringComparison.OrdinalIgnoreCase))
{
var dssePath = NormalizePath(name["dsse:".Length..]);
if (string.IsNullOrWhiteSpace(dssePath))
{
continue;
}
var artifact = ResolveSignatureArtifact(dssePath, artifacts.Keys);
GetArtifactBuilder(artifacts, artifact).SetSignature(status, message);
}
}
}
catch (Exception ex)
{
warnings.Add($"failed to parse verification report '{reportPath}': {ex.Message}");
}
}
private static async Task ApplyBundleReverificationAsync(
string bundlePath,
IDictionary<string, ArtifactVerificationBuilder> artifacts,
ISet<string> warnings,
CancellationToken cancellationToken)
{
if (!Directory.Exists(bundlePath))
{
warnings.Add($"reverify bundle path is not a directory: {bundlePath}");
return;
}
var manifestPath = Path.Combine(bundlePath, "manifest.json");
if (!File.Exists(manifestPath))
{
warnings.Add($"reverify bundle missing manifest.json: {bundlePath}");
return;
}
try
{
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(manifestJson);
if (!document.RootElement.TryGetProperty("bundle", out var bundleNode)
|| !bundleNode.TryGetProperty("artifacts", out var artifactsNode)
|| artifactsNode.ValueKind != JsonValueKind.Array)
{
warnings.Add("reverify bundle manifest does not contain bundle.artifacts");
return;
}
foreach (var artifactNode in artifactsNode.EnumerateArray())
{
var artifactPath = artifactNode.TryGetProperty("path", out var pathNode)
&& pathNode.ValueKind == JsonValueKind.String
? NormalizePath(pathNode.GetString() ?? string.Empty)
: string.Empty;
if (string.IsNullOrWhiteSpace(artifactPath))
{
continue;
}
var digest = artifactNode.TryGetProperty("digest", out var digestNode)
&& digestNode.ValueKind == JsonValueKind.String
? digestNode.GetString()
: null;
var builder = GetArtifactBuilder(artifacts, artifactPath);
var fullArtifactPath = Path.Combine(
bundlePath,
artifactPath.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(fullArtifactPath))
{
builder.SetHash("fail", $"artifact not found: {artifactPath}");
}
else if (string.IsNullOrWhiteSpace(digest))
{
builder.SetHash("warning", "manifest digest missing");
}
else if (!digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
builder.SetHash("warning", $"unsupported digest algorithm: {digest}");
}
else
{
var expected = digest["sha256:".Length..].Trim().ToLowerInvariant();
var actual = await ComputeSha256Async(fullArtifactPath, cancellationToken).ConfigureAwait(false);
var matches = string.Equals(actual, expected, StringComparison.OrdinalIgnoreCase);
builder.SetHash(matches ? "pass" : "fail", matches ? "hash matches" : "hash mismatch");
}
var dssePath = FindSidecarDssePath(bundlePath, artifactPath);
if (dssePath is null)
{
builder.SetSignature("warning", "dsse sidecar not found");
}
else
{
var signatureStatus = await ReadSignatureStatusAsync(dssePath, cancellationToken).ConfigureAwait(false);
builder.SetSignature(signatureStatus.Status, signatureStatus.Message);
}
}
// Ensure standalone DSSE files appear even if not tied to artifact list.
foreach (var dsseFile in Directory.GetFiles(bundlePath, "*.dsse.json", SearchOption.AllDirectories)
.OrderBy(static item => item, StringComparer.Ordinal))
{
var relativePath = NormalizePath(Path.GetRelativePath(bundlePath, dsseFile));
var artifact = ResolveSignatureArtifact(relativePath, artifacts.Keys);
var builder = GetArtifactBuilder(artifacts, artifact);
if (builder.SignatureStatus != "unknown")
{
continue;
}
var signatureStatus = await ReadSignatureStatusAsync(dsseFile, cancellationToken).ConfigureAwait(false);
builder.SetSignature(signatureStatus.Status, signatureStatus.Message);
}
}
catch (Exception ex)
{
warnings.Add($"failed to reverify bundle '{bundlePath}': {ex.Message}");
}
}
private static async Task<CompareDeterminismVerification?> ParseDeterminismManifestAsync(
string manifestPath,
ISet<string> warnings,
CancellationToken cancellationToken)
{
if (!File.Exists(manifestPath))
{
warnings.Add($"determinism manifest not found: {manifestPath}");
return null;
}
try
{
var json = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
var score = TryReadDouble(root, "overall_score", "overallScore");
var threshold = root.TryGetProperty("thresholds", out var thresholdsNode)
? TryReadDouble(thresholdsNode, "overall_min", "overallMin")
: null;
var status = "unknown";
if (score.HasValue && threshold.HasValue)
{
status = score.Value >= threshold.Value ? "pass" : "fail";
}
else if (score.HasValue)
{
status = "warning";
}
var imageCount = root.TryGetProperty("images", out var imagesNode)
&& imagesNode.ValueKind == JsonValueKind.Array
? imagesNode.GetArrayLength()
: 0;
return new CompareDeterminismVerification
{
ManifestPath = manifestPath,
OverallScore = score,
Threshold = threshold,
Status = status,
ImageCount = imageCount
};
}
catch (Exception ex)
{
warnings.Add($"failed to parse determinism manifest '{manifestPath}': {ex.Message}");
return null;
}
}
private static ArtifactVerificationBuilder GetArtifactBuilder(
IDictionary<string, ArtifactVerificationBuilder> artifacts,
string artifact)
{
if (artifacts.TryGetValue(artifact, out var existing))
{
return existing;
}
var created = new ArtifactVerificationBuilder(artifact);
artifacts[artifact] = created;
return created;
}
private static string ResolveSignatureArtifact(string dssePath, IEnumerable<string> knownArtifacts)
{
var normalized = NormalizePath(dssePath);
if (string.IsNullOrWhiteSpace(normalized))
{
return "unknown";
}
var candidate = normalized.EndsWith(".dsse.json", StringComparison.OrdinalIgnoreCase)
? normalized[..^10]
: normalized;
foreach (var artifact in knownArtifacts)
{
if (string.Equals(artifact, candidate, StringComparison.OrdinalIgnoreCase))
{
return artifact;
}
}
var candidateName = Path.GetFileNameWithoutExtension(candidate);
foreach (var artifact in knownArtifacts)
{
if (string.Equals(
Path.GetFileNameWithoutExtension(artifact),
candidateName,
StringComparison.OrdinalIgnoreCase))
{
return artifact;
}
}
return candidate;
}
private static string NormalizePath(string value)
{
return value.Replace('\\', '/').Trim();
}
private static string? FindSidecarDssePath(string bundlePath, string artifactPath)
{
var artifactFilePath = Path.Combine(bundlePath, artifactPath.Replace('/', Path.DirectorySeparatorChar));
var candidates = new[]
{
$"{artifactFilePath}.dsse.json",
Path.ChangeExtension(artifactFilePath, ".dsse.json"),
Path.Combine(Path.GetDirectoryName(artifactFilePath) ?? bundlePath, $"{Path.GetFileName(artifactFilePath)}.dsse.json")
};
return candidates.FirstOrDefault(File.Exists);
}
private static async Task<(string Status, string Message)> ReadSignatureStatusAsync(string dssePath, CancellationToken cancellationToken)
{
try
{
var json = await File.ReadAllTextAsync(dssePath, cancellationToken).ConfigureAwait(false);
using var document = JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty("signatures", out var signaturesNode)
|| signaturesNode.ValueKind != JsonValueKind.Array)
{
return ("fail", "signatures array missing");
}
return signaturesNode.GetArrayLength() > 0
? ("pass", "signature(s) present")
: ("fail", "no signatures present");
}
catch (Exception ex)
{
return ("fail", $"failed to parse dsse file: {ex.Message}");
}
}
private static async Task<string> ComputeSha256Async(string filePath, CancellationToken cancellationToken)
{
var bytes = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false);
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
}
private static string ToStatus(bool passed, string severity)
{
if (passed)
{
return "pass";
}
return string.Equals(severity, "warning", StringComparison.OrdinalIgnoreCase)
? "warning"
: "fail";
}
private static double? TryReadDouble(JsonElement node, params string[] propertyNames)
{
foreach (var propertyName in propertyNames)
{
if (!node.TryGetProperty(propertyName, out var property))
{
continue;
}
if (property.ValueKind == JsonValueKind.Number && property.TryGetDouble(out var numeric))
{
return numeric;
}
}
return null;
}
private static string ComputeOverallStatus(
IReadOnlyCollection<CompareArtifactVerification> artifacts,
IReadOnlyCollection<string> warnings,
CompareDeterminismVerification? determinism)
{
if (artifacts.Any(static a => a.HashStatus == "fail" || a.SignatureStatus == "fail")
|| string.Equals(determinism?.Status, "fail", StringComparison.OrdinalIgnoreCase))
{
return "FAILED";
}
if (artifacts.Any(static a => a.HashStatus == "warning" || a.SignatureStatus == "warning")
|| warnings.Count > 0
|| string.Equals(determinism?.Status, "warning", StringComparison.OrdinalIgnoreCase))
{
return "PASSED_WITH_WARNINGS";
}
return "PASSED";
}
private static string MergeOverallStatus(string fromReport, string computed)
{
var reportRank = Rank(fromReport);
var computedRank = Rank(computed);
return reportRank >= computedRank ? NormalizeOverall(fromReport) : NormalizeOverall(computed);
}
private static int Rank(string status)
{
return NormalizeOverall(status) switch
{
"FAILED" => 3,
"PASSED_WITH_WARNINGS" => 2,
"PASSED" => 1,
_ => 0
};
}
private static string NormalizeOverall(string status)
{
return status.Trim().ToUpperInvariant() switch
{
"FAILED" => "FAILED",
"PASSED_WITH_WARNINGS" => "PASSED_WITH_WARNINGS",
"PASSED" => "PASSED",
_ => "UNKNOWN"
};
}
private sealed class ArtifactVerificationBuilder
{
public ArtifactVerificationBuilder(string artifact)
{
Artifact = artifact;
}
public string Artifact { get; }
public string HashStatus { get; private set; } = "unknown";
public string SignatureStatus { get; private set; } = "unknown";
public string? HashMessage { get; private set; }
public string? SignatureMessage { get; private set; }
public void SetHash(string status, string? message)
{
HashStatus = PromoteStatus(HashStatus, status);
HashMessage = message;
}
public void SetSignature(string status, string? message)
{
SignatureStatus = PromoteStatus(SignatureStatus, status);
SignatureMessage = message;
}
public CompareArtifactVerification Build()
{
return new CompareArtifactVerification
{
Artifact = Artifact,
HashStatus = HashStatus,
SignatureStatus = SignatureStatus,
HashMessage = HashMessage,
SignatureMessage = SignatureMessage
};
}
private static string PromoteStatus(string current, string incoming)
{
var currentRank = StatusRank(current);
var incomingRank = StatusRank(incoming);
return incomingRank >= currentRank
? incoming.ToLowerInvariant()
: current.ToLowerInvariant();
}
private static int StatusRank(string status)
{
return status switch
{
_ when string.Equals(status, "fail", StringComparison.OrdinalIgnoreCase) => 3,
_ when string.Equals(status, "warning", StringComparison.OrdinalIgnoreCase) => 2,
_ when string.Equals(status, "pass", StringComparison.OrdinalIgnoreCase) => 1,
_ => 0
};
}
}
}
public sealed record CompareVerificationOverlay
{
public required string Source { get; init; }
public bool Reverified { get; init; }
public required string OverallStatus { get; init; }
public required IReadOnlyList<CompareArtifactVerification> Artifacts { get; init; }
public CompareDeterminismVerification? Determinism { get; init; }
public required IReadOnlyList<string> Warnings { get; init; }
}
public sealed record CompareArtifactVerification
{
public required string Artifact { get; init; }
public required string HashStatus { get; init; }
public required string SignatureStatus { get; init; }
public string? HashMessage { get; init; }
public string? SignatureMessage { get; init; }
}
public sealed record CompareDeterminismVerification
{
public required string ManifestPath { get; init; }
public double? OverallScore { get; init; }
public double? Threshold { get; init; }
public required string Status { get; init; }
public int ImageCount { get; init; }
}

View File

@@ -61,7 +61,11 @@ public static class EvidenceCommandGroup
BuildReplayCommand(verboseOption),
BuildProofCommand(verboseOption),
BuildProvenanceCommand(verboseOption),
BuildSealCommand(verboseOption)
BuildSealCommand(verboseOption),
// Sprint: SPRINT_20260208_032_Cli_oci_referrers_for_evidence_storage
EvidenceReferrerCommands.BuildPushReferrerCommand(services, verboseOption, cancellationToken),
EvidenceReferrerCommands.BuildListReferrersCommand(services, verboseOption, cancellationToken)
};
return evidence;

View File

@@ -0,0 +1,565 @@
// -----------------------------------------------------------------------------
// EvidenceReferrerCommands.cs
// Sprint: SPRINT_20260208_032_Cli_oci_referrers_for_evidence_storage
// Task: T1/T2 — push-referrer and list-referrers commands for OCI evidence storage
// -----------------------------------------------------------------------------
using System.CommandLine;
using System.CommandLine.Parsing;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Cli.Services;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Commands;
/// <summary>
/// CLI commands for pushing and listing OCI referrers for evidence storage.
/// Enables `stella evidence push-referrer` and `stella evidence list-referrers`.
/// </summary>
public static class EvidenceReferrerCommands
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <summary>
/// Builds the `evidence push-referrer` command.
/// Pushes an evidence artifact as an OCI referrer to a registry.
/// </summary>
public static Command BuildPushReferrerCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var imageOption = new Option<string>("--image")
{
Description = "OCI image reference to attach the referrer to (e.g., registry/repo@sha256:abc...).",
Required = true
};
var artifactTypeOption = new Option<string>("--artifact-type")
{
Description = "OCI artifact type for the referrer (e.g., application/vnd.stellaops.verdict.attestation.v1+json).",
Required = true
};
var fileOption = new Option<string>("--file")
{
Description = "Path to the evidence artifact file to push as a referrer.",
Required = true
};
var annotationOption = new Option<string[]?>("--annotation")
{
Description = "Key=value annotation to attach to the referrer manifest. Can be specified multiple times."
};
var offlineOption = new Option<bool>("--offline")
{
Description = "Simulate push without connecting to a registry (for offline/air-gap testing)."
};
var command = new Command("push-referrer", "Push an evidence artifact as an OCI referrer to a container registry.")
{
imageOption,
artifactTypeOption,
fileOption,
annotationOption,
offlineOption,
verboseOption
};
command.SetAction(async (parseResult, ct) =>
{
var image = parseResult.GetValue(imageOption)!;
var artifactType = parseResult.GetValue(artifactTypeOption)!;
var filePath = parseResult.GetValue(fileOption)!;
var annotations = parseResult.GetValue(annotationOption);
var offline = parseResult.GetValue(offlineOption);
var verbose = parseResult.GetValue(verboseOption);
var loggerFactory = services.GetService<ILoggerFactory>() ?? new LoggerFactory();
var logger = loggerFactory.CreateLogger(typeof(EvidenceReferrerCommands));
return await ExecutePushReferrerAsync(
services, image, artifactType, filePath, annotations, offline, verbose, logger, ct);
});
return command;
}
/// <summary>
/// Builds the `evidence list-referrers` command.
/// Lists all OCI referrers attached to an artifact digest.
/// </summary>
public static Command BuildListReferrersCommand(
IServiceProvider services,
Option<bool> verboseOption,
CancellationToken cancellationToken)
{
var imageOption = new Option<string>("--image")
{
Description = "OCI image reference to list referrers for.",
Required = true
};
var digestOption = new Option<string?>("--digest")
{
Description = "Specific digest to list referrers for. If omitted, resolves from image reference."
};
var artifactTypeOption = new Option<string?>("--artifact-type")
{
Description = "Filter referrers by artifact type."
};
var formatOption = new Option<string>("--format")
{
Description = "Output format: table (default) or json."
};
formatOption.SetDefaultValue("table");
var offlineOption = new Option<bool>("--offline")
{
Description = "Use simulated data instead of connecting to a registry."
};
var command = new Command("list-referrers", "List all OCI referrers attached to an artifact digest.")
{
imageOption,
digestOption,
artifactTypeOption,
formatOption,
offlineOption,
verboseOption
};
command.SetAction(async (parseResult, ct) =>
{
var image = parseResult.GetValue(imageOption)!;
var digest = parseResult.GetValue(digestOption);
var artifactType = parseResult.GetValue(artifactTypeOption);
var format = parseResult.GetValue(formatOption) ?? "table";
var offline = parseResult.GetValue(offlineOption);
var verbose = parseResult.GetValue(verboseOption);
var loggerFactory = services.GetService<ILoggerFactory>() ?? new LoggerFactory();
var logger = loggerFactory.CreateLogger(typeof(EvidenceReferrerCommands));
return await ExecuteListReferrersAsync(
services, image, digest, artifactType, format, offline, verbose, logger, ct);
});
return command;
}
// ── Push referrer implementation ───────────────────────────────────
internal static async Task<int> ExecutePushReferrerAsync(
IServiceProvider services,
string image,
string artifactType,
string filePath,
string[]? annotations,
bool offline,
bool verbose,
ILogger logger,
CancellationToken ct)
{
if (!File.Exists(filePath))
{
Console.Error.WriteLine($"Error: file not found: {filePath}");
return 1;
}
var fileBytes = await File.ReadAllBytesAsync(filePath, ct);
var fileDigest = ComputeSha256Digest(fileBytes);
var parsedAnnotations = ParseAnnotations(annotations);
if (verbose)
{
logger.LogInformation("Pushing referrer: image={Image}, artifactType={ArtifactType}, file={File}, digest={Digest}, size={Size}",
image, artifactType, filePath, fileDigest, fileBytes.Length);
}
if (offline)
{
return HandleOfflinePush(image, artifactType, filePath, fileDigest, fileBytes.Length, parsedAnnotations);
}
var ociClient = services.GetService<IOciRegistryClient>();
if (ociClient is null)
{
Console.Error.WriteLine("Error: OCI registry client not configured.");
return 1;
}
var reference = ParseImageReference(image);
if (reference is null)
{
Console.Error.WriteLine($"Error: could not parse image reference: {image}");
return 1;
}
try
{
// Resolve the subject digest if not provided
var subjectDigest = reference.Digest;
if (string.IsNullOrEmpty(subjectDigest))
{
subjectDigest = await ociClient.ResolveDigestAsync(reference, ct);
}
// Build the referrer manifest
var manifest = BuildReferrerManifest(artifactType, fileDigest, fileBytes.Length, subjectDigest, parsedAnnotations);
var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions);
Console.WriteLine($"Pushed referrer:");
Console.WriteLine($" Subject: {reference.Repository}@{subjectDigest}");
Console.WriteLine($" Artifact: {artifactType}");
Console.WriteLine($" Layer digest: {fileDigest}");
Console.WriteLine($" Layer size: {fileBytes.Length}");
Console.WriteLine($" Manifest: {ComputeSha256Digest(Encoding.UTF8.GetBytes(manifestJson))}");
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to push referrer to {Image}", image);
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
}
// ── List referrers implementation ──────────────────────────────────
internal static async Task<int> ExecuteListReferrersAsync(
IServiceProvider services,
string image,
string? digest,
string? artifactType,
string format,
bool offline,
bool verbose,
ILogger logger,
CancellationToken ct)
{
if (verbose)
{
logger.LogInformation("Listing referrers: image={Image}, digest={Digest}, artifactType={ArtifactType}",
image, digest, artifactType);
}
if (offline)
{
return HandleOfflineList(image, digest, artifactType, format);
}
var ociClient = services.GetService<IOciRegistryClient>();
if (ociClient is null)
{
Console.Error.WriteLine("Error: OCI registry client not configured.");
return 1;
}
var reference = ParseImageReference(image);
if (reference is null)
{
Console.Error.WriteLine($"Error: could not parse image reference: {image}");
return 1;
}
try
{
var resolvedDigest = digest ?? reference.Digest;
if (string.IsNullOrEmpty(resolvedDigest))
{
resolvedDigest = await ociClient.ResolveDigestAsync(reference, ct);
}
var referrers = await ociClient.GetReferrersAsync(
reference.Registry, reference.Repository, resolvedDigest, artifactType, ct);
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
var json = JsonSerializer.Serialize(referrers, SerializerOptions);
Console.WriteLine(json);
}
else
{
RenderReferrersTable(referrers, resolvedDigest);
}
return 0;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to list referrers for {Image}", image);
Console.Error.WriteLine($"Error: {ex.Message}");
return 1;
}
}
// ── Offline handlers ───────────────────────────────────────────────
internal static int HandleOfflinePush(
string image,
string artifactType,
string filePath,
string fileDigest,
int fileSize,
Dictionary<string, string> annotations)
{
var manifest = BuildReferrerManifest(
artifactType, fileDigest, fileSize, "sha256:offline-subject-digest", annotations);
var manifestJson = JsonSerializer.Serialize(manifest, SerializerOptions);
Console.WriteLine("[offline] Simulated push-referrer:");
Console.WriteLine($" Image: {image}");
Console.WriteLine($" Artifact: {artifactType}");
Console.WriteLine($" File: {filePath}");
Console.WriteLine($" Layer digest: {fileDigest}");
Console.WriteLine($" Layer size: {fileSize}");
Console.WriteLine($" Manifest:");
Console.WriteLine(manifestJson);
return 0;
}
internal static int HandleOfflineList(
string image,
string? digest,
string? artifactType,
string format)
{
var simulatedReferrers = new List<OciReferrerDescriptor>
{
new()
{
MediaType = "application/vnd.oci.image.manifest.v2+json",
ArtifactType = OciMediaTypes.VerdictAttestation,
Digest = "sha256:aabbccdd00112233445566778899aabbccddeeff00112233445566778899aabb",
Size = 1024,
Annotations = new Dictionary<string, string>
{
["org.opencontainers.image.created"] = "2026-02-08T12:00:00Z",
[OciAnnotations.StellaVerdictDecision] = "PASS"
}
},
new()
{
MediaType = "application/vnd.oci.image.manifest.v2+json",
ArtifactType = OciMediaTypes.SbomAttestation,
Digest = "sha256:11223344556677889900aabbccddeeff11223344556677889900aabbccddeeff",
Size = 4096,
Annotations = new Dictionary<string, string>
{
["org.opencontainers.image.created"] = "2026-02-08T12:00:01Z",
[OciAnnotations.StellaSbomDigest] = "sha256:sbom-digest-example"
}
}
};
// Apply artifact type filter
if (!string.IsNullOrEmpty(artifactType))
{
simulatedReferrers = simulatedReferrers
.Where(r => string.Equals(r.ArtifactType, artifactType, StringComparison.Ordinal))
.ToList();
}
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
{
var json = JsonSerializer.Serialize(simulatedReferrers, SerializerOptions);
Console.WriteLine(json);
}
else
{
var resolvedDigest = digest ?? "sha256:offline-subject-digest";
Console.WriteLine($"[offline] Simulated referrers for {image}:");
RenderReferrersTable(simulatedReferrers, resolvedDigest);
}
return 0;
}
// ── Helpers ─────────────────────────────────────────────────────────
internal static OciImageReference? ParseImageReference(string image)
{
// Format: registry/repository@sha256:digest or registry/repository:tag
var atIdx = image.IndexOf('@');
string? digest = null;
string? tag = null;
string registryRepo;
if (atIdx >= 0)
{
digest = image[(atIdx + 1)..];
registryRepo = image[..atIdx];
}
else
{
var colonIdx = image.LastIndexOf(':');
// Avoid splitting on port numbers (e.g., registry:5000/repo)
if (colonIdx >= 0 && image[(colonIdx + 1)..].All(c => !char.IsDigit(c) || image[(colonIdx + 1)..].Contains('/')))
{
// Simple heuristic: if after : there's no /, it's a tag
var afterColon = image[(colonIdx + 1)..];
if (!afterColon.Contains('/'))
{
tag = afterColon;
registryRepo = image[..colonIdx];
}
else
{
registryRepo = image;
}
}
else
{
registryRepo = image;
}
}
var slashIdx = registryRepo.IndexOf('/');
if (slashIdx < 0) return null;
return new OciImageReference
{
Registry = registryRepo[..slashIdx],
Repository = registryRepo[(slashIdx + 1)..],
Tag = tag,
Digest = digest,
Original = image
};
}
internal static ReferrerManifest BuildReferrerManifest(
string artifactType,
string layerDigest,
int layerSize,
string subjectDigest,
Dictionary<string, string> annotations)
{
return new ReferrerManifest
{
SchemaVersion = 2,
MediaType = "application/vnd.oci.image.manifest.v2+json",
ArtifactType = artifactType,
Config = new ManifestDescriptor
{
MediaType = "application/vnd.oci.empty.v1+json",
Digest = "sha256:44136fa355b311bfa0680e70cf7b5b35e2b5615f2e49a8e9c5c7e2f5f1b1f7d0",
Size = 2
},
Layers =
[
new ManifestDescriptor
{
MediaType = artifactType,
Digest = layerDigest,
Size = layerSize
}
],
Subject = new ManifestDescriptor
{
MediaType = "application/vnd.oci.image.manifest.v2+json",
Digest = subjectDigest,
Size = 0
},
Annotations = annotations.Count > 0 ? annotations : null
};
}
internal static Dictionary<string, string> ParseAnnotations(string[]? annotations)
{
var result = new Dictionary<string, string>();
if (annotations is null) return result;
foreach (var annotation in annotations)
{
var eqIdx = annotation.IndexOf('=');
if (eqIdx > 0)
{
result[annotation[..eqIdx]] = annotation[(eqIdx + 1)..];
}
}
return result;
}
private static string ComputeSha256Digest(byte[] data)
{
var hash = SHA256.HashData(data);
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
private static void RenderReferrersTable(IReadOnlyList<OciReferrerDescriptor> referrers, string subjectDigest)
{
Console.WriteLine($"Subject: {subjectDigest}");
Console.WriteLine($"Referrers: {referrers.Count}");
Console.WriteLine();
if (referrers.Count == 0)
{
Console.WriteLine(" (no referrers found)");
return;
}
Console.WriteLine($" {"ARTIFACT TYPE",-55} {"DIGEST",-20} {"SIZE",8}");
Console.WriteLine($" {new string('-', 55)} {new string('-', 20)} {new string('-', 8)}");
foreach (var r in referrers)
{
var shortDigest = r.Digest.Length > 19 ? r.Digest[..19] + "…" : r.Digest;
Console.WriteLine($" {r.ArtifactType ?? "(unknown)",-55} {shortDigest,-20} {r.Size,8}");
}
}
// ── Internal DTOs ──────────────────────────────────────────────────
internal sealed record ReferrerManifest
{
[JsonPropertyName("schemaVersion")]
public int SchemaVersion { get; init; }
[JsonPropertyName("mediaType")]
public string? MediaType { get; init; }
[JsonPropertyName("artifactType")]
public string? ArtifactType { get; init; }
[JsonPropertyName("config")]
public ManifestDescriptor? Config { get; init; }
[JsonPropertyName("layers")]
public List<ManifestDescriptor> Layers { get; init; } = [];
[JsonPropertyName("subject")]
public ManifestDescriptor? Subject { get; init; }
[JsonPropertyName("annotations")]
public Dictionary<string, string>? Annotations { get; init; }
}
internal sealed record ManifestDescriptor
{
[JsonPropertyName("mediaType")]
public string? MediaType { get; init; }
[JsonPropertyName("digest")]
public string Digest { get; init; } = string.Empty;
[JsonPropertyName("size")]
public long Size { get; init; }
}
}

View File

@@ -0,0 +1,85 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "stellaops/cli/unknowns-export.schema.json",
"title": "StellaOps Unknowns Export Envelope",
"type": "object",
"additionalProperties": false,
"required": [
"schemaVersion",
"exportedAt",
"itemCount",
"items"
],
"properties": {
"schemaVersion": {
"type": "string",
"description": "Version marker for forward-compatible parsing.",
"examples": ["unknowns.export.v1"]
},
"exportedAt": {
"type": "string",
"format": "date-time",
"description": "Deterministic export timestamp derived from payload contents."
},
"itemCount": {
"type": "integer",
"minimum": 0
},
"items": {
"type": "array",
"items": {
"$ref": "#/$defs/unknown"
}
}
},
"$defs": {
"unknown": {
"type": "object",
"additionalProperties": true,
"required": [
"id",
"packageId",
"packageVersion",
"band",
"score",
"reasonCode",
"reasonCodeShort",
"firstSeenAt",
"lastEvaluatedAt"
],
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"packageId": {
"type": "string"
},
"packageVersion": {
"type": "string"
},
"band": {
"type": "string",
"enum": ["hot", "warm", "cold"]
},
"score": {
"type": "number"
},
"reasonCode": {
"type": "string"
},
"reasonCodeShort": {
"type": "string"
},
"firstSeenAt": {
"type": "string",
"format": "date-time"
},
"lastEvaluatedAt": {
"type": "string",
"format": "date-time"
}
}
}
}
}

View File

@@ -23,6 +23,8 @@ namespace StellaOps.Cli.Commands;
/// </summary>
public static class UnknownsCommandGroup
{
private const string DefaultUnknownsExportSchemaVersion = "unknowns.export.v1";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
@@ -395,6 +397,13 @@ public static class UnknownsCommandGroup
Description = "Output format: json, csv, ndjson"
};
formatOption.SetDefaultValue("json");
formatOption.FromAmong("json", "csv", "ndjson");
var schemaVersionOption = new Option<string>("--schema-version")
{
Description = "Schema version to stamp into exported artifacts."
};
schemaVersionOption.SetDefaultValue(DefaultUnknownsExportSchemaVersion);
var outputOption = new Option<string?>("--output", new[] { "-o" })
{
@@ -404,6 +413,7 @@ public static class UnknownsCommandGroup
var exportCommand = new Command("export", "Export unknowns with fingerprints and triggers for offline analysis");
exportCommand.Add(bandOption);
exportCommand.Add(formatOption);
exportCommand.Add(schemaVersionOption);
exportCommand.Add(outputOption);
exportCommand.Add(verboseOption);
@@ -411,10 +421,11 @@ public static class UnknownsCommandGroup
{
var band = parseResult.GetValue(bandOption);
var format = parseResult.GetValue(formatOption) ?? "json";
var schemaVersion = parseResult.GetValue(schemaVersionOption) ?? DefaultUnknownsExportSchemaVersion;
var output = parseResult.GetValue(outputOption);
var verbose = parseResult.GetValue(verboseOption);
return await HandleExportAsync(services, band, format, output, verbose, cancellationToken);
return await HandleExportAsync(services, band, format, schemaVersion, output, verbose, cancellationToken);
});
return exportCommand;
@@ -1009,6 +1020,7 @@ public static class UnknownsCommandGroup
IServiceProvider services,
string? band,
string format,
string schemaVersion,
string? outputPath,
bool verbose,
CancellationToken ct)
@@ -1052,9 +1064,15 @@ public static class UnknownsCommandGroup
// Deterministic ordering by band priority, then score descending
var sorted = result.Items
.OrderBy(u => u.Band switch { "hot" => 0, "warm" => 1, "cold" => 2, _ => 3 })
.OrderBy(u => u.Band.ToLowerInvariant() switch { "hot" => 0, "warm" => 1, "cold" => 2, _ => 3 })
.ThenByDescending(u => u.Score)
.ThenBy(u => u.PackageId, StringComparer.Ordinal)
.ThenBy(u => u.PackageVersion, StringComparer.Ordinal)
.ThenBy(u => u.Id)
.ToList();
var exportedAt = sorted.Count == 0
? DateTimeOffset.UnixEpoch
: sorted.Max(u => u.LastEvaluatedAt).ToUniversalTime();
TextWriter writer = outputPath is not null
? new StreamWriter(outputPath)
@@ -1065,17 +1083,33 @@ public static class UnknownsCommandGroup
switch (format.ToLowerInvariant())
{
case "csv":
await WriteCsvAsync(writer, sorted);
await WriteCsvAsync(writer, sorted, schemaVersion, exportedAt);
break;
case "ndjson":
await writer.WriteLineAsync(JsonSerializer.Serialize(new
{
schemaVersion,
exportedAt,
itemCount = sorted.Count
}, JsonOptions));
foreach (var item in sorted)
{
await writer.WriteLineAsync(JsonSerializer.Serialize(item, JsonOptions));
await writer.WriteLineAsync(JsonSerializer.Serialize(new
{
schemaVersion,
item
}, JsonOptions));
}
break;
case "json":
default:
await writer.WriteLineAsync(JsonSerializer.Serialize(sorted, JsonOptions));
await writer.WriteLineAsync(JsonSerializer.Serialize(new UnknownsExportEnvelope
{
SchemaVersion = schemaVersion,
ExportedAt = exportedAt,
ItemCount = sorted.Count,
Items = sorted
}, JsonOptions));
break;
}
}
@@ -1102,8 +1136,13 @@ public static class UnknownsCommandGroup
}
}
private static async Task WriteCsvAsync(TextWriter writer, IReadOnlyList<UnknownDto> items)
private static async Task WriteCsvAsync(
TextWriter writer,
IReadOnlyList<UnknownDto> items,
string schemaVersion,
DateTimeOffset exportedAt)
{
await writer.WriteLineAsync($"# schema_version={schemaVersion}; exported_at={exportedAt:O}; item_count={items.Count}");
// CSV header
await writer.WriteLineAsync("id,package_id,package_version,band,score,reason_code,fingerprint_id,first_seen_at,last_evaluated_at");
@@ -1590,6 +1629,14 @@ public static class UnknownsCommandGroup
public int TotalCount { get; init; }
}
private sealed record UnknownsExportEnvelope
{
public string SchemaVersion { get; init; } = DefaultUnknownsExportSchemaVersion;
public DateTimeOffset ExportedAt { get; init; }
public int ItemCount { get; init; }
public IReadOnlyList<UnknownDto> Items { get; init; } = [];
}
private sealed record UnknownDto
{
public Guid Id { get; init; }