using System.Collections.Concurrent; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using StellaOps.Canonical.Json; namespace StellaOps.Testing.Determinism; /// /// Stores and retrieves determinism baselines for artifact comparison. /// Baselines are SHA-256 hashes of canonical artifact representations used to detect drift. /// public sealed class DeterminismBaselineStore { private readonly string _baselineDirectory; private readonly ConcurrentDictionary _cache = new(); private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } }; /// /// Creates a baseline store with the specified directory. /// /// Directory path for storing baselines. public DeterminismBaselineStore(string baselineDirectory) { ArgumentException.ThrowIfNullOrWhiteSpace(baselineDirectory); _baselineDirectory = baselineDirectory; } /// /// Creates a baseline store using the default baseline directory. /// Default: tests/baselines/determinism relative to repository root. /// /// Repository root directory. /// Configured baseline store. public static DeterminismBaselineStore CreateDefault(string repositoryRoot) { ArgumentException.ThrowIfNullOrWhiteSpace(repositoryRoot); var baselineDir = Path.Combine(repositoryRoot, "tests", "baselines", "determinism"); return new DeterminismBaselineStore(baselineDir); } /// /// Stores a baseline for an artifact. /// /// Type of artifact (e.g., "sbom", "vex", "policy-verdict"). /// Name of the artifact (e.g., "alpine-3.18-spdx"). /// The baseline to store. /// Cancellation token. 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; } /// /// Retrieves a baseline for an artifact. /// /// Type of artifact. /// Name of the artifact. /// Cancellation token. /// The baseline if found, null otherwise. public async Task 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(json, JsonOptions); if (baseline is not null) { _cache[key] = baseline; } return baseline; } /// /// Compares an artifact against its stored baseline. /// /// Type of artifact. /// Name of the artifact. /// Current SHA-256 hash of the artifact. /// Cancellation token. /// Comparison result indicating match, drift, or missing baseline. public async Task 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}." }; } /// /// Lists all baselines in the store. /// /// Cancellation token. /// Collection of baseline entries. public async Task> ListBaselinesAsync( CancellationToken cancellationToken = default) { var entries = new List(); 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(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(); } /// /// Creates a baseline from an artifact. /// /// The artifact bytes to hash. /// Version identifier for this baseline. /// Optional metadata about the baseline. /// Created baseline. public static DeterminismBaseline CreateBaseline( ReadOnlySpan artifactBytes, string version, IReadOnlyDictionary? 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 }; } /// /// Creates a baseline from a JSON artifact with canonical serialization. /// /// The artifact type. /// The artifact to serialize and hash. /// Version identifier for this baseline. /// Optional metadata about the baseline. /// Created baseline. public static DeterminismBaseline CreateBaselineFromJson( T artifact, string version, IReadOnlyDictionary? 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 }; } /// /// Gets the baseline directory path. /// 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(); } } /// /// A stored baseline for determinism comparison. /// public sealed record DeterminismBaseline { /// /// SHA-256 hash of the canonical artifact representation (hex-encoded). /// [JsonPropertyName("canonicalHash")] public required string CanonicalHash { get; init; } /// /// Hash algorithm used (always "SHA-256"). /// [JsonPropertyName("algorithm")] public required string Algorithm { get; init; } /// /// Version identifier for this baseline (e.g., "1.0.0", git SHA, or timestamp). /// [JsonPropertyName("version")] public required string Version { get; init; } /// /// UTC timestamp when this baseline was created or updated. /// [JsonPropertyName("updatedAt")] public required DateTimeOffset UpdatedAt { get; init; } /// /// Optional metadata about the baseline. /// [JsonPropertyName("metadata")] public IReadOnlyDictionary? Metadata { get; init; } } /// /// Result of comparing an artifact against its baseline. /// public sealed record BaselineComparisonResult { /// /// Type of artifact compared. /// [JsonPropertyName("artifactType")] public required string ArtifactType { get; init; } /// /// Name of artifact compared. /// [JsonPropertyName("artifactName")] public required string ArtifactName { get; init; } /// /// Comparison status. /// [JsonPropertyName("status")] public required BaselineStatus Status { get; init; } /// /// Current hash of the artifact. /// [JsonPropertyName("currentHash")] public required string CurrentHash { get; init; } /// /// Baseline hash (null if missing). /// [JsonPropertyName("baselineHash")] public string? BaselineHash { get; init; } /// /// Baseline version (null if missing). /// [JsonPropertyName("baselineVersion")] public string? BaselineVersion { get; init; } /// /// Human-readable message describing the result. /// [JsonPropertyName("message")] public required string Message { get; init; } } /// /// Status of a baseline comparison. /// public enum BaselineStatus { /// /// Artifact matches baseline hash. /// Match, /// /// Artifact hash differs from baseline (drift detected). /// Drift, /// /// No baseline exists for this artifact. /// Missing } /// /// Entry in the baseline registry. /// public sealed record BaselineEntry { /// /// Type of artifact. /// [JsonPropertyName("artifactType")] public required string ArtifactType { get; init; } /// /// Name of artifact. /// [JsonPropertyName("artifactName")] public required string ArtifactName { get; init; } /// /// Canonical hash of the baseline. /// [JsonPropertyName("canonicalHash")] public required string CanonicalHash { get; init; } /// /// Version identifier. /// [JsonPropertyName("version")] public required string Version { get; init; } /// /// When baseline was last updated. /// [JsonPropertyName("updatedAt")] public required DateTimeOffset UpdatedAt { get; init; } /// /// File path of the baseline. /// [JsonPropertyName("filePath")] public required string FilePath { get; init; } }