using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using StellaOps.Concelier.Exporter.Json; using StellaOps.Concelier.Models; namespace StellaOps.Concelier.Exporter.Json.Tests; public sealed class JsonExportSnapshotBuilderTests : IDisposable { private readonly string _root; public JsonExportSnapshotBuilderTests() { _root = Directory.CreateTempSubdirectory("concelier-json-export-tests").FullName; } [Fact] public async Task WritesDeterministicTree() { var options = new JsonExportOptions { OutputRoot = _root }; var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver()); var exportedAt = DateTimeOffset.Parse("2024-07-15T12:00:00Z", CultureInfo.InvariantCulture); var advisories = new[] { CreateAdvisory( advisoryKey: "CVE-2024-9999", aliases: new[] { "GHSA-zzzz-yyyy-xxxx", "CVE-2024-9999" }, title: "Deterministic Snapshot", severity: "critical"), CreateAdvisory( advisoryKey: "VENDOR-2024-42", aliases: new[] { "ALIAS-1", "ALIAS-2" }, title: "Vendor Advisory", severity: "medium"), }; var result = await builder.WriteAsync(advisories, exportedAt, cancellationToken: CancellationToken.None); Assert.Equal(advisories.Length, result.AdvisoryCount); Assert.Equal(advisories.Length, result.Advisories.Length); Assert.Equal( advisories.Select(a => a.AdvisoryKey).OrderBy(key => key, StringComparer.Ordinal), result.Advisories.Select(a => a.AdvisoryKey).OrderBy(key => key, StringComparer.Ordinal)); Assert.Equal(exportedAt, result.ExportedAt); var expectedFiles = result.FilePaths.OrderBy(x => x, StringComparer.Ordinal).ToArray(); Assert.Contains("nvd/2024/CVE-2024-9999.json", expectedFiles); Assert.Contains("misc/VENDOR-2024-42.json", expectedFiles); var cvePath = ResolvePath(result.ExportDirectory, "nvd/2024/CVE-2024-9999.json"); Assert.True(File.Exists(cvePath)); var actualJson = await File.ReadAllTextAsync(cvePath, CancellationToken.None); Assert.Equal(SnapshotSerializer.ToSnapshot(advisories[0]), actualJson); } [Fact] public async Task ProducesIdenticalBytesAcrossRuns() { var options = new JsonExportOptions { OutputRoot = _root }; var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver()); var exportedAt = DateTimeOffset.Parse("2024-05-01T00:00:00Z", CultureInfo.InvariantCulture); var advisories = new[] { CreateAdvisory("CVE-2024-1000", new[] { "CVE-2024-1000", "GHSA-aaaa-bbbb-cccc" }, "Snapshot Stable", "high"), }; var first = await builder.WriteAsync(advisories, exportedAt, exportName: "20240501T000000Z", CancellationToken.None); var firstDigest = ComputeDigest(first); var second = await builder.WriteAsync(advisories, exportedAt, exportName: "20240501T000000Z", CancellationToken.None); var secondDigest = ComputeDigest(second); Assert.Equal(Convert.ToHexString(firstDigest), Convert.ToHexString(secondDigest)); } [Fact] public async Task WriteAsync_NormalizesInputOrdering() { var options = new JsonExportOptions { OutputRoot = _root }; var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver()); var exportedAt = DateTimeOffset.Parse("2024-06-01T00:00:00Z", CultureInfo.InvariantCulture); var advisoryA = CreateAdvisory("CVE-2024-1000", new[] { "CVE-2024-1000" }, "Alpha", "high"); var advisoryB = CreateAdvisory("VENDOR-0001", new[] { "VENDOR-0001" }, "Vendor Advisory", "medium"); var result = await builder.WriteAsync(new[] { advisoryB, advisoryA }, exportedAt, cancellationToken: CancellationToken.None); var expectedOrder = result.FilePaths.OrderBy(path => path, StringComparer.Ordinal).ToArray(); Assert.Equal(expectedOrder, result.FilePaths.ToArray()); } [Fact] public async Task WriteAsync_EnumeratesStreamOnlyOnce() { var options = new JsonExportOptions { OutputRoot = _root }; var builder = new JsonExportSnapshotBuilder(options, new VulnListJsonExportPathResolver()); var exportedAt = DateTimeOffset.Parse("2024-08-01T00:00:00Z", CultureInfo.InvariantCulture); var advisories = new[] { CreateAdvisory("CVE-2024-2000", new[] { "CVE-2024-2000" }, "Streaming One", "medium"), CreateAdvisory("CVE-2024-2001", new[] { "CVE-2024-2001" }, "Streaming Two", "low"), }; var sequence = new SingleEnumerationAsyncSequence(advisories); var result = await builder.WriteAsync(sequence, exportedAt, cancellationToken: CancellationToken.None); Assert.Equal(advisories.Length, result.AdvisoryCount); Assert.Equal(advisories.Length, result.Advisories.Length); } private static Advisory CreateAdvisory(string advisoryKey, string[] aliases, string title, string severity) { return new Advisory( advisoryKey: advisoryKey, title: title, summary: null, language: "EN", published: DateTimeOffset.Parse("2024-01-01T00:00:00Z", CultureInfo.InvariantCulture), modified: DateTimeOffset.Parse("2024-01-02T00:00:00Z", CultureInfo.InvariantCulture), severity: severity, exploitKnown: false, aliases: aliases, references: new[] { new AdvisoryReference("https://example.com/advisory", "advisory", null, null, AdvisoryProvenance.Empty), }, affectedPackages: new[] { new AffectedPackage( AffectedPackageTypes.SemVer, "sample/package", platform: null, versionRanges: Array.Empty(), statuses: Array.Empty(), provenance: Array.Empty()), }, cvssMetrics: Array.Empty(), provenance: new[] { new AdvisoryProvenance("concelier", "normalized", "canonical", DateTimeOffset.Parse("2024-01-02T00:00:00Z", CultureInfo.InvariantCulture)), }); } private static byte[] ComputeDigest(JsonExportResult result) { using var sha256 = SHA256.Create(); foreach (var relative in result.FilePaths.OrderBy(x => x, StringComparer.Ordinal)) { var fullPath = ResolvePath(result.ExportDirectory, relative); var bytes = File.ReadAllBytes(fullPath); sha256.TransformBlock(bytes, 0, bytes.Length, null, 0); } sha256.TransformFinalBlock(Array.Empty(), 0, 0); return sha256.Hash ?? Array.Empty(); } private static string ResolvePath(string root, string relative) { var segments = relative.Split('/', StringSplitOptions.RemoveEmptyEntries); return Path.Combine(new[] { root }.Concat(segments).ToArray()); } public void Dispose() { try { if (Directory.Exists(_root)) { Directory.Delete(_root, recursive: true); } } catch { // best effort cleanup } } private sealed class SingleEnumerationAsyncSequence : IAsyncEnumerable { private readonly IReadOnlyList _advisories; private int _enumerated; public SingleEnumerationAsyncSequence(IReadOnlyList advisories) { _advisories = advisories ?? throw new ArgumentNullException(nameof(advisories)); } public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) { if (Interlocked.Exchange(ref _enumerated, 1) == 1) { throw new InvalidOperationException("Sequence was enumerated more than once."); } return Enumerate(cancellationToken); async IAsyncEnumerator Enumerate([EnumeratorCancellation] CancellationToken ct) { foreach (var advisory in _advisories) { ct.ThrowIfCancellationRequested(); yield return advisory; await Task.Yield(); } } } } }