Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/Chain/ChainCommandGroup.cs
2026-01-07 09:43:12 +02:00

1219 lines
42 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// CLI commands for attestation chain operations.
/// </summary>
public static class ChainCommandGroup
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// Build the chain command tree.
/// </summary>
public static Command BuildChainCommand(Option<bool> 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;
}
/// <summary>
/// T026: Build the 'chain show' subcommand.
/// Shows attestation chain from a starting attestation.
/// </summary>
private static Command BuildShowCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
{
var attestationIdArg = new Argument<string>("attestation-id")
{
Description = "Attestation ID (sha256:...) to show chain for"
};
var directionOption = new Option<ChainDirection>("--direction", "-d")
{
Description = "Chain traversal direction"
};
directionOption.SetDefaultValue(ChainDirection.Full);
var maxDepthOption = new Option<int>("--max-depth", "-m")
{
Description = "Maximum traversal depth"
};
maxDepthOption.SetDefaultValue(5);
var formatOption = new Option<OutputFormat>("--format", "-f")
{
Description = "Output format (json, table, summary)"
};
formatOption.SetDefaultValue(OutputFormat.Summary);
var serverOption = new Option<string?>("--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;
}
/// <summary>
/// T027: Build the 'chain verify' subcommand.
/// Verifies the integrity of an attestation chain.
/// </summary>
private static Command BuildVerifyCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
{
var attestationIdArg = new Argument<string>("attestation-id")
{
Description = "Attestation ID (sha256:...) to verify chain for"
};
var artifactOption = new Option<string?>("--artifact", "-a")
{
Description = "Artifact digest to verify chain for (alternative to attestation-id)"
};
var strictOption = new Option<bool>("--strict")
{
Description = "Require complete chain with no missing attestations"
};
var verifySignaturesOption = new Option<bool>("--verify-signatures")
{
Description = "Verify signatures on all attestations in chain"
};
var formatOption = new Option<OutputFormat>("--format", "-f")
{
Description = "Output format (json, table, summary)"
};
formatOption.SetDefaultValue(OutputFormat.Summary);
var serverOption = new Option<string?>("--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;
}
/// <summary>
/// Build the 'chain graph' subcommand.
/// Generates a graph visualization of the attestation chain.
/// </summary>
private static Command BuildGraphCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
{
var attestationIdArg = new Argument<string>("attestation-id")
{
Description = "Attestation ID (sha256:...) to generate graph for"
};
var graphFormatOption = new Option<GraphFormat>("--graph-format", "-g")
{
Description = "Graph output format (mermaid, dot, json)"
};
graphFormatOption.SetDefaultValue(GraphFormat.Mermaid);
var maxDepthOption = new Option<int>("--max-depth", "-m")
{
Description = "Maximum traversal depth"
};
maxDepthOption.SetDefaultValue(5);
var outputOption = new Option<string?>("--output", "-o")
{
Description = "Output graph to file (prints to stdout if not specified)"
};
var serverOption = new Option<string?>("--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;
}
/// <summary>
/// T028: Build the 'chain layer' subcommand.
/// Operations for per-layer attestations.
/// </summary>
private static Command BuildLayerCommand(Option<bool> 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<bool> verboseOption, CancellationToken cancellationToken)
{
var imageOption = new Option<string>("--image", "-i")
{
Description = "OCI image reference",
Required = true
};
var formatOption = new Option<OutputFormat>("--format", "-f")
{
Description = "Output format (json, table, summary)"
};
formatOption.SetDefaultValue(OutputFormat.Table);
var serverOption = new Option<string?>("--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<bool> verboseOption, CancellationToken cancellationToken)
{
var imageOption = new Option<string>("--image", "-i")
{
Description = "OCI image reference",
Required = true
};
var layerIndexOption = new Option<int>("--layer", "-l")
{
Description = "Layer index (0-based)",
Required = true
};
var formatOption = new Option<OutputFormat>("--format", "-f")
{
Description = "Output format (json, summary)"
};
formatOption.SetDefaultValue(OutputFormat.Summary);
var serverOption = new Option<string?>("--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<bool> verboseOption, CancellationToken cancellationToken)
{
var imageOption = new Option<string>("--image", "-i")
{
Description = "OCI image reference",
Required = true
};
var outputOption = new Option<string?>("--output", "-o")
{
Description = "Output layer attestations to file"
};
var signOption = new Option<bool>("--sign")
{
Description = "Sign the layer attestations"
};
var serverOption = new Option<string?>("--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<int> 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<ChainShowResult>(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<int> 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<ArtifactLookupResult>(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<ChainShowResult>(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<int> 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<GraphResult>(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<int> 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<LayerListResult>(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<int> 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<LayerAttestationInfo>(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<int> 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<VerifyCheck>();
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<string>();
var inStack = new HashSet<string>();
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<ChainNodeInfo>? Nodes { get; init; }
[JsonPropertyName("links")]
public IReadOnlyList<ChainLinkInfo>? 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<VerifyCheck> 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<LayerInfo>? 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
}