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
154 lines
5.3 KiB
C#
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);
|