Files
git.stella-ops.org/src/Scanner/__Libraries/StellaOps.Scanner.Reachability/ReachabilityRichGraphPublisher.cs
StellaOps Bot 6e45066e37
Some checks failed
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (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
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
up
2025-12-13 09:37:15 +02:00

154 lines
5.3 KiB
C#

using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.Cache.Abstractions;
namespace StellaOps.Scanner.Reachability;
public interface IRichGraphPublisher
{
Task<RichGraphPublishResult> PublishAsync(RichGraph graph, string analysisId, IFileContentAddressableStore cas, string workRoot, CancellationToken cancellationToken = default);
}
/// <summary>
/// Stores richgraph-v1 JSON in CAS and emits a deterministic DSSE envelope for graph attestations.
/// CAS paths follow the richgraph-v1 contract: cas://reachability/graphs/{blake3}
/// </summary>
public sealed class ReachabilityRichGraphPublisher : IRichGraphPublisher
{
private readonly RichGraphWriter _writer;
public ReachabilityRichGraphPublisher(RichGraphWriter writer)
{
_writer = writer ?? throw new ArgumentNullException(nameof(writer));
}
public async Task<RichGraphPublishResult> PublishAsync(
RichGraph graph,
string analysisId,
IFileContentAddressableStore cas,
string workRoot,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentNullException.ThrowIfNull(cas);
ArgumentException.ThrowIfNullOrWhiteSpace(analysisId);
ArgumentException.ThrowIfNullOrWhiteSpace(workRoot);
Directory.CreateDirectory(workRoot);
var writeResult = await _writer.WriteAsync(graph, workRoot, analysisId, cancellationToken).ConfigureAwait(false);
// Use BLAKE3 graph_hash as the CAS key per CONTRACT-RICHGRAPH-V1-015
var casKey = ExtractHashDigest(writeResult.GraphHash);
await using var graphStream = File.OpenRead(writeResult.GraphPath);
var casEntry = await cas.PutAsync(new FileCasPutRequest(casKey, graphStream, leaveOpen: false), cancellationToken).ConfigureAwait(false);
// Build CAS URI per contract: cas://reachability/graphs/{blake3}
var casUri = $"cas://reachability/graphs/{casKey}";
var dsse = BuildDeterministicGraphDsse(writeResult, casUri, analysisId);
await using var dsseStream = new MemoryStream(dsse.EnvelopeJson, writable: false);
var dsseKey = $"{casKey}.dsse";
var dsseEntry = await cas.PutAsync(new FileCasPutRequest(dsseKey, dsseStream, leaveOpen: false), cancellationToken).ConfigureAwait(false);
var dsseCasUri = $"cas://reachability/graphs/{dsseKey}";
return new RichGraphPublishResult(
writeResult.GraphHash,
casEntry.RelativePath,
casUri,
dsseEntry.RelativePath,
dsseCasUri,
dsse.Digest,
writeResult.NodeCount,
writeResult.EdgeCount);
}
private static GraphDsse BuildDeterministicGraphDsse(RichGraphWriteResult writeResult, string casUri, string analysisId)
{
var graphHash = writeResult.GraphHash;
var predicate = new
{
version = "1.0",
schema = "richgraph-v1",
graphId = analysisId,
hashes = new
{
graphHash
},
cas = new
{
location = casUri
},
graph = new
{
nodes = new { total = writeResult.NodeCount },
edges = new { total = writeResult.EdgeCount }
}
};
var payloadType = "application/vnd.stellaops.graph.predicate+json";
var payloadBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(predicate, new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = false
}));
var signatureHex = ComputeSha256Hex(payloadBytes);
var envelope = new
{
payloadType,
payload = Base64UrlEncode(payloadBytes),
signatures = new[]
{
new { keyid = "scanner-deterministic", sig = Base64UrlEncode(Encoding.UTF8.GetBytes(signatureHex)) }
}
};
var envelopeJson = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(envelope, new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
WriteIndented = false
}));
return new GraphDsse(envelopeJson, $"sha256:{signatureHex}");
}
private static string ComputeSha256Hex(ReadOnlySpan<byte> data)
{
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(data, hash);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string Base64UrlEncode(ReadOnlySpan<byte> data)
{
var base64 = Convert.ToBase64String(data);
return base64.Replace("+", "-").Replace("/", "_").TrimEnd('=');
}
/// <summary>
/// Extracts the hex digest from a prefixed hash (e.g., "blake3:abc123" → "abc123").
/// </summary>
private static string ExtractHashDigest(string prefixedHash)
{
var colonIndex = prefixedHash.IndexOf(':');
return colonIndex >= 0 ? prefixedHash[(colonIndex + 1)..] : prefixedHash;
}
}
public sealed record RichGraphPublishResult(
string GraphHash,
string RelativePath,
string CasUri,
string DsseRelativePath,
string DsseCasUri,
string DsseDigest,
int NodeCount,
int EdgeCount);
internal sealed record GraphDsse(byte[] EnvelopeJson, string Digest);