using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; 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; namespace StellaOps.Signals.Services; /// /// Ingests edge-bundle DSSE envelopes, attaches to graph_hash, enforces quarantine for revoked edges. /// public sealed class EdgeBundleIngestionService : IEdgeBundleIngestionService { private readonly ILogger _logger; private readonly SignalsOptions _options; // In-memory storage (in production, would use repository) private readonly ConcurrentDictionary> _bundlesByGraphHash = new(); private readonly ConcurrentDictionary> _revokedEdgeKeys = new(); private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true }; public EdgeBundleIngestionService( ILogger logger, IOptions options) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); } public async Task IngestAsync( string tenantId, Stream bundleStream, Stream? dsseStream, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentNullException.ThrowIfNull(bundleStream); // Parse the bundle JSON using var bundleMs = new MemoryStream(); await bundleStream.CopyToAsync(bundleMs, cancellationToken).ConfigureAwait(false); bundleMs.Position = 0; var bundleJson = await JsonDocument.ParseAsync(bundleMs, cancellationToken: cancellationToken).ConfigureAwait(false); var root = bundleJson.RootElement; // Extract bundle fields var bundleId = GetStringOrDefault(root, "bundleId", $"bundle:{Guid.NewGuid():N}"); var graphHash = GetStringOrDefault(root, "graphHash", string.Empty); var bundleReason = GetStringOrDefault(root, "bundleReason", "Custom"); var customReason = GetStringOrDefault(root, "customReason", null); var generatedAtStr = GetStringOrDefault(root, "generatedAt", null); if (string.IsNullOrWhiteSpace(graphHash)) { throw new InvalidOperationException("Edge bundle missing required 'graphHash' field"); } var generatedAt = !string.IsNullOrWhiteSpace(generatedAtStr) ? DateTimeOffset.Parse(generatedAtStr) : DateTimeOffset.UtcNow; // Parse edges var edges = new List(); var revokedCount = 0; if (root.TryGetProperty("edges", out var edgesElement) && edgesElement.ValueKind == JsonValueKind.Array) { foreach (var edgeEl in edgesElement.EnumerateArray()) { var edge = new EdgeBundleEdgeDocument { From = GetStringOrDefault(edgeEl, "from", string.Empty), To = GetStringOrDefault(edgeEl, "to", string.Empty), Kind = GetStringOrDefault(edgeEl, "kind", "call"), Reason = GetStringOrDefault(edgeEl, "reason", "Unknown"), Revoked = edgeEl.TryGetProperty("revoked", out var r) && r.GetBoolean(), Confidence = edgeEl.TryGetProperty("confidence", out var c) ? c.GetDouble() : 0.5, Purl = GetStringOrDefault(edgeEl, "purl", null), SymbolDigest = GetStringOrDefault(edgeEl, "symbolDigest", null), Evidence = GetStringOrDefault(edgeEl, "evidence", null) }; edges.Add(edge); if (edge.Revoked) { revokedCount++; } } } // Compute content hash bundleMs.Position = 0; var contentHash = ComputeSha256(bundleMs); // Parse DSSE if provided string? dsseDigest = null; if (dsseStream is not null) { using var dsseMs = new MemoryStream(); await dsseStream.CopyToAsync(dsseMs, cancellationToken).ConfigureAwait(false); dsseMs.Position = 0; dsseDigest = $"sha256:{ComputeSha256(dsseMs)}"; } // Build CAS URIs var graphHashDigest = ExtractHashDigest(graphHash); var casUri = $"cas://reachability/edges/{graphHashDigest}/{bundleId}"; var dsseCasUri = dsseStream is not null ? $"{casUri}.dsse" : null; // Create document var document = new EdgeBundleDocument { BundleId = bundleId, GraphHash = graphHash, TenantId = tenantId, BundleReason = bundleReason, CustomReason = customReason, Edges = edges, ContentHash = $"sha256:{contentHash}", DsseDigest = dsseDigest, CasUri = casUri, DsseCasUri = dsseCasUri, Verified = dsseStream is not null, // Simple verification - in production would verify signature RevokedCount = revokedCount, IngestedAt = DateTimeOffset.UtcNow, GeneratedAt = generatedAt }; // Store document var storageKey = $"{tenantId}:{graphHash}"; _bundlesByGraphHash.AddOrUpdate( storageKey, _ => new List { document }, (_, list) => { // Remove existing bundle with same ID list.RemoveAll(b => b.BundleId == bundleId); list.Add(document); return list; }); // Update revoked edge index for quarantine enforcement if (revokedCount > 0) { var revokedEdges = edges.Where(e => e.Revoked).Select(e => $"{e.From}>{e.To}").ToHashSet(); _revokedEdgeKeys.AddOrUpdate( storageKey, _ => revokedEdges, (_, existing) => { foreach (var key in revokedEdges) { existing.Add(key); } return existing; }); } var quarantined = revokedCount > 0; _logger.LogInformation( "Ingested edge bundle {BundleId} for graph {GraphHash} with {EdgeCount} edges ({RevokedCount} revoked, quarantine={Quarantined})", bundleId, graphHash, edges.Count, revokedCount, quarantined); return new EdgeBundleIngestResponse( bundleId, graphHash, bundleReason, casUri, dsseCasUri, edges.Count, revokedCount, quarantined); } public Task GetBundlesForGraphAsync( string tenantId, string graphHash, CancellationToken cancellationToken = default) { var key = $"{tenantId}:{graphHash}"; if (_bundlesByGraphHash.TryGetValue(key, out var bundles)) { return Task.FromResult(bundles.ToArray()); } return Task.FromResult(Array.Empty()); } public Task GetRevokedEdgesAsync( string tenantId, string graphHash, CancellationToken cancellationToken = default) { var key = $"{tenantId}:{graphHash}"; if (_bundlesByGraphHash.TryGetValue(key, out var bundles)) { var revoked = bundles .SelectMany(b => b.Edges) .Where(e => e.Revoked) .ToArray(); return Task.FromResult(revoked); } return Task.FromResult(Array.Empty()); } public Task IsEdgeRevokedAsync( string tenantId, string graphHash, string fromId, string toId, CancellationToken cancellationToken = default) { var key = $"{tenantId}:{graphHash}"; if (_revokedEdgeKeys.TryGetValue(key, out var revokedKeys)) { var edgeKey = $"{fromId}>{toId}"; return Task.FromResult(revokedKeys.Contains(edgeKey)); } return Task.FromResult(false); } private static string GetStringOrDefault(JsonElement element, string propertyName, string? defaultValue) { if (element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String) { return prop.GetString() ?? defaultValue ?? string.Empty; } return defaultValue ?? string.Empty; } private static string ComputeSha256(Stream stream) { using var sha = SHA256.Create(); var hash = sha.ComputeHash(stream); return Convert.ToHexString(hash).ToLowerInvariant(); } private static string ExtractHashDigest(string prefixedHash) { var colonIndex = prefixedHash.IndexOf(':'); return colonIndex >= 0 ? prefixedHash[(colonIndex + 1)..] : prefixedHash; } }