Add tests for SBOM generation determinism across multiple formats

- Created `StellaOps.TestKit.Tests` project for unit tests related to determinism.
- Implemented `DeterminismManifestTests` to validate deterministic output for canonical bytes and strings, file read/write operations, and error handling for invalid schema versions.
- Added `SbomDeterminismTests` to ensure identical inputs produce consistent SBOMs across SPDX 3.0.1 and CycloneDX 1.6/1.7 formats, including parallel execution tests.
- Updated project references in `StellaOps.Integration.Determinism` to include the new determinism testing library.
This commit is contained in:
master
2025-12-23 18:56:12 +02:00
committed by StellaOps Bot
parent 7ac70ece71
commit 491e883653
409 changed files with 23797 additions and 17779 deletions

View File

@@ -0,0 +1,454 @@
using System.Collections.Concurrent;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Canonical.Json;
namespace StellaOps.Testing.Determinism;
/// <summary>
/// Stores and retrieves determinism baselines for artifact comparison.
/// Baselines are SHA-256 hashes of canonical artifact representations used to detect drift.
/// </summary>
public sealed class DeterminismBaselineStore
{
private readonly string _baselineDirectory;
private readonly ConcurrentDictionary<string, DeterminismBaseline> _cache = new();
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
/// <summary>
/// Creates a baseline store with the specified directory.
/// </summary>
/// <param name="baselineDirectory">Directory path for storing baselines.</param>
public DeterminismBaselineStore(string baselineDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(baselineDirectory);
_baselineDirectory = baselineDirectory;
}
/// <summary>
/// Creates a baseline store using the default baseline directory.
/// Default: tests/baselines/determinism relative to repository root.
/// </summary>
/// <param name="repositoryRoot">Repository root directory.</param>
/// <returns>Configured baseline store.</returns>
public static DeterminismBaselineStore CreateDefault(string repositoryRoot)
{
ArgumentException.ThrowIfNullOrWhiteSpace(repositoryRoot);
var baselineDir = Path.Combine(repositoryRoot, "tests", "baselines", "determinism");
return new DeterminismBaselineStore(baselineDir);
}
/// <summary>
/// Stores a baseline for an artifact.
/// </summary>
/// <param name="artifactType">Type of artifact (e.g., "sbom", "vex", "policy-verdict").</param>
/// <param name="artifactName">Name of the artifact (e.g., "alpine-3.18-spdx").</param>
/// <param name="baseline">The baseline to store.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task StoreBaselineAsync(
string artifactType,
string artifactName,
DeterminismBaseline baseline,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactType);
ArgumentException.ThrowIfNullOrWhiteSpace(artifactName);
ArgumentNullException.ThrowIfNull(baseline);
var key = GetBaselineKey(artifactType, artifactName);
var filePath = GetBaselineFilePath(artifactType, artifactName);
// Ensure directory exists
var directory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
// Serialize and write
var json = JsonSerializer.Serialize(baseline, JsonOptions);
await File.WriteAllTextAsync(filePath, json, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
// Update cache
_cache[key] = baseline;
}
/// <summary>
/// Retrieves a baseline for an artifact.
/// </summary>
/// <param name="artifactType">Type of artifact.</param>
/// <param name="artifactName">Name of the artifact.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The baseline if found, null otherwise.</returns>
public async Task<DeterminismBaseline?> GetBaselineAsync(
string artifactType,
string artifactName,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactType);
ArgumentException.ThrowIfNullOrWhiteSpace(artifactName);
var key = GetBaselineKey(artifactType, artifactName);
// Check cache first
if (_cache.TryGetValue(key, out var cached))
{
return cached;
}
// Load from file
var filePath = GetBaselineFilePath(artifactType, artifactName);
if (!File.Exists(filePath))
{
return null;
}
var json = await File.ReadAllTextAsync(filePath, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
var baseline = JsonSerializer.Deserialize<DeterminismBaseline>(json, JsonOptions);
if (baseline is not null)
{
_cache[key] = baseline;
}
return baseline;
}
/// <summary>
/// Compares an artifact against its stored baseline.
/// </summary>
/// <param name="artifactType">Type of artifact.</param>
/// <param name="artifactName">Name of the artifact.</param>
/// <param name="currentHash">Current SHA-256 hash of the artifact.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Comparison result indicating match, drift, or missing baseline.</returns>
public async Task<BaselineComparisonResult> CompareAsync(
string artifactType,
string artifactName,
string currentHash,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(artifactType);
ArgumentException.ThrowIfNullOrWhiteSpace(artifactName);
ArgumentException.ThrowIfNullOrWhiteSpace(currentHash);
var baseline = await GetBaselineAsync(artifactType, artifactName, cancellationToken).ConfigureAwait(false);
if (baseline is null)
{
return new BaselineComparisonResult
{
ArtifactType = artifactType,
ArtifactName = artifactName,
Status = BaselineStatus.Missing,
CurrentHash = currentHash,
BaselineHash = null,
Message = $"No baseline found for {artifactType}/{artifactName}. Run with UPDATE_BASELINES=true to create."
};
}
var isMatch = string.Equals(baseline.CanonicalHash, currentHash, StringComparison.OrdinalIgnoreCase);
return new BaselineComparisonResult
{
ArtifactType = artifactType,
ArtifactName = artifactName,
Status = isMatch ? BaselineStatus.Match : BaselineStatus.Drift,
CurrentHash = currentHash,
BaselineHash = baseline.CanonicalHash,
BaselineVersion = baseline.Version,
Message = isMatch
? $"Artifact {artifactType}/{artifactName} matches baseline."
: $"DRIFT DETECTED: {artifactType}/{artifactName} hash changed from {baseline.CanonicalHash} to {currentHash}."
};
}
/// <summary>
/// Lists all baselines in the store.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Collection of baseline entries.</returns>
public async Task<IReadOnlyList<BaselineEntry>> ListBaselinesAsync(
CancellationToken cancellationToken = default)
{
var entries = new List<BaselineEntry>();
if (!Directory.Exists(_baselineDirectory))
{
return entries;
}
var files = Directory.GetFiles(_baselineDirectory, "*.baseline.json", SearchOption.AllDirectories);
foreach (var file in files)
{
try
{
var json = await File.ReadAllTextAsync(file, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
var baseline = JsonSerializer.Deserialize<DeterminismBaseline>(json, JsonOptions);
if (baseline is not null)
{
var relativePath = Path.GetRelativePath(_baselineDirectory, file);
var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
entries.Add(new BaselineEntry
{
ArtifactType = parts.Length > 1 ? parts[0] : "unknown",
ArtifactName = Path.GetFileNameWithoutExtension(Path.GetFileNameWithoutExtension(file)),
CanonicalHash = baseline.CanonicalHash,
Version = baseline.Version,
UpdatedAt = baseline.UpdatedAt,
FilePath = file
});
}
}
catch
{
// Skip invalid baseline files
}
}
return entries.OrderBy(e => e.ArtifactType).ThenBy(e => e.ArtifactName).ToList();
}
/// <summary>
/// Creates a baseline from an artifact.
/// </summary>
/// <param name="artifactBytes">The artifact bytes to hash.</param>
/// <param name="version">Version identifier for this baseline.</param>
/// <param name="metadata">Optional metadata about the baseline.</param>
/// <returns>Created baseline.</returns>
public static DeterminismBaseline CreateBaseline(
ReadOnlySpan<byte> artifactBytes,
string version,
IReadOnlyDictionary<string, string>? metadata = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var hash = CanonJson.Sha256Hex(artifactBytes);
return new DeterminismBaseline
{
CanonicalHash = hash,
Algorithm = "SHA-256",
Version = version,
UpdatedAt = DateTimeOffset.UtcNow,
Metadata = metadata
};
}
/// <summary>
/// Creates a baseline from a JSON artifact with canonical serialization.
/// </summary>
/// <typeparam name="T">The artifact type.</typeparam>
/// <param name="artifact">The artifact to serialize and hash.</param>
/// <param name="version">Version identifier for this baseline.</param>
/// <param name="metadata">Optional metadata about the baseline.</param>
/// <returns>Created baseline.</returns>
public static DeterminismBaseline CreateBaselineFromJson<T>(
T artifact,
string version,
IReadOnlyDictionary<string, string>? metadata = null)
{
ArgumentNullException.ThrowIfNull(artifact);
ArgumentException.ThrowIfNullOrWhiteSpace(version);
var canonicalBytes = CanonJson.Canonicalize(artifact);
var hash = CanonJson.Sha256Hex(canonicalBytes);
return new DeterminismBaseline
{
CanonicalHash = hash,
Algorithm = "SHA-256",
Version = version,
UpdatedAt = DateTimeOffset.UtcNow,
Metadata = metadata
};
}
/// <summary>
/// Gets the baseline directory path.
/// </summary>
public string BaselineDirectory => _baselineDirectory;
private string GetBaselineFilePath(string artifactType, string artifactName)
{
var safeType = SanitizePathComponent(artifactType);
var safeName = SanitizePathComponent(artifactName);
return Path.Combine(_baselineDirectory, safeType, $"{safeName}.baseline.json");
}
private static string GetBaselineKey(string artifactType, string artifactName)
{
return $"{artifactType}/{artifactName}".ToLowerInvariant();
}
private static string SanitizePathComponent(string component)
{
var invalid = Path.GetInvalidFileNameChars();
var sanitized = new StringBuilder(component.Length);
foreach (var c in component)
{
sanitized.Append(invalid.Contains(c) ? '_' : c);
}
return sanitized.ToString();
}
}
/// <summary>
/// A stored baseline for determinism comparison.
/// </summary>
public sealed record DeterminismBaseline
{
/// <summary>
/// SHA-256 hash of the canonical artifact representation (hex-encoded).
/// </summary>
[JsonPropertyName("canonicalHash")]
public required string CanonicalHash { get; init; }
/// <summary>
/// Hash algorithm used (always "SHA-256").
/// </summary>
[JsonPropertyName("algorithm")]
public required string Algorithm { get; init; }
/// <summary>
/// Version identifier for this baseline (e.g., "1.0.0", git SHA, or timestamp).
/// </summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
/// <summary>
/// UTC timestamp when this baseline was created or updated.
/// </summary>
[JsonPropertyName("updatedAt")]
public required DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// Optional metadata about the baseline.
/// </summary>
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
}
/// <summary>
/// Result of comparing an artifact against its baseline.
/// </summary>
public sealed record BaselineComparisonResult
{
/// <summary>
/// Type of artifact compared.
/// </summary>
[JsonPropertyName("artifactType")]
public required string ArtifactType { get; init; }
/// <summary>
/// Name of artifact compared.
/// </summary>
[JsonPropertyName("artifactName")]
public required string ArtifactName { get; init; }
/// <summary>
/// Comparison status.
/// </summary>
[JsonPropertyName("status")]
public required BaselineStatus Status { get; init; }
/// <summary>
/// Current hash of the artifact.
/// </summary>
[JsonPropertyName("currentHash")]
public required string CurrentHash { get; init; }
/// <summary>
/// Baseline hash (null if missing).
/// </summary>
[JsonPropertyName("baselineHash")]
public string? BaselineHash { get; init; }
/// <summary>
/// Baseline version (null if missing).
/// </summary>
[JsonPropertyName("baselineVersion")]
public string? BaselineVersion { get; init; }
/// <summary>
/// Human-readable message describing the result.
/// </summary>
[JsonPropertyName("message")]
public required string Message { get; init; }
}
/// <summary>
/// Status of a baseline comparison.
/// </summary>
public enum BaselineStatus
{
/// <summary>
/// Artifact matches baseline hash.
/// </summary>
Match,
/// <summary>
/// Artifact hash differs from baseline (drift detected).
/// </summary>
Drift,
/// <summary>
/// No baseline exists for this artifact.
/// </summary>
Missing
}
/// <summary>
/// Entry in the baseline registry.
/// </summary>
public sealed record BaselineEntry
{
/// <summary>
/// Type of artifact.
/// </summary>
[JsonPropertyName("artifactType")]
public required string ArtifactType { get; init; }
/// <summary>
/// Name of artifact.
/// </summary>
[JsonPropertyName("artifactName")]
public required string ArtifactName { get; init; }
/// <summary>
/// Canonical hash of the baseline.
/// </summary>
[JsonPropertyName("canonicalHash")]
public required string CanonicalHash { get; init; }
/// <summary>
/// Version identifier.
/// </summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
/// <summary>
/// When baseline was last updated.
/// </summary>
[JsonPropertyName("updatedAt")]
public required DateTimeOffset UpdatedAt { get; init; }
/// <summary>
/// File path of the baseline.
/// </summary>
[JsonPropertyName("filePath")]
public required string FilePath { get; init; }
}

