using System.Text; using System.Text.Json; using System.Text.Json.Serialization; namespace StellaOps.Testing.Determinism; /// /// Reader for determinism manifest files with validation. /// public sealed class DeterminismManifestReader { private static readonly JsonSerializerOptions DefaultOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } }; /// /// Deserializes a determinism manifest from JSON bytes. /// /// UTF-8 encoded JSON bytes. /// Deserialized determinism manifest. /// If JSON is invalid. /// If manifest validation fails. public static DeterminismManifest FromBytes(ReadOnlySpan jsonBytes) { var manifest = JsonSerializer.Deserialize(jsonBytes, DefaultOptions); if (manifest is null) { throw new JsonException("Failed to deserialize determinism manifest: result was null."); } ValidateManifest(manifest); return manifest; } /// /// Deserializes a determinism manifest from a JSON string. /// /// JSON string. /// Deserialized determinism manifest. /// If JSON is invalid. /// If manifest validation fails. public static DeterminismManifest FromString(string json) { ArgumentException.ThrowIfNullOrWhiteSpace(json); var bytes = Encoding.UTF8.GetBytes(json); return FromBytes(bytes); } /// /// Reads a determinism manifest from a file. /// /// File path to read from. /// Cancellation token. /// Deserialized determinism manifest. /// If file does not exist. /// If JSON is invalid. /// If manifest validation fails. public static async Task 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); } /// /// Reads a determinism manifest from a file synchronously. /// /// File path to read from. /// Deserialized determinism manifest. /// If file does not exist. /// If JSON is invalid. /// If manifest validation fails. 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); } /// /// Tries to read a determinism manifest from a file, returning null if the file doesn't exist. /// /// File path to read from. /// Cancellation token. /// Deserialized manifest or null if file doesn't exist. /// If JSON is invalid. /// If manifest validation fails. public static async Task 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); } /// /// Validates a determinism manifest. /// /// The manifest to validate. /// If validation fails. 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 }; } }