Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Commands/ProvCommandGroup.cs
2025-12-25 19:52:30 +02:00

512 lines
17 KiB
C#

// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// Command group for Provcache operations.
/// Implements minimal proof export/import for air-gap scenarios.
/// </summary>
public static class ProvCommandGroup
{
/// <summary>
/// Build the prov command tree.
/// </summary>
public static Command BuildProvCommand(
IServiceProvider services,
Option<bool> 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<bool> verboseOption,
CancellationToken cancellationToken)
{
var verikeyOption = new Option<string>("--verikey", "-k")
{
Description = "The VeriKey (sha256:...) identifying the cache entry to export",
Required = true
};
var densityOption = new Option<string>("--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<int>("--chunks", "-c")
{
Description = "Number of chunks to include for standard density (default: 3)"
};
chunksOption.SetDefaultValue(3);
var outputOption = new Option<string>("--output", "-o")
{
Description = "Output file path for the bundle (default: proof-<verikey>.json)",
Required = true
};
var signOption = new Option<bool>("--sign", "-s")
{
Description = "Sign the exported bundle"
};
var signerOption = new Option<string?>("--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<bool> verboseOption,
CancellationToken cancellationToken)
{
var inputArg = new Argument<string>("input")
{
Description = "Path to the proof bundle file"
};
var lazyFetchOption = new Option<bool>("--lazy-fetch")
{
Description = "Enable lazy chunk fetching for missing chunks"
};
var backendOption = new Option<string?>("--backend")
{
Description = "Backend URL for lazy fetch (e.g., https://stellaops.example.com)"
};
var chunksDirOption = new Option<string?>("--chunks-dir")
{
Description = "Local directory containing chunk files for offline import"
};
var outputOption = new Option<string>("--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<bool> verboseOption,
CancellationToken cancellationToken)
{
var inputArg = new Argument<string>("input")
{
Description = "Path to the proof bundle file to verify"
};
var signerCertOption = new Option<string?>("--signer-cert")
{
Description = "Path to signer certificate for signature verification"
};
var outputOption = new Option<string>("--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<int> HandleExportAsync(
IServiceProvider services,
string verikey,
string densityStr,
int chunks,
string output,
bool sign,
string? signer,
bool verbose,
CancellationToken cancellationToken)
{
var loggerFactory = services.GetService<ILoggerFactory>();
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<IMinimalProofExporter>();
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<int> HandleImportAsync(
IServiceProvider services,
string input,
bool lazyFetch,
string? backend,
string? chunksDir,
string output,
bool verbose,
CancellationToken cancellationToken)
{
var loggerFactory = services.GetService<ILoggerFactory>();
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<IMinimalProofExporter>();
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<int> HandleVerifyAsync(
IServiceProvider services,
string input,
string? signerCert,
string output,
bool verbose,
CancellationToken cancellationToken)
{
var loggerFactory = services.GetService<ILoggerFactory>();
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<IMinimalProofExporter>();
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<MinimalProofBundle>(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
}