Files
git.stella-ops.org/src/__Libraries/StellaOps.Testing.Determinism/Determinism/DeterminismBaselineStore.cs
master 5590a99a1a 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.
2025-12-23 23:51:58 +02:00

455 lines
15 KiB
C#

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; }
}