using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; using System.Security.Cryptography; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Signals.Models; using StellaOps.Signals.Options; using StellaOps.Signals.Services.Models; namespace StellaOps.Signals.Services; /// /// Writes reachability union bundles (runtime + static) into the CAS layout: reachability_graphs/<analysisId>/ /// Validates meta.json hashes before persisting. /// public sealed class ReachabilityUnionIngestionService : IReachabilityUnionIngestionService { private static readonly string[] RequiredFiles = { "nodes.ndjson", "edges.ndjson", "meta.json" }; private readonly ILogger logger; private readonly SignalsOptions options; public ReachabilityUnionIngestionService( ILogger logger, IOptions options) { this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.options = options?.Value ?? throw new ArgumentNullException(nameof(options)); } public async Task IngestAsync(string analysisId, Stream zipStream, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(analysisId); ArgumentNullException.ThrowIfNull(zipStream); var casRoot = Path.Combine(options.Storage.RootPath, "reachability_graphs", analysisId.Trim()); if (Directory.Exists(casRoot)) { Directory.Delete(casRoot, recursive: true); } Directory.CreateDirectory(casRoot); using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: true); var entries = archive.Entries.ToDictionary(e => e.FullName, StringComparer.OrdinalIgnoreCase); foreach (var required in RequiredFiles) { if (!entries.ContainsKey(required)) { throw new InvalidOperationException($"Union bundle missing required file: {required}"); } } var metaEntry = entries["meta.json"]; await using var metaBuffer = new MemoryStream(); await using (var metaStream = metaEntry.Open()) { await metaStream.CopyToAsync(metaBuffer, cancellationToken).ConfigureAwait(false); } metaBuffer.Position = 0; using var metaDoc = await JsonDocument.ParseAsync(metaBuffer, cancellationToken: cancellationToken).ConfigureAwait(false); var metaRoot = metaDoc.RootElement; var filesElement = metaRoot.TryGetProperty("files", out var f) && f.ValueKind == JsonValueKind.Array ? f : throw new InvalidOperationException("meta.json is missing required 'files' array"); var recorded = filesElement.EnumerateArray() .Select(el => new { Path = el.GetProperty("path").GetString() ?? string.Empty, Sha = el.GetProperty("sha256").GetString() ?? string.Empty, Records = el.TryGetProperty("records", out var r) && r.ValueKind == JsonValueKind.Number ? r.GetInt32() : (int?)null }) .ToList(); metaBuffer.Position = 0; var metaPath = Path.Combine(casRoot, "meta.json"); await using (var metaDest = File.Create(metaPath)) { await metaBuffer.CopyToAsync(metaDest, cancellationToken).ConfigureAwait(false); } var filesForResponse = new List(); foreach (var file in recorded) { if (!entries.TryGetValue(file.Path, out var zipEntry)) { throw new InvalidOperationException($"meta.json references missing file '{file.Path}'."); } var destPath = Path.Combine(casRoot, file.Path.Replace('/', Path.DirectorySeparatorChar)); Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); using (var entryStream = zipEntry.Open()) using (var dest = File.Create(destPath)) { await entryStream.CopyToAsync(dest, cancellationToken).ConfigureAwait(false); } var actualSha = ComputeSha256Hex(destPath); if (!string.Equals(actualSha, file.Sha, StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException($"SHA mismatch for {file.Path}: expected {file.Sha}, actual {actualSha}."); } filesForResponse.Add(new ReachabilityUnionFile(file.Path, actualSha, file.Records)); } logger.LogInformation("Ingested reachability union bundle {AnalysisId} with {FileCount} files.", analysisId, filesForResponse.Count); return new ReachabilityUnionIngestResponse(analysisId, $"cas://reachability_graphs/{analysisId}", filesForResponse); } private static string ComputeSha256Hex(string path) { using var stream = File.OpenRead(path); var buffer = new byte[8192]; using var sha = SHA256.Create(); int read; while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) { sha.TransformBlock(buffer, 0, read, null, 0); } sha.TransformFinalBlock(Array.Empty(), 0, 0); return Convert.ToHexString(sha.Hash!).ToLowerInvariant(); } }