257 lines
8.1 KiB
C#
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; }
|
|
}
|