// ----------------------------------------------------------------------------- // ChainCommandGroup.cs // Sprint: SPRINT_20260106_003_004_ATTESTOR_chain_linking // Task: T026, T027, T028 // Description: CLI commands for attestation chain operations. // ----------------------------------------------------------------------------- using System.CommandLine; using System.Text.Json; using System.Text.Json.Serialization; namespace StellaOps.Cli.Commands.Chain; /// /// CLI commands for attestation chain operations. /// public static class ChainCommandGroup { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; /// /// Build the chain command tree. /// public static Command BuildChainCommand(Option verboseOption, CancellationToken cancellationToken) { var chainCommand = new Command("chain", "Attestation chain traversal and verification"); chainCommand.Add(BuildShowCommand(verboseOption, cancellationToken)); chainCommand.Add(BuildVerifyCommand(verboseOption, cancellationToken)); chainCommand.Add(BuildGraphCommand(verboseOption, cancellationToken)); chainCommand.Add(BuildLayerCommand(verboseOption, cancellationToken)); return chainCommand; } /// /// T026: Build the 'chain show' subcommand. /// Shows attestation chain from a starting attestation. /// private static Command BuildShowCommand(Option verboseOption, CancellationToken cancellationToken) { var attestationIdArg = new Argument("attestation-id") { Description = "Attestation ID (sha256:...) to show chain for" }; var directionOption = new Option("--direction", "-d") { Description = "Chain traversal direction" }; directionOption.SetDefaultValue(ChainDirection.Full); var maxDepthOption = new Option("--max-depth", "-m") { Description = "Maximum traversal depth" }; maxDepthOption.SetDefaultValue(5); var formatOption = new Option("--format", "-f") { Description = "Output format (json, table, summary)" }; formatOption.SetDefaultValue(OutputFormat.Summary); var serverOption = new Option("--server", "-s") { Description = "StellaOps server URL (uses STELLAOPS_BACKEND_URL if not specified)" }; var showCommand = new Command("show", "Show attestation chain from a starting attestation") { attestationIdArg, directionOption, maxDepthOption, formatOption, serverOption, verboseOption }; showCommand.SetAction(async (parseResult, ct) => { var attestationId = parseResult.GetValue(attestationIdArg) ?? string.Empty; var direction = parseResult.GetValue(directionOption); var maxDepth = parseResult.GetValue(maxDepthOption); var format = parseResult.GetValue(formatOption); var server = parseResult.GetValue(serverOption); var verbose = parseResult.GetValue(verboseOption); return await ExecuteShowAsync( attestationId, direction, maxDepth, format, server, verbose, cancellationToken); }); return showCommand; } /// /// T027: Build the 'chain verify' subcommand. /// Verifies the integrity of an attestation chain. /// private static Command BuildVerifyCommand(Option verboseOption, CancellationToken cancellationToken) { var attestationIdArg = new Argument("attestation-id") { Description = "Attestation ID (sha256:...) to verify chain for" }; var artifactOption = new Option("--artifact", "-a") { Description = "Artifact digest to verify chain for (alternative to attestation-id)" }; var strictOption = new Option("--strict") { Description = "Require complete chain with no missing attestations" }; var verifySignaturesOption = new Option("--verify-signatures") { Description = "Verify signatures on all attestations in chain" }; var formatOption = new Option("--format", "-f") { Description = "Output format (json, table, summary)" }; formatOption.SetDefaultValue(OutputFormat.Summary); var serverOption = new Option("--server", "-s") { Description = "StellaOps server URL (uses STELLAOPS_BACKEND_URL if not specified)" }; var verifyCommand = new Command("verify", "Verify the integrity of an attestation chain") { attestationIdArg, artifactOption, strictOption, verifySignaturesOption, formatOption, serverOption, verboseOption }; verifyCommand.SetAction(async (parseResult, ct) => { var attestationId = parseResult.GetValue(attestationIdArg) ?? string.Empty; var artifact = parseResult.GetValue(artifactOption); var strict = parseResult.GetValue(strictOption); var verifySignatures = parseResult.GetValue(verifySignaturesOption); var format = parseResult.GetValue(formatOption); var server = parseResult.GetValue(serverOption); var verbose = parseResult.GetValue(verboseOption); return await ExecuteVerifyAsync( attestationId, artifact, strict, verifySignatures, format, server, verbose, cancellationToken); }); return verifyCommand; } /// /// Build the 'chain graph' subcommand. /// Generates a graph visualization of the attestation chain. /// private static Command BuildGraphCommand(Option verboseOption, CancellationToken cancellationToken) { var attestationIdArg = new Argument("attestation-id") { Description = "Attestation ID (sha256:...) to generate graph for" }; var graphFormatOption = new Option("--graph-format", "-g") { Description = "Graph output format (mermaid, dot, json)" }; graphFormatOption.SetDefaultValue(GraphFormat.Mermaid); var maxDepthOption = new Option("--max-depth", "-m") { Description = "Maximum traversal depth" }; maxDepthOption.SetDefaultValue(5); var outputOption = new Option("--output", "-o") { Description = "Output graph to file (prints to stdout if not specified)" }; var serverOption = new Option("--server", "-s") { Description = "StellaOps server URL (uses STELLAOPS_BACKEND_URL if not specified)" }; var graphCommand = new Command("graph", "Generate a graph visualization of the attestation chain") { attestationIdArg, graphFormatOption, maxDepthOption, outputOption, serverOption, verboseOption }; graphCommand.SetAction(async (parseResult, ct) => { var attestationId = parseResult.GetValue(attestationIdArg) ?? string.Empty; var graphFormat = parseResult.GetValue(graphFormatOption); var maxDepth = parseResult.GetValue(maxDepthOption); var output = parseResult.GetValue(outputOption); var server = parseResult.GetValue(serverOption); var verbose = parseResult.GetValue(verboseOption); return await ExecuteGraphAsync( attestationId, graphFormat, maxDepth, output, server, verbose, cancellationToken); }); return graphCommand; } /// /// T028: Build the 'chain layer' subcommand. /// Operations for per-layer attestations. /// private static Command BuildLayerCommand(Option verboseOption, CancellationToken cancellationToken) { var layerCommand = new Command("layer", "Per-layer attestation operations"); layerCommand.Add(BuildLayerListCommand(verboseOption, cancellationToken)); layerCommand.Add(BuildLayerShowCommand(verboseOption, cancellationToken)); layerCommand.Add(BuildLayerCreateCommand(verboseOption, cancellationToken)); return layerCommand; } private static Command BuildLayerListCommand(Option verboseOption, CancellationToken cancellationToken) { var imageOption = new Option("--image", "-i") { Description = "OCI image reference", Required = true }; var formatOption = new Option("--format", "-f") { Description = "Output format (json, table, summary)" }; formatOption.SetDefaultValue(OutputFormat.Table); var serverOption = new Option("--server", "-s") { Description = "StellaOps server URL" }; var listCommand = new Command("list", "List per-layer attestations for an image") { imageOption, formatOption, serverOption, verboseOption }; listCommand.SetAction(async (parseResult, ct) => { var image = parseResult.GetValue(imageOption) ?? string.Empty; var format = parseResult.GetValue(formatOption); var server = parseResult.GetValue(serverOption); var verbose = parseResult.GetValue(verboseOption); return await ExecuteLayerListAsync(image, format, server, verbose, cancellationToken); }); return listCommand; } private static Command BuildLayerShowCommand(Option verboseOption, CancellationToken cancellationToken) { var imageOption = new Option("--image", "-i") { Description = "OCI image reference", Required = true }; var layerIndexOption = new Option("--layer", "-l") { Description = "Layer index (0-based)", Required = true }; var formatOption = new Option("--format", "-f") { Description = "Output format (json, summary)" }; formatOption.SetDefaultValue(OutputFormat.Summary); var serverOption = new Option("--server", "-s") { Description = "StellaOps server URL" }; var showCommand = new Command("show", "Show attestation for a specific image layer") { imageOption, layerIndexOption, formatOption, serverOption, verboseOption }; showCommand.SetAction(async (parseResult, ct) => { var image = parseResult.GetValue(imageOption) ?? string.Empty; var layerIndex = parseResult.GetValue(layerIndexOption); var format = parseResult.GetValue(formatOption); var server = parseResult.GetValue(serverOption); var verbose = parseResult.GetValue(verboseOption); return await ExecuteLayerShowAsync(image, layerIndex, format, server, verbose, cancellationToken); }); return showCommand; } private static Command BuildLayerCreateCommand(Option verboseOption, CancellationToken cancellationToken) { var imageOption = new Option("--image", "-i") { Description = "OCI image reference", Required = true }; var outputOption = new Option("--output", "-o") { Description = "Output layer attestations to file" }; var signOption = new Option("--sign") { Description = "Sign the layer attestations" }; var serverOption = new Option("--server", "-s") { Description = "StellaOps server URL" }; var createCommand = new Command("create", "Create per-layer attestations for an image") { imageOption, outputOption, signOption, serverOption, verboseOption }; createCommand.SetAction(async (parseResult, ct) => { var image = parseResult.GetValue(imageOption) ?? string.Empty; var output = parseResult.GetValue(outputOption); var sign = parseResult.GetValue(signOption); var server = parseResult.GetValue(serverOption); var verbose = parseResult.GetValue(verboseOption); return await ExecuteLayerCreateAsync(image, output, sign, server, verbose, cancellationToken); }); return createCommand; } #region Command Handlers private static async Task ExecuteShowAsync( string attestationId, ChainDirection direction, int maxDepth, OutputFormat format, string? server, bool verbose, CancellationToken ct) { try { if (string.IsNullOrWhiteSpace(attestationId)) { Console.Error.WriteLine("Error: attestation-id is required"); return 1; } if (verbose) { Console.WriteLine($"Showing attestation chain for {attestationId}"); Console.WriteLine($" Direction: {direction}"); Console.WriteLine($" Max depth: {maxDepth}"); } var backendUrl = server ?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL"); if (string.IsNullOrWhiteSpace(backendUrl)) { Console.Error.WriteLine("Error: Server URL not specified. Use --server or set STELLAOPS_BACKEND_URL"); return 1; } // Call the chain API using var httpClient = new HttpClient { BaseAddress = new Uri(backendUrl) }; var endpoint = direction switch { ChainDirection.Upstream => $"api/v1/chains/{Uri.EscapeDataString(attestationId)}/upstream?maxDepth={maxDepth}", ChainDirection.Downstream => $"api/v1/chains/{Uri.EscapeDataString(attestationId)}/downstream?maxDepth={maxDepth}", _ => $"api/v1/chains/{Uri.EscapeDataString(attestationId)}?maxDepth={maxDepth}" }; var response = await httpClient.GetAsync(endpoint, ct); if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(ct); Console.Error.WriteLine($"Error: API returned {(int)response.StatusCode} - {errorContent}"); return 2; } var json = await response.Content.ReadAsStringAsync(ct); var chainResponse = JsonSerializer.Deserialize(json, JsonOptions); if (chainResponse is null) { Console.Error.WriteLine("Error: Failed to parse chain response"); return 2; } OutputChainResult(chainResponse, format, direction); return 0; } catch (HttpRequestException ex) { Console.Error.WriteLine($"Error: Failed to connect to server - {ex.Message}"); return 2; } catch (Exception ex) { Console.Error.WriteLine($"Error: {ex.Message}"); return 2; } } private static async Task ExecuteVerifyAsync( string attestationId, string? artifact, bool strict, bool verifySignatures, OutputFormat format, string? server, bool verbose, CancellationToken ct) { try { if (string.IsNullOrWhiteSpace(attestationId) && string.IsNullOrWhiteSpace(artifact)) { Console.Error.WriteLine("Error: Either attestation-id or --artifact is required"); return 1; } if (verbose) { Console.WriteLine($"Verifying attestation chain"); if (!string.IsNullOrWhiteSpace(attestationId)) Console.WriteLine($" Attestation ID: {attestationId}"); if (!string.IsNullOrWhiteSpace(artifact)) Console.WriteLine($" Artifact: {artifact}"); Console.WriteLine($" Strict mode: {strict}"); Console.WriteLine($" Verify signatures: {verifySignatures}"); } var backendUrl = server ?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL"); if (string.IsNullOrWhiteSpace(backendUrl)) { Console.Error.WriteLine("Error: Server URL not specified. Use --server or set STELLAOPS_BACKEND_URL"); return 1; } // Call the chain API using var httpClient = new HttpClient { BaseAddress = new Uri(backendUrl) }; var targetId = attestationId; if (string.IsNullOrWhiteSpace(targetId) && !string.IsNullOrWhiteSpace(artifact)) { // Look up attestation by artifact var lookupEndpoint = $"api/v1/chains/artifact/{Uri.EscapeDataString(artifact)}"; var lookupResponse = await httpClient.GetAsync(lookupEndpoint, ct); if (!lookupResponse.IsSuccessStatusCode) { Console.Error.WriteLine($"Error: No attestations found for artifact {artifact}"); return 2; } var lookupJson = await lookupResponse.Content.ReadAsStringAsync(ct); var lookupResult = JsonSerializer.Deserialize(lookupJson, JsonOptions); targetId = lookupResult?.RootAttestationId ?? string.Empty; } var endpoint = $"api/v1/chains/{Uri.EscapeDataString(targetId)}?maxDepth=10"; var response = await httpClient.GetAsync(endpoint, ct); if (!response.IsSuccessStatusCode) { Console.Error.WriteLine($"Error: API returned {(int)response.StatusCode}"); return 2; } var json = await response.Content.ReadAsStringAsync(ct); var chainResponse = JsonSerializer.Deserialize(json, JsonOptions); if (chainResponse is null) { Console.Error.WriteLine("Error: Failed to parse chain response"); return 2; } // Verify chain integrity var verifyResult = VerifyChainIntegrity(chainResponse, strict, verifySignatures); OutputVerifyResult(verifyResult, format); return verifyResult.Valid ? 0 : 1; } catch (HttpRequestException ex) { Console.Error.WriteLine($"Error: Failed to connect to server - {ex.Message}"); return 2; } catch (Exception ex) { Console.Error.WriteLine($"Error: {ex.Message}"); return 2; } } private static async Task ExecuteGraphAsync( string attestationId, GraphFormat graphFormat, int maxDepth, string? output, string? server, bool verbose, CancellationToken ct) { try { if (string.IsNullOrWhiteSpace(attestationId)) { Console.Error.WriteLine("Error: attestation-id is required"); return 1; } if (verbose) { Console.WriteLine($"Generating {graphFormat} graph for {attestationId}"); Console.WriteLine($" Max depth: {maxDepth}"); } var backendUrl = server ?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL"); if (string.IsNullOrWhiteSpace(backendUrl)) { Console.Error.WriteLine("Error: Server URL not specified. Use --server or set STELLAOPS_BACKEND_URL"); return 1; } // Call the chain graph API using var httpClient = new HttpClient { BaseAddress = new Uri(backendUrl) }; var formatParam = graphFormat.ToString().ToLowerInvariant(); var endpoint = $"api/v1/chains/{Uri.EscapeDataString(attestationId)}/graph?format={formatParam}&maxDepth={maxDepth}"; var response = await httpClient.GetAsync(endpoint, ct); if (!response.IsSuccessStatusCode) { Console.Error.WriteLine($"Error: API returned {(int)response.StatusCode}"); return 2; } var json = await response.Content.ReadAsStringAsync(ct); var graphResponse = JsonSerializer.Deserialize(json, JsonOptions); if (graphResponse is null) { Console.Error.WriteLine("Error: Failed to parse graph response"); return 2; } var graphContent = graphResponse.Graph; if (!string.IsNullOrWhiteSpace(output)) { await File.WriteAllTextAsync(output, graphContent, ct); Console.WriteLine($"Graph written to {output}"); } else { Console.WriteLine(graphContent); } return 0; } catch (HttpRequestException ex) { Console.Error.WriteLine($"Error: Failed to connect to server - {ex.Message}"); return 2; } catch (Exception ex) { Console.Error.WriteLine($"Error: {ex.Message}"); return 2; } } private static async Task ExecuteLayerListAsync( string image, OutputFormat format, string? server, bool verbose, CancellationToken ct) { try { if (verbose) { Console.WriteLine($"Listing layer attestations for {image}"); } var backendUrl = server ?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL"); if (string.IsNullOrWhiteSpace(backendUrl)) { Console.Error.WriteLine("Error: Server URL not specified. Use --server or set STELLAOPS_BACKEND_URL"); return 1; } // Call the layer attestation API using var httpClient = new HttpClient { BaseAddress = new Uri(backendUrl) }; var endpoint = $"api/v1/attestor/layers/{Uri.EscapeDataString(image)}"; var response = await httpClient.GetAsync(endpoint, ct); if (!response.IsSuccessStatusCode) { Console.Error.WriteLine($"Error: API returned {(int)response.StatusCode}"); return 2; } var json = await response.Content.ReadAsStringAsync(ct); var layers = JsonSerializer.Deserialize(json, JsonOptions); if (layers is null) { Console.Error.WriteLine("Error: Failed to parse layer response"); return 2; } OutputLayerList(layers, format); return 0; } catch (HttpRequestException ex) { Console.Error.WriteLine($"Error: Failed to connect to server - {ex.Message}"); return 2; } catch (Exception ex) { Console.Error.WriteLine($"Error: {ex.Message}"); return 2; } } private static async Task ExecuteLayerShowAsync( string image, int layerIndex, OutputFormat format, string? server, bool verbose, CancellationToken ct) { try { if (verbose) { Console.WriteLine($"Showing layer {layerIndex} attestation for {image}"); } var backendUrl = server ?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL"); if (string.IsNullOrWhiteSpace(backendUrl)) { Console.Error.WriteLine("Error: Server URL not specified. Use --server or set STELLAOPS_BACKEND_URL"); return 1; } using var httpClient = new HttpClient { BaseAddress = new Uri(backendUrl) }; var endpoint = $"api/v1/attestor/layers/{Uri.EscapeDataString(image)}/{layerIndex}"; var response = await httpClient.GetAsync(endpoint, ct); if (!response.IsSuccessStatusCode) { Console.Error.WriteLine($"Error: API returned {(int)response.StatusCode}"); return 2; } var json = await response.Content.ReadAsStringAsync(ct); if (format == OutputFormat.Json) { Console.WriteLine(json); } else { var layer = JsonSerializer.Deserialize(json, JsonOptions); if (layer is not null) { Console.WriteLine($"Layer {layerIndex} Attestation"); Console.WriteLine(new string('=', 40)); Console.WriteLine($" Layer digest: {layer.LayerDigest}"); Console.WriteLine($" Attestation ID: {layer.AttestationId}"); Console.WriteLine($" Predicate type: {layer.PredicateType}"); Console.WriteLine($" Created: {layer.CreatedAt:yyyy-MM-dd HH:mm:ss} UTC"); } } return 0; } catch (HttpRequestException ex) { Console.Error.WriteLine($"Error: Failed to connect to server - {ex.Message}"); return 2; } catch (Exception ex) { Console.Error.WriteLine($"Error: {ex.Message}"); return 2; } } private static async Task ExecuteLayerCreateAsync( string image, string? output, bool sign, string? server, bool verbose, CancellationToken ct) { try { if (verbose) { Console.WriteLine($"Creating layer attestations for {image}"); Console.WriteLine($" Sign: {sign}"); } var backendUrl = server ?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL"); if (string.IsNullOrWhiteSpace(backendUrl)) { Console.Error.WriteLine("Error: Server URL not specified. Use --server or set STELLAOPS_BACKEND_URL"); return 1; } using var httpClient = new HttpClient { BaseAddress = new Uri(backendUrl) }; var endpoint = $"api/v1/attestor/layers/{Uri.EscapeDataString(image)}/create?sign={sign}"; var response = await httpClient.PostAsync(endpoint, null, ct); if (!response.IsSuccessStatusCode) { Console.Error.WriteLine($"Error: API returned {(int)response.StatusCode}"); return 2; } var json = await response.Content.ReadAsStringAsync(ct); if (!string.IsNullOrWhiteSpace(output)) { await File.WriteAllTextAsync(output, json, ct); Console.WriteLine($"Layer attestations written to {output}"); } else { Console.WriteLine(json); } return 0; } catch (HttpRequestException ex) { Console.Error.WriteLine($"Error: Failed to connect to server - {ex.Message}"); return 2; } catch (Exception ex) { Console.Error.WriteLine($"Error: {ex.Message}"); return 2; } } #endregion #region Output Helpers private static void OutputChainResult(ChainShowResult chain, OutputFormat format, ChainDirection direction) { if (format == OutputFormat.Json) { Console.WriteLine(JsonSerializer.Serialize(chain, JsonOptions)); return; } Console.WriteLine(); Console.WriteLine($"Attestation Chain ({direction})"); Console.WriteLine(new string('=', 50)); Console.WriteLine($" Attestation ID: {chain.AttestationId}"); Console.WriteLine($" Direction: {chain.Direction}"); Console.WriteLine($" Total nodes: {chain.Summary?.TotalNodes ?? 0}"); Console.WriteLine($" Max depth: {chain.Summary?.MaxDepth ?? 0}"); Console.WriteLine($" Complete: {chain.Summary?.IsComplete ?? false}"); Console.WriteLine(); if (chain.Nodes?.Count > 0) { if (format == OutputFormat.Table) { Console.WriteLine("DEPTH ATTESTATION ID PREDICATE TYPE LABEL"); Console.WriteLine(new string('-', 100)); foreach (var node in chain.Nodes.OrderBy(n => n.Depth)) { var shortId = node.AttestationId.Length > 40 ? node.AttestationId[..40] + "..." : node.AttestationId; Console.WriteLine($"{node.Depth,5} {shortId,-42} {node.PredicateType,-25} {node.Label ?? "-"}"); } } else { Console.WriteLine("Nodes:"); foreach (var node in chain.Nodes.OrderBy(n => n.Depth)) { var prefix = node.IsRoot ? "[ROOT]" : node.IsLeaf ? "[LEAF]" : " "; Console.WriteLine($" {prefix} {node.AttestationId}"); Console.WriteLine($" Predicate: {node.PredicateType}"); Console.WriteLine($" Depth: {node.Depth}"); if (!string.IsNullOrEmpty(node.Signer)) Console.WriteLine($" Signer: {node.Signer}"); Console.WriteLine(); } } } } private static ChainVerifyResult VerifyChainIntegrity(ChainShowResult chain, bool strict, bool verifySignatures) { var checks = new List(); var valid = true; // Check chain completeness var isComplete = chain.Summary?.IsComplete ?? false; checks.Add(new VerifyCheck( Check: "chain_complete", Status: isComplete ? "pass" : (strict ? "fail" : "warn"), Details: isComplete ? "Chain has no missing attestations" : "Chain has missing attestations")); if (strict && !isComplete) valid = false; // Check root exists var hasRoot = chain.Nodes?.Any(n => n.IsRoot) ?? false; checks.Add(new VerifyCheck( Check: "root_exists", Status: hasRoot ? "pass" : "fail", Details: hasRoot ? "Chain has a root attestation" : "No root attestation found")); if (!hasRoot) valid = false; // Check for cycles (by verifying DAG structure) var hasCycle = DetectCycle(chain); checks.Add(new VerifyCheck( Check: "no_cycles", Status: hasCycle ? "fail" : "pass", Details: hasCycle ? "Cycle detected in chain" : "Chain is a valid DAG")); if (hasCycle) valid = false; // Check link consistency var linksValid = VerifyLinkConsistency(chain); checks.Add(new VerifyCheck( Check: "links_valid", Status: linksValid ? "pass" : "fail", Details: linksValid ? "All links reference existing nodes" : "Some links reference missing nodes")); if (!linksValid) valid = false; // Signature verification (placeholder - actual impl would verify DSSE signatures) if (verifySignatures) { checks.Add(new VerifyCheck( Check: "signatures", Status: "skip", Details: "Signature verification not yet implemented in CLI")); } return new ChainVerifyResult( Valid: valid, AttestationId: chain.AttestationId, TotalNodes: chain.Summary?.TotalNodes ?? 0, MaxDepth: chain.Summary?.MaxDepth ?? 0, IsComplete: isComplete, Checks: checks); } private static bool DetectCycle(ChainShowResult chain) { // Simple cycle detection via DFS if (chain.Nodes is null || chain.Links is null) return false; var nodeIds = chain.Nodes.Select(n => n.AttestationId).ToHashSet(); var visited = new HashSet(); var inStack = new HashSet(); bool DFS(string nodeId) { if (inStack.Contains(nodeId)) return true; // Cycle found if (visited.Contains(nodeId)) return false; visited.Add(nodeId); inStack.Add(nodeId); var outgoingLinks = chain.Links .Where(l => l.SourceAttestationId == nodeId) .Select(l => l.TargetAttestationId); foreach (var target in outgoingLinks) { if (DFS(target)) return true; } inStack.Remove(nodeId); return false; } foreach (var nodeId in nodeIds) { if (DFS(nodeId)) return true; } return false; } private static bool VerifyLinkConsistency(ChainShowResult chain) { if (chain.Nodes is null || chain.Links is null) return true; var nodeIds = chain.Nodes.Select(n => n.AttestationId).ToHashSet(); foreach (var link in chain.Links) { if (!nodeIds.Contains(link.SourceAttestationId) || !nodeIds.Contains(link.TargetAttestationId)) { return false; } } return true; } private static void OutputVerifyResult(ChainVerifyResult result, OutputFormat format) { if (format == OutputFormat.Json) { Console.WriteLine(JsonSerializer.Serialize(result, JsonOptions)); return; } Console.WriteLine(); Console.WriteLine("Chain Verification Result"); Console.WriteLine(new string('=', 40)); Console.WriteLine($" Status: {(result.Valid ? "PASS" : "FAIL")}"); Console.WriteLine($" Attestation ID: {result.AttestationId}"); Console.WriteLine($" Total nodes: {result.TotalNodes}"); Console.WriteLine($" Max depth: {result.MaxDepth}"); Console.WriteLine($" Complete: {result.IsComplete}"); Console.WriteLine(); Console.WriteLine("Verification Checks:"); Console.WriteLine(new string('-', 40)); foreach (var check in result.Checks) { var statusIcon = check.Status switch { "pass" => "[PASS]", "fail" => "[FAIL]", "warn" => "[WARN]", "skip" => "[SKIP]", _ => "[????]" }; Console.WriteLine($" {statusIcon} {check.Check}: {check.Details}"); } Console.WriteLine(); } private static void OutputLayerList(LayerListResult layers, OutputFormat format) { if (format == OutputFormat.Json) { Console.WriteLine(JsonSerializer.Serialize(layers, JsonOptions)); return; } Console.WriteLine(); Console.WriteLine($"Layer Attestations for {layers.Image}"); Console.WriteLine(new string('=', 60)); Console.WriteLine($" Total layers: {layers.TotalLayers}"); Console.WriteLine($" Attested layers: {layers.AttestedLayers}"); Console.WriteLine(); if (layers.Layers?.Count > 0) { if (format == OutputFormat.Table) { Console.WriteLine("INDEX LAYER DIGEST ATTESTATION ID STATUS"); Console.WriteLine(new string('-', 110)); foreach (var layer in layers.Layers.OrderBy(l => l.Index)) { var shortDigest = layer.LayerDigest.Length > 45 ? layer.LayerDigest[..45] + "..." : layer.LayerDigest; var shortAttId = string.IsNullOrEmpty(layer.AttestationId) ? "-" : (layer.AttestationId.Length > 30 ? layer.AttestationId[..30] + "..." : layer.AttestationId); var status = string.IsNullOrEmpty(layer.AttestationId) ? "missing" : "attested"; Console.WriteLine($"{layer.Index,5} {shortDigest,-48} {shortAttId,-32} {status}"); } } else { Console.WriteLine("Layers:"); foreach (var layer in layers.Layers.OrderBy(l => l.Index)) { var status = string.IsNullOrEmpty(layer.AttestationId) ? "(not attested)" : "(attested)"; Console.WriteLine($" Layer {layer.Index}: {layer.LayerDigest} {status}"); } } } } #endregion #region DTOs public enum ChainDirection { Upstream, Downstream, Full } public enum OutputFormat { Json, Table, Summary } public enum GraphFormat { Mermaid, Dot, Json } private sealed record ChainShowResult { [JsonPropertyName("attestationId")] public string AttestationId { get; init; } = string.Empty; [JsonPropertyName("direction")] public string Direction { get; init; } = string.Empty; [JsonPropertyName("nodes")] public IReadOnlyList? Nodes { get; init; } [JsonPropertyName("links")] public IReadOnlyList? Links { get; init; } [JsonPropertyName("summary")] public ChainSummaryInfo? Summary { get; init; } } private sealed record ChainNodeInfo { [JsonPropertyName("attestationId")] public string AttestationId { get; init; } = string.Empty; [JsonPropertyName("predicateType")] public string PredicateType { get; init; } = string.Empty; [JsonPropertyName("depth")] public int Depth { get; init; } [JsonPropertyName("isRoot")] public bool IsRoot { get; init; } [JsonPropertyName("isLeaf")] public bool IsLeaf { get; init; } [JsonPropertyName("signer")] public string? Signer { get; init; } [JsonPropertyName("label")] public string? Label { get; init; } } private sealed record ChainLinkInfo { [JsonPropertyName("sourceAttestationId")] public string SourceAttestationId { get; init; } = string.Empty; [JsonPropertyName("targetAttestationId")] public string TargetAttestationId { get; init; } = string.Empty; } private sealed record ChainSummaryInfo { [JsonPropertyName("totalNodes")] public int TotalNodes { get; init; } [JsonPropertyName("maxDepth")] public int MaxDepth { get; init; } [JsonPropertyName("isComplete")] public bool IsComplete { get; init; } } private sealed record ChainVerifyResult( bool Valid, string AttestationId, int TotalNodes, int MaxDepth, bool IsComplete, IReadOnlyList Checks); private sealed record VerifyCheck( string Check, string Status, string? Details = null); private sealed record GraphResult { [JsonPropertyName("graph")] public string Graph { get; init; } = string.Empty; [JsonPropertyName("format")] public string Format { get; init; } = string.Empty; } private sealed record ArtifactLookupResult { [JsonPropertyName("rootAttestationId")] public string? RootAttestationId { get; init; } } private sealed record LayerListResult { [JsonPropertyName("image")] public string Image { get; init; } = string.Empty; [JsonPropertyName("totalLayers")] public int TotalLayers { get; init; } [JsonPropertyName("attestedLayers")] public int AttestedLayers { get; init; } [JsonPropertyName("layers")] public IReadOnlyList? Layers { get; init; } } private sealed record LayerInfo { [JsonPropertyName("index")] public int Index { get; init; } [JsonPropertyName("layerDigest")] public string LayerDigest { get; init; } = string.Empty; [JsonPropertyName("attestationId")] public string? AttestationId { get; init; } } private sealed record LayerAttestationInfo { [JsonPropertyName("layerIndex")] public int LayerIndex { get; init; } [JsonPropertyName("layerDigest")] public string LayerDigest { get; init; } = string.Empty; [JsonPropertyName("attestationId")] public string AttestationId { get; init; } = string.Empty; [JsonPropertyName("predicateType")] public string PredicateType { get; init; } = string.Empty; [JsonPropertyName("createdAt")] public DateTimeOffset CreatedAt { get; init; } } #endregion }