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