- 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.
239 lines
8.7 KiB
C#
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
|
|
};
|
|
}
|
|
}
|