View File

@@ -0,0 +1,215 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace StellaOps.Testing.Determinism;
/// <summary>
/// Determinism gates for verifying reproducible outputs.
/// Ensures that operations produce identical results across multiple executions.
/// </summary>
public static class DeterminismGate
{
/// <summary>
/// Verifies that a function produces identical output across multiple invocations.
/// </summary>
/// <param name="operation">The operation to test.</param>
/// <param name="iterations">Number of times to execute (default: 3).</param>
public static void AssertDeterministic(Func<string> operation, int iterations = 3)
{
if (iterations < 2)
{
throw new ArgumentException("Iterations must be at least 2", nameof(iterations));
}
string? baseline = null;
var results = new List<string>();
for (int i = 0; i < iterations; i++)
{
var result = operation();
results.Add(result);
if (baseline == null)
{
baseline = result;
}
else if (result != baseline)
{
throw new DeterminismViolationException(
$"Determinism violation detected at iteration {i + 1}.\n\n" +
$"Baseline (iteration 1):\n{baseline}\n\n" +
$"Different (iteration {i + 1}):\n{result}");
}
}
}
/// <summary>
/// Verifies that a function produces identical binary output across multiple invocations.
/// </summary>
public static void AssertDeterministic(Func<byte[]> operation, int iterations = 3)
{
if (iterations < 2)
{
throw new ArgumentException("Iterations must be at least 2", nameof(iterations));
}
byte[]? baseline = null;
for (int i = 0; i < iterations; i++)
{
var result = operation();
if (baseline == null)
{
baseline = result;
}
else if (!result.SequenceEqual(baseline))
{
throw new DeterminismViolationException(
$"Binary determinism violation detected at iteration {i + 1}.\n" +
$"Baseline hash: {ComputeHash(baseline)}\n" +
$"Current hash: {ComputeHash(result)}");
}
}
}
/// <summary>
/// Verifies that a function producing JSON has stable property ordering and formatting.
/// </summary>
public static void AssertJsonDeterministic(Func<string> operation, int iterations = 3)
{
AssertDeterministic(() =>
{
var json = operation();
// Canonicalize to detect property ordering issues
return CanonicalizeJson(json);
}, iterations);
}
/// <summary>
/// Verifies that an object's JSON serialization is deterministic.
/// </summary>
public static void AssertJsonDeterministic<T>(Func<T> operation, int iterations = 3)
{
AssertDeterministic(() =>
{
var obj = operation();
var json = JsonSerializer.Serialize(obj, new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = null
});
return CanonicalizeJson(json);
}, iterations);
}
/// <summary>
/// Verifies that two objects produce identical canonical JSON.
/// </summary>
public static void AssertCanonicallyEqual(object expected, object actual)
{
var expectedJson = JsonSerializer.Serialize(expected);
var actualJson = JsonSerializer.Serialize(actual);
var expectedCanonical = CanonicalizeJson(expectedJson);
var actualCanonical = CanonicalizeJson(actualJson);
if (expectedCanonical != actualCanonical)
{
throw new DeterminismViolationException(
$"Canonical JSON mismatch:\n\nExpected:\n{expectedCanonical}\n\nActual:\n{actualCanonical}");
}
}
/// <summary>
/// Computes a stable SHA256 hash of text content.
/// </summary>
public static string ComputeHash(string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
return ComputeHash(bytes);
}
/// <summary>
/// Computes a stable SHA256 hash of binary content.
/// </summary>
public static string ComputeHash(byte[] content)
{
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(content);
return Convert.ToHexString(hash).ToLowerInvariant();
}
/// <summary>
/// Canonicalizes JSON for comparison (stable property ordering, no whitespace).
/// </summary>
private static string CanonicalizeJson(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions
{
WriteIndented = false,
PropertyNamingPolicy = null,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
}
catch (JsonException ex)
{
throw new DeterminismViolationException($"Failed to parse JSON for canonicalization: {ex.Message}", ex);
}
}
/// <summary>
/// Verifies that file paths are sorted deterministically (for SBOM manifests).
/// </summary>
public static void AssertSortedPaths(IEnumerable<string> paths)
{
var pathList = paths.ToList();
var sortedPaths = pathList.OrderBy(p => p, StringComparer.Ordinal).ToList();
if (!pathList.SequenceEqual(sortedPaths))
{
throw new DeterminismViolationException(
$"Path ordering is non-deterministic.\n\n" +
$"Actual order:\n{string.Join("\n", pathList.Take(10))}\n\n" +
$"Expected (sorted) order:\n{string.Join("\n", sortedPaths.Take(10))}");
}
}
/// <summary>
/// Verifies that timestamps are in UTC and ISO 8601 format.
/// </summary>
public static void AssertUtcIso8601(string timestamp)
{
if (!DateTimeOffset.TryParse(timestamp, out var dto))
{
throw new DeterminismViolationException($"Invalid timestamp format: {timestamp}");
}
if (dto.Offset != TimeSpan.Zero)
{
throw new DeterminismViolationException(
$"Timestamp is not UTC: {timestamp} (offset: {dto.Offset})");
}
// Verify ISO 8601 format with 'Z' suffix
var iso8601 = dto.ToString("o");
if (!iso8601.EndsWith("Z"))
{
throw new DeterminismViolationException(
$"Timestamp does not have 'Z' suffix: {timestamp}");
}
}
}
/// <summary>
/// Exception thrown when determinism violations are detected.
/// </summary>
public sealed class DeterminismViolationException : Exception
{
public DeterminismViolationException(string message) : base(message) { }
public DeterminismViolationException(string message, Exception innerException) : base(message, innerException) { }
}

