// ----------------------------------------------------------------------------- // ProvCommandGroup.cs // Sprint: SPRINT_8200_0001_0002 (Provcache Invalidation & Air-Gap) // Tasks: PROV-8200-135 to PROV-8200-143 - CLI commands for provcache operations. // Description: CLI commands for minimal proof export, import, and verification. // ----------------------------------------------------------------------------- using System.CommandLine; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using StellaOps.Cli.Extensions; using StellaOps.Provcache; namespace StellaOps.Cli.Commands; /// /// Command group for Provcache operations. /// Implements minimal proof export/import for air-gap scenarios. /// public static class ProvCommandGroup { /// /// Build the prov command tree. /// public static Command BuildProvCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var provCommand = new Command("prov", "Provenance cache operations for air-gap scenarios"); provCommand.Add(BuildExportCommand(services, verboseOption, cancellationToken)); provCommand.Add(BuildImportCommand(services, verboseOption, cancellationToken)); provCommand.Add(BuildVerifyCommand(services, verboseOption, cancellationToken)); return provCommand; } private static Command BuildExportCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var verikeyOption = new Option("--verikey", "-k") { Description = "The VeriKey (sha256:...) identifying the cache entry to export", Required = true }; var densityOption = new Option("--density", "-d") { Description = "Evidence density level: lite (digest only), standard (+ first N chunks), strict (all chunks)" }; densityOption.SetDefaultValue("standard"); densityOption.FromAmong("lite", "standard", "strict"); var chunksOption = new Option("--chunks", "-c") { Description = "Number of chunks to include for standard density (default: 3)" }; chunksOption.SetDefaultValue(3); var outputOption = new Option("--output", "-o") { Description = "Output file path for the bundle (default: proof-.json)", Required = true }; var signOption = new Option("--sign", "-s") { Description = "Sign the exported bundle" }; var signerOption = new Option("--signer") { Description = "Signer key ID to use (if --sign is specified)" }; var command = new Command("export", "Export a minimal proof bundle for air-gapped transfer") { verikeyOption, densityOption, chunksOption, outputOption, signOption, signerOption, verboseOption }; command.SetAction(async (parseResult, ct) => { var verikey = parseResult.GetValue(verikeyOption) ?? string.Empty; var densityStr = parseResult.GetValue(densityOption) ?? "standard"; var chunks = parseResult.GetValue(chunksOption); var output = parseResult.GetValue(outputOption) ?? string.Empty; var sign = parseResult.GetValue(signOption); var signer = parseResult.GetValue(signerOption); var verbose = parseResult.GetValue(verboseOption); return await HandleExportAsync( services, verikey, densityStr, chunks, output, sign, signer, verbose, ct); }); return command; } private static Command BuildImportCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var inputArg = new Argument("input") { Description = "Path to the proof bundle file" }; var lazyFetchOption = new Option("--lazy-fetch") { Description = "Enable lazy chunk fetching for missing chunks" }; var backendOption = new Option("--backend") { Description = "Backend URL for lazy fetch (e.g., https://stellaops.example.com)" }; var chunksDirOption = new Option("--chunks-dir") { Description = "Local directory containing chunk files for offline import" }; var outputOption = new Option("--output", "-o") { Description = "Output format: text, json" }; outputOption.SetDefaultValue("text"); outputOption.FromAmong("text", "json"); var command = new Command("import", "Import a minimal proof bundle") { inputArg, lazyFetchOption, backendOption, chunksDirOption, outputOption, verboseOption }; command.SetAction(async (parseResult, ct) => { var input = parseResult.GetValue(inputArg) ?? string.Empty; var lazyFetch = parseResult.GetValue(lazyFetchOption); var backend = parseResult.GetValue(backendOption); var chunksDir = parseResult.GetValue(chunksDirOption); var output = parseResult.GetValue(outputOption) ?? "text"; var verbose = parseResult.GetValue(verboseOption); return await HandleImportAsync( services, input, lazyFetch, backend, chunksDir, output, verbose, ct); }); return command; } private static Command BuildVerifyCommand( IServiceProvider services, Option verboseOption, CancellationToken cancellationToken) { var inputArg = new Argument("input") { Description = "Path to the proof bundle file to verify" }; var signerCertOption = new Option("--signer-cert") { Description = "Path to signer certificate for signature verification" }; var outputOption = new Option("--output", "-o") { Description = "Output format: text, json" }; outputOption.SetDefaultValue("text"); outputOption.FromAmong("text", "json"); var command = new Command("verify", "Verify a proof bundle without importing") { inputArg, signerCertOption, outputOption, verboseOption }; command.SetAction(async (parseResult, ct) => { var input = parseResult.GetValue(inputArg) ?? string.Empty; var signerCert = parseResult.GetValue(signerCertOption); var output = parseResult.GetValue(outputOption) ?? "text"; var verbose = parseResult.GetValue(verboseOption); return await HandleVerifyAsync( services, input, signerCert, output, verbose, ct); }); return command; } #region Handlers private static async Task HandleExportAsync( IServiceProvider services, string verikey, string densityStr, int chunks, string output, bool sign, string? signer, bool verbose, CancellationToken cancellationToken) { var loggerFactory = services.GetService(); var logger = loggerFactory?.CreateLogger("ProvCommands"); if (verbose) { logger?.LogInformation("Exporting proof bundle for {VeriKey} with density {Density}", verikey, densityStr); } var density = densityStr.ToLowerInvariant() switch { "lite" => ProofDensity.Lite, "standard" => ProofDensity.Standard, "strict" => ProofDensity.Strict, _ => ProofDensity.Standard }; try { var exporter = services.GetService(); if (exporter is null) { Console.Error.WriteLine("Error: Provcache services not configured."); return 1; } var options = new MinimalProofExportOptions { Density = density, StandardDensityChunkCount = chunks, Sign = sign, SigningKeyId = signer, ExportedBy = Environment.MachineName }; Console.WriteLine($"Exporting proof bundle: {verikey}"); Console.WriteLine($" Density: {density}"); Console.WriteLine($" Output: {output}"); using var fileStream = File.Create(output); await exporter.ExportToStreamAsync(verikey, options, fileStream, cancellationToken); var fileInfo = new FileInfo(output); Console.WriteLine($" Size: {fileInfo.Length:N0} bytes"); Console.WriteLine("[green]Export complete.[/]"); return 0; } catch (InvalidOperationException ex) { Console.Error.WriteLine($"Error: {ex.Message}"); return 1; } catch (Exception ex) { Console.Error.WriteLine($"Export failed: {ex.Message}"); if (verbose) { Console.Error.WriteLine(ex.ToString()); } return 1; } } private static async Task HandleImportAsync( IServiceProvider services, string input, bool lazyFetch, string? backend, string? chunksDir, string output, bool verbose, CancellationToken cancellationToken) { var loggerFactory = services.GetService(); var logger = loggerFactory?.CreateLogger("ProvCommands"); if (!File.Exists(input)) { Console.Error.WriteLine($"Error: File not found: {input}"); return 1; } if (verbose) { logger?.LogInformation("Importing proof bundle from {Input}", input); } try { var exporter = services.GetService(); if (exporter is null) { Console.Error.WriteLine("Error: Provcache services not configured."); return 1; } Console.WriteLine($"Importing proof bundle: {input}"); using var fileStream = File.OpenRead(input); var result = await exporter.ImportFromStreamAsync(fileStream, cancellationToken); if (output == "json") { var json = System.Text.Json.JsonSerializer.Serialize(result, new System.Text.Json.JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); Console.WriteLine(json); } else { Console.WriteLine($" Success: {result.Success}"); Console.WriteLine($" Chunks imported: {result.ChunksImported}"); Console.WriteLine($" Chunks pending: {result.ChunksPending}"); Console.WriteLine($" Merkle valid: {result.Verification.MerkleRootValid}"); Console.WriteLine($" Digest valid: {result.Verification.DigestValid}"); Console.WriteLine($" Chunks valid: {result.Verification.ChunksValid}"); if (result.Verification.SignatureValid.HasValue) { Console.WriteLine($" Signature valid: {result.Verification.SignatureValid.Value}"); } if (result.Warnings.Count > 0) { Console.WriteLine(" Warnings:"); foreach (var warning in result.Warnings) { Console.WriteLine($" - {warning}"); } } if (result.ChunksPending > 0 && lazyFetch) { Console.WriteLine($"\n Lazy fetch enabled: {result.ChunksPending} chunks can be fetched on demand."); if (!string.IsNullOrEmpty(backend)) { Console.WriteLine($" Backend: {backend}"); } if (!string.IsNullOrEmpty(chunksDir)) { Console.WriteLine($" Chunks dir: {chunksDir}"); } } } return result.Success ? 0 : 1; } catch (Exception ex) { Console.Error.WriteLine($"Import failed: {ex.Message}"); if (verbose) { Console.Error.WriteLine(ex.ToString()); } return 1; } } private static async Task HandleVerifyAsync( IServiceProvider services, string input, string? signerCert, string output, bool verbose, CancellationToken cancellationToken) { var loggerFactory = services.GetService(); var logger = loggerFactory?.CreateLogger("ProvCommands"); if (!File.Exists(input)) { Console.Error.WriteLine($"Error: File not found: {input}"); return 1; } if (verbose) { logger?.LogInformation("Verifying proof bundle: {Input}", input); } try { var exporter = services.GetService(); if (exporter is null) { Console.Error.WriteLine("Error: Provcache services not configured."); return 1; } Console.WriteLine($"Verifying proof bundle: {input}"); var jsonBytes = await File.ReadAllBytesAsync(input, cancellationToken); var bundle = System.Text.Json.JsonSerializer.Deserialize(jsonBytes, new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true, PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); if (bundle is null) { Console.Error.WriteLine("Error: Failed to parse bundle file."); return 1; } var verification = await exporter.VerifyAsync(bundle, cancellationToken); if (output == "json") { var json = System.Text.Json.JsonSerializer.Serialize(verification, new System.Text.Json.JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase }); Console.WriteLine(json); } else { Console.WriteLine($" Digest valid: {verification.DigestValid}"); Console.WriteLine($" Merkle root valid: {verification.MerkleRootValid}"); Console.WriteLine($" Chunks valid: {verification.ChunksValid}"); if (verification.SignatureValid.HasValue) { Console.WriteLine($" Signature valid: {verification.SignatureValid.Value}"); } if (verification.FailedChunkIndices.Count > 0) { Console.WriteLine($" Failed chunks: {string.Join(", ", verification.FailedChunkIndices)}"); } var overall = verification.DigestValid && verification.MerkleRootValid && verification.ChunksValid && (verification.SignatureValid ?? true); Console.WriteLine(); if (overall) { Console.WriteLine("[green]Verification PASSED[/]"); } else { Console.WriteLine("[red]Verification FAILED[/]"); } } var success = verification.DigestValid && verification.MerkleRootValid && verification.ChunksValid; return success ? 0 : 1; } catch (Exception ex) { Console.Error.WriteLine($"Verification failed: {ex.Message}"); if (verbose) { Console.Error.WriteLine(ex.ToString()); } return 1; } } #endregion }