Files
git.stella-ops.org/src/Signals/StellaOps.Signals/Services/EdgeBundleIngestionService.cs
StellaOps Bot 233873f620
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
up
2025-12-14 15:50:38 +02:00

262 lines
9.3 KiB
C#

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;
/// <summary>
/// Ingests edge-bundle DSSE envelopes, attaches to graph_hash, enforces quarantine for revoked edges.
/// </summary>
public sealed class EdgeBundleIngestionService : IEdgeBundleIngestionService
{
private readonly ILogger<EdgeBundleIngestionService> _logger;
private readonly SignalsOptions _options;
// In-memory storage (in production, would use repository)
private readonly ConcurrentDictionary<string, List<EdgeBundleDocument>> _bundlesByGraphHash = new();
private readonly ConcurrentDictionary<string, HashSet<string>> _revokedEdgeKeys = new();
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
public EdgeBundleIngestionService(
ILogger<EdgeBundleIngestionService> logger,
IOptions<SignalsOptions> options)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
}
public async Task<EdgeBundleIngestResponse> 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<EdgeBundleEdgeDocument>();
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<EdgeBundleDocument> { 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<EdgeBundleDocument[]> 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<EdgeBundleDocument>());
}
public Task<EdgeBundleEdgeDocument[]> 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<EdgeBundleEdgeDocument>());
}
public Task<bool> 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;
}
}