// // Copyright (c) StellaOps. Licensed under the BUSL-1.1. // using System.Collections.Immutable; using System.Security.Cryptography; using System.Text; using System.Text.Json; using Microsoft.Extensions.Logging; using StellaOps.Evidence.Pack.Models; namespace StellaOps.Evidence.Pack; /// /// Implementation of with pluggable type resolvers. /// Sprint: SPRINT_20260109_011_005 Task: EVPK-004 /// internal sealed class EvidenceResolver : IEvidenceResolver { private readonly ImmutableDictionary _resolvers; private readonly ILogger _logger; /// /// Creates a new EvidenceResolver. /// public EvidenceResolver( IEnumerable resolvers, ILogger logger) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); var builder = ImmutableDictionary.CreateBuilder( StringComparer.OrdinalIgnoreCase); foreach (var resolver in resolvers) { foreach (var type in resolver.SupportedTypes) { builder[type] = resolver; } } _resolvers = builder.ToImmutable(); _logger.LogDebug("Initialized EvidenceResolver with {Count} type resolvers", _resolvers.Count); } /// public async Task ResolveAndSnapshotAsync( string type, string path, CancellationToken cancellationToken) { _logger.LogDebug("Resolving evidence: type={Type}, path={Path}", type, path); if (!_resolvers.TryGetValue(type, out var resolver)) { throw new UnsupportedEvidenceTypeException(type, _resolvers.Keys); } var snapshot = await resolver.ResolveAsync(type, path, cancellationToken) .ConfigureAwait(false); var digest = ComputeSnapshotDigest(snapshot); _logger.LogDebug("Resolved evidence: type={Type}, digest={Digest}", type, digest); return new ResolvedEvidence { Snapshot = snapshot, Digest = digest, ResolvedAt = DateTimeOffset.UtcNow }; } /// public async Task VerifyEvidenceAsync( EvidenceItem evidence, CancellationToken cancellationToken) { _logger.LogDebug("Verifying evidence: {EvidenceId}", evidence.EvidenceId); try { // Parse the URI to get type and path var (type, path) = ParseStellaUri(evidence.Uri); if (!_resolvers.TryGetValue(type, out var resolver)) { return new EvidenceResolutionResult { EvidenceId = evidence.EvidenceId, Uri = evidence.Uri, Resolved = false, DigestMatches = false, Error = $"Unsupported evidence type: {type}" }; } // Resolve current state var currentSnapshot = await resolver.ResolveAsync(type, path, cancellationToken) .ConfigureAwait(false); var currentDigest = ComputeSnapshotDigest(currentSnapshot); var digestMatches = string.Equals( evidence.Digest, currentDigest, StringComparison.OrdinalIgnoreCase); _logger.LogDebug( "Evidence {EvidenceId} verification: digestMatches={DigestMatches}", evidence.EvidenceId, digestMatches); return new EvidenceResolutionResult { EvidenceId = evidence.EvidenceId, Uri = evidence.Uri, Resolved = true, DigestMatches = digestMatches, Error = digestMatches ? null : "Digest mismatch - evidence has changed since collection" }; } catch (EvidenceNotFoundException ex) { _logger.LogWarning(ex, "Evidence not found: {EvidenceId}", evidence.EvidenceId); return new EvidenceResolutionResult { EvidenceId = evidence.EvidenceId, Uri = evidence.Uri, Resolved = false, DigestMatches = false, Error = ex.Message }; } catch (Exception ex) { _logger.LogError(ex, "Failed to verify evidence: {EvidenceId}", evidence.EvidenceId); return new EvidenceResolutionResult { EvidenceId = evidence.EvidenceId, Uri = evidence.Uri, Resolved = false, DigestMatches = false, Error = $"Verification failed: {ex.Message}" }; } } /// public bool SupportsType(string type) { return _resolvers.ContainsKey(type); } private static (string type, string path) ParseStellaUri(string uri) { // Expected format: stella://type/path if (!uri.StartsWith("stella://", StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException($"Invalid evidence URI format: {uri}. Expected stella://type/path"); } var withoutScheme = uri[9..]; // Remove "stella://" var slashIndex = withoutScheme.IndexOf('/', StringComparison.Ordinal); if (slashIndex <= 0) { throw new ArgumentException($"Invalid evidence URI format: {uri}. Missing path."); } var type = withoutScheme[..slashIndex]; var path = withoutScheme[(slashIndex + 1)..]; return (type, path); } private static string ComputeSnapshotDigest(EvidenceSnapshot snapshot) { // Canonical JSON for digest computation var json = JsonSerializer.Serialize(new { type = snapshot.Type, data = snapshot.Data.OrderBy(kvp => kvp.Key, StringComparer.Ordinal) .ToDictionary(kvp => kvp.Key, kvp => kvp.Value) }, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = false }); var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json)); return $"sha256:{Convert.ToHexStringLower(hash)}"; } } /// /// Interface for type-specific evidence resolvers. /// public interface ITypeResolver { /// /// Gets the evidence types this resolver can handle. /// IEnumerable SupportedTypes { get; } /// /// Resolves evidence and creates a snapshot. /// Task ResolveAsync( string type, string path, CancellationToken cancellationToken); } /// /// Exception thrown when an evidence type is not supported. /// public sealed class UnsupportedEvidenceTypeException : Exception { /// /// Creates a new UnsupportedEvidenceTypeException. /// public UnsupportedEvidenceTypeException(string type, IEnumerable supportedTypes) : base($"Unsupported evidence type: {type}. Supported types: {string.Join(", ", supportedTypes)}") { Type = type; SupportedTypes = supportedTypes.ToImmutableArray(); } /// Gets the unsupported type. public string Type { get; } /// Gets the supported types. public ImmutableArray SupportedTypes { get; } } /// /// Exception thrown when evidence cannot be found. /// public sealed class EvidenceNotFoundException : Exception { /// /// Creates a new EvidenceNotFoundException. /// public EvidenceNotFoundException(string type, string path) : base($"Evidence not found: {type}/{path}") { Type = type; Path = path; } /// Gets the evidence type. public string Type { get; } /// Gets the evidence path. public string Path { get; } }