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;
///
/// Options for edge-bundle DSSE publishing.
///
public sealed record EdgeBundlePublisherOptions
{
///
/// Whether to publish DSSE envelopes for edge bundles.
/// Default: true.
///
public bool Enabled { get; init; } = true;
///
/// Maximum number of edge-bundle DSSEs to publish to Rekor per graph.
/// Default: 5 (capped to prevent volume spikes).
///
public int MaxRekorPublishesPerGraph { get; init; } = 5;
///
/// Whether to publish runtime-hit bundles.
///
public bool PublishRuntimeHits { get; init; } = true;
///
/// Whether to publish init-array/static-init bundles.
///
public bool PublishInitArray { get; init; } = true;
///
/// Whether to publish third-party edge bundles.
///
public bool PublishThirdParty { get; init; } = false;
///
/// Whether to publish contested (low-confidence) edge bundles.
///
public bool PublishContested { get; init; } = false;
///
/// Whether to publish revoked edge bundles.
///
public bool PublishRevoked { get; init; } = true;
///
/// Confidence threshold below which edges are considered contested.
///
public double ContestedConfidenceThreshold { get; init; } = 0.5;
}
///
/// Result of publishing an edge bundle.
///
public sealed record EdgeBundlePublishResult(
string BundleId,
string GraphHash,
EdgeBundleReason BundleReason,
string ContentHash,
string RelativePath,
string CasUri,
string DsseRelativePath,
string DsseCasUri,
string DsseDigest,
int EdgeCount);
///
/// Interface for edge-bundle DSSE publishing.
///
public interface IEdgeBundlePublisher
{
Task PublishAsync(
EdgeBundle bundle,
IFileContentAddressableStore cas,
CancellationToken cancellationToken = default);
}
///
/// Publishes edge bundles to CAS with deterministic DSSE envelopes.
/// CAS paths follow: cas://reachability/edges/{graph_hash}/{bundle_id}[.dsse]
///
public sealed class EdgeBundlePublisher : IEdgeBundlePublisher
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public async Task PublishAsync(
EdgeBundle bundle,
IFileContentAddressableStore cas,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(bundle);
ArgumentNullException.ThrowIfNull(cas);
var canonical = bundle.Canonical();
var contentHash = canonical.ComputeContentHash();
var hashDigest = ExtractHashDigest(contentHash);
var graphHashDigest = ExtractHashDigest(canonical.GraphHash);
// Build the bundle JSON
var bundleJson = SerializeBundle(canonical);
// Store bundle JSON in CAS
// Path: cas://reachability/edges/{graph_hash}/{bundle_id}
var bundleKey = $"edges/{graphHashDigest}/{canonical.BundleId}";
await using var bundleStream = new MemoryStream(bundleJson, writable: false);
var bundleEntry = await cas.PutAsync(new FileCasPutRequest(bundleKey, bundleStream, leaveOpen: false), cancellationToken).ConfigureAwait(false);
var casUri = $"cas://reachability/edges/{graphHashDigest}/{canonical.BundleId}";
// Build and store DSSE envelope
var dsse = BuildDeterministicEdgeBundleDsse(canonical, casUri, contentHash);
await using var dsseStream = new MemoryStream(dsse.EnvelopeJson, writable: false);
var dsseKey = $"edges/{graphHashDigest}/{canonical.BundleId}.dsse";
var dsseEntry = await cas.PutAsync(new FileCasPutRequest(dsseKey, dsseStream, leaveOpen: false), cancellationToken).ConfigureAwait(false);
var dsseCasUri = $"cas://reachability/edges/{graphHashDigest}/{canonical.BundleId}.dsse";
return new EdgeBundlePublishResult(
BundleId: canonical.BundleId,
GraphHash: canonical.GraphHash,
BundleReason: canonical.BundleReason,
ContentHash: contentHash,
RelativePath: bundleEntry.RelativePath,
CasUri: casUri,
DsseRelativePath: dsseEntry.RelativePath,
DsseCasUri: dsseCasUri,
DsseDigest: dsse.Digest,
EdgeCount: canonical.Edges.Count);
}
private static byte[] SerializeBundle(EdgeBundle bundle)
{
var payload = new
{
schema = "edge-bundle-v1",
bundleId = bundle.BundleId,
graphHash = bundle.GraphHash,
bundleReason = bundle.BundleReason.ToString(),
customReason = bundle.CustomReason,
generatedAt = bundle.GeneratedAt.ToString("O"),
edges = bundle.Edges.Select(e => new
{
from = e.From,
to = e.To,
kind = e.Kind,
reason = e.Reason.ToString(),
revoked = e.Revoked,
confidence = e.Confidence,
purl = e.Purl,
symbolDigest = e.SymbolDigest,
evidence = e.Evidence
}).ToArray()
};
return Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload, JsonOptions));
}
private static EdgeBundleDsse BuildDeterministicEdgeBundleDsse(EdgeBundle bundle, string casUri, string contentHash)
{
var predicate = new
{
version = "1.0",
schema = "edge-bundle-v1",
bundleId = bundle.BundleId,
graphHash = bundle.GraphHash,
bundleReason = bundle.BundleReason.ToString(),
hashes = new
{
contentHash
},
cas = new
{
location = casUri
},
edges = new
{
total = bundle.Edges.Count,
revoked = bundle.Edges.Count(e => e.Revoked),
reasons = bundle.Edges
.GroupBy(e => e.Reason)
.OrderBy(g => (int)g.Key)
.ToDictionary(g => g.Key.ToString(), g => g.Count())
}
};
var payloadType = "application/vnd.stellaops.edgebundle.predicate+json";
var payloadBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(predicate, JsonOptions));
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, JsonOptions));
return new EdgeBundleDsse(envelopeJson, $"sha256:{signatureHex}");
}
private static string ComputeSha256Hex(ReadOnlySpan data)
{
Span hash = stackalloc byte[32];
SHA256.HashData(data, hash);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string Base64UrlEncode(ReadOnlySpan data)
{
var base64 = Convert.ToBase64String(data);
return base64.Replace("+", "-").Replace("/", "_").TrimEnd('=');
}
private static string ExtractHashDigest(string prefixedHash)
{
var colonIndex = prefixedHash.IndexOf(':');
return colonIndex >= 0 ? prefixedHash[(colonIndex + 1)..] : prefixedHash;
}
}
internal sealed record EdgeBundleDsse(byte[] EnvelopeJson, string Digest);