Files
git.stella-ops.org/src/__Libraries/StellaOps.Evidence.Pack/EvidenceResolver.cs

257 lines
8.1 KiB
C#

// <copyright file="EvidenceResolver.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under the BUSL-1.1.
// </copyright>
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;
/// <summary>
/// Implementation of <see cref="IEvidenceResolver"/> with pluggable type resolvers.
/// Sprint: SPRINT_20260109_011_005 Task: EVPK-004
/// </summary>
internal sealed class EvidenceResolver : IEvidenceResolver
{
private readonly ImmutableDictionary<string, ITypeResolver> _resolvers;
private readonly ILogger<EvidenceResolver> _logger;
/// <summary>
/// Creates a new EvidenceResolver.
/// </summary>
public EvidenceResolver(
IEnumerable<ITypeResolver> resolvers,
ILogger<EvidenceResolver> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
var builder = ImmutableDictionary.CreateBuilder<string, ITypeResolver>(
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);
}
/// <inheritdoc/>
public async Task<ResolvedEvidence> 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
};
}
/// <inheritdoc/>
public async Task<EvidenceResolutionResult> 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}"
};
}
}
/// <inheritdoc/>
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)}";
}
}
/// <summary>
/// Interface for type-specific evidence resolvers.
/// </summary>
public interface ITypeResolver
{
/// <summary>
/// Gets the evidence types this resolver can handle.
/// </summary>
IEnumerable<string> SupportedTypes { get; }
/// <summary>
/// Resolves evidence and creates a snapshot.
/// </summary>
Task<EvidenceSnapshot> ResolveAsync(
string type,
string path,
CancellationToken cancellationToken);
}
/// <summary>
/// Exception thrown when an evidence type is not supported.
/// </summary>
public sealed class UnsupportedEvidenceTypeException : Exception
{
/// <summary>
/// Creates a new UnsupportedEvidenceTypeException.
/// </summary>
public UnsupportedEvidenceTypeException(string type, IEnumerable<string> supportedTypes)
: base($"Unsupported evidence type: {type}. Supported types: {string.Join(", ", supportedTypes)}")
{
Type = type;
SupportedTypes = supportedTypes.ToImmutableArray();
}
/// <summary>Gets the unsupported type.</summary>
public string Type { get; }
/// <summary>Gets the supported types.</summary>
public ImmutableArray<string> SupportedTypes { get; }
}
/// <summary>
/// Exception thrown when evidence cannot be found.
/// </summary>
public sealed class EvidenceNotFoundException : Exception
{
/// <summary>
/// Creates a new EvidenceNotFoundException.
/// </summary>
public EvidenceNotFoundException(string type, string path)
: base($"Evidence not found: {type}/{path}")
{
Type = type;
Path = path;
}
/// <summary>Gets the evidence type.</summary>
public string Type { get; }
/// <summary>Gets the evidence path.</summary>
public string Path { get; }
}