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:
@@ -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; }
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user