View File

@@ -0,0 +1,322 @@
using System.Text.Json.Serialization;
namespace StellaOps.Testing.Determinism;
/// <summary>
/// Determinism manifest tracking artifact reproducibility with canonical bytes hash,
/// version stamps, and toolchain information.
/// </summary>
public sealed record DeterminismManifest
{
/// <summary>
/// Version of this manifest schema (currently "1.0").
/// </summary>
[JsonPropertyName("schemaVersion")]
public required string SchemaVersion { get; init; }
/// <summary>
/// Artifact being tracked for determinism.
/// </summary>
[JsonPropertyName("artifact")]
public required ArtifactInfo Artifact { get; init; }
/// <summary>
/// Hash of the canonical representation of the artifact.
/// </summary>
[JsonPropertyName("canonicalHash")]
public required CanonicalHashInfo CanonicalHash { get; init; }
/// <summary>
/// Version stamps of all inputs used to generate the artifact.
/// </summary>
[JsonPropertyName("inputs")]
public InputStamps? Inputs { get; init; }
/// <summary>
/// Toolchain version information.
/// </summary>
[JsonPropertyName("toolchain")]
public required ToolchainInfo Toolchain { get; init; }
/// <summary>
/// UTC timestamp when artifact was generated (ISO 8601).
/// </summary>
[JsonPropertyName("generatedAt")]
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>
/// Reproducibility metadata.
/// </summary>
[JsonPropertyName("reproducibility")]
public ReproducibilityMetadata? Reproducibility { get; init; }
/// <summary>
/// Verification instructions for reproducing the artifact.
/// </summary>
[JsonPropertyName("verification")]
public VerificationInfo? Verification { get; init; }
/// <summary>
/// Optional cryptographic signatures of this manifest.
/// </summary>
[JsonPropertyName("signatures")]
public IReadOnlyList<SignatureInfo>? Signatures { get; init; }
}
/// <summary>
/// Artifact being tracked for determinism.
/// </summary>
public sealed record ArtifactInfo
{
/// <summary>
/// Type of artifact.
/// </summary>
[JsonPropertyName("type")]
public required string Type { get; init; }
/// <summary>
/// Artifact identifier or name.
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Artifact version or timestamp.
/// </summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
/// <summary>
/// Artifact format (e.g., 'SPDX 3.0.1', 'CycloneDX 1.6', 'OpenVEX').
/// </summary>
[JsonPropertyName("format")]
public string? Format { get; init; }
/// <summary>
/// Additional artifact-specific metadata.
/// </summary>
[JsonPropertyName("metadata")]
public IReadOnlyDictionary<string, object?>? Metadata { get; init; }
}
/// <summary>
/// Hash of the canonical representation of the artifact.
/// </summary>
public sealed record CanonicalHashInfo
{
/// <summary>
/// Hash algorithm used (SHA-256, SHA-384, SHA-512).
/// </summary>
[JsonPropertyName("algorithm")]
public required string Algorithm { get; init; }
/// <summary>
/// Hex-encoded hash value.
/// </summary>
[JsonPropertyName("value")]
public required string Value { get; init; }
/// <summary>
/// Encoding of the hash value (hex or base64).
/// </summary>
[JsonPropertyName("encoding")]
public required string Encoding { get; init; }
}
/// <summary>
/// Version stamps of all inputs used to generate the artifact.
/// </summary>
public sealed record InputStamps
{
/// <summary>
/// SHA-256 hash of the vulnerability feed snapshot used.
/// </summary>
[JsonPropertyName("feedSnapshotHash")]
public string? FeedSnapshotHash { get; init; }
/// <summary>
/// SHA-256 hash of the policy manifest used.
/// </summary>
[JsonPropertyName("policyManifestHash")]
public string? PolicyManifestHash { get; init; }
/// <summary>
/// Git commit SHA or source code hash.
/// </summary>
[JsonPropertyName("sourceCodeHash")]
public string? SourceCodeHash { get; init; }
/// <summary>
/// Hash of dependency lockfile (e.g., package-lock.json, Cargo.lock).
/// </summary>
[JsonPropertyName("dependencyLockfileHash")]
public string? DependencyLockfileHash { get; init; }
/// <summary>
/// Container base image digest (sha256:...).
/// </summary>
[JsonPropertyName("baseImageDigest")]
public string? BaseImageDigest { get; init; }
/// <summary>
/// Hashes of all VEX documents used as input.
/// </summary>
[JsonPropertyName("vexDocumentHashes")]
public IReadOnlyList<string>? VexDocumentHashes { get; init; }
/// <summary>
/// Custom input hashes specific to artifact type.
/// </summary>
[JsonPropertyName("custom")]
public IReadOnlyDictionary<string, string>? Custom { get; init; }
}
/// <summary>
/// Toolchain version information.
/// </summary>
public sealed record ToolchainInfo
{
/// <summary>
/// Runtime platform (e.g., '.NET 10.0', 'Node.js 20.0').
/// </summary>
[JsonPropertyName("platform")]
public required string Platform { get; init; }
/// <summary>
/// Toolchain component versions.
/// </summary>
[JsonPropertyName("components")]
public required IReadOnlyList<ComponentInfo> Components { get; init; }
/// <summary>
/// Compiler information if applicable.
/// </summary>
[JsonPropertyName("compiler")]
public CompilerInfo? Compiler { get; init; }
}
/// <summary>
/// Toolchain component version.
/// </summary>
public sealed record ComponentInfo
{
/// <summary>
/// Component name (e.g., 'StellaOps.Scanner', 'CycloneDX Generator').
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Semantic version or git SHA.
/// </summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
/// <summary>
/// Optional: SHA-256 hash of the component binary.
/// </summary>
[JsonPropertyName("hash")]
public string? Hash { get; init; }
}
/// <summary>
/// Compiler information.
/// </summary>
public sealed record CompilerInfo
{
/// <summary>
/// Compiler name (e.g., 'Roslyn', 'rustc').
/// </summary>
[JsonPropertyName("name")]
public required string Name { get; init; }
/// <summary>
/// Compiler version.
/// </summary>
[JsonPropertyName("version")]
public required string Version { get; init; }
}
/// <summary>
/// Reproducibility metadata.
/// </summary>
public sealed record ReproducibilityMetadata
{
/// <summary>
/// Deterministic random seed if used.
/// </summary>
[JsonPropertyName("deterministicSeed")]
public int? DeterministicSeed { get; init; }
/// <summary>
/// Whether system clock was fixed during generation.
/// </summary>
[JsonPropertyName("clockFixed")]
public bool? ClockFixed { get; init; }
/// <summary>
/// Ordering guarantee for collections in output.
/// </summary>
[JsonPropertyName("orderingGuarantee")]
public string? OrderingGuarantee { get; init; }
/// <summary>
/// Normalization rules applied (e.g., 'UTF-8', 'LF line endings', 'no whitespace').
/// </summary>
[JsonPropertyName("normalizationRules")]
public IReadOnlyList<string>? NormalizationRules { get; init; }
}
/// <summary>
/// Verification instructions for reproducing the artifact.
/// </summary>
public sealed record VerificationInfo
{
/// <summary>
/// Command to regenerate the artifact.
/// </summary>
[JsonPropertyName("command")]
public string? Command { get; init; }
/// <summary>
/// Expected SHA-256 hash after reproduction.
/// </summary>
[JsonPropertyName("expectedHash")]
public string? ExpectedHash { get; init; }
/// <summary>
/// Baseline manifest file path for regression testing.
/// </summary>
[JsonPropertyName("baseline")]
public string? Baseline { get; init; }
}
/// <summary>
/// Cryptographic signature of the manifest.
/// </summary>
public sealed record SignatureInfo
{
/// <summary>
/// Signature algorithm (e.g., 'ES256', 'RS256').
/// </summary>
[JsonPropertyName("algorithm")]
public required string Algorithm { get; init; }
/// <summary>
/// Key identifier used for signing.
/// </summary>
[JsonPropertyName("keyId")]
public required string KeyId { get; init; }
/// <summary>
/// Base64-encoded signature.
/// </summary>
[JsonPropertyName("signature")]
public required string Signature { get; init; }
/// <summary>
/// UTC timestamp when signature was created.
/// </summary>
[JsonPropertyName("timestamp")]
public DateTimeOffset? Timestamp { get; init; }
}

