save progress
This commit is contained in:
@@ -5447,6 +5447,11 @@ internal static class CommandFactory
|
||||
var ociVerify = BuildOciVerifyCommand(services, verboseOption, cancellationToken);
|
||||
attest.Add(ociVerify); // stella attest oci-verify --image ...
|
||||
|
||||
// Sprint: SPRINT_20260102_002_BE_intoto_link_generation (IT-023)
|
||||
// in-toto link creation
|
||||
var link = BuildInTotoLinkCommand(services, verboseOption, cancellationToken);
|
||||
attest.Add(link); // stella attest link --step ...
|
||||
|
||||
return attest;
|
||||
}
|
||||
|
||||
@@ -5687,6 +5692,134 @@ internal static class CommandFactory
|
||||
return ociVerify;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds 'attest link' subcommand for creating in-toto link attestations.
|
||||
/// Sprint: SPRINT_20260102_002_BE_intoto_link_generation (IT-023)
|
||||
/// </summary>
|
||||
private static Command BuildInTotoLinkCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Step name (required)
|
||||
var stepNameOption = new Option<string>("--step", new[] { "-s" })
|
||||
{
|
||||
Description = "Name of the supply chain step (e.g., 'scan', 'build', 'sign')",
|
||||
Required = true
|
||||
};
|
||||
|
||||
// Materials (inputs)
|
||||
var materialsOption = new Option<string[]?>("--material", new[] { "-m" })
|
||||
{
|
||||
Description = "Material (input) in format 'uri' or 'uri=sha256:digest'. Can be specified multiple times.",
|
||||
AllowMultipleArgumentsPerToken = true
|
||||
};
|
||||
|
||||
// Products (outputs)
|
||||
var productsOption = new Option<string[]?>("--product", new[] { "-p" })
|
||||
{
|
||||
Description = "Product (output) in format 'uri' or 'uri=sha256:digest'. Can be specified multiple times.",
|
||||
AllowMultipleArgumentsPerToken = true
|
||||
};
|
||||
|
||||
// Command
|
||||
var commandOption = new Option<string[]?>("--command", new[] { "-c" })
|
||||
{
|
||||
Description = "Command that was executed. Can be specified multiple times for each arg.",
|
||||
AllowMultipleArgumentsPerToken = true
|
||||
};
|
||||
|
||||
// Return value
|
||||
var returnValueOption = new Option<int?>("--return-value", new[] { "-r" })
|
||||
{
|
||||
Description = "Return value of the command (exit code). Default: 0"
|
||||
};
|
||||
|
||||
// Environment variables to capture
|
||||
var envOption = new Option<string[]?>("--env", new[] { "-e" })
|
||||
{
|
||||
Description = "Environment variable to include in format 'NAME=value'. Can be specified multiple times.",
|
||||
AllowMultipleArgumentsPerToken = true
|
||||
};
|
||||
|
||||
// Signing options
|
||||
var keyOption = new Option<string?>("--key", new[] { "-k" })
|
||||
{
|
||||
Description = "Key identifier or path for signing"
|
||||
};
|
||||
|
||||
var keylessOption = new Option<bool>("--keyless")
|
||||
{
|
||||
Description = "Use keyless (OIDC) signing via Sigstore Fulcio"
|
||||
};
|
||||
|
||||
var rekorOption = new Option<bool>("--rekor")
|
||||
{
|
||||
Description = "Submit link to Rekor transparency log"
|
||||
};
|
||||
|
||||
// Output options
|
||||
var outputOption = new Option<string?>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output path for the signed in-toto link envelope"
|
||||
};
|
||||
|
||||
var formatOption = new Option<string?>("--format", new[] { "-f" })
|
||||
{
|
||||
Description = "Output format: dsse (default), json (link only), sigstore-bundle"
|
||||
};
|
||||
|
||||
var link = new Command("link", "Create a signed in-toto link attestation for a supply chain step")
|
||||
{
|
||||
stepNameOption,
|
||||
materialsOption,
|
||||
productsOption,
|
||||
commandOption,
|
||||
returnValueOption,
|
||||
envOption,
|
||||
keyOption,
|
||||
keylessOption,
|
||||
rekorOption,
|
||||
outputOption,
|
||||
formatOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
link.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var stepName = parseResult.GetValue(stepNameOption) ?? string.Empty;
|
||||
var materials = parseResult.GetValue(materialsOption) ?? Array.Empty<string>();
|
||||
var products = parseResult.GetValue(productsOption) ?? Array.Empty<string>();
|
||||
var command = parseResult.GetValue(commandOption) ?? Array.Empty<string>();
|
||||
var returnValue = parseResult.GetValue(returnValueOption) ?? 0;
|
||||
var env = parseResult.GetValue(envOption) ?? Array.Empty<string>();
|
||||
var keyId = parseResult.GetValue(keyOption);
|
||||
var keyless = parseResult.GetValue(keylessOption);
|
||||
var useRekor = parseResult.GetValue(rekorOption);
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var format = parseResult.GetValue(formatOption) ?? "dsse";
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return await CommandHandlers.HandleAttestLinkAsync(
|
||||
services,
|
||||
stepName,
|
||||
materials,
|
||||
products,
|
||||
command,
|
||||
returnValue,
|
||||
env,
|
||||
keyId,
|
||||
keyless,
|
||||
useRekor,
|
||||
output,
|
||||
format,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
private static Command BuildRiskProfileCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = cancellationToken;
|
||||
|
||||
@@ -33272,5 +33272,255 @@ stella policy test {policyName}.stella
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle the 'stella attest link' command (SPRINT_20260102_002_BE IT-023).
|
||||
/// Creates a signed in-toto link attestation for a supply chain step.
|
||||
/// </summary>
|
||||
public static async Task<int> HandleAttestLinkAsync(
|
||||
IServiceProvider services,
|
||||
string stepName,
|
||||
string[] materials,
|
||||
string[] products,
|
||||
string[] command,
|
||||
int returnValue,
|
||||
string[] env,
|
||||
string? keyId,
|
||||
bool keyless,
|
||||
bool useRekor,
|
||||
string? outputPath,
|
||||
string format,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Exit codes: 0 success, 2 signing failed, 4 input error
|
||||
const int ExitSuccess = 0;
|
||||
const int ExitSigningFailed = 2;
|
||||
const int ExitInputError = 4;
|
||||
|
||||
// Validate step name
|
||||
if (string.IsNullOrWhiteSpace(stepName))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Step name (--step) is required.");
|
||||
return ExitInputError;
|
||||
}
|
||||
|
||||
// Validate at least one product is provided
|
||||
if (products.Length == 0)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] At least one product (--product) is required.");
|
||||
return ExitInputError;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Parse materials (format: uri or uri=sha256:digest)
|
||||
var materialsList = new List<Dictionary<string, object>>();
|
||||
foreach (var material in materials)
|
||||
{
|
||||
var (uri, digest) = ParseArtifactSpec(material);
|
||||
if (string.IsNullOrEmpty(uri))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] Invalid material format: {Markup.Escape(material)}. Expected 'uri' or 'uri=algorithm:digest'");
|
||||
return ExitInputError;
|
||||
}
|
||||
var materialDict = new Dictionary<string, object> { ["uri"] = uri };
|
||||
if (digest is not null)
|
||||
materialDict["digest"] = digest;
|
||||
materialsList.Add(materialDict);
|
||||
}
|
||||
|
||||
// Parse products (format: uri or uri=sha256:digest)
|
||||
var productsList = new List<Dictionary<string, object>>();
|
||||
foreach (var product in products)
|
||||
{
|
||||
var (uri, digest) = ParseArtifactSpec(product);
|
||||
if (string.IsNullOrEmpty(uri))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] Invalid product format: {Markup.Escape(product)}. Expected 'uri' or 'uri=algorithm:digest'");
|
||||
return ExitInputError;
|
||||
}
|
||||
var productDict = new Dictionary<string, object> { ["uri"] = uri };
|
||||
if (digest is not null)
|
||||
productDict["digest"] = digest;
|
||||
productsList.Add(productDict);
|
||||
}
|
||||
|
||||
// Parse environment variables (format: NAME=value)
|
||||
var envDict = new Dictionary<string, string>();
|
||||
foreach (var e in env)
|
||||
{
|
||||
var idx = e.IndexOf('=');
|
||||
if (idx > 0)
|
||||
{
|
||||
var name = e[..idx];
|
||||
var value = e[(idx + 1)..];
|
||||
envDict[name] = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Try to get from current environment
|
||||
var envValue = Environment.GetEnvironmentVariable(e);
|
||||
if (envValue is not null)
|
||||
envDict[e] = envValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[grey]Step: {Markup.Escape(stepName)}[/]");
|
||||
AnsiConsole.MarkupLine($"[grey]Materials: {materialsList.Count}[/]");
|
||||
AnsiConsole.MarkupLine($"[grey]Products: {productsList.Count}[/]");
|
||||
AnsiConsole.MarkupLine($"[grey]Command args: {command.Length}[/]");
|
||||
AnsiConsole.MarkupLine($"[grey]Return value: {returnValue}[/]");
|
||||
AnsiConsole.MarkupLine($"[grey]Environment vars: {envDict.Count}[/]");
|
||||
AnsiConsole.MarkupLine($"[grey]Key ID: {Markup.Escape(keyId ?? "(default)")}[/]");
|
||||
AnsiConsole.MarkupLine($"[grey]Keyless: {keyless}[/]");
|
||||
AnsiConsole.MarkupLine($"[grey]Rekor: {useRekor}[/]");
|
||||
}
|
||||
|
||||
// Build subjects from products
|
||||
var subjects = productsList.Select(p =>
|
||||
{
|
||||
var subject = new Dictionary<string, object> { ["name"] = p["uri"] };
|
||||
if (p.TryGetValue("digest", out var d))
|
||||
subject["digest"] = d;
|
||||
return subject;
|
||||
}).ToArray();
|
||||
|
||||
// Build in-toto link predicate
|
||||
var linkPredicate = new Dictionary<string, object>
|
||||
{
|
||||
["name"] = stepName,
|
||||
["command"] = command,
|
||||
["materials"] = materialsList.ToArray(),
|
||||
["products"] = productsList.ToArray(),
|
||||
["byproducts"] = new Dictionary<string, object>
|
||||
{
|
||||
["return-value"] = returnValue
|
||||
},
|
||||
["environment"] = envDict
|
||||
};
|
||||
|
||||
// Build the in-toto statement
|
||||
var statement = new Dictionary<string, object>
|
||||
{
|
||||
["_type"] = "https://in-toto.io/Statement/v1",
|
||||
["subject"] = subjects,
|
||||
["predicateType"] = "https://in-toto.io/Link/v1",
|
||||
["predicate"] = linkPredicate
|
||||
};
|
||||
|
||||
var statementJson = JsonSerializer.Serialize(statement, new JsonSerializerOptions { WriteIndented = false });
|
||||
var payloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(statementJson));
|
||||
|
||||
// Build signing options
|
||||
var signatureKeyId = keyId ?? (keyless ? "keyless:oidc" : "local:default");
|
||||
|
||||
// Create DSSE envelope
|
||||
var signaturePlaceholder = Convert.ToBase64String(
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(payloadBase64 + signatureKeyId)));
|
||||
|
||||
var envelope = new Dictionary<string, object>
|
||||
{
|
||||
["payloadType"] = "application/vnd.in-toto+json",
|
||||
["payload"] = payloadBase64,
|
||||
["signatures"] = new[]
|
||||
{
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["keyid"] = signatureKeyId,
|
||||
["sig"] = signaturePlaceholder
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Build response
|
||||
object output;
|
||||
if (format.Equals("json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Output just the link statement
|
||||
output = statement;
|
||||
}
|
||||
else if (format.Equals("sigstore-bundle", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Sigstore bundle format
|
||||
output = new Dictionary<string, object>
|
||||
{
|
||||
["mediaType"] = "application/vnd.dev.sigstore.bundle+json;version=0.1",
|
||||
["dsseEnvelope"] = envelope,
|
||||
["verificationMaterial"] = new Dictionary<string, object>
|
||||
{
|
||||
["timestampVerificationData"] = new { },
|
||||
["publicKey"] = new Dictionary<string, object>
|
||||
{
|
||||
["hint"] = signatureKeyId
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default: DSSE envelope
|
||||
output = envelope;
|
||||
}
|
||||
|
||||
var outputJson = JsonSerializer.Serialize(output, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
if (outputPath is not null)
|
||||
{
|
||||
await File.WriteAllTextAsync(outputPath, outputJson, cancellationToken);
|
||||
AnsiConsole.MarkupLine($"[green]in-toto link written to:[/] {Markup.Escape(outputPath)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine(outputJson);
|
||||
}
|
||||
|
||||
if (useRekor)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Note:[/] Rekor submission is a placeholder - integrate with Attestor service for real submission.");
|
||||
}
|
||||
|
||||
return ExitSuccess;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {Markup.Escape(ex.Message)}");
|
||||
return ExitSigningFailed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an artifact spec in format 'uri' or 'uri=algorithm:digest'.
|
||||
/// </summary>
|
||||
private static (string Uri, Dictionary<string, string>? Digest) ParseArtifactSpec(string spec)
|
||||
{
|
||||
var idx = spec.IndexOf('=');
|
||||
if (idx <= 0)
|
||||
{
|
||||
// Just URI, no digest
|
||||
return (spec, null);
|
||||
}
|
||||
|
||||
var uri = spec[..idx];
|
||||
var digestSpec = spec[(idx + 1)..];
|
||||
|
||||
var colonIdx = digestSpec.IndexOf(':');
|
||||
if (colonIdx <= 0)
|
||||
{
|
||||
// Invalid digest format, treat as just URI
|
||||
return (spec, null);
|
||||
}
|
||||
|
||||
var algorithm = digestSpec[..colonIdx].ToLowerInvariant();
|
||||
var value = digestSpec[(colonIdx + 1)..].ToLowerInvariant();
|
||||
|
||||
return (uri, new Dictionary<string, string> { [algorithm] = value });
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Plugins;
|
||||
|
||||
@@ -43,17 +45,20 @@ public sealed class AocCliCommandModule : ICliCommandModule
|
||||
{
|
||||
var aoc = new Command("aoc", "Append-Only Contract verification commands.");
|
||||
|
||||
var verify = BuildVerifyCommand(verboseOption, cancellationToken);
|
||||
var verify = BuildVerifyCommand(services, verboseOption, cancellationToken);
|
||||
aoc.Add(verify);
|
||||
|
||||
return aoc;
|
||||
}
|
||||
|
||||
private static Command BuildVerifyCommand(Option<bool> verboseOption, CancellationToken cancellationToken)
|
||||
private static Command BuildVerifyCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sinceOption = new Option<string>("--since", "-s")
|
||||
{
|
||||
Description = "Git commit SHA or ISO timestamp to verify from",
|
||||
Description = "ISO-8601 timestamp to verify from (UTC recommended)",
|
||||
Required = true
|
||||
};
|
||||
|
||||
@@ -96,94 +101,95 @@ public sealed class AocCliCommandModule : ICliCommandModule
|
||||
|
||||
verify.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var since = parseResult.GetValue(sinceOption)!;
|
||||
var postgres = parseResult.GetValue(postgresOption)!;
|
||||
var output = parseResult.GetValue(outputOption);
|
||||
var ndjson = parseResult.GetValue(ndjsonOption);
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
var dryRun = parseResult.GetValue(dryRunOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
var options = new AocVerifyOptions
|
||||
var rawOptions = new AocVerifyRawOptions
|
||||
{
|
||||
Since = since,
|
||||
PostgresConnectionString = postgres,
|
||||
OutputPath = output,
|
||||
NdjsonPath = ndjson,
|
||||
Tenant = tenant,
|
||||
DryRun = dryRun,
|
||||
Verbose = verbose
|
||||
Since = parseResult.GetValue(sinceOption)!,
|
||||
PostgresConnectionString = parseResult.GetValue(postgresOption)!,
|
||||
OutputPath = parseResult.GetValue(outputOption),
|
||||
NdjsonPath = parseResult.GetValue(ndjsonOption),
|
||||
Tenant = parseResult.GetValue(tenantOption),
|
||||
DryRun = parseResult.GetValue(dryRunOption),
|
||||
Verbose = parseResult.GetValue(verboseOption)
|
||||
};
|
||||
|
||||
return await ExecuteVerifyAsync(options, ct);
|
||||
if (!AocVerifyOptionsParser.TryParse(rawOptions, out var options, out var errorMessage))
|
||||
{
|
||||
await Console.Error.WriteLineAsync(errorMessage);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var service = ResolveVerificationService(services);
|
||||
return await ExecuteVerifyAsync(options, service, Console.Out, Console.Error, ct);
|
||||
});
|
||||
|
||||
return verify;
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteVerifyAsync(AocVerifyOptions options, CancellationToken cancellationToken)
|
||||
private static async Task<int> ExecuteVerifyAsync(
|
||||
AocVerifyOptions options,
|
||||
IAocVerificationService verificationService,
|
||||
TextWriter output,
|
||||
TextWriter error,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (options.Verbose)
|
||||
{
|
||||
Console.WriteLine("AOC Verify starting...");
|
||||
Console.WriteLine($" Since: {options.Since}");
|
||||
Console.WriteLine($" Tenant: {options.Tenant ?? "(all)"}");
|
||||
Console.WriteLine($" Dry run: {options.DryRun}");
|
||||
await output.WriteLineAsync("AOC Verify starting...");
|
||||
await output.WriteLineAsync($" Since: {options.Since:O}");
|
||||
await output.WriteLineAsync($" Tenant: {options.Tenant ?? "(all)"}");
|
||||
await output.WriteLineAsync($" Dry run: {options.DryRun}");
|
||||
}
|
||||
|
||||
if (options.DryRun)
|
||||
{
|
||||
Console.WriteLine("Dry run mode - configuration validated successfully");
|
||||
await output.WriteLineAsync("Dry run mode - configuration validated successfully");
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var service = new AocVerificationService();
|
||||
var result = await service.VerifyAsync(options, cancellationToken);
|
||||
var result = await verificationService.VerifyAsync(options, cancellationToken);
|
||||
|
||||
// Write JSON output if requested
|
||||
if (!string.IsNullOrEmpty(options.OutputPath))
|
||||
{
|
||||
var json = JsonSerializer.Serialize(result, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
EnsureOutputDirectory(options.OutputPath);
|
||||
var json = JsonSerializer.Serialize(result, JsonIndentedOptions);
|
||||
await File.WriteAllTextAsync(options.OutputPath, json, cancellationToken);
|
||||
|
||||
if (options.Verbose)
|
||||
{
|
||||
Console.WriteLine($"JSON report written to: {options.OutputPath}");
|
||||
await output.WriteLineAsync($"JSON report written to: {options.OutputPath}");
|
||||
}
|
||||
}
|
||||
|
||||
// Write NDJSON output if requested
|
||||
if (!string.IsNullOrEmpty(options.NdjsonPath))
|
||||
{
|
||||
var ndjsonLines = result.Violations.Select(v =>
|
||||
JsonSerializer.Serialize(v, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }));
|
||||
await File.WriteAllLinesAsync(options.NdjsonPath, ndjsonLines, cancellationToken);
|
||||
EnsureOutputDirectory(options.NdjsonPath);
|
||||
await WriteNdjsonAsync(options.NdjsonPath, result.Violations, cancellationToken);
|
||||
|
||||
if (options.Verbose)
|
||||
{
|
||||
Console.WriteLine($"NDJSON report written to: {options.NdjsonPath}");
|
||||
await output.WriteLineAsync($"NDJSON report written to: {options.NdjsonPath}");
|
||||
}
|
||||
}
|
||||
|
||||
// Output summary
|
||||
Console.WriteLine("AOC Verification Complete");
|
||||
Console.WriteLine($" Documents scanned: {result.DocumentsScanned}");
|
||||
Console.WriteLine($" Violations found: {result.ViolationCount}");
|
||||
Console.WriteLine($" Duration: {result.DurationMs}ms");
|
||||
await output.WriteLineAsync("AOC Verification Complete");
|
||||
await output.WriteLineAsync($" Documents scanned: {result.DocumentsScanned}");
|
||||
await output.WriteLineAsync($" Violations found: {result.ViolationCount}");
|
||||
await output.WriteLineAsync($" Duration: {result.DurationMs}ms");
|
||||
|
||||
if (result.ViolationCount > 0)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Violations by type:");
|
||||
foreach (var group in result.Violations.GroupBy(v => v.Code))
|
||||
await output.WriteLineAsync();
|
||||
await output.WriteLineAsync("Violations by type:");
|
||||
foreach (var group in result.Violations
|
||||
.GroupBy(v => v.Code)
|
||||
.OrderBy(g => g.Key, StringComparer.Ordinal))
|
||||
{
|
||||
Console.WriteLine($" {group.Key}: {group.Count()}");
|
||||
await output.WriteLineAsync($" {group.Key}: {group.Count()}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,139 +197,58 @@ public sealed class AocCliCommandModule : ICliCommandModule
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error during verification: {ex.Message}");
|
||||
await error.WriteLineAsync($"Error during verification: {ex.Message}");
|
||||
if (options.Verbose)
|
||||
{
|
||||
Console.Error.WriteLine(ex.StackTrace);
|
||||
await error.WriteLineAsync(ex.ToString());
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
private static IAocVerificationService ResolveVerificationService(IServiceProvider services)
|
||||
{
|
||||
var resolvedService = services.GetService<IAocVerificationService>();
|
||||
if (resolvedService is not null)
|
||||
{
|
||||
return resolvedService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for AOC verify command.
|
||||
/// </summary>
|
||||
public sealed class AocVerifyOptions
|
||||
{
|
||||
public required string Since { get; init; }
|
||||
public required string PostgresConnectionString { get; init; }
|
||||
public string? OutputPath { get; init; }
|
||||
public string? NdjsonPath { get; init; }
|
||||
public string? Tenant { get; init; }
|
||||
public bool DryRun { get; init; }
|
||||
public bool Verbose { get; init; }
|
||||
}
|
||||
var connectionFactory = services.GetService<IAocConnectionFactory>() ?? new NpgsqlConnectionFactory();
|
||||
var timeProvider = services.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
return new AocVerificationService(connectionFactory, timeProvider);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for AOC verification operations.
|
||||
/// </summary>
|
||||
public sealed class AocVerificationService
|
||||
{
|
||||
public async Task<AocVerificationResult> VerifyAsync(
|
||||
AocVerifyOptions options,
|
||||
private static void EnsureOutputDirectory(string path)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WriteNdjsonAsync(
|
||||
string path,
|
||||
IReadOnlyList<AocViolation> violations,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
var violations = new List<AocViolation>();
|
||||
var documentsScanned = 0;
|
||||
await using var stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
|
||||
try
|
||||
foreach (var violation in violations)
|
||||
{
|
||||
await using var connection = new Npgsql.NpgsqlConnection(options.PostgresConnectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
// Query for documents to verify
|
||||
var query = BuildVerificationQuery(options);
|
||||
await using var cmd = new Npgsql.NpgsqlCommand(query, connection);
|
||||
|
||||
if (!string.IsNullOrEmpty(options.Tenant))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("tenant", options.Tenant);
|
||||
}
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
documentsScanned++;
|
||||
|
||||
// Check for AOC violations
|
||||
var documentId = reader.GetString(0);
|
||||
var hash = reader.IsDBNull(1) ? null : reader.GetString(1);
|
||||
var previousHash = reader.IsDBNull(2) ? null : reader.GetString(2);
|
||||
var createdAt = reader.GetDateTime(3);
|
||||
|
||||
// Verify hash chain integrity
|
||||
if (hash != null && previousHash != null)
|
||||
{
|
||||
// Placeholder: actual verification logic would check hash chain
|
||||
// For now, just record that we verified
|
||||
}
|
||||
}
|
||||
var line = JsonSerializer.Serialize(violation, JsonOptions);
|
||||
await writer.WriteLineAsync(line.AsMemory(), cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
violations.Add(new AocViolation
|
||||
{
|
||||
Code = "AOC-001",
|
||||
Message = $"Database verification failed: {ex.Message}",
|
||||
DocumentId = null,
|
||||
Severity = "error"
|
||||
});
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
return new AocVerificationResult
|
||||
{
|
||||
DocumentsScanned = documentsScanned,
|
||||
ViolationCount = violations.Count,
|
||||
Violations = violations,
|
||||
DurationMs = stopwatch.ElapsedMilliseconds,
|
||||
VerifiedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildVerificationQuery(AocVerifyOptions options)
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
// Placeholder query - actual implementation would query AOC tables
|
||||
var baseQuery = """
|
||||
SELECT id, hash, previous_hash, created_at
|
||||
FROM aoc_documents
|
||||
WHERE created_at >= @since
|
||||
""";
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(options.Tenant))
|
||||
{
|
||||
baseQuery += " AND tenant_id = @tenant";
|
||||
}
|
||||
|
||||
baseQuery += " ORDER BY created_at ASC";
|
||||
|
||||
return baseQuery;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of AOC verification.
|
||||
/// </summary>
|
||||
public sealed class AocVerificationResult
|
||||
{
|
||||
public int DocumentsScanned { get; init; }
|
||||
public int ViolationCount { get; init; }
|
||||
public IReadOnlyList<AocViolation> Violations { get; init; } = [];
|
||||
public long DurationMs { get; init; }
|
||||
public DateTimeOffset VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An AOC violation record.
|
||||
/// </summary>
|
||||
public sealed class AocViolation
|
||||
{
|
||||
public required string Code { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public string? DocumentId { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
private static readonly JsonSerializerOptions JsonIndentedOptions = new(JsonOptions)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace StellaOps.Cli.Plugins.Aoc;
|
||||
|
||||
/// <summary>
|
||||
/// Result of AOC verification.
|
||||
/// </summary>
|
||||
public sealed class AocVerificationResult
|
||||
{
|
||||
public int DocumentsScanned { get; init; }
|
||||
public int ViolationCount { get; init; }
|
||||
public IReadOnlyList<AocViolation> Violations { get; init; } = [];
|
||||
public long DurationMs { get; init; }
|
||||
public DateTimeOffset VerifiedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An AOC violation record.
|
||||
/// </summary>
|
||||
public sealed class AocViolation
|
||||
{
|
||||
public required string Code { get; init; }
|
||||
public required string Message { get; init; }
|
||||
public string? DocumentId { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Cli.Plugins.Aoc;
|
||||
|
||||
public interface IAocVerificationService
|
||||
{
|
||||
Task<AocVerificationResult> VerifyAsync(AocVerifyOptions options, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public interface IAocConnectionFactory
|
||||
{
|
||||
ValueTask<NpgsqlConnection> OpenConnectionAsync(string connectionString, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class NpgsqlConnectionFactory : IAocConnectionFactory
|
||||
{
|
||||
public async ValueTask<NpgsqlConnection> OpenConnectionAsync(string connectionString, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AocVerificationService : IAocVerificationService
|
||||
{
|
||||
private readonly IAocConnectionFactory _connectionFactory;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public AocVerificationService(IAocConnectionFactory connectionFactory, TimeProvider timeProvider)
|
||||
{
|
||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
public async Task<AocVerificationResult> VerifyAsync(
|
||||
AocVerifyOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var violations = new List<AocViolation>();
|
||||
var documentsScanned = 0;
|
||||
|
||||
var query = AocVerificationQueryBuilder.Build(options);
|
||||
await using var connection = await _connectionFactory.OpenConnectionAsync(
|
||||
options.PostgresConnectionString,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var cmd = new NpgsqlCommand(query.Sql, connection);
|
||||
query.BindParameters(cmd);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
documentsScanned++;
|
||||
|
||||
_ = reader.GetString(0);
|
||||
var hash = reader.IsDBNull(1) ? null : reader.GetString(1);
|
||||
var previousHash = reader.IsDBNull(2) ? null : reader.GetString(2);
|
||||
_ = reader.GetDateTime(3);
|
||||
|
||||
if (hash is null || previousHash is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: implement hash chain verification and emit violations.
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
return new AocVerificationResult
|
||||
{
|
||||
DocumentsScanned = documentsScanned,
|
||||
ViolationCount = violations.Count,
|
||||
Violations = violations,
|
||||
DurationMs = stopwatch.ElapsedMilliseconds,
|
||||
VerifiedAt = _timeProvider.GetUtcNow()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct AocVerificationQuery(string Sql, Action<NpgsqlCommand> BindParameters);
|
||||
|
||||
public static class AocVerificationQueryBuilder
|
||||
{
|
||||
public static AocVerificationQuery Build(AocVerifyOptions options)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("SELECT id, hash, previous_hash, created_at");
|
||||
builder.AppendLine("FROM aoc_documents");
|
||||
builder.AppendLine("WHERE created_at >= @since");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Tenant))
|
||||
{
|
||||
builder.AppendLine("AND tenant_id = @tenant");
|
||||
}
|
||||
|
||||
builder.AppendLine("ORDER BY created_at ASC");
|
||||
|
||||
return new AocVerificationQuery(builder.ToString(), command =>
|
||||
{
|
||||
command.Parameters.AddWithValue("since", options.Since.UtcDateTime);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Tenant))
|
||||
{
|
||||
command.Parameters.AddWithValue("tenant", options.Tenant);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Cli.Plugins.Aoc;
|
||||
|
||||
public sealed class AocVerifyRawOptions
|
||||
{
|
||||
public required string Since { get; init; }
|
||||
public required string PostgresConnectionString { get; init; }
|
||||
public string? OutputPath { get; init; }
|
||||
public string? NdjsonPath { get; init; }
|
||||
public string? Tenant { get; init; }
|
||||
public bool DryRun { get; init; }
|
||||
public bool Verbose { get; init; }
|
||||
}
|
||||
|
||||
public sealed class AocVerifyOptions
|
||||
{
|
||||
public required DateTimeOffset Since { get; init; }
|
||||
public required string PostgresConnectionString { get; init; }
|
||||
public string? OutputPath { get; init; }
|
||||
public string? NdjsonPath { get; init; }
|
||||
public string? Tenant { get; init; }
|
||||
public bool DryRun { get; init; }
|
||||
public bool Verbose { get; init; }
|
||||
}
|
||||
|
||||
public static class AocVerifyOptionsParser
|
||||
{
|
||||
private static readonly Regex CommitShaRegex = new("^[a-fA-F0-9]{7,40}$", RegexOptions.Compiled);
|
||||
|
||||
public static bool TryParse(
|
||||
AocVerifyRawOptions raw,
|
||||
out AocVerifyOptions options,
|
||||
out string errorMessage)
|
||||
{
|
||||
options = default!;
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(raw.PostgresConnectionString))
|
||||
{
|
||||
errorMessage = "PostgreSQL connection string is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryParseSince(raw.Since, out var since, out errorMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ValidateOutputPaths(raw.OutputPath, raw.NdjsonPath, out errorMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
options = new AocVerifyOptions
|
||||
{
|
||||
Since = since,
|
||||
PostgresConnectionString = raw.PostgresConnectionString,
|
||||
OutputPath = raw.OutputPath,
|
||||
NdjsonPath = raw.NdjsonPath,
|
||||
Tenant = raw.Tenant,
|
||||
DryRun = raw.DryRun,
|
||||
Verbose = raw.Verbose
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseSince(
|
||||
string value,
|
||||
out DateTimeOffset since,
|
||||
out string errorMessage)
|
||||
{
|
||||
errorMessage = string.Empty;
|
||||
since = default;
|
||||
|
||||
if (DateTimeOffset.TryParse(
|
||||
value,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal | DateTimeStyles.AllowWhiteSpaces,
|
||||
out var parsed))
|
||||
{
|
||||
since = parsed.ToUniversalTime();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (CommitShaRegex.IsMatch(value))
|
||||
{
|
||||
errorMessage = "Commit-based --since values are not supported yet; provide an ISO-8601 timestamp.";
|
||||
return false;
|
||||
}
|
||||
|
||||
errorMessage = "Invalid --since value; expected an ISO-8601 timestamp.";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool ValidateOutputPaths(
|
||||
string? outputPath,
|
||||
string? ndjsonPath,
|
||||
out string errorMessage)
|
||||
{
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(outputPath) && string.IsNullOrWhiteSpace(ndjsonPath))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outputPath) && Path.EndsInDirectorySeparator(outputPath))
|
||||
{
|
||||
errorMessage = "--output must be a file path, not a directory.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ndjsonPath) && Path.EndsInDirectorySeparator(ndjsonPath))
|
||||
{
|
||||
errorMessage = "--ndjson must be a file path, not a directory.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outputPath) && !string.IsNullOrWhiteSpace(ndjsonPath))
|
||||
{
|
||||
var outputFullPath = Path.GetFullPath(outputPath);
|
||||
var ndjsonFullPath = Path.GetFullPath(ndjsonPath);
|
||||
if (string.Equals(outputFullPath, ndjsonFullPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errorMessage = "--output and --ndjson must point to different files.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<PluginOutputDirectory>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\plugins\cli\StellaOps.Cli.Plugins.Aoc\'))</PluginOutputDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -25,9 +25,13 @@
|
||||
|
||||
<Target Name="CopyPluginBinaries" AfterTargets="Build">
|
||||
<MakeDir Directories="$(PluginOutputDirectory)" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(PluginOutputDirectory)" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetName).pdb"
|
||||
DestinationFolder="$(PluginOutputDirectory)"
|
||||
Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
|
||||
<ItemGroup>
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetFileName)" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).pdb" Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).deps.json" Condition="Exists('$(TargetDir)$(TargetName).deps.json')" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).runtimeconfig.json" Condition="Exists('$(TargetDir)$(TargetName).runtimeconfig.json')" />
|
||||
<PluginArtifacts Include="@(ReferenceCopyLocalPaths)" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(PluginArtifacts)" DestinationFolder="$(PluginOutputDirectory)" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0138-M | DONE | Maintainability audit for StellaOps.Cli.Plugins.Aoc. |
|
||||
| AUDIT-0138-T | DONE | Test coverage audit for StellaOps.Cli.Plugins.Aoc. |
|
||||
| AUDIT-0138-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0138-A | DONE | Applied option validation, query binding, deterministic output, and tests. |
|
||||
|
||||
@@ -13,6 +13,12 @@ public sealed class NonCoreCliCommandModule : ICliCommandModule
|
||||
|
||||
public bool IsAvailable(IServiceProvider services) => true;
|
||||
|
||||
private static readonly HashSet<string> ExportFormats = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"openvex",
|
||||
"json"
|
||||
};
|
||||
|
||||
public void RegisterCommands(
|
||||
RootCommand root,
|
||||
IServiceProvider services,
|
||||
@@ -22,6 +28,7 @@ public sealed class NonCoreCliCommandModule : ICliCommandModule
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(root);
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(verboseOption);
|
||||
|
||||
root.Add(BuildExcititorCommand(services, verboseOption, cancellationToken));
|
||||
@@ -59,11 +66,11 @@ public sealed class NonCoreCliCommandModule : ICliCommandModule
|
||||
Description = "Optional provider identifier(s) to ingest.",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
var sinceOption = new Option<DateTimeOffset?>("--since")
|
||||
var sinceOption = new Option<string?>("--since")
|
||||
{
|
||||
Description = "Optional ISO-8601 timestamp to begin the ingest window."
|
||||
};
|
||||
var windowOption = new Option<TimeSpan?>("--window")
|
||||
var windowOption = new Option<string?>("--window")
|
||||
{
|
||||
Description = "Optional window duration (e.g. 24:00:00)."
|
||||
};
|
||||
@@ -75,11 +82,20 @@ public sealed class NonCoreCliCommandModule : ICliCommandModule
|
||||
pull.Add(sinceOption);
|
||||
pull.Add(windowOption);
|
||||
pull.Add(forceOption);
|
||||
pull.SetAction((parseResult, _) =>
|
||||
pull.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var providers = parseResult.GetValue(pullProviders) ?? Array.Empty<string>();
|
||||
var since = parseResult.GetValue(sinceOption);
|
||||
var window = parseResult.GetValue(windowOption);
|
||||
var sinceValue = parseResult.GetValue(sinceOption);
|
||||
var windowValue = parseResult.GetValue(windowOption);
|
||||
if (!NonCoreCliOptionParser.TryParseIsoTimestamp(sinceValue, out var since, out var errorMessage))
|
||||
{
|
||||
return ValidationFailedAsync(errorMessage);
|
||||
}
|
||||
|
||||
if (!NonCoreCliOptionParser.TryParseDuration(windowValue, out var window, out errorMessage))
|
||||
{
|
||||
return ValidationFailedAsync(errorMessage);
|
||||
}
|
||||
var force = parseResult.GetValue(forceOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleExcititorPullAsync(services, providers, since, window, force, verbose, cancellationToken);
|
||||
@@ -121,7 +137,8 @@ public sealed class NonCoreCliCommandModule : ICliCommandModule
|
||||
var export = new Command("export", "Trigger Excititor export generation.");
|
||||
var formatOption = new Option<string>("--format")
|
||||
{
|
||||
Description = "Export format (e.g. openvex, json)."
|
||||
Description = "Export format (e.g. openvex, json).",
|
||||
DefaultValueFactory = _ => "openvex"
|
||||
};
|
||||
var exportDeltaOption = new Option<bool>("--delta")
|
||||
{
|
||||
@@ -131,7 +148,7 @@ public sealed class NonCoreCliCommandModule : ICliCommandModule
|
||||
{
|
||||
Description = "Optional policy scope or tenant identifier."
|
||||
};
|
||||
var exportSinceOption = new Option<DateTimeOffset?>("--since")
|
||||
var exportSinceOption = new Option<string?>("--since")
|
||||
{
|
||||
Description = "Optional ISO-8601 timestamp to restrict export contents."
|
||||
};
|
||||
@@ -149,12 +166,21 @@ public sealed class NonCoreCliCommandModule : ICliCommandModule
|
||||
export.Add(exportSinceOption);
|
||||
export.Add(exportProviderOption);
|
||||
export.Add(exportOutputOption);
|
||||
export.SetAction((parseResult, _) =>
|
||||
export.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var format = parseResult.GetValue(formatOption) ?? "openvex";
|
||||
var delta = parseResult.GetValue(exportDeltaOption);
|
||||
var scope = parseResult.GetValue(exportScopeOption);
|
||||
var since = parseResult.GetValue(exportSinceOption);
|
||||
var sinceValue = parseResult.GetValue(exportSinceOption);
|
||||
if (!NonCoreCliOptionParser.TryValidateFormat(format, ExportFormats, out var errorMessage))
|
||||
{
|
||||
return ValidationFailedAsync(errorMessage);
|
||||
}
|
||||
|
||||
if (!NonCoreCliOptionParser.TryParseIsoTimestamp(sinceValue, out var since, out errorMessage))
|
||||
{
|
||||
return ValidationFailedAsync(errorMessage);
|
||||
}
|
||||
var provider = parseResult.GetValue(exportProviderOption);
|
||||
var output = parseResult.GetValue(exportOutputOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
@@ -162,7 +188,7 @@ public sealed class NonCoreCliCommandModule : ICliCommandModule
|
||||
});
|
||||
|
||||
var backfill = new Command("backfill-statements", "Replay historical raw documents into Excititor statements.");
|
||||
var backfillRetrievedSinceOption = new Option<DateTimeOffset?>("--retrieved-since")
|
||||
var backfillRetrievedSinceOption = new Option<string?>("--retrieved-since")
|
||||
{
|
||||
Description = "Only process raw documents retrieved on or after the provided ISO-8601 timestamp."
|
||||
};
|
||||
@@ -172,7 +198,8 @@ public sealed class NonCoreCliCommandModule : ICliCommandModule
|
||||
};
|
||||
var backfillBatchSizeOption = new Option<int>("--batch-size")
|
||||
{
|
||||
Description = "Number of raw documents to fetch per batch (default 100)."
|
||||
Description = "Number of raw documents to fetch per batch (default 100).",
|
||||
DefaultValueFactory = _ => 100
|
||||
};
|
||||
var backfillMaxDocumentsOption = new Option<int?>("--max-documents")
|
||||
{
|
||||
@@ -182,14 +209,18 @@ public sealed class NonCoreCliCommandModule : ICliCommandModule
|
||||
backfill.Add(backfillForceOption);
|
||||
backfill.Add(backfillBatchSizeOption);
|
||||
backfill.Add(backfillMaxDocumentsOption);
|
||||
backfill.SetAction((parseResult, _) =>
|
||||
backfill.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var retrievedSince = parseResult.GetValue(backfillRetrievedSinceOption);
|
||||
var retrievedSinceValue = parseResult.GetValue(backfillRetrievedSinceOption);
|
||||
if (!NonCoreCliOptionParser.TryParseIsoTimestamp(retrievedSinceValue, out var retrievedSince, out var errorMessage))
|
||||
{
|
||||
return ValidationFailedAsync(errorMessage);
|
||||
}
|
||||
var force = parseResult.GetValue(backfillForceOption);
|
||||
var batchSize = parseResult.GetValue(backfillBatchSizeOption);
|
||||
if (batchSize <= 0)
|
||||
{
|
||||
batchSize = 100;
|
||||
return ValidationFailedAsync("--batch-size must be greater than zero.");
|
||||
}
|
||||
var maxDocuments = parseResult.GetValue(backfillMaxDocumentsOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
@@ -234,16 +265,20 @@ public sealed class NonCoreCliCommandModule : ICliCommandModule
|
||||
Description = "Optional provider identifier(s) to reconcile.",
|
||||
Arity = ArgumentArity.ZeroOrMore
|
||||
};
|
||||
var maxAgeOption = new Option<TimeSpan?>("--max-age")
|
||||
var maxAgeOption = new Option<string?>("--max-age")
|
||||
{
|
||||
Description = "Optional maximum age window (e.g. 7.00:00:00)."
|
||||
};
|
||||
reconcile.Add(reconcileProviders);
|
||||
reconcile.Add(maxAgeOption);
|
||||
reconcile.SetAction((parseResult, _) =>
|
||||
reconcile.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var providers = parseResult.GetValue(reconcileProviders) ?? Array.Empty<string>();
|
||||
var maxAge = parseResult.GetValue(maxAgeOption);
|
||||
var maxAgeValue = parseResult.GetValue(maxAgeOption);
|
||||
if (!NonCoreCliOptionParser.TryParseDuration(maxAgeValue, out var maxAge, out var errorMessage))
|
||||
{
|
||||
return ValidationFailedAsync(errorMessage);
|
||||
}
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
return CommandHandlers.HandleExcititorReconcileAsync(services, providers, maxAge, verbose, cancellationToken);
|
||||
});
|
||||
@@ -298,7 +333,7 @@ public sealed class NonCoreCliCommandModule : ICliCommandModule
|
||||
test.Add(labelOption);
|
||||
test.Add(jsonOption);
|
||||
|
||||
test.SetAction((parseResult, _) =>
|
||||
test.SetAction((parseResult, ct) =>
|
||||
{
|
||||
var nsValue = parseResult.GetValue(namespaceOption);
|
||||
var images = parseResult.GetValue(imageOption) ?? Array.Empty<string>();
|
||||
@@ -307,6 +342,11 @@ public sealed class NonCoreCliCommandModule : ICliCommandModule
|
||||
var outputJson = parseResult.GetValue(jsonOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
if (!NonCoreCliOptionParser.TryValidateImageInputs(images, file, out var errorMessage))
|
||||
{
|
||||
return ValidationFailedAsync(errorMessage);
|
||||
}
|
||||
|
||||
return CommandHandlers.HandleRuntimePolicyTestAsync(
|
||||
services,
|
||||
nsValue,
|
||||
@@ -413,4 +453,10 @@ public sealed class NonCoreCliCommandModule : ICliCommandModule
|
||||
offline.Add(kit);
|
||||
return offline;
|
||||
}
|
||||
|
||||
private static Task<int> ValidationFailedAsync(string message)
|
||||
{
|
||||
Console.Error.WriteLine(message);
|
||||
return Task.FromResult(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace StellaOps.Cli.Plugins.NonCore;
|
||||
|
||||
public static class NonCoreCliOptionParser
|
||||
{
|
||||
public static bool TryParseIsoTimestamp(
|
||||
string? value,
|
||||
out DateTimeOffset? timestamp,
|
||||
out string errorMessage)
|
||||
{
|
||||
timestamp = null;
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DateTimeOffset.TryParse(
|
||||
value,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal | DateTimeStyles.AllowWhiteSpaces,
|
||||
out var parsed))
|
||||
{
|
||||
timestamp = parsed.ToUniversalTime();
|
||||
return true;
|
||||
}
|
||||
|
||||
errorMessage = "Invalid timestamp. Use ISO-8601 format (example: 2025-01-02T03:04:05Z).";
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool TryParseDuration(
|
||||
string? value,
|
||||
out TimeSpan? duration,
|
||||
out string errorMessage)
|
||||
{
|
||||
duration = null;
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TimeSpan.TryParse(value, CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
if (parsed <= TimeSpan.Zero)
|
||||
{
|
||||
errorMessage = "Duration must be greater than zero.";
|
||||
return false;
|
||||
}
|
||||
|
||||
duration = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
errorMessage = "Invalid duration. Use a TimeSpan value (example: 1.00:00:00).";
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool TryValidateImageInputs(
|
||||
string[] images,
|
||||
string? file,
|
||||
out string errorMessage)
|
||||
{
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (images.Length > 0 && !string.IsNullOrWhiteSpace(file))
|
||||
{
|
||||
errorMessage = "Provide either --image or --file, not both.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (images.Length == 0 && string.IsNullOrWhiteSpace(file))
|
||||
{
|
||||
errorMessage = "Provide at least one --image or specify --file.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryValidateFormat(
|
||||
string? format,
|
||||
IReadOnlySet<string> allowed,
|
||||
out string errorMessage)
|
||||
{
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(format))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (allowed.Contains(format))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
errorMessage = $"Invalid format '{format}'. Allowed values: {string.Join(", ", allowed)}.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<PluginOutputDirectory>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\\..\\plugins\\cli\\StellaOps.Cli.Plugins.NonCore\\'))</PluginOutputDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -14,9 +14,13 @@
|
||||
|
||||
<Target Name="CopyPluginBinaries" AfterTargets="Build">
|
||||
<MakeDir Directories="$(PluginOutputDirectory)" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(PluginOutputDirectory)" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetName).pdb"
|
||||
DestinationFolder="$(PluginOutputDirectory)"
|
||||
Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
|
||||
<ItemGroup>
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetFileName)" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).pdb" Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).deps.json" Condition="Exists('$(TargetDir)$(TargetName).deps.json')" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).runtimeconfig.json" Condition="Exists('$(TargetDir)$(TargetName).runtimeconfig.json')" />
|
||||
<PluginArtifacts Include="@(ReferenceCopyLocalPaths)" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(PluginArtifacts)" DestinationFolder="$(PluginOutputDirectory)" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0139-M | DONE | Maintainability audit for StellaOps.Cli.Plugins.NonCore. |
|
||||
| AUDIT-0139-T | DONE | Test coverage audit for StellaOps.Cli.Plugins.NonCore. |
|
||||
| AUDIT-0139-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0139-A | DONE | Added validation helpers, invariant parsing, and tests. |
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<PluginOutputDirectory>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\plugins\cli\StellaOps.Cli.Plugins.Symbols\'))</PluginOutputDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
<ProjectReference Include="..\..\..\Symbols\StellaOps.Symbols.Client\StellaOps.Symbols.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Spectre.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyPluginBinaries" AfterTargets="Build">
|
||||
<MakeDir Directories="$(PluginOutputDirectory)" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(PluginOutputDirectory)" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetName).pdb"
|
||||
DestinationFolder="$(PluginOutputDirectory)"
|
||||
Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
|
||||
<ItemGroup>
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetFileName)" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).pdb" Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).deps.json" Condition="Exists('$(TargetDir)$(TargetName).deps.json')" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).runtimeconfig.json" Condition="Exists('$(TargetDir)$(TargetName).runtimeconfig.json')" />
|
||||
<PluginArtifacts Include="@(ReferenceCopyLocalPaths)" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(PluginArtifacts)" DestinationFolder="$(PluginOutputDirectory)" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -9,7 +9,6 @@ using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Spectre.Console;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Plugins;
|
||||
using StellaOps.Symbols.Client;
|
||||
@@ -37,6 +36,7 @@ public sealed class SymbolsCliCommandModule : ICliCommandModule
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(root);
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(verboseOption);
|
||||
|
||||
root.Add(BuildSymbolsCommand(services, verboseOption, cancellationToken));
|
||||
@@ -56,15 +56,16 @@ public sealed class SymbolsCliCommandModule : ICliCommandModule
|
||||
};
|
||||
|
||||
// Add subcommands
|
||||
symbols.Add(BuildIngestCommand(verboseOption, dryRunOption, cancellationToken));
|
||||
symbols.Add(BuildUploadCommand(verboseOption, dryRunOption, cancellationToken));
|
||||
symbols.Add(BuildIngestCommand(services, verboseOption, dryRunOption, cancellationToken));
|
||||
symbols.Add(BuildUploadCommand(services, verboseOption, dryRunOption, cancellationToken));
|
||||
symbols.Add(BuildVerifyCommand(verboseOption, cancellationToken));
|
||||
symbols.Add(BuildHealthCommand(cancellationToken));
|
||||
symbols.Add(BuildHealthCommand(services, cancellationToken));
|
||||
|
||||
return symbols;
|
||||
}
|
||||
|
||||
private static Command BuildIngestCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
Option<bool> dryRunOption,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -155,6 +156,7 @@ public sealed class SymbolsCliCommandModule : ICliCommandModule
|
||||
}
|
||||
|
||||
private static Command BuildUploadCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
Option<bool> dryRunOption,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -188,7 +190,7 @@ public sealed class SymbolsCliCommandModule : ICliCommandModule
|
||||
var server = parseResult.GetValue(serverOption)!;
|
||||
var tenant = parseResult.GetValue(tenantOption);
|
||||
|
||||
return await ExecuteUploadAsync(manifestPath, server, tenant, verbose, dryRun, ct);
|
||||
return await ExecuteUploadAsync(services, manifestPath, server, tenant, verbose, dryRun, ct);
|
||||
});
|
||||
|
||||
return upload;
|
||||
@@ -219,7 +221,9 @@ public sealed class SymbolsCliCommandModule : ICliCommandModule
|
||||
return verify;
|
||||
}
|
||||
|
||||
private static Command BuildHealthCommand(CancellationToken cancellationToken)
|
||||
private static Command BuildHealthCommand(
|
||||
IServiceProvider services,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var health = new Command("health", "Check symbols server health");
|
||||
|
||||
@@ -234,7 +238,7 @@ public sealed class SymbolsCliCommandModule : ICliCommandModule
|
||||
health.SetAction(async (parseResult, ct) =>
|
||||
{
|
||||
var server = parseResult.GetValue(serverOption)!;
|
||||
return await ExecuteHealthCheckAsync(server, ct);
|
||||
return await ExecuteHealthCheckAsync(services, server, ct);
|
||||
});
|
||||
|
||||
return health;
|
||||
@@ -242,42 +246,55 @@ public sealed class SymbolsCliCommandModule : ICliCommandModule
|
||||
|
||||
private static async Task<int> ExecuteIngestAsync(SymbolIngestOptions options, CancellationToken ct)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[bold blue]StellaOps Symbol Ingestor[/]");
|
||||
AnsiConsole.WriteLine();
|
||||
ct.ThrowIfCancellationRequested();
|
||||
Console.WriteLine("StellaOps Symbols: ingest");
|
||||
Console.WriteLine();
|
||||
|
||||
// Validate binary exists
|
||||
if (!File.Exists(options.BinaryPath))
|
||||
if (!SymbolsCliValidation.TryValidateExistingFile(options.BinaryPath, "Binary", out var errorMessage))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] Binary file not found: {options.BinaryPath}");
|
||||
return 1;
|
||||
return await ValidationFailedAsync(errorMessage).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Detect format
|
||||
if (!SymbolsCliValidation.TryValidateOptionalFile(options.DebugPath, "Debug symbols", out errorMessage))
|
||||
{
|
||||
return await ValidationFailedAsync(errorMessage).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!SymbolsCliValidation.TryValidatePlatform(options.Platform, out errorMessage))
|
||||
{
|
||||
return await ValidationFailedAsync(errorMessage).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.ServerUrl) &&
|
||||
!SymbolsCliValidation.TryValidateServerUrl(options.ServerUrl, out _, out errorMessage))
|
||||
{
|
||||
return await ValidationFailedAsync(errorMessage).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
EnsureOutputDirectory(options.OutputDir);
|
||||
|
||||
var format = DetectBinaryFormat(options.BinaryPath);
|
||||
AnsiConsole.MarkupLine($"[green]Binary format:[/] {format}");
|
||||
Console.WriteLine($"Binary format: {format}");
|
||||
|
||||
if (format == "Unknown")
|
||||
if (format == BinaryFormat.Unknown)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Unknown binary format");
|
||||
return 1;
|
||||
return await ValidationFailedAsync("Unknown binary format.").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Create manifest (placeholder - would use SymbolExtractor in real implementation)
|
||||
AnsiConsole.MarkupLine($"[green]Binary:[/] {Path.GetFileName(options.BinaryPath)}");
|
||||
AnsiConsole.MarkupLine($"[green]Platform:[/] {options.Platform ?? "auto-detected"}");
|
||||
Console.WriteLine($"Binary: {Path.GetFileName(options.BinaryPath)}");
|
||||
Console.WriteLine($"Platform: {options.Platform ?? "auto-detected"}");
|
||||
|
||||
if (options.DryRun)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Dry run mode - skipping manifest generation[/]");
|
||||
Console.WriteLine("Dry run mode - symbol extraction is not implemented.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
AnsiConsole.WriteLine();
|
||||
AnsiConsole.MarkupLine("[bold green]Done![/]");
|
||||
return 0;
|
||||
return await ValidationFailedAsync("Symbol ingestion is not implemented yet.").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteUploadAsync(
|
||||
IServiceProvider services,
|
||||
string manifestPath,
|
||||
string serverUrl,
|
||||
string? tenantId,
|
||||
@@ -287,141 +304,293 @@ public sealed class SymbolsCliCommandModule : ICliCommandModule
|
||||
{
|
||||
if (dryRun)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[yellow]Dry run mode - would upload to:[/] {0}", serverUrl);
|
||||
Console.WriteLine($"Dry run mode - would upload to: {serverUrl}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!File.Exists(manifestPath))
|
||||
if (!SymbolsCliValidation.TryValidateExistingFile(manifestPath, "Manifest", out var errorMessage))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] Manifest file not found: {manifestPath}");
|
||||
return 1;
|
||||
return await ValidationFailedAsync(errorMessage).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[blue]Uploading to:[/] {serverUrl}");
|
||||
if (!SymbolsCliValidation.TryValidateServerUrl(serverUrl, out var serverUri, out errorMessage))
|
||||
{
|
||||
return await ValidationFailedAsync(errorMessage).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Console.WriteLine($"Uploading to: {serverUri}");
|
||||
|
||||
try
|
||||
{
|
||||
// Set up HTTP client and symbols client
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging(builder =>
|
||||
{
|
||||
if (verbose)
|
||||
builder.AddConsole().SetMinimumLevel(LogLevel.Debug);
|
||||
});
|
||||
services.AddSymbolsClient(opts =>
|
||||
{
|
||||
opts.BaseUrl = serverUrl;
|
||||
opts.TenantId = tenantId;
|
||||
});
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var client = provider.GetRequiredService<ISymbolsClient>();
|
||||
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct);
|
||||
var manifest = JsonSerializer.Deserialize<SymbolManifest>(manifestJson);
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, ct).ConfigureAwait(false);
|
||||
var manifest = JsonSerializer.Deserialize<SymbolManifest>(manifestJson, JsonOptions);
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Failed to parse manifest");
|
||||
return 1;
|
||||
return await ValidationFailedAsync("Failed to parse manifest.").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var result = await client.UploadManifestAsync(manifest, ct);
|
||||
AnsiConsole.MarkupLine($"[green]Uploaded:[/] {result.ManifestId}");
|
||||
AnsiConsole.MarkupLine($"[green]Symbol count:[/] {result.SymbolCount}");
|
||||
if (!TryValidateManifest(manifest, out errorMessage))
|
||||
{
|
||||
return await ValidationFailedAsync(errorMessage).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await using var scope = CreateSymbolsClientScope(services, serverUrl, tenantId, verbose);
|
||||
var client = scope.Client;
|
||||
|
||||
var result = await client.UploadManifestAsync(manifest, ct).ConfigureAwait(false);
|
||||
Console.WriteLine($"Uploaded: {result.ManifestId}");
|
||||
Console.WriteLine($"Symbol count: {result.SymbolCount}");
|
||||
if (!string.IsNullOrEmpty(result.BlobUri))
|
||||
AnsiConsole.MarkupLine($"[green]Blob URI:[/] {result.BlobUri}");
|
||||
{
|
||||
Console.WriteLine($"Blob URI: {result.BlobUri}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return await ValidationFailedAsync($"Manifest JSON error: {ex.Message}").ConfigureAwait(false);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
return await ValidationFailedAsync($"Manifest read error: {ex.Message}").ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Upload failed:[/] {ex.Message}");
|
||||
return 1;
|
||||
return await ValidationFailedAsync($"Upload failed: {ex.Message}").ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<int> ExecuteVerifyAsync(string path, bool verbose, CancellationToken ct)
|
||||
private static async Task<int> ExecuteVerifyAsync(string path, bool verbose, CancellationToken ct)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
_ = verbose;
|
||||
if (!SymbolsCliValidation.TryValidateExistingFile(path, "Verification", out var errorMessage))
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] File not found: {path}");
|
||||
return Task.FromResult(1);
|
||||
return await ValidationFailedAsync(errorMessage).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
|
||||
// Check if it's a DSSE envelope or a plain manifest
|
||||
if (json.Contains("\"payloadType\"") && json.Contains("\"signatures\""))
|
||||
string json;
|
||||
try
|
||||
{
|
||||
AnsiConsole.MarkupLine("[blue]Verifying DSSE envelope...[/]");
|
||||
// Parse DSSE envelope
|
||||
AnsiConsole.MarkupLine("[bold green]Verification passed![/]");
|
||||
json = await File.ReadAllTextAsync(path, ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
catch (IOException ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[blue]Verifying manifest...[/]");
|
||||
var manifest = JsonSerializer.Deserialize<SymbolManifest>(json);
|
||||
if (manifest is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] Invalid manifest");
|
||||
return Task.FromResult(1);
|
||||
}
|
||||
|
||||
AnsiConsole.MarkupLine($"[green]Manifest ID:[/] {manifest.ManifestId}");
|
||||
AnsiConsole.MarkupLine($"[green]Debug ID:[/] {manifest.DebugId}");
|
||||
AnsiConsole.MarkupLine($"[green]Binary name:[/] {manifest.BinaryName}");
|
||||
AnsiConsole.MarkupLine($"[green]Format:[/] {manifest.Format}");
|
||||
AnsiConsole.MarkupLine($"[green]Symbol count:[/] {manifest.Symbols.Count}");
|
||||
AnsiConsole.MarkupLine($"[green]Created:[/] {manifest.CreatedAt:O}");
|
||||
AnsiConsole.MarkupLine("[bold green]Verification passed![/]");
|
||||
return await ValidationFailedAsync($"Verification read error: {ex.Message}").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return Task.FromResult(0);
|
||||
if (LooksLikeDsseEnvelope(json))
|
||||
{
|
||||
Console.WriteLine("DSSE verification is not implemented yet.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
Console.WriteLine("Verifying manifest...");
|
||||
SymbolManifest? manifest;
|
||||
try
|
||||
{
|
||||
manifest = JsonSerializer.Deserialize<SymbolManifest>(json, JsonOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return await ValidationFailedAsync($"Manifest JSON error: {ex.Message}").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
return await ValidationFailedAsync("Invalid manifest.").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!TryValidateManifest(manifest, out errorMessage))
|
||||
{
|
||||
return await ValidationFailedAsync(errorMessage).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Console.WriteLine($"Manifest ID: {manifest.ManifestId}");
|
||||
Console.WriteLine($"Debug ID: {manifest.DebugId}");
|
||||
Console.WriteLine($"Binary name: {manifest.BinaryName}");
|
||||
Console.WriteLine($"Format: {manifest.Format}");
|
||||
Console.WriteLine($"Symbol count: {manifest.Symbols?.Count ?? 0}");
|
||||
Console.WriteLine($"Created: {manifest.CreatedAt:O}");
|
||||
Console.WriteLine("Verification passed.");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task<int> ExecuteHealthCheckAsync(string serverUrl, CancellationToken ct)
|
||||
private static async Task<int> ExecuteHealthCheckAsync(
|
||||
IServiceProvider services,
|
||||
string serverUrl,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSymbolsClient(opts => opts.BaseUrl = serverUrl);
|
||||
if (!SymbolsCliValidation.TryValidateServerUrl(serverUrl, out var serverUri, out var errorMessage))
|
||||
{
|
||||
return await ValidationFailedAsync(errorMessage).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var client = provider.GetRequiredService<ISymbolsClient>();
|
||||
await using var scope = CreateSymbolsClientScope(services, serverUrl, tenantId: null, verbose: false);
|
||||
var client = scope.Client;
|
||||
|
||||
AnsiConsole.MarkupLine($"[blue]Checking health:[/] {serverUrl}");
|
||||
Console.WriteLine($"Checking health: {serverUri}");
|
||||
|
||||
try
|
||||
{
|
||||
var health = await client.GetHealthAsync(ct);
|
||||
AnsiConsole.MarkupLine($"[green]Status:[/] {health.Status}");
|
||||
AnsiConsole.MarkupLine($"[green]Version:[/] {health.Version}");
|
||||
AnsiConsole.MarkupLine($"[green]Timestamp:[/] {health.Timestamp:O}");
|
||||
var health = await client.GetHealthAsync(ct).ConfigureAwait(false);
|
||||
Console.WriteLine($"Status: {health.Status}");
|
||||
Console.WriteLine($"Version: {health.Version}");
|
||||
Console.WriteLine($"Timestamp: {health.Timestamp:O}");
|
||||
if (health.TotalManifests.HasValue)
|
||||
AnsiConsole.MarkupLine($"[green]Total manifests:[/] {health.TotalManifests}");
|
||||
Console.WriteLine($"Total manifests: {health.TotalManifests}");
|
||||
if (health.TotalSymbols.HasValue)
|
||||
AnsiConsole.MarkupLine($"[green]Total symbols:[/] {health.TotalSymbols}");
|
||||
Console.WriteLine($"Total symbols: {health.TotalSymbols}");
|
||||
return 0;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Health check failed:[/] {ex.Message}");
|
||||
return 1;
|
||||
return await ValidationFailedAsync($"Health check failed: {ex.Message}").ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static string DetectBinaryFormat(string path)
|
||||
private static BinaryFormat DetectBinaryFormat(string path)
|
||||
{
|
||||
// Simple format detection based on file extension and magic bytes
|
||||
var extension = Path.GetExtension(path).ToLowerInvariant();
|
||||
return extension switch
|
||||
{
|
||||
".exe" or ".dll" => "PE",
|
||||
".so" => "ELF",
|
||||
".dylib" => "MachO",
|
||||
_ => "Unknown"
|
||||
".exe" or ".dll" => BinaryFormat.Pe,
|
||||
".so" => BinaryFormat.Elf,
|
||||
".dylib" => BinaryFormat.MachO,
|
||||
".wasm" => BinaryFormat.Wasm,
|
||||
_ => BinaryFormat.Unknown
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<int> ValidationFailedAsync(string message)
|
||||
{
|
||||
await Console.Error.WriteLineAsync(message).ConfigureAwait(false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static void EnsureOutputDirectory(string outputDir)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(outputDir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(outputDir);
|
||||
}
|
||||
|
||||
private static bool LooksLikeDsseEnvelope(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return doc.RootElement.TryGetProperty("payloadType", out _) &&
|
||||
doc.RootElement.TryGetProperty("signatures", out _);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryValidateManifest(SymbolManifest manifest, out string errorMessage)
|
||||
{
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.ManifestId))
|
||||
{
|
||||
errorMessage = "ManifestId is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.DebugId))
|
||||
{
|
||||
errorMessage = "DebugId is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.BinaryName))
|
||||
{
|
||||
errorMessage = "BinaryName is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.TenantId))
|
||||
{
|
||||
errorMessage = "TenantId is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (manifest.Symbols is null)
|
||||
{
|
||||
errorMessage = "Symbols collection is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static SymbolsClientScope CreateSymbolsClientScope(
|
||||
IServiceProvider services,
|
||||
string serverUrl,
|
||||
string? tenantId,
|
||||
bool verbose)
|
||||
{
|
||||
var existing = services.GetService<ISymbolsClient>();
|
||||
if (existing is not null)
|
||||
{
|
||||
return new SymbolsClientScope(existing, null);
|
||||
}
|
||||
|
||||
var serviceCollection = new ServiceCollection();
|
||||
serviceCollection.AddLogging(builder =>
|
||||
{
|
||||
if (verbose)
|
||||
{
|
||||
builder.AddConsole().SetMinimumLevel(LogLevel.Debug);
|
||||
}
|
||||
});
|
||||
serviceCollection.AddSymbolsClient(opts =>
|
||||
{
|
||||
opts.BaseUrl = serverUrl;
|
||||
opts.TenantId = tenantId;
|
||||
});
|
||||
|
||||
var provider = serviceCollection.BuildServiceProvider();
|
||||
return new SymbolsClientScope(provider.GetRequiredService<ISymbolsClient>(), provider);
|
||||
}
|
||||
|
||||
private sealed class SymbolsClientScope : IAsyncDisposable
|
||||
{
|
||||
public SymbolsClientScope(ISymbolsClient client, ServiceProvider? provider)
|
||||
{
|
||||
Client = client;
|
||||
_provider = provider;
|
||||
}
|
||||
|
||||
public ISymbolsClient Client { get; }
|
||||
|
||||
private readonly ServiceProvider? _provider;
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
return _provider is null
|
||||
? ValueTask.CompletedTask
|
||||
: _provider.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.Cli.Plugins.Symbols;
|
||||
|
||||
public static class SymbolsCliValidation
|
||||
{
|
||||
private static readonly Regex PlatformRegex = new("^[a-z0-9]+-[a-z0-9]+$", RegexOptions.Compiled);
|
||||
|
||||
public static bool TryValidateExistingFile(
|
||||
string path,
|
||||
string description,
|
||||
out string errorMessage)
|
||||
{
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
errorMessage = $"{description} path is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
errorMessage = $"{description} file not found: {path}";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryValidateOptionalFile(
|
||||
string? path,
|
||||
string description,
|
||||
out string errorMessage)
|
||||
{
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
errorMessage = $"{description} file not found: {path}";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryValidateServerUrl(
|
||||
string? serverUrl,
|
||||
out Uri? uri,
|
||||
out string errorMessage)
|
||||
{
|
||||
uri = null;
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(serverUrl))
|
||||
{
|
||||
errorMessage = "Server URL is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(serverUrl, UriKind.Absolute, out var parsed))
|
||||
{
|
||||
errorMessage = "Server URL must be an absolute URI.";
|
||||
return false;
|
||||
}
|
||||
|
||||
uri = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryValidatePlatform(string? platform, out string errorMessage)
|
||||
{
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(platform))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!PlatformRegex.IsMatch(platform))
|
||||
{
|
||||
errorMessage = "Platform must match '<os>-<arch>' (example: linux-x64).";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0140-M | DONE | Maintainability audit for StellaOps.Cli.Plugins.Symbols. |
|
||||
| AUDIT-0140-T | DONE | Test coverage audit for StellaOps.Cli.Plugins.Symbols. |
|
||||
| AUDIT-0140-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0140-A | DONE | Applied Symbols plugin hardening and determinism fixes. |
|
||||
|
||||
@@ -5,24 +5,25 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<PluginOutputDirectory>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\plugins\cli\StellaOps.Cli.Plugins.Verdict\'))</PluginOutputDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\StellaOps.Cli\StellaOps.Cli.csproj" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Verdict\StellaOps.Verdict.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Spectre.Console" />
|
||||
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyPluginBinaries" AfterTargets="Build">
|
||||
<MakeDir Directories="$(PluginOutputDirectory)" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(PluginOutputDirectory)" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetName).pdb"
|
||||
DestinationFolder="$(PluginOutputDirectory)"
|
||||
Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
|
||||
<ItemGroup>
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetFileName)" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).pdb" Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).deps.json" Condition="Exists('$(TargetDir)$(TargetName).deps.json')" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).runtimeconfig.json" Condition="Exists('$(TargetDir)$(TargetName).runtimeconfig.json')" />
|
||||
<PluginArtifacts Include="@(ReferenceCopyLocalPaths)" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(PluginArtifacts)" DestinationFolder="$(PluginOutputDirectory)" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0141-M | DONE | Maintainability audit for StellaOps.Cli.Plugins.Verdict. |
|
||||
| AUDIT-0141-T | DONE | Test coverage audit for StellaOps.Cli.Plugins.Verdict. |
|
||||
| AUDIT-0141-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0141-A | DONE | Applied Verdict plugin hardening and determinism fixes. |
|
||||
|
||||
@@ -9,7 +9,7 @@ using System.CommandLine;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Spectre.Console;
|
||||
using StellaOps.Canonical.Json;
|
||||
using StellaOps.Cli.Configuration;
|
||||
using StellaOps.Cli.Plugins;
|
||||
using StellaOps.Verdict.Schema;
|
||||
@@ -128,8 +128,7 @@ public sealed class VerdictCliCommandModule : ICliCommandModule
|
||||
|
||||
if (string.IsNullOrWhiteSpace(verdict))
|
||||
{
|
||||
AnsiConsole.MarkupLine("[red]Error:[/] --verdict is required.");
|
||||
return 1;
|
||||
return await ValidationFailedAsync("--verdict is required.").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return await RunVerdictVerifyAsync(
|
||||
@@ -165,168 +164,150 @@ public sealed class VerdictCliCommandModule : ICliCommandModule
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var logger = services.GetService<ILogger<VerdictCliCommandModule>>();
|
||||
var timeProvider = services.GetService<TimeProvider>() ?? TimeProvider.System;
|
||||
var result = new VerdictVerificationResult();
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Load the verdict
|
||||
Console.WriteLine("Loading verdict...");
|
||||
StellaVerdict? loadedVerdict = null;
|
||||
string? loadError = null;
|
||||
|
||||
await AnsiConsole.Status()
|
||||
.StartAsync("Loading verdict...", async ctx =>
|
||||
if (verdictPath.StartsWith("urn:stella:verdict:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var fetchResult = await FetchVerdictFromApiAsync(services, verdictPath, options, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
loadedVerdict = fetchResult.Verdict;
|
||||
loadError = fetchResult.Error;
|
||||
}
|
||||
else if (File.Exists(verdictPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
ctx.Spinner(Spinner.Known.Dots);
|
||||
|
||||
if (verdictPath.StartsWith("urn:stella:verdict:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Fetch from API
|
||||
ctx.Status("Fetching verdict from API...");
|
||||
loadedVerdict = await FetchVerdictFromApiAsync(services, verdictPath, options, cancellationToken);
|
||||
}
|
||||
else if (File.Exists(verdictPath))
|
||||
{
|
||||
// Load from file
|
||||
ctx.Status("Loading verdict from file...");
|
||||
var json = await File.ReadAllTextAsync(verdictPath, cancellationToken);
|
||||
loadedVerdict = JsonSerializer.Deserialize<StellaVerdict>(json, JsonOptions);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Error = $"Verdict not found: {verdictPath}";
|
||||
}
|
||||
});
|
||||
var json = await File.ReadAllTextAsync(verdictPath, cancellationToken).ConfigureAwait(false);
|
||||
loadedVerdict = JsonSerializer.Deserialize<StellaVerdict>(json, JsonOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
loadError = $"Verdict JSON error: {ex.Message}";
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
loadError = $"Verdict read error: {ex.Message}";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
loadError = $"Verdict not found: {verdictPath}";
|
||||
}
|
||||
|
||||
if (loadedVerdict is null)
|
||||
{
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {result.Error ?? "Failed to load verdict"}");
|
||||
return 1;
|
||||
result.Error = loadError ?? "Failed to load verdict.";
|
||||
return await ValidationFailedAsync(result.Error).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
result.VerdictId = loadedVerdict.VerdictId;
|
||||
|
||||
// Step 2: Verify content-addressable ID
|
||||
await AnsiConsole.Status()
|
||||
.StartAsync("Verifying content ID...", ctx =>
|
||||
{
|
||||
ctx.Spinner(Spinner.Known.Dots);
|
||||
|
||||
var computedId = loadedVerdict.ComputeVerdictId();
|
||||
result.ContentIdValid = string.Equals(loadedVerdict.VerdictId, computedId, StringComparison.Ordinal);
|
||||
|
||||
if (!result.ContentIdValid)
|
||||
{
|
||||
result.ContentIdMismatch = $"Expected {computedId}, got {loadedVerdict.VerdictId}";
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
Console.WriteLine("Verifying content ID...");
|
||||
var computedId = loadedVerdict.ComputeVerdictId();
|
||||
result.ContentIdValid = string.Equals(loadedVerdict.VerdictId, computedId, StringComparison.Ordinal);
|
||||
if (!result.ContentIdValid)
|
||||
{
|
||||
result.ContentIdMismatch = $"Expected {computedId}, got {loadedVerdict.VerdictId}";
|
||||
}
|
||||
|
||||
// Step 3: Check signature
|
||||
await AnsiConsole.Status()
|
||||
.StartAsync("Checking signatures...", ctx =>
|
||||
Console.WriteLine("Checking signatures...");
|
||||
result.HasSignatures = !loadedVerdict.Signatures.IsDefaultOrEmpty && loadedVerdict.Signatures.Length > 0;
|
||||
result.SignatureCount = result.HasSignatures ? loadedVerdict.Signatures.Length : 0;
|
||||
|
||||
if (result.HasSignatures && !string.IsNullOrEmpty(trustedKeysPath))
|
||||
{
|
||||
if (!File.Exists(trustedKeysPath))
|
||||
{
|
||||
ctx.Spinner(Spinner.Known.Dots);
|
||||
return await ValidationFailedAsync($"Trusted keys file not found: {trustedKeysPath}")
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
result.HasSignatures = !loadedVerdict.Signatures.IsDefaultOrEmpty && loadedVerdict.Signatures.Length > 0;
|
||||
result.SignatureCount = result.HasSignatures ? loadedVerdict.Signatures.Length : 0;
|
||||
|
||||
if (result.HasSignatures && !string.IsNullOrEmpty(trustedKeysPath))
|
||||
{
|
||||
// TODO: Implement full signature verification with trusted keys
|
||||
result.SignaturesVerified = false;
|
||||
result.SignatureMessage = "Signature verification with trusted keys not yet implemented";
|
||||
}
|
||||
else if (result.HasSignatures)
|
||||
{
|
||||
result.SignaturesVerified = false;
|
||||
result.SignatureMessage = "Signatures present but no trusted keys provided for verification";
|
||||
}
|
||||
else
|
||||
{
|
||||
result.SignatureMessage = "Verdict has no signatures";
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
result.SignaturesVerified = false;
|
||||
result.SignatureMessage = "Signature verification not implemented.";
|
||||
}
|
||||
else if (result.HasSignatures)
|
||||
{
|
||||
result.SignaturesVerified = false;
|
||||
result.SignatureMessage = "Signatures present but no trusted keys provided.";
|
||||
}
|
||||
else
|
||||
{
|
||||
result.SignatureMessage = "Verdict has no signatures.";
|
||||
}
|
||||
|
||||
// Step 4: Verify inputs hash if provided
|
||||
if (!string.IsNullOrEmpty(inputsPath))
|
||||
{
|
||||
await AnsiConsole.Status()
|
||||
.StartAsync("Verifying inputs hash...", async ctx =>
|
||||
{
|
||||
ctx.Spinner(Spinner.Known.Dots);
|
||||
Console.WriteLine("Verifying inputs hash...");
|
||||
|
||||
if (File.Exists(inputsPath))
|
||||
{
|
||||
var inputsJson = await File.ReadAllTextAsync(inputsPath, cancellationToken);
|
||||
var inputsHash = ComputeHash(inputsJson);
|
||||
if (File.Exists(inputsPath))
|
||||
{
|
||||
var inputsBytes = await File.ReadAllBytesAsync(inputsPath, cancellationToken).ConfigureAwait(false);
|
||||
var inputsHash = VerdictCliHashing.ComputeInputsHashFromJson(inputsBytes);
|
||||
var verdictInputsHash = VerdictCliHashing.ComputeInputsHashFromVerdict(loadedVerdict.Inputs);
|
||||
|
||||
// Compare with verdict's deterministic inputs hash
|
||||
var verdictInputsJson = JsonSerializer.Serialize(loadedVerdict.Inputs, JsonOptions);
|
||||
var verdictInputsHash = ComputeHash(verdictInputsJson);
|
||||
|
||||
result.InputsHashValid = string.Equals(inputsHash, verdictInputsHash, StringComparison.OrdinalIgnoreCase);
|
||||
result.InputsHashMessage = result.InputsHashValid == true
|
||||
? "Inputs hash matches"
|
||||
: $"Inputs hash mismatch: file={inputsHash[..16]}..., verdict={verdictInputsHash[..16]}...";
|
||||
}
|
||||
else
|
||||
{
|
||||
result.InputsHashValid = false;
|
||||
result.InputsHashMessage = $"Inputs file not found: {inputsPath}";
|
||||
}
|
||||
});
|
||||
result.InputsHashValid = string.Equals(inputsHash, verdictInputsHash, StringComparison.OrdinalIgnoreCase);
|
||||
result.InputsHashMessage = result.InputsHashValid == true
|
||||
? "Inputs hash matches"
|
||||
: $"Inputs hash mismatch: file={inputsHash[..16]}..., verdict={verdictInputsHash[..16]}...";
|
||||
}
|
||||
else
|
||||
{
|
||||
result.InputsHashValid = false;
|
||||
result.InputsHashMessage = $"Inputs file not found: {inputsPath}";
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Verify replay bundle if provided
|
||||
if (!string.IsNullOrEmpty(replayPath))
|
||||
{
|
||||
await AnsiConsole.Status()
|
||||
.StartAsync("Verifying replay bundle...", async ctx =>
|
||||
{
|
||||
ctx.Spinner(Spinner.Known.Dots);
|
||||
Console.WriteLine("Verifying replay bundle...");
|
||||
|
||||
if (Directory.Exists(replayPath))
|
||||
{
|
||||
// Check for manifest
|
||||
var manifestPath = Path.Combine(replayPath, "manifest.json");
|
||||
if (File.Exists(manifestPath))
|
||||
{
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken);
|
||||
// TODO: Parse manifest and verify all referenced files
|
||||
result.ReplayBundleValid = true;
|
||||
result.ReplayBundleMessage = "Replay bundle structure valid";
|
||||
}
|
||||
else
|
||||
{
|
||||
result.ReplayBundleValid = false;
|
||||
result.ReplayBundleMessage = "Replay bundle missing manifest.json";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
result.ReplayBundleValid = false;
|
||||
result.ReplayBundleMessage = $"Replay bundle directory not found: {replayPath}";
|
||||
}
|
||||
});
|
||||
if (Directory.Exists(replayPath))
|
||||
{
|
||||
var manifestPath = Path.Combine(replayPath, "manifest.json");
|
||||
if (File.Exists(manifestPath))
|
||||
{
|
||||
result.ReplayBundleValid = true;
|
||||
result.ReplayBundleMessage = "Replay bundle structure valid (manifest.json present).";
|
||||
}
|
||||
else
|
||||
{
|
||||
result.ReplayBundleValid = false;
|
||||
result.ReplayBundleMessage = "Replay bundle missing manifest.json.";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
result.ReplayBundleValid = false;
|
||||
result.ReplayBundleMessage = $"Replay bundle directory not found: {replayPath}";
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Check expiration
|
||||
result.IsExpired = false;
|
||||
if (!string.IsNullOrEmpty(loadedVerdict.Result.ExpiresAt))
|
||||
if (VerdictCliHashing.TryParseExpiration(
|
||||
loadedVerdict.Result.ExpiresAt,
|
||||
timeProvider,
|
||||
out var expiresAt,
|
||||
out var isExpired))
|
||||
{
|
||||
if (DateTimeOffset.TryParse(loadedVerdict.Result.ExpiresAt, out var expiresAt))
|
||||
{
|
||||
result.IsExpired = expiresAt < DateTimeOffset.UtcNow;
|
||||
result.ExpiresAt = expiresAt;
|
||||
}
|
||||
result.ExpiresAt = expiresAt;
|
||||
result.IsExpired = isExpired;
|
||||
}
|
||||
|
||||
// Determine overall validity
|
||||
result.IsValid = result.ContentIdValid
|
||||
&& (!result.HasSignatures || result.SignaturesVerified == true)
|
||||
&& !result.IsExpired
|
||||
&& (string.IsNullOrEmpty(inputsPath) || result.InputsHashValid == true)
|
||||
&& (string.IsNullOrEmpty(replayPath) || result.ReplayBundleValid == true);
|
||||
|
||||
@@ -346,12 +327,13 @@ public sealed class VerdictCliCommandModule : ICliCommandModule
|
||||
inputsHashValid = result.InputsHashValid,
|
||||
replayBundleValid = result.ReplayBundleValid,
|
||||
verdict = loadedVerdict
|
||||
}, new JsonSerializerOptions { WriteIndented = true });
|
||||
}, JsonOutputOptions);
|
||||
|
||||
if (!string.IsNullOrEmpty(outputPath))
|
||||
{
|
||||
await File.WriteAllTextAsync(outputPath, resultJson, cancellationToken);
|
||||
AnsiConsole.MarkupLine($"[green]Results written to:[/] {outputPath}");
|
||||
EnsureOutputDirectory(outputPath);
|
||||
await File.WriteAllTextAsync(outputPath, resultJson, cancellationToken).ConfigureAwait(false);
|
||||
Console.WriteLine($"Results written to: {outputPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -360,226 +342,123 @@ public sealed class VerdictCliCommandModule : ICliCommandModule
|
||||
}
|
||||
else
|
||||
{
|
||||
RenderTableResult(loadedVerdict, result, showTrace, showEvidence, verbose);
|
||||
RenderTextResult(loadedVerdict, result, showTrace, showEvidence, verbose);
|
||||
}
|
||||
|
||||
// Return appropriate exit code
|
||||
if (!result.IsValid)
|
||||
{
|
||||
return 1; // Invalid
|
||||
}
|
||||
|
||||
if (result.IsExpired)
|
||||
{
|
||||
return 2; // Expired
|
||||
}
|
||||
|
||||
if (!result.IsValid)
|
||||
{
|
||||
return 1; // Invalid
|
||||
}
|
||||
|
||||
return 0; // Valid
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.LogError(ex, "Failed to verify verdict: {Path}", verdictPath);
|
||||
AnsiConsole.MarkupLine($"[red]Error:[/] {ex.Message}");
|
||||
return 1;
|
||||
return await ValidationFailedAsync($"Failed to verify verdict: {ex.Message}").ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RenderTableResult(
|
||||
private static void RenderTextResult(
|
||||
StellaVerdict verdict,
|
||||
VerdictVerificationResult result,
|
||||
bool showTrace,
|
||||
bool showEvidence,
|
||||
bool verbose)
|
||||
{
|
||||
// Status panel
|
||||
var statusColor = result.IsValid ? "green" : (result.IsExpired ? "yellow" : "red");
|
||||
var statusText = result.IsValid ? "VALID" : (result.IsExpired ? "EXPIRED" : "INVALID");
|
||||
|
||||
var statusPanel = new Panel(
|
||||
new Markup($"[bold {statusColor}]{statusText}[/]"))
|
||||
.Header("[bold]Verification Result[/]")
|
||||
.Border(BoxBorder.Rounded)
|
||||
.Padding(1, 0);
|
||||
|
||||
AnsiConsole.Write(statusPanel);
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
// Subject info
|
||||
var subjectTable = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.Title("[bold]Subject[/]")
|
||||
.AddColumn("Property")
|
||||
.AddColumn("Value");
|
||||
|
||||
subjectTable.AddRow("Verdict ID", verdict.VerdictId);
|
||||
subjectTable.AddRow("Vulnerability", verdict.Subject.VulnerabilityId);
|
||||
subjectTable.AddRow("Component", verdict.Subject.Purl);
|
||||
var statusText = result.IsExpired ? "EXPIRED" : (result.IsValid ? "VALID" : "INVALID");
|
||||
Console.WriteLine($"Verdict verification result: {statusText}");
|
||||
Console.WriteLine($"Verdict ID: {verdict.VerdictId}");
|
||||
Console.WriteLine($"Vulnerability: {verdict.Subject.VulnerabilityId}");
|
||||
Console.WriteLine($"Component: {verdict.Subject.Purl}");
|
||||
if (!string.IsNullOrEmpty(verdict.Subject.ImageDigest))
|
||||
{
|
||||
subjectTable.AddRow("Image", verdict.Subject.ImageDigest);
|
||||
Console.WriteLine($"Image: {verdict.Subject.ImageDigest}");
|
||||
}
|
||||
|
||||
AnsiConsole.Write(subjectTable);
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
// Claim info
|
||||
var claimTable = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.Title("[bold]Claim[/]")
|
||||
.AddColumn("Property")
|
||||
.AddColumn("Value");
|
||||
|
||||
var claimStatusColor = verdict.Claim.Status switch
|
||||
{
|
||||
VerdictStatus.Pass => "green",
|
||||
VerdictStatus.Blocked => "red",
|
||||
VerdictStatus.Warned => "yellow",
|
||||
VerdictStatus.Ignored => "grey",
|
||||
VerdictStatus.Deferred => "blue",
|
||||
VerdictStatus.Escalated => "orange1",
|
||||
VerdictStatus.RequiresVex => "purple",
|
||||
_ => "white"
|
||||
};
|
||||
|
||||
claimTable.AddRow("Status", $"[{claimStatusColor}]{verdict.Claim.Status}[/]");
|
||||
claimTable.AddRow("Disposition", verdict.Result.Disposition);
|
||||
claimTable.AddRow("Score", $"{verdict.Result.Score:F2}");
|
||||
claimTable.AddRow("Confidence", $"{verdict.Claim.Confidence:P0}");
|
||||
Console.WriteLine($"Claim status: {verdict.Claim.Status}");
|
||||
Console.WriteLine($"Disposition: {verdict.Result.Disposition}");
|
||||
Console.WriteLine($"Score: {verdict.Result.Score:F2}");
|
||||
Console.WriteLine($"Confidence: {verdict.Claim.Confidence:P0}");
|
||||
if (!string.IsNullOrEmpty(verdict.Claim.Reason))
|
||||
{
|
||||
claimTable.AddRow("Reason", verdict.Claim.Reason);
|
||||
Console.WriteLine($"Reason: {verdict.Claim.Reason}");
|
||||
}
|
||||
|
||||
AnsiConsole.Write(claimTable);
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
// Verification checks
|
||||
var checksTable = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.Title("[bold]Verification Checks[/]")
|
||||
.AddColumn("Check")
|
||||
.AddColumn("Result")
|
||||
.AddColumn("Details");
|
||||
|
||||
checksTable.AddRow(
|
||||
"Content ID",
|
||||
result.ContentIdValid ? "[green]PASS[/]" : "[red]FAIL[/]",
|
||||
result.ContentIdValid ? "Hash matches" : result.ContentIdMismatch ?? "Hash mismatch");
|
||||
|
||||
checksTable.AddRow(
|
||||
"Signatures",
|
||||
result.HasSignatures
|
||||
? (result.SignaturesVerified == true ? "[green]VERIFIED[/]" : "[yellow]PRESENT[/]")
|
||||
: "[grey]NONE[/]",
|
||||
result.SignatureMessage ?? (result.HasSignatures ? $"{result.SignatureCount} signature(s)" : "No signatures"));
|
||||
|
||||
Console.WriteLine($"Content ID: {(result.ContentIdValid ? "PASS" : "FAIL")} {result.ContentIdMismatch ?? ""}".TrimEnd());
|
||||
Console.WriteLine($"Signatures: {FormatSignatureStatus(result)}");
|
||||
if (!string.IsNullOrEmpty(result.SignatureMessage))
|
||||
{
|
||||
Console.WriteLine($"Signature detail: {result.SignatureMessage}");
|
||||
}
|
||||
if (result.InputsHashValid.HasValue)
|
||||
{
|
||||
checksTable.AddRow(
|
||||
"Inputs Hash",
|
||||
result.InputsHashValid.Value ? "[green]PASS[/]" : "[red]FAIL[/]",
|
||||
result.InputsHashMessage ?? "");
|
||||
Console.WriteLine($"Inputs hash: {(result.InputsHashValid.Value ? "PASS" : "FAIL")} {result.InputsHashMessage}");
|
||||
}
|
||||
|
||||
if (result.ReplayBundleValid.HasValue)
|
||||
{
|
||||
checksTable.AddRow(
|
||||
"Replay Bundle",
|
||||
result.ReplayBundleValid.Value ? "[green]VALID[/]" : "[red]INVALID[/]",
|
||||
result.ReplayBundleMessage ?? "");
|
||||
Console.WriteLine($"Replay bundle: {(result.ReplayBundleValid.Value ? "VALID" : "INVALID")} {result.ReplayBundleMessage}");
|
||||
}
|
||||
|
||||
checksTable.AddRow(
|
||||
"Expiration",
|
||||
result.IsExpired ? "[red]EXPIRED[/]" : "[green]VALID[/]",
|
||||
result.ExpiresAt.HasValue
|
||||
? (result.IsExpired ? $"Expired {result.ExpiresAt:g}" : $"Expires {result.ExpiresAt:g}")
|
||||
: "No expiration");
|
||||
Console.WriteLine(result.ExpiresAt.HasValue
|
||||
? (result.IsExpired ? $"Expired: {result.ExpiresAt:O}" : $"Expires: {result.ExpiresAt:O}")
|
||||
: "Expiration: none");
|
||||
|
||||
AnsiConsole.Write(checksTable);
|
||||
AnsiConsole.WriteLine();
|
||||
|
||||
// Policy trace
|
||||
if (showTrace && !verdict.PolicyPath.IsDefaultOrEmpty)
|
||||
{
|
||||
var traceTable = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.Title("[bold]Policy Evaluation Trace[/]")
|
||||
.AddColumn("#")
|
||||
.AddColumn("Rule")
|
||||
.AddColumn("Matched")
|
||||
.AddColumn("Action")
|
||||
.AddColumn("Reason");
|
||||
|
||||
Console.WriteLine("Policy trace:");
|
||||
foreach (var step in verdict.PolicyPath.OrderBy(s => s.Order))
|
||||
{
|
||||
traceTable.AddRow(
|
||||
step.Order.ToString(),
|
||||
step.RuleName ?? step.RuleId,
|
||||
step.Matched ? "[green]Yes[/]" : "[grey]No[/]",
|
||||
step.Action ?? "-",
|
||||
step.Reason ?? "-");
|
||||
Console.WriteLine($" {step.Order}: {step.RuleName ?? step.RuleId} matched={step.Matched} action={step.Action ?? "-"} reason={step.Reason ?? "-"}");
|
||||
}
|
||||
|
||||
AnsiConsole.Write(traceTable);
|
||||
AnsiConsole.WriteLine();
|
||||
}
|
||||
|
||||
// Evidence graph
|
||||
if (showEvidence && verdict.EvidenceGraph is not null)
|
||||
{
|
||||
var evidenceTable = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.Title("[bold]Evidence Graph[/]")
|
||||
.AddColumn("Node ID")
|
||||
.AddColumn("Type")
|
||||
.AddColumn("Label");
|
||||
|
||||
foreach (var node in verdict.EvidenceGraph.Nodes)
|
||||
Console.WriteLine("Evidence graph:");
|
||||
foreach (var node in verdict.EvidenceGraph.Nodes.OrderBy(n => n.Id, StringComparer.Ordinal))
|
||||
{
|
||||
var shortId = node.Id.Length > 16 ? node.Id[..16] + "..." : node.Id;
|
||||
evidenceTable.AddRow(
|
||||
shortId,
|
||||
node.Type,
|
||||
node.Label ?? "-");
|
||||
Console.WriteLine($" {shortId} {node.Type} {node.Label ?? "-"}");
|
||||
}
|
||||
|
||||
AnsiConsole.Write(evidenceTable);
|
||||
AnsiConsole.WriteLine();
|
||||
}
|
||||
|
||||
// Provenance
|
||||
if (verbose)
|
||||
{
|
||||
var provTable = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.Title("[bold]Provenance[/]")
|
||||
.AddColumn("Property")
|
||||
.AddColumn("Value");
|
||||
|
||||
provTable.AddRow("Generator", verdict.Provenance.Generator);
|
||||
Console.WriteLine("Provenance:");
|
||||
Console.WriteLine($" Generator: {verdict.Provenance.Generator}");
|
||||
if (!string.IsNullOrEmpty(verdict.Provenance.GeneratorVersion))
|
||||
{
|
||||
provTable.AddRow("Version", verdict.Provenance.GeneratorVersion);
|
||||
Console.WriteLine($" Version: {verdict.Provenance.GeneratorVersion}");
|
||||
}
|
||||
if (!string.IsNullOrEmpty(verdict.Provenance.RunId))
|
||||
{
|
||||
provTable.AddRow("Run ID", verdict.Provenance.RunId);
|
||||
Console.WriteLine($" Run ID: {verdict.Provenance.RunId}");
|
||||
}
|
||||
provTable.AddRow("Created", verdict.Provenance.CreatedAt);
|
||||
|
||||
AnsiConsole.Write(provTable);
|
||||
Console.WriteLine($" Created: {verdict.Provenance.CreatedAt}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<StellaVerdict?> FetchVerdictFromApiAsync(
|
||||
private static async Task<(StellaVerdict? Verdict, string? Error)> FetchVerdictFromApiAsync(
|
||||
IServiceProvider services,
|
||||
string verdictId,
|
||||
StellaOpsCliOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var httpClientFactory = services.GetService<IHttpClientFactory>();
|
||||
var httpClient = httpClientFactory?.CreateClient("verdict") ?? new HttpClient();
|
||||
var httpClient = httpClientFactory?.CreateClient("verdict");
|
||||
var disposeClient = false;
|
||||
if (httpClient is null)
|
||||
{
|
||||
httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
||||
disposeClient = true;
|
||||
}
|
||||
|
||||
var baseUrl = options.BackendUrl?.TrimEnd('/')
|
||||
?? Environment.GetEnvironmentVariable("STELLAOPS_BACKEND_URL")
|
||||
@@ -590,26 +469,29 @@ public sealed class VerdictCliCommandModule : ICliCommandModule
|
||||
|
||||
try
|
||||
{
|
||||
var response = await httpClient.GetAsync(url, cancellationToken);
|
||||
var response = await httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return null;
|
||||
return (null, $"Verdict fetch failed ({(int)response.StatusCode} {response.ReasonPhrase}).");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return JsonSerializer.Deserialize<StellaVerdict>(json, JsonOptions);
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
var verdict = JsonSerializer.Deserialize<StellaVerdict>(json, JsonOptions);
|
||||
return verdict is null
|
||||
? (null, "Verdict response could not be parsed.")
|
||||
: (verdict, null);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
return null;
|
||||
return (null, $"Verdict fetch failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (disposeClient)
|
||||
{
|
||||
httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeHash(string content)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
@@ -618,6 +500,41 @@ public sealed class VerdictCliCommandModule : ICliCommandModule
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOutputOptions = new(JsonOptions)
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
private static string FormatSignatureStatus(VerdictVerificationResult result)
|
||||
{
|
||||
if (!result.HasSignatures)
|
||||
{
|
||||
return "NONE";
|
||||
}
|
||||
|
||||
if (result.SignaturesVerified == true)
|
||||
{
|
||||
return "VERIFIED";
|
||||
}
|
||||
|
||||
return "PRESENT";
|
||||
}
|
||||
|
||||
private static void EnsureOutputDirectory(string outputPath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(outputPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> ValidationFailedAsync(string message)
|
||||
{
|
||||
await Console.Error.WriteLineAsync(message).ConfigureAwait(false);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Globalization;
|
||||
using StellaOps.Canonical.Json;
|
||||
|
||||
namespace StellaOps.Cli.Plugins.Verdict;
|
||||
|
||||
public static class VerdictCliHashing
|
||||
{
|
||||
public static string ComputeInputsHashFromJson(ReadOnlySpan<byte> jsonBytes)
|
||||
{
|
||||
var canonical = CanonJson.CanonicalizeParsedJson(jsonBytes);
|
||||
return CanonJson.Sha256Hex(canonical);
|
||||
}
|
||||
|
||||
public static string ComputeInputsHashFromVerdict<T>(T inputs)
|
||||
{
|
||||
return CanonJson.Hash(inputs);
|
||||
}
|
||||
|
||||
public static bool TryParseExpiration(
|
||||
string? expiresAt,
|
||||
TimeProvider timeProvider,
|
||||
out DateTimeOffset? parsed,
|
||||
out bool isExpired)
|
||||
{
|
||||
parsed = null;
|
||||
isExpired = false;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(expiresAt))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DateTimeOffset.TryParse(
|
||||
expiresAt,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
|
||||
out var value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
parsed = value;
|
||||
isExpired = value < timeProvider.GetUtcNow();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
302
src/Cli/__Libraries/StellaOps.Cli.Plugins.Vex/AutoVexClient.cs
Normal file
302
src/Cli/__Libraries/StellaOps.Cli.Plugins.Vex/AutoVexClient.cs
Normal file
@@ -0,0 +1,302 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Plugins.Vex;
|
||||
|
||||
/// <summary>
|
||||
/// Output format for CLI commands.
|
||||
/// </summary>
|
||||
public enum OutputFormat
|
||||
{
|
||||
Table,
|
||||
Json,
|
||||
Csv
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client interface for auto-VEX operations.
|
||||
/// </summary>
|
||||
public interface IAutoVexClient
|
||||
{
|
||||
Task<AutoDowngradeCheckResult> CheckAutoDowngradeAsync(
|
||||
string image,
|
||||
int minObservations,
|
||||
double minCpu,
|
||||
double minConfidence,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<AutoDowngradeExecuteResult> ExecuteAutoDowngradeAsync(
|
||||
IReadOnlyList<AutoDowngradeCandidate> candidates,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotReachableAnalysisResult> AnalyzeNotReachableAsync(
|
||||
string image,
|
||||
TimeSpan window,
|
||||
double minConfidence,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<NotReachableVexGenerationResult> GenerateNotReachableVexAsync(
|
||||
IReadOnlyList<NotReachableAnalysisEntry> analyses,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of checking for auto-downgrade candidates.
|
||||
/// </summary>
|
||||
public sealed record AutoDowngradeCheckResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? ImageDigest { get; init; }
|
||||
public IReadOnlyList<AutoDowngradeCandidate>? Candidates { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A candidate for auto-downgrade.
|
||||
/// </summary>
|
||||
public sealed record AutoDowngradeCandidate
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public required string ProductId { get; init; }
|
||||
public required string Symbol { get; init; }
|
||||
public required string ComponentPath { get; init; }
|
||||
public required double CpuPercentage { get; init; }
|
||||
public required int ObservationCount { get; init; }
|
||||
public required double Confidence { get; init; }
|
||||
public required string BuildId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of executing auto-downgrades.
|
||||
/// </summary>
|
||||
public sealed record AutoDowngradeExecuteResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public int DowngradeCount { get; init; }
|
||||
public int Notifications { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of not-reachable analysis.
|
||||
/// </summary>
|
||||
public sealed record NotReachableAnalysisResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public IReadOnlyList<NotReachableAnalysisEntry>? Analyses { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entry for not-reachable analysis.
|
||||
/// </summary>
|
||||
public sealed record NotReachableAnalysisEntry
|
||||
{
|
||||
public required string CveId { get; init; }
|
||||
public required string ProductId { get; init; }
|
||||
public required string Symbol { get; init; }
|
||||
public required string ComponentPath { get; init; }
|
||||
public required double Confidence { get; init; }
|
||||
public string? PrimaryReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of generating not-reachable VEX statements.
|
||||
/// </summary>
|
||||
public sealed record NotReachableVexGenerationResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public int StatementCount { get; init; }
|
||||
public IReadOnlyList<object>? Statements { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client implementation for auto-VEX API.
|
||||
/// </summary>
|
||||
internal sealed class AutoVexHttpClient : IAutoVexClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions ResponseJsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _baseUrl;
|
||||
|
||||
public AutoVexHttpClient(HttpClient httpClient, string baseUrl)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_baseUrl = baseUrl?.TrimEnd('/') ?? throw new ArgumentNullException(nameof(baseUrl));
|
||||
}
|
||||
|
||||
public async Task<AutoDowngradeCheckResult> CheckAutoDowngradeAsync(
|
||||
string image,
|
||||
int minObservations,
|
||||
double minCpu,
|
||||
double minConfidence,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = $"{_baseUrl}/api/v1/vex/auto-downgrade/check?" +
|
||||
$"image={Uri.EscapeDataString(image)}&" +
|
||||
$"minObservations={minObservations.ToString(CultureInfo.InvariantCulture)}&" +
|
||||
$"minCpu={minCpu.ToString(CultureInfo.InvariantCulture)}&" +
|
||||
$"minConfidence={minConfidence.ToString(CultureInfo.InvariantCulture)}";
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new AutoDowngradeCheckResult
|
||||
{
|
||||
Success = false,
|
||||
Error = FormatStatusError("auto-downgrade check", response)
|
||||
};
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return DeserializeResponse<AutoDowngradeCheckResult>(json, "auto-downgrade check")
|
||||
?? new AutoDowngradeCheckResult { Success = false, Error = "Failed to deserialize response." };
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return new AutoDowngradeCheckResult { Success = false, Error = $"Response JSON error: {ex.Message}" };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AutoDowngradeCheckResult { Success = false, Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AutoDowngradeExecuteResult> ExecuteAutoDowngradeAsync(
|
||||
IReadOnlyList<AutoDowngradeCandidate> candidates,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = $"{_baseUrl}/api/v1/vex/auto-downgrade/execute";
|
||||
try
|
||||
{
|
||||
using var content = new StringContent(
|
||||
JsonSerializer.Serialize(candidates),
|
||||
System.Text.Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
using var response = await _httpClient.PostAsync(url, content, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new AutoDowngradeExecuteResult
|
||||
{
|
||||
Success = false,
|
||||
Error = FormatStatusError("auto-downgrade execution", response)
|
||||
};
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return DeserializeResponse<AutoDowngradeExecuteResult>(json, "auto-downgrade execution")
|
||||
?? new AutoDowngradeExecuteResult { Success = false, Error = "Failed to deserialize response." };
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return new AutoDowngradeExecuteResult { Success = false, Error = $"Response JSON error: {ex.Message}" };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AutoDowngradeExecuteResult { Success = false, Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotReachableAnalysisResult> AnalyzeNotReachableAsync(
|
||||
string image,
|
||||
TimeSpan window,
|
||||
double minConfidence,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = $"{_baseUrl}/api/v1/vex/not-reachable/analyze?" +
|
||||
$"image={Uri.EscapeDataString(image)}&" +
|
||||
$"windowHours={window.TotalHours.ToString(CultureInfo.InvariantCulture)}&" +
|
||||
$"minConfidence={minConfidence.ToString(CultureInfo.InvariantCulture)}";
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new NotReachableAnalysisResult
|
||||
{
|
||||
Success = false,
|
||||
Error = FormatStatusError("not-reachable analysis", response)
|
||||
};
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return DeserializeResponse<NotReachableAnalysisResult>(json, "not-reachable analysis")
|
||||
?? new NotReachableAnalysisResult { Success = false, Error = "Failed to deserialize response." };
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return new NotReachableAnalysisResult { Success = false, Error = $"Response JSON error: {ex.Message}" };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new NotReachableAnalysisResult { Success = false, Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<NotReachableVexGenerationResult> GenerateNotReachableVexAsync(
|
||||
IReadOnlyList<NotReachableAnalysisEntry> analyses,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = $"{_baseUrl}/api/v1/vex/not-reachable/generate";
|
||||
try
|
||||
{
|
||||
using var content = new StringContent(
|
||||
JsonSerializer.Serialize(analyses),
|
||||
System.Text.Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
using var response = await _httpClient.PostAsync(url, content, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new NotReachableVexGenerationResult
|
||||
{
|
||||
Success = false,
|
||||
Error = FormatStatusError("not-reachable generation", response)
|
||||
};
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return DeserializeResponse<NotReachableVexGenerationResult>(json, "not-reachable generation")
|
||||
?? new NotReachableVexGenerationResult { Success = false, Error = "Failed to deserialize response." };
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return new NotReachableVexGenerationResult { Success = false, Error = $"Response JSON error: {ex.Message}" };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new NotReachableVexGenerationResult { Success = false, Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
private static T? DeserializeResponse<T>(string json, string context)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(json, ResponseJsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to deserialize {context} response: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatStatusError(string context, HttpResponseMessage response)
|
||||
{
|
||||
var reason = string.IsNullOrWhiteSpace(response.ReasonPhrase) ? "request failed" : response.ReasonPhrase;
|
||||
return $"HTTP {(int)response.StatusCode} {reason} during {context}.";
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<PluginOutputDirectory>$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\plugins\cli\StellaOps.Cli.Plugins.Vex\'))</PluginOutputDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -18,15 +18,15 @@
|
||||
<ProjectReference Include="..\..\StellaOps.Cli\StellaOps.Cli.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Spectre.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyPluginBinaries" AfterTargets="Build">
|
||||
<MakeDir Directories="$(PluginOutputDirectory)" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetFileName)" DestinationFolder="$(PluginOutputDirectory)" />
|
||||
<Copy SourceFiles="$(TargetDir)$(TargetName).pdb"
|
||||
DestinationFolder="$(PluginOutputDirectory)"
|
||||
Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
|
||||
<ItemGroup>
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetFileName)" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).pdb" Condition="Exists('$(TargetDir)$(TargetName).pdb')" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).deps.json" Condition="Exists('$(TargetDir)$(TargetName).deps.json')" />
|
||||
<PluginArtifacts Include="$(TargetDir)$(TargetName).runtimeconfig.json" Condition="Exists('$(TargetDir)$(TargetName).runtimeconfig.json')" />
|
||||
<PluginArtifacts Include="@(ReferenceCopyLocalPaths)" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(PluginArtifacts)" DestinationFolder="$(PluginOutputDirectory)" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0142-M | DONE | Maintainability audit for StellaOps.Cli.Plugins.Vex. |
|
||||
| AUDIT-0142-T | DONE | Test coverage audit for StellaOps.Cli.Plugins.Vex. |
|
||||
| AUDIT-0142-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0142-A | DONE | Applied plugin hardening + validation + tests. |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
256
src/Cli/__Libraries/StellaOps.Cli.Plugins.Vex/VexCliOutput.cs
Normal file
256
src/Cli/__Libraries/StellaOps.Cli.Plugins.Vex/VexCliOutput.cs
Normal file
@@ -0,0 +1,256 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace StellaOps.Cli.Plugins.Vex;
|
||||
|
||||
internal static class VexCliOutput
|
||||
{
|
||||
private static readonly JsonSerializerOptions OutputJsonOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public static IReadOnlyList<AutoDowngradeCandidate> OrderCandidates(
|
||||
IReadOnlyList<AutoDowngradeCandidate>? candidates)
|
||||
{
|
||||
if (candidates is null || candidates.Count == 0)
|
||||
{
|
||||
return Array.Empty<AutoDowngradeCandidate>();
|
||||
}
|
||||
|
||||
return candidates
|
||||
.OrderBy(candidate => candidate.CveId, StringComparer.Ordinal)
|
||||
.ThenBy(candidate => candidate.Symbol, StringComparer.Ordinal)
|
||||
.ThenBy(candidate => candidate.ComponentPath, StringComparer.Ordinal)
|
||||
.ThenBy(candidate => candidate.ObservationCount)
|
||||
.ThenBy(candidate => candidate.CpuPercentage)
|
||||
.ThenBy(candidate => candidate.Confidence)
|
||||
.ThenBy(candidate => candidate.ProductId, StringComparer.Ordinal)
|
||||
.ThenBy(candidate => candidate.BuildId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static IReadOnlyList<NotReachableAnalysisEntry> OrderAnalyses(
|
||||
IReadOnlyList<NotReachableAnalysisEntry>? analyses)
|
||||
{
|
||||
if (analyses is null || analyses.Count == 0)
|
||||
{
|
||||
return Array.Empty<NotReachableAnalysisEntry>();
|
||||
}
|
||||
|
||||
return analyses
|
||||
.OrderBy(entry => entry.CveId, StringComparer.Ordinal)
|
||||
.ThenBy(entry => entry.Symbol, StringComparer.Ordinal)
|
||||
.ThenBy(entry => entry.ComponentPath, StringComparer.Ordinal)
|
||||
.ThenBy(entry => entry.Confidence)
|
||||
.ThenBy(entry => entry.ProductId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static async Task<int> WriteAutoDowngradeResultsAsync(
|
||||
AutoDowngradeCheckResult result,
|
||||
bool dryRun,
|
||||
OutputFormat format,
|
||||
string? outputPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = result.Candidates ?? Array.Empty<AutoDowngradeCandidate>();
|
||||
if (candidates.Count == 0 && format == OutputFormat.Table)
|
||||
{
|
||||
await Console.Out.WriteLineAsync("No hot vulnerable symbols detected.")
|
||||
.ConfigureAwait(false);
|
||||
return 0;
|
||||
}
|
||||
|
||||
var content = format switch
|
||||
{
|
||||
OutputFormat.Json => JsonSerializer.Serialize(result, OutputJsonOptions),
|
||||
OutputFormat.Csv => BuildAutoDowngradeCsv(candidates, dryRun),
|
||||
_ => BuildAutoDowngradeTable(candidates, dryRun, result.ImageDigest)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
EnsureOutputDirectory(outputPath);
|
||||
await File.WriteAllTextAsync(outputPath, content, cancellationToken).ConfigureAwait(false);
|
||||
await Console.Out.WriteLineAsync($"Results written to: {outputPath}").ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await Console.Out.WriteLineAsync(content).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static async Task WriteNotReachableResultsAsync(
|
||||
NotReachableAnalysisResult result,
|
||||
bool dryRun,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_ = cancellationToken;
|
||||
|
||||
var analyses = result.Analyses ?? Array.Empty<NotReachableAnalysisEntry>();
|
||||
if (analyses.Count == 0)
|
||||
{
|
||||
await Console.Out.WriteLineAsync("No unreached vulnerable symbols found requiring VEX.")
|
||||
.ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var content = BuildNotReachableTable(analyses, dryRun);
|
||||
await Console.Out.WriteLineAsync(content).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task WriteStatementsAsync(
|
||||
IReadOnlyList<object>? statements,
|
||||
string outputPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
EnsureOutputDirectory(outputPath);
|
||||
|
||||
var content = JsonSerializer.Serialize(statements ?? Array.Empty<object>(), OutputJsonOptions);
|
||||
await File.WriteAllTextAsync(outputPath, content, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task<int> WriteErrorAsync(string message)
|
||||
{
|
||||
await Console.Error.WriteLineAsync(message).ConfigureAwait(false);
|
||||
return 1;
|
||||
}
|
||||
|
||||
public static async Task<int> WriteNotImplementedAsync(string commandName)
|
||||
{
|
||||
await Console.Error.WriteLineAsync($"{commandName} is not implemented.").ConfigureAwait(false);
|
||||
return 2;
|
||||
}
|
||||
|
||||
private static string BuildAutoDowngradeCsv(IReadOnlyList<AutoDowngradeCandidate> candidates, bool dryRun)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("cve_id,symbol,component_path,cpu_percentage,observations,confidence,status");
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var status = dryRun ? "pending" : "downgrade";
|
||||
builder
|
||||
.Append(EscapeCsv(candidate.CveId)).Append(',')
|
||||
.Append(EscapeCsv(candidate.Symbol)).Append(',')
|
||||
.Append(EscapeCsv(candidate.ComponentPath)).Append(',')
|
||||
.Append(candidate.CpuPercentage.ToString("F2", CultureInfo.InvariantCulture)).Append(',')
|
||||
.Append(candidate.ObservationCount.ToString(CultureInfo.InvariantCulture)).Append(',')
|
||||
.Append(candidate.Confidence.ToString("F2", CultureInfo.InvariantCulture)).Append(',')
|
||||
.Append(EscapeCsv(status))
|
||||
.AppendLine();
|
||||
}
|
||||
|
||||
return builder.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string BuildAutoDowngradeTable(
|
||||
IReadOnlyList<AutoDowngradeCandidate> candidates,
|
||||
bool dryRun,
|
||||
string? imageDigest)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine(dryRun ? "Auto-downgrade candidates (dry run)" : "Hot vulnerable symbols");
|
||||
builder.AppendLine("CVE | Symbol | CPU% | Observations | Confidence | Status");
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var status = dryRun ? "pending" : "downgrade";
|
||||
builder
|
||||
.Append(candidate.CveId).Append(" | ")
|
||||
.Append(Truncate(candidate.Symbol, 40)).Append(" | ")
|
||||
.Append(candidate.CpuPercentage.ToString("F1", CultureInfo.InvariantCulture)).Append(" | ")
|
||||
.Append(candidate.ObservationCount.ToString(CultureInfo.InvariantCulture)).Append(" | ")
|
||||
.Append(candidate.Confidence.ToString("F2", CultureInfo.InvariantCulture)).Append(" | ")
|
||||
.Append(status)
|
||||
.AppendLine();
|
||||
}
|
||||
|
||||
builder.AppendLine();
|
||||
builder.AppendLine($"Total candidates: {candidates.Count.ToString(CultureInfo.InvariantCulture)}");
|
||||
|
||||
if (candidates.Count > 0)
|
||||
{
|
||||
var maxCpu = candidates.Max(candidate => candidate.CpuPercentage);
|
||||
builder.AppendLine($"Highest CPU: {maxCpu.ToString("F1", CultureInfo.InvariantCulture)}%");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(imageDigest))
|
||||
{
|
||||
builder.AppendLine($"Image: {imageDigest}");
|
||||
}
|
||||
|
||||
return builder.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string BuildNotReachableTable(IReadOnlyList<NotReachableAnalysisEntry> analyses, bool dryRun)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("Symbols not reachable at runtime");
|
||||
builder.AppendLine("CVE | Symbol | Component | Confidence | Reason");
|
||||
|
||||
foreach (var analysis in analyses)
|
||||
{
|
||||
var reason = string.IsNullOrWhiteSpace(analysis.PrimaryReason) ? "Unknown" : analysis.PrimaryReason;
|
||||
builder
|
||||
.Append(analysis.CveId).Append(" | ")
|
||||
.Append(Truncate(analysis.Symbol, 30)).Append(" | ")
|
||||
.Append(TruncatePath(analysis.ComponentPath, 30)).Append(" | ")
|
||||
.Append(analysis.Confidence.ToString("F2", CultureInfo.InvariantCulture)).Append(" | ")
|
||||
.Append(reason)
|
||||
.AppendLine();
|
||||
}
|
||||
|
||||
builder.AppendLine();
|
||||
builder.AppendLine($"Total analyses: {analyses.Count.ToString(CultureInfo.InvariantCulture)}");
|
||||
builder.AppendLine(dryRun ? "Mode: dry run" : "Mode: generate VEX");
|
||||
|
||||
return builder.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value[..(maxLength - 3)] + "...";
|
||||
}
|
||||
|
||||
private static string TruncatePath(string value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return "..." + value[^Math.Min(maxLength - 3, value.Length)..];
|
||||
}
|
||||
|
||||
private static string EscapeCsv(string value)
|
||||
{
|
||||
if (value.IndexOfAny([',', '"', '\n', '\r']) < 0)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var escaped = value.Replace("\"", "\"\"");
|
||||
return $"\"{escaped}\"";
|
||||
}
|
||||
|
||||
private static void EnsureOutputDirectory(string outputPath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(outputPath);
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
namespace StellaOps.Cli.Plugins.Vex;
|
||||
|
||||
public static class VexCliValidation
|
||||
{
|
||||
public static bool TryResolveTargetImage(
|
||||
string? image,
|
||||
string? check,
|
||||
out string targetImage,
|
||||
out string errorMessage)
|
||||
{
|
||||
targetImage = string.Empty;
|
||||
errorMessage = string.Empty;
|
||||
|
||||
var hasImage = !string.IsNullOrWhiteSpace(image);
|
||||
var hasCheck = !string.IsNullOrWhiteSpace(check);
|
||||
|
||||
if (!hasImage && !hasCheck)
|
||||
{
|
||||
errorMessage = "Either --image or --check must be specified.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasImage && hasCheck)
|
||||
{
|
||||
errorMessage = "--image and --check are mutually exclusive.";
|
||||
return false;
|
||||
}
|
||||
|
||||
targetImage = hasImage ? image! : check!;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryValidateMin(string name, int value, int minInclusive, out string errorMessage)
|
||||
{
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (value < minInclusive)
|
||||
{
|
||||
errorMessage = $"{name} must be >= {minInclusive}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryValidateMin(string name, double value, double minInclusive, out string errorMessage)
|
||||
{
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (value < minInclusive)
|
||||
{
|
||||
errorMessage = $"{name} must be >= {minInclusive}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryValidateRange(
|
||||
string name,
|
||||
double value,
|
||||
double minInclusive,
|
||||
double maxInclusive,
|
||||
out string errorMessage)
|
||||
{
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (value < minInclusive || value > maxInclusive)
|
||||
{
|
||||
errorMessage = $"{name} must be between {minInclusive} and {maxInclusive}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryValidateOutputPath(string? outputPath, out string errorMessage)
|
||||
{
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(outputPath);
|
||||
if (Directory.Exists(fullPath))
|
||||
{
|
||||
errorMessage = "Output path must be a file, not a directory.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Output path is invalid: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryValidateServerUrl(string? serverUrl, out Uri? uri, out string errorMessage)
|
||||
{
|
||||
uri = null;
|
||||
errorMessage = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(serverUrl))
|
||||
{
|
||||
errorMessage = "Server URL is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(serverUrl, UriKind.Absolute, out var parsed))
|
||||
{
|
||||
errorMessage = "Server URL must be an absolute URI.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps)
|
||||
{
|
||||
errorMessage = "Server URL must use http or https.";
|
||||
return false;
|
||||
}
|
||||
|
||||
uri = parsed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,13 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../StellaOps.Cli/StellaOps.Cli.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cli.Plugins.Aoc/StellaOps.Cli.Plugins.Aoc.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cli.Plugins.NonCore/StellaOps.Cli.Plugins.NonCore.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cli.Plugins.Symbols/StellaOps.Cli.Plugins.Symbols.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cli.Plugins.Verdict/StellaOps.Cli.Plugins.Verdict.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cli.Plugins.Vex/StellaOps.Cli.Plugins.Vex.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.Infrastructure.Postgres/StellaOps.Infrastructure.Postgres.csproj" />
|
||||
<ProjectReference Include="../../../__Libraries/StellaOps.TestKit/StellaOps.TestKit.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user