321 lines
13 KiB
C#
321 lines
13 KiB
C#
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using StellaOps.Tools.GoldenPairs.Models;
|
|
using StellaOps.Tools.GoldenPairs.Serialization;
|
|
using StellaOps.Tools.GoldenPairs.Services;
|
|
using System.Collections.Immutable;
|
|
using System.CommandLine;
|
|
using System.CommandLine.Invocation;
|
|
using System.CommandLine.Parsing;
|
|
using System.Text;
|
|
|
|
namespace StellaOps.Tools.GoldenPairs;
|
|
|
|
public static class GoldenPairsApp
|
|
{
|
|
public static int RunAsync(string[] args)
|
|
{
|
|
var repoRootOption = new Option<DirectoryInfo?>("--repo-root")
|
|
{
|
|
Description = "Repository root (defaults to nearest folder containing src/StellaOps.sln)."
|
|
};
|
|
|
|
var datasetRootOption = new Option<DirectoryInfo?>("--dataset-root")
|
|
{
|
|
Description = "Dataset root (defaults to datasets/golden-pairs under repo root)."
|
|
};
|
|
|
|
var root = new RootCommand("Golden pairs corpus tooling.");
|
|
root.Add(repoRootOption);
|
|
root.Add(datasetRootOption);
|
|
|
|
root.Add(BuildMirrorCommand(repoRootOption, datasetRootOption));
|
|
root.Add(BuildDiffCommand(repoRootOption, datasetRootOption));
|
|
root.Add(BuildValidateCommand(repoRootOption, datasetRootOption));
|
|
|
|
var parseResult = root.Parse(args);
|
|
return parseResult.Invoke();
|
|
}
|
|
|
|
private static Command BuildMirrorCommand(Option<DirectoryInfo?> repoRootOption, Option<DirectoryInfo?> datasetRootOption)
|
|
{
|
|
var cveArgument = new Argument<string>("cve") { Description = "CVE identifier to mirror." };
|
|
var command = new Command("mirror", "Fetch artifacts for a golden pair.");
|
|
command.Add(cveArgument);
|
|
|
|
command.SetAction(async (parseResult, cancellationToken) =>
|
|
{
|
|
var context = ResolveContext(parseResult, repoRootOption, datasetRootOption);
|
|
if (context is null)
|
|
{
|
|
return 2;
|
|
}
|
|
|
|
using var provider = context.Provider;
|
|
var loader = provider.GetRequiredService<GoldenPairLoader>();
|
|
var mirror = provider.GetRequiredService<IPackageMirrorService>();
|
|
var layout = provider.GetRequiredService<GoldenPairLayout>();
|
|
|
|
var cve = parseResult.GetValue(cveArgument) ?? string.Empty;
|
|
var loadResult = await loader.LoadAsync(cve, cancellationToken).ConfigureAwait(false);
|
|
if (!loadResult.IsValid || loadResult.Metadata is null)
|
|
{
|
|
return ReportLoadErrors(loadResult.Errors);
|
|
}
|
|
|
|
var metadata = loadResult.Metadata;
|
|
Directory.CreateDirectory(layout.GetOriginalDirectory(metadata.Cve));
|
|
Directory.CreateDirectory(layout.GetPatchedDirectory(metadata.Cve));
|
|
|
|
var originalResult = await mirror.FetchAsync(metadata.Original, layout.GetOriginalDirectory(metadata.Cve), cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (!originalResult.Success)
|
|
{
|
|
Console.Error.WriteLine($"[FAIL] Original mirror failed: {originalResult.ErrorMessage}");
|
|
return 1;
|
|
}
|
|
|
|
var originalPath = EnsureArtifactNamed(metadata, layout.GetOriginalDirectory(metadata.Cve), originalResult.LocalPath);
|
|
WriteShaFile(originalPath, originalResult.ActualSha256);
|
|
|
|
var patchedResult = await mirror.FetchAsync(metadata.Patched, layout.GetPatchedDirectory(metadata.Cve), cancellationToken)
|
|
.ConfigureAwait(false);
|
|
if (!patchedResult.Success)
|
|
{
|
|
Console.Error.WriteLine($"[FAIL] Patched mirror failed: {patchedResult.ErrorMessage}");
|
|
return 1;
|
|
}
|
|
|
|
var patchedPath = EnsureArtifactNamed(metadata, layout.GetPatchedDirectory(metadata.Cve), patchedResult.LocalPath);
|
|
WriteShaFile(patchedPath, patchedResult.ActualSha256);
|
|
|
|
Console.WriteLine($"[OK] Mirrored {metadata.Cve} into {layout.GetPairDirectory(metadata.Cve)}");
|
|
return 0;
|
|
});
|
|
|
|
return command;
|
|
}
|
|
|
|
private static Command BuildDiffCommand(Option<DirectoryInfo?> repoRootOption, Option<DirectoryInfo?> datasetRootOption)
|
|
{
|
|
var cveArgument = new Argument<string>("cve") { Description = "CVE identifier to diff." };
|
|
var command = new Command("diff", "Run diff analysis on a golden pair.");
|
|
command.Add(cveArgument);
|
|
|
|
var outputOption = new Option<string>("--output")
|
|
{
|
|
Description = "Output format: json or table.",
|
|
DefaultValueFactory = _ => "json"
|
|
};
|
|
command.Add(outputOption);
|
|
|
|
command.SetAction(async (parseResult, cancellationToken) =>
|
|
{
|
|
var context = ResolveContext(parseResult, repoRootOption, datasetRootOption);
|
|
if (context is null)
|
|
{
|
|
return 2;
|
|
}
|
|
|
|
using var provider = context.Provider;
|
|
var loader = provider.GetRequiredService<GoldenPairLoader>();
|
|
var diff = provider.GetRequiredService<IDiffPipelineService>();
|
|
var layout = provider.GetRequiredService<GoldenPairLayout>();
|
|
|
|
var cve = parseResult.GetValue(cveArgument) ?? string.Empty;
|
|
var loadResult = await loader.LoadAsync(cve, cancellationToken).ConfigureAwait(false);
|
|
if (!loadResult.IsValid || loadResult.Metadata is null)
|
|
{
|
|
return ReportLoadErrors(loadResult.Errors);
|
|
}
|
|
|
|
GoldenDiffReport report;
|
|
try
|
|
{
|
|
report = await diff.DiffAsync(loadResult.Metadata, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.Error.WriteLine($"[FAIL] Diff failed: {ex.Message}");
|
|
return 1;
|
|
}
|
|
|
|
var reportJson = GoldenPairsJsonSerializer.SerializeIndented(report);
|
|
var reportPath = layout.GetDiffReportPath(loadResult.Metadata.Cve);
|
|
Directory.CreateDirectory(Path.GetDirectoryName(reportPath)!);
|
|
await File.WriteAllTextAsync(reportPath, reportJson, cancellationToken).ConfigureAwait(false);
|
|
|
|
var output = parseResult.GetValue(outputOption) ?? "json";
|
|
if (string.Equals(output, "table", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
WriteTableReport(report);
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine(reportJson);
|
|
}
|
|
|
|
Console.WriteLine($"[OK] Diff report written to {reportPath}");
|
|
return report.MatchesExpected ? 0 : 1;
|
|
});
|
|
|
|
return command;
|
|
}
|
|
|
|
private static Command BuildValidateCommand(Option<DirectoryInfo?> repoRootOption, Option<DirectoryInfo?> datasetRootOption)
|
|
{
|
|
var command = new Command("validate", "Validate all golden pairs in the corpus.");
|
|
var failFastOption = new Option<bool>("--fail-fast")
|
|
{
|
|
Description = "Stop at first failure."
|
|
};
|
|
command.Add(failFastOption);
|
|
|
|
command.SetAction(async (parseResult, cancellationToken) =>
|
|
{
|
|
var context = ResolveContext(parseResult, repoRootOption, datasetRootOption);
|
|
if (context is null)
|
|
{
|
|
return 2;
|
|
}
|
|
|
|
using var provider = context.Provider;
|
|
var loader = provider.GetRequiredService<GoldenPairLoader>();
|
|
var diff = provider.GetRequiredService<IDiffPipelineService>();
|
|
var layout = provider.GetRequiredService<GoldenPairLayout>();
|
|
|
|
var failFast = parseResult.GetValue(failFastOption);
|
|
var pairDirectories = Directory.EnumerateDirectories(layout.DatasetRoot, "CVE-*", SearchOption.TopDirectoryOnly)
|
|
.OrderBy(path => path, StringComparer.Ordinal)
|
|
.ToArray();
|
|
|
|
var failures = 0;
|
|
foreach (var pairDir in pairDirectories)
|
|
{
|
|
var metadataPath = Path.Combine(pairDir, GoldenPairLayout.DefaultMetadataFileName);
|
|
var loadResult = await loader.LoadFromPathAsync(metadataPath, cancellationToken).ConfigureAwait(false);
|
|
if (!loadResult.IsValid || loadResult.Metadata is null)
|
|
{
|
|
failures++;
|
|
Console.Error.WriteLine($"[FAIL] {Path.GetFileName(pairDir)}: metadata invalid.");
|
|
if (failFast)
|
|
{
|
|
return 1;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
var report = await diff.DiffAsync(loadResult.Metadata, cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
var reportJson = GoldenPairsJsonSerializer.SerializeIndented(report);
|
|
await File.WriteAllTextAsync(layout.GetDiffReportPath(loadResult.Metadata.Cve), reportJson, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (report.MatchesExpected)
|
|
{
|
|
Console.WriteLine($"[OK] {loadResult.Metadata.Cve} validated.");
|
|
}
|
|
else
|
|
{
|
|
failures++;
|
|
Console.Error.WriteLine($"[FAIL] {loadResult.Metadata.Cve} mismatch.");
|
|
if (failFast)
|
|
{
|
|
return 1;
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
failures++;
|
|
Console.Error.WriteLine($"[FAIL] {loadResult.Metadata.Cve}: {ex.Message}");
|
|
if (failFast)
|
|
{
|
|
return 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
Console.WriteLine($"Summary: {pairDirectories.Length - failures}/{pairDirectories.Length} passed.");
|
|
return failures == 0 ? 0 : 1;
|
|
});
|
|
|
|
return command;
|
|
}
|
|
|
|
private static GoldenPairsContext? ResolveContext(
|
|
ParseResult parseResult,
|
|
Option<DirectoryInfo?> repoRootOption,
|
|
Option<DirectoryInfo?> datasetRootOption)
|
|
{
|
|
var repoRoot = parseResult.GetValue(repoRootOption)?.FullName;
|
|
var datasetRoot = parseResult.GetValue(datasetRootOption)?.FullName;
|
|
|
|
var resolvedRepoRoot = GoldenPairsPaths.TryResolveRepoRoot(repoRoot);
|
|
if (resolvedRepoRoot is null)
|
|
{
|
|
Console.Error.WriteLine("[FAIL] Unable to resolve repo root. Provide --repo-root explicitly.");
|
|
return null;
|
|
}
|
|
|
|
var resolvedDatasetRoot = GoldenPairsPaths.ResolveDatasetRoot(resolvedRepoRoot, datasetRoot);
|
|
var metadataSchemaPath = GoldenPairsPaths.ResolveMetadataSchemaPath(resolvedRepoRoot);
|
|
var indexSchemaPath = GoldenPairsPaths.ResolveIndexSchemaPath(resolvedRepoRoot);
|
|
|
|
var provider = GoldenPairsServiceFactory.Build(resolvedDatasetRoot, metadataSchemaPath, indexSchemaPath);
|
|
return new GoldenPairsContext(resolvedRepoRoot, resolvedDatasetRoot, provider);
|
|
}
|
|
|
|
private static int ReportLoadErrors(ImmutableArray<GoldenPairLoadError> errors)
|
|
{
|
|
foreach (var error in errors)
|
|
{
|
|
var location = string.IsNullOrWhiteSpace(error.InstanceLocation) ? string.Empty : $" ({error.InstanceLocation})";
|
|
Console.Error.WriteLine($"[FAIL] {error.Message}{location}");
|
|
}
|
|
|
|
return 1;
|
|
}
|
|
|
|
private static string EnsureArtifactNamed(GoldenPairMetadata metadata, string destinationDirectory, string currentPath)
|
|
{
|
|
var expectedPath = Path.Combine(destinationDirectory, metadata.Artifact.Name);
|
|
if (string.Equals(currentPath, expectedPath, StringComparison.Ordinal))
|
|
{
|
|
return currentPath;
|
|
}
|
|
|
|
if (File.Exists(expectedPath))
|
|
{
|
|
File.Delete(expectedPath);
|
|
}
|
|
|
|
File.Move(currentPath, expectedPath);
|
|
return expectedPath;
|
|
}
|
|
|
|
private static void WriteShaFile(string filePath, string sha256)
|
|
{
|
|
var shaPath = $"{filePath}.sha256";
|
|
File.WriteAllText(shaPath, $"{sha256}\n", Encoding.ASCII);
|
|
}
|
|
|
|
private static void WriteTableReport(GoldenDiffReport report)
|
|
{
|
|
Console.WriteLine($"CVE: {report.Cve}");
|
|
Console.WriteLine($"Verdict: {report.Verdict.ToString().ToLowerInvariant()} ({report.Confidence:F2})");
|
|
Console.WriteLine($"Matches expected: {report.MatchesExpected}");
|
|
Console.WriteLine("Sections:");
|
|
foreach (var section in report.Sections)
|
|
{
|
|
Console.WriteLine($" {section.Name} - {section.Status.ToString().ToLowerInvariant()}");
|
|
}
|
|
}
|
|
|
|
private sealed record GoldenPairsContext(
|
|
string RepoRoot,
|
|
string DatasetRoot,
|
|
ServiceProvider Provider);
|
|
}
|