Files
git.stella-ops.org/src/__Libraries/StellaOps.Testing.Determinism/Determinism/DeterminismManifestReader.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

239 lines
8.7 KiB
C#

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