using StellaOps.EvidenceLocker.Core.Builders; using StellaOps.EvidenceLocker.Core.Domain; using StellaOps.EvidenceLocker.Core.Repositories; using System.Collections.Immutable; using System.Text; namespace StellaOps.EvidenceLocker.Infrastructure.Builders; internal sealed class EvidenceBundleBuilder( IEvidenceBundleRepository repository, IMerkleTreeCalculator merkleTreeCalculator) : IEvidenceBundleBuilder { public async Task BuildAsync( EvidenceBundleBuildRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); var manifest = CreateManifest(request); var rootHash = merkleTreeCalculator.CalculateRootHash( manifest.Entries.Select(entry => $"{entry.CanonicalPath}|{entry.Sha256}")); await repository.SetBundleAssemblyAsync( request.BundleId, request.TenantId, EvidenceBundleStatus.Sealed, rootHash, request.CreatedAt, cancellationToken); return new EvidenceBundleBuildResult(rootHash, manifest); } private static EvidenceBundleManifest CreateManifest(EvidenceBundleBuildRequest request) { var normalizedMetadata = request.Metadata?.OrderBy(pair => pair.Key, StringComparer.Ordinal) .ToImmutableDictionary(pair => pair.Key, pair => pair.Value) ?? ImmutableDictionary.Empty; var entries = request.Materials? .Select(material => CreateEntry(material)) .OrderBy(entry => entry.CanonicalPath, StringComparer.Ordinal) .ToImmutableArray() ?? ImmutableArray.Empty; return new EvidenceBundleManifest( request.BundleId, request.TenantId, request.Kind, request.CreatedAt, normalizedMetadata, entries); } private static EvidenceManifestEntry CreateEntry(EvidenceBundleMaterial material) { var canonicalSection = NormalizeSection(material.Section); var canonicalPath = $"{canonicalSection}/{NormalizePath(material.Path)}"; var attributes = material.Attributes? .OrderBy(pair => pair.Key, StringComparer.Ordinal) .ToImmutableDictionary(pair => pair.Key, pair => pair.Value) ?? ImmutableDictionary.Empty; return new EvidenceManifestEntry( canonicalSection, canonicalPath, material.Sha256.ToLowerInvariant(), material.SizeBytes, material.MediaType, attributes); } private static string NormalizeSection(string value) { if (string.IsNullOrWhiteSpace(value)) { return "root"; } return NormalizeSegment(value); } private static string NormalizePath(string value) { if (string.IsNullOrEmpty(value)) { return "item"; } var segments = value.Split(['/','\\'], StringSplitOptions.RemoveEmptyEntries); var normalized = segments .Where(segment => segment is not "." and not "..") .Select(NormalizeSegment); return string.Join('/', normalized); } private static string NormalizeSegment(string value) { Span buffer = stackalloc char[value.Length]; var index = 0; foreach (var ch in value.Trim()) { if (char.IsLetterOrDigit(ch)) { buffer[index++] = char.ToLowerInvariant(ch); } else if (ch is '-' or '_' || ch == '.') { buffer[index++] = ch; } else { buffer[index++] = '-'; } } return index > 0 ? new string(buffer[..index]) : "item"; } }