View File

@@ -0,0 +1,238 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Testing.Determinism;
/// <summary>
/// Reader for determinism manifest files with validation.
/// </summary>
public sealed class DeterminismManifestReader
{
private static readonly JsonSerializerOptions DefaultOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
/// <summary>
/// Deserializes a determinism manifest from JSON bytes.
/// </summary>
/// <param name="jsonBytes">UTF-8 encoded JSON bytes.</param>
/// <returns>Deserialized determinism manifest.</returns>
/// <exception cref="JsonException">If JSON is invalid.</exception>
/// <exception cref="InvalidOperationException">If manifest validation fails.</exception>
public static DeterminismManifest FromBytes(ReadOnlySpan<byte> jsonBytes)
{
var manifest = JsonSerializer.Deserialize<DeterminismManifest>(jsonBytes, DefaultOptions);
if (manifest is null)
{
throw new JsonException("Failed to deserialize determinism manifest: result was null.");
}
ValidateManifest(manifest);
return manifest;
}
/// <summary>
/// Deserializes a determinism manifest from a JSON string.
/// </summary>
/// <param name="json">JSON string.</param>
/// <returns>Deserialized determinism manifest.</returns>
/// <exception cref="JsonException">If JSON is invalid.</exception>
/// <exception cref="InvalidOperationException">If manifest validation fails.</exception>
public static DeterminismManifest FromString(string json)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
var bytes = Encoding.UTF8.GetBytes(json);
return FromBytes(bytes);
}
/// <summary>
/// Reads a determinism manifest from a file.
/// </summary>
/// <param name="filePath">File path to read from.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Deserialized determinism manifest.</returns>
/// <exception cref="FileNotFoundException">If file does not exist.</exception>
/// <exception cref="JsonException">If JSON is invalid.</exception>
/// <exception cref="InvalidOperationException">If manifest validation fails.</exception>
public static async Task<DeterminismManifest> ReadFromFileAsync(
string filePath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"Determinism manifest file not found: {filePath}");
}
var bytes = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false);
return FromBytes(bytes);
}
/// <summary>
/// Reads a determinism manifest from a file synchronously.
/// </summary>
/// <param name="filePath">File path to read from.</param>
/// <returns>Deserialized determinism manifest.</returns>
/// <exception cref="FileNotFoundException">If file does not exist.</exception>
/// <exception cref="JsonException">If JSON is invalid.</exception>
/// <exception cref="InvalidOperationException">If manifest validation fails.</exception>
public static DeterminismManifest ReadFromFile(string filePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"Determinism manifest file not found: {filePath}");
}
var bytes = File.ReadAllBytes(filePath);
return FromBytes(bytes);
}
/// <summary>
/// Tries to read a determinism manifest from a file, returning null if the file doesn't exist.
/// </summary>
/// <param name="filePath">File path to read from.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Deserialized manifest or null if file doesn't exist.</returns>
/// <exception cref="JsonException">If JSON is invalid.</exception>
/// <exception cref="InvalidOperationException">If manifest validation fails.</exception>
public static async Task<DeterminismManifest?> TryReadFromFileAsync(
string filePath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
if (!File.Exists(filePath))
{
return null;
}
var bytes = await File.ReadAllBytesAsync(filePath, cancellationToken).ConfigureAwait(false);
return FromBytes(bytes);
}
/// <summary>
/// Validates a determinism manifest.
/// </summary>
/// <param name="manifest">The manifest to validate.</param>
/// <exception cref="InvalidOperationException">If validation fails.</exception>
private static void ValidateManifest(DeterminismManifest manifest)
{
// Validate schema version
if (string.IsNullOrWhiteSpace(manifest.SchemaVersion))
{
throw new InvalidOperationException("Determinism manifest schemaVersion is required.");
}
if (manifest.SchemaVersion != "1.0")
{
throw new InvalidOperationException($"Unsupported schema version: {manifest.SchemaVersion}. Expected '1.0'.");
}
// Validate artifact
if (manifest.Artifact is null)
{
throw new InvalidOperationException("Determinism manifest artifact is required.");
}
if (string.IsNullOrWhiteSpace(manifest.Artifact.Type))
{
throw new InvalidOperationException("Artifact type is required.");
}
if (string.IsNullOrWhiteSpace(manifest.Artifact.Name))
{
throw new InvalidOperationException("Artifact name is required.");
}
if (string.IsNullOrWhiteSpace(manifest.Artifact.Version))
{
throw new InvalidOperationException("Artifact version is required.");
}
// Validate canonical hash
if (manifest.CanonicalHash is null)
{
throw new InvalidOperationException("Determinism manifest canonicalHash is required.");
}
if (string.IsNullOrWhiteSpace(manifest.CanonicalHash.Algorithm))
{
throw new InvalidOperationException("CanonicalHash algorithm is required.");
}
if (!IsSupportedHashAlgorithm(manifest.CanonicalHash.Algorithm))
{
throw new InvalidOperationException($"Unsupported hash algorithm: {manifest.CanonicalHash.Algorithm}. Supported: SHA-256, SHA-384, SHA-512.");
}
if (string.IsNullOrWhiteSpace(manifest.CanonicalHash.Value))
{
throw new InvalidOperationException("CanonicalHash value is required.");
}
if (string.IsNullOrWhiteSpace(manifest.CanonicalHash.Encoding))
{
throw new InvalidOperationException("CanonicalHash encoding is required.");
}
if (manifest.CanonicalHash.Encoding != "hex" && manifest.CanonicalHash.Encoding != "base64")
{
throw new InvalidOperationException($"Unsupported hash encoding: {manifest.CanonicalHash.Encoding}. Supported: hex, base64.");
}
// Validate toolchain
if (manifest.Toolchain is null)
{
throw new InvalidOperationException("Determinism manifest toolchain is required.");
}
if (string.IsNullOrWhiteSpace(manifest.Toolchain.Platform))
{
throw new InvalidOperationException("Toolchain platform is required.");
}
if (manifest.Toolchain.Components is null || manifest.Toolchain.Components.Count == 0)
{
throw new InvalidOperationException("Toolchain components are required (at least one component).");
}
foreach (var component in manifest.Toolchain.Components)
{
if (string.IsNullOrWhiteSpace(component.Name))
{
throw new InvalidOperationException("Toolchain component name is required.");
}
if (string.IsNullOrWhiteSpace(component.Version))
{
throw new InvalidOperationException("Toolchain component version is required.");
}
}
// Validate generatedAt
if (manifest.GeneratedAt == default)
{
throw new InvalidOperationException("Determinism manifest generatedAt is required.");
}
}
private static bool IsSupportedHashAlgorithm(string algorithm)
{
return algorithm switch
{
"SHA-256" => true,
"SHA-384" => true,
"SHA-512" => true,
_ => false
};
}
}

View File

@@ -0,0 +1,183 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using StellaOps.Canonical.Json;
namespace StellaOps.Testing.Determinism;
/// <summary>
/// Writer for determinism manifest files with canonical JSON serialization.
/// </summary>
public sealed class DeterminismManifestWriter
{
private static readonly JsonSerializerOptions DefaultOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
/// <summary>
/// Serializes a determinism manifest to canonical JSON bytes.
/// Uses StellaOps.Canonical.Json for deterministic output.
/// </summary>
/// <param name="manifest">The manifest to serialize.</param>
/// <returns>UTF-8 encoded canonical JSON bytes.</returns>
public static byte[] ToCanonicalBytes(DeterminismManifest manifest)
{
ArgumentNullException.ThrowIfNull(manifest);
// Validate schema version
if (manifest.SchemaVersion != "1.0")
{
throw new InvalidOperationException($"Unsupported schema version: {manifest.SchemaVersion}. Expected '1.0'.");
}
// Canonicalize using CanonJson for deterministic output
return CanonJson.Canonicalize(manifest, DefaultOptions);
}
/// <summary>
/// Serializes a determinism manifest to a canonical JSON string.
/// </summary>
/// <param name="manifest">The manifest to serialize.</param>
/// <returns>UTF-8 encoded canonical JSON string.</returns>
public static string ToCanonicalString(DeterminismManifest manifest)
{
var bytes = ToCanonicalBytes(manifest);
return Encoding.UTF8.GetString(bytes);
}
/// <summary>
/// Writes a determinism manifest to a file with canonical JSON serialization.
/// </summary>
/// <param name="manifest">The manifest to write.</param>
/// <param name="filePath">File path to write to.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public static async Task WriteToFileAsync(
DeterminismManifest manifest,
string filePath,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(manifest);
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
var bytes = ToCanonicalBytes(manifest);
await File.WriteAllBytesAsync(filePath, bytes, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Writes a determinism manifest to a file synchronously.
/// </summary>
/// <param name="manifest">The manifest to write.</param>
/// <param name="filePath">File path to write to.</param>
public static void WriteToFile(DeterminismManifest manifest, string filePath)
{
ArgumentNullException.ThrowIfNull(manifest);
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
var bytes = ToCanonicalBytes(manifest);
File.WriteAllBytes(filePath, bytes);
}
/// <summary>
/// Computes the SHA-256 hash of the canonical representation of a manifest.
/// </summary>
/// <param name="manifest">The manifest to hash.</param>
/// <returns>64-character lowercase hex string.</returns>
public static string ComputeCanonicalHash(DeterminismManifest manifest)
{
var bytes = ToCanonicalBytes(manifest);
return CanonJson.Sha256Hex(bytes);
}
/// <summary>
/// Creates a determinism manifest for an artifact with computed canonical hash.
/// </summary>
/// <param name="artifactBytes">The artifact bytes to hash.</param>
/// <param name="artifactInfo">Artifact metadata.</param>
/// <param name="toolchain">Toolchain information.</param>
/// <param name="inputs">Optional input stamps.</param>
/// <param name="reproducibility">Optional reproducibility metadata.</param>
/// <param name="verification">Optional verification info.</param>
/// <returns>Determinism manifest with computed canonical hash.</returns>
public static DeterminismManifest CreateManifest(
ReadOnlySpan<byte> artifactBytes,
ArtifactInfo artifactInfo,
ToolchainInfo toolchain,
InputStamps? inputs = null,
ReproducibilityMetadata? reproducibility = null,
VerificationInfo? verification = null)
{
ArgumentNullException.ThrowIfNull(artifactInfo);
ArgumentNullException.ThrowIfNull(toolchain);
var canonicalHash = CanonJson.Sha256Hex(artifactBytes);
return new DeterminismManifest
{
SchemaVersion = "1.0",
Artifact = artifactInfo,
CanonicalHash = new CanonicalHashInfo
{
Algorithm = "SHA-256",
Value = canonicalHash,
Encoding = "hex"
},
Inputs = inputs,
Toolchain = toolchain,
GeneratedAt = DateTimeOffset.UtcNow,
Reproducibility = reproducibility,
Verification = verification,
Signatures = null
};
}
/// <summary>
/// Creates a determinism manifest for a JSON artifact (SBOM, VEX, policy verdict, etc.)
/// with canonical JSON serialization before hashing.
/// </summary>
/// <typeparam name="T">The artifact type.</typeparam>
/// <param name="artifact">The artifact to serialize and hash.</param>
/// <param name="artifactInfo">Artifact metadata.</param>
/// <param name="toolchain">Toolchain information.</param>
/// <param name="inputs">Optional input stamps.</param>
/// <param name="reproducibility">Optional reproducibility metadata.</param>
/// <param name="verification">Optional verification info.</param>
/// <returns>Determinism manifest with computed canonical hash.</returns>
public static DeterminismManifest CreateManifestForJsonArtifact<T>(
T artifact,
ArtifactInfo artifactInfo,
ToolchainInfo toolchain,
InputStamps? inputs = null,
ReproducibilityMetadata? reproducibility = null,
VerificationInfo? verification = null)
{
ArgumentNullException.ThrowIfNull(artifact);
ArgumentNullException.ThrowIfNull(artifactInfo);
ArgumentNullException.ThrowIfNull(toolchain);
// Canonicalize the artifact using CanonJson for deterministic serialization
var canonicalBytes = CanonJson.Canonicalize(artifact);
var canonicalHash = CanonJson.Sha256Hex(canonicalBytes);
return new DeterminismManifest
{
SchemaVersion = "1.0",
Artifact = artifactInfo,
CanonicalHash = new CanonicalHashInfo
{
Algorithm = "SHA-256",
Value = canonicalHash,
Encoding = "hex"
},
Inputs = inputs,
Toolchain = toolchain,
GeneratedAt = DateTimeOffset.UtcNow,
Reproducibility = reproducibility,
Verification = verification,
Signatures = null
};
}
}

View File

@@ -0,0 +1,374 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace StellaOps.Testing.Determinism;
/// <summary>
/// Summary of determinism validation results for CI artifact output.
/// This is the "determinism.json" file emitted by CI workflows.
/// </summary>
public sealed record DeterminismSummary
{
/// <summary>
/// Schema version for this summary format.
/// </summary>
[JsonPropertyName("schemaVersion")]
public string SchemaVersion { get; init; } = "1.0";
/// <summary>
/// UTC timestamp when this summary was generated.
/// </summary>
[JsonPropertyName("generatedAt")]
public required DateTimeOffset GeneratedAt { get; init; }
/// <summary>
/// Git commit SHA or other source identifier.
/// </summary>
[JsonPropertyName("sourceRef")]
public string? SourceRef { get; init; }
/// <summary>
/// CI run identifier (e.g., GitHub Actions run ID).
/// </summary>
[JsonPropertyName("ciRunId")]
public string? CiRunId { get; init; }
/// <summary>
/// Overall status of the determinism check.
/// </summary>
[JsonPropertyName("status")]
public required DeterminismCheckStatus Status { get; init; }
/// <summary>
/// Summary statistics.
/// </summary>
[JsonPropertyName("statistics")]
public required DeterminismStatistics Statistics { get; init; }
/// <summary>
/// Individual artifact comparison results.
/// </summary>
[JsonPropertyName("results")]
public required IReadOnlyList<BaselineComparisonResult> Results { get; init; }
/// <summary>
/// Artifacts with detected drift (subset of results for quick access).
/// </summary>
[JsonPropertyName("drift")]
public IReadOnlyList<DriftEntry>? Drift { get; init; }
/// <summary>
/// Artifacts missing baselines (subset of results for quick access).
/// </summary>
[JsonPropertyName("missing")]
public IReadOnlyList<MissingEntry>? Missing { get; init; }
}
/// <summary>
/// Overall status of determinism check.
/// </summary>
public enum DeterminismCheckStatus
{
/// <summary>
/// All artifacts match their baselines.
/// </summary>
Pass,
/// <summary>
/// One or more artifacts have drifted from their baselines.
/// </summary>
Fail,
/// <summary>
/// New artifacts detected without baselines (warning, not failure by default).
/// </summary>
Warning
}
/// <summary>
/// Summary statistics for determinism check.
/// </summary>
public sealed record DeterminismStatistics
{
/// <summary>
/// Total number of artifacts checked.
/// </summary>
[JsonPropertyName("total")]
public required int Total { get; init; }
/// <summary>
/// Number of artifacts matching their baselines.
/// </summary>
[JsonPropertyName("matched")]
public required int Matched { get; init; }
/// <summary>
/// Number of artifacts with detected drift.
/// </summary>
[JsonPropertyName("drifted")]
public required int Drifted { get; init; }
/// <summary>
/// Number of artifacts missing baselines.
/// </summary>
[JsonPropertyName("missing")]
public required int Missing { get; init; }
}
/// <summary>
/// Entry for an artifact that has drifted from its baseline.
/// </summary>
public sealed record DriftEntry
{
/// <summary>
/// Type of artifact.
/// </summary>
[JsonPropertyName("artifactType")]
public required string ArtifactType { get; init; }
/// <summary>
/// Name of artifact.
/// </summary>
[JsonPropertyName("artifactName")]
public required string ArtifactName { get; init; }
/// <summary>
/// Previous baseline hash.
/// </summary>
[JsonPropertyName("baselineHash")]
public required string BaselineHash { get; init; }
/// <summary>
/// Current computed hash.
/// </summary>
[JsonPropertyName("currentHash")]
public required string CurrentHash { get; init; }
}
/// <summary>
/// Entry for an artifact missing a baseline.
/// </summary>
public sealed record MissingEntry
{
/// <summary>
/// Type of artifact.
/// </summary>
[JsonPropertyName("artifactType")]
public required string ArtifactType { get; init; }
/// <summary>
/// Name of artifact.
/// </summary>
[JsonPropertyName("artifactName")]
public required string ArtifactName { get; init; }
/// <summary>
/// Current computed hash (to be used as baseline).
/// </summary>
[JsonPropertyName("currentHash")]
public required string CurrentHash { get; init; }
}
/// <summary>
/// Builder for creating determinism summaries from comparison results.
/// </summary>
public sealed class DeterminismSummaryBuilder
{
private readonly List<BaselineComparisonResult> _results = new();
private string? _sourceRef;
private string? _ciRunId;
private bool _failOnMissing;
/// <summary>
/// Sets the source reference (git commit SHA).
/// </summary>
public DeterminismSummaryBuilder WithSourceRef(string sourceRef)
{
_sourceRef = sourceRef;
return this;
}
/// <summary>
/// Sets the CI run identifier.
/// </summary>
public DeterminismSummaryBuilder WithCiRunId(string ciRunId)
{
_ciRunId = ciRunId;
return this;
}
/// <summary>
/// Configures whether missing baselines should cause failure.
/// </summary>
public DeterminismSummaryBuilder FailOnMissingBaselines(bool fail = true)
{
_failOnMissing = fail;
return this;
}
/// <summary>
/// Adds a comparison result.
/// </summary>
public DeterminismSummaryBuilder AddResult(BaselineComparisonResult result)
{
ArgumentNullException.ThrowIfNull(result);
_results.Add(result);
return this;
}
/// <summary>
/// Adds multiple comparison results.
/// </summary>
public DeterminismSummaryBuilder AddResults(IEnumerable<BaselineComparisonResult> results)
{
ArgumentNullException.ThrowIfNull(results);
_results.AddRange(results);
return this;
}
/// <summary>
/// Builds the determinism summary.
/// </summary>
public DeterminismSummary Build()
{
var matched = _results.Count(r => r.Status == BaselineStatus.Match);
var drifted = _results.Count(r => r.Status == BaselineStatus.Drift);
var missing = _results.Count(r => r.Status == BaselineStatus.Missing);
var status = DetermineStatus(drifted, missing);
var drift = _results
.Where(r => r.Status == BaselineStatus.Drift)
.Select(r => new DriftEntry
{
ArtifactType = r.ArtifactType,
ArtifactName = r.ArtifactName,
BaselineHash = r.BaselineHash!,
CurrentHash = r.CurrentHash
})
.ToList();
var missingEntries = _results
.Where(r => r.Status == BaselineStatus.Missing)
.Select(r => new MissingEntry
{
ArtifactType = r.ArtifactType,
ArtifactName = r.ArtifactName,
CurrentHash = r.CurrentHash
})
.ToList();
return new DeterminismSummary
{
GeneratedAt = DateTimeOffset.UtcNow,
SourceRef = _sourceRef,
CiRunId = _ciRunId,
Status = status,
Statistics = new DeterminismStatistics
{
Total = _results.Count,
Matched = matched,
Drifted = drifted,
Missing = missing
},
Results = _results.ToList(),
Drift = drift.Count > 0 ? drift : null,
Missing = missingEntries.Count > 0 ? missingEntries : null
};
}
private DeterminismCheckStatus DetermineStatus(int drifted, int missing)
{
if (drifted > 0)
{
return DeterminismCheckStatus.Fail;
}
if (missing > 0 && _failOnMissing)
{
return DeterminismCheckStatus.Fail;
}
if (missing > 0)
{
return DeterminismCheckStatus.Warning;
}
return DeterminismCheckStatus.Pass;
}
}
/// <summary>
/// Writer for determinism summary files.
/// </summary>
public static class DeterminismSummaryWriter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};
/// <summary>
/// Writes a determinism summary to a file.
/// </summary>
/// <param name="summary">The summary to write.</param>
/// <param name="filePath">Output file path.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public static async Task WriteToFileAsync(
DeterminismSummary summary,
string filePath,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(summary);
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
var directory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(summary, JsonOptions);
await File.WriteAllTextAsync(filePath, json, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Serializes a determinism summary to JSON string.
/// </summary>
/// <param name="summary">The summary to serialize.</param>
/// <returns>JSON string.</returns>
public static string ToJson(DeterminismSummary summary)
{
ArgumentNullException.ThrowIfNull(summary);
return JsonSerializer.Serialize(summary, JsonOptions);
}
/// <summary>
/// Writes hash files (sha256.txt) for each artifact in the summary.
/// </summary>
/// <param name="summary">The summary containing artifacts.</param>
/// <param name="outputDirectory">Directory to write hash files.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public static async Task WriteHashFilesAsync(
DeterminismSummary summary,
string outputDirectory,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(summary);
ArgumentException.ThrowIfNullOrWhiteSpace(outputDirectory);
Directory.CreateDirectory(outputDirectory);
foreach (var result in summary.Results)
{
var hashFileName = $"{result.ArtifactType}_{result.ArtifactName}.sha256.txt";
var hashFilePath = Path.Combine(outputDirectory, hashFileName);
var content = $"{result.CurrentHash} {result.ArtifactType}/{result.ArtifactName}";
await File.WriteAllTextAsync(hashFilePath, content, Encoding.UTF8, cancellationToken).ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>true</IsPackable>
<Description>Determinism manifest writer/reader for reproducible artifact tracking</Description>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Canonical.Json\StellaOps.Canonical.Json.csproj" />
</ItemGroup>
</Project>