feat: Add tests for RichGraphPublisher and RichGraphWriter
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
- Implement unit tests for RichGraphPublisher to verify graph publishing to CAS. - Implement unit tests for RichGraphWriter to ensure correct writing of canonical graphs and metadata. feat: Implement AOC Guard validation logic - Add AOC Guard validation logic to enforce document structure and field constraints. - Introduce violation codes for various validation errors. - Implement tests for AOC Guard to validate expected behavior. feat: Create Console Status API client and service - Implement ConsoleStatusClient for fetching console status and streaming run events. - Create ConsoleStatusService to manage console status polling and event subscriptions. - Add tests for ConsoleStatusClient to verify API interactions. feat: Develop Console Status component - Create ConsoleStatusComponent for displaying console status and run events. - Implement UI for showing status metrics and handling user interactions. - Add styles for console status display. test: Add tests for Console Status store - Implement tests for ConsoleStatusStore to verify event handling and state management.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
/// <summary>
|
||||
/// Builds canonical CodeIDs used by richgraph-v1 to anchor symbols when names are missing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Format: <c>code:<lang>:<base64url-sha256></c> where the hash is computed over a
|
||||
/// canonical tuple that is stable across machines and paths.
|
||||
/// </remarks>
|
||||
public static class CodeId
|
||||
{
|
||||
public static string ForBinary(string buildId, string section, string? relativePath)
|
||||
{
|
||||
var tuple = $"{Norm(buildId)}\0{Norm(section)}\0{Norm(relativePath)}";
|
||||
return Build("binary", tuple);
|
||||
}
|
||||
|
||||
public static string ForDotNet(string assemblyName, string moduleName, string? mvid)
|
||||
{
|
||||
var tuple = $"{Norm(assemblyName)}\0{Norm(moduleName)}\0{Norm(mvid)}";
|
||||
return Build("dotnet", tuple);
|
||||
}
|
||||
|
||||
public static string ForNode(string packageName, string entryPath)
|
||||
{
|
||||
var tuple = $"{Norm(packageName)}\0{Norm(entryPath)}";
|
||||
return Build("node", tuple);
|
||||
}
|
||||
|
||||
public static string FromSymbolId(string symbolId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(symbolId);
|
||||
return Build("sym", symbolId.Trim());
|
||||
}
|
||||
|
||||
private static string Build(string lang, string tuple)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(tuple));
|
||||
var base64 = Convert.ToBase64String(hash)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
return $"code:{lang}:{base64}";
|
||||
}
|
||||
|
||||
private static string Norm(string? value) => (value ?? string.Empty).Trim();
|
||||
}
|
||||
@@ -144,17 +144,18 @@ public sealed partial class DotNetReachabilityLifter : IReachabilityLifter
|
||||
|
||||
// Add assembly node
|
||||
var assemblySymbol = SymbolId.ForDotNet(info.AssemblyName, string.Empty, string.Empty, string.Empty);
|
||||
builder.AddNode(
|
||||
symbolId: assemblySymbol,
|
||||
lang: SymbolId.Lang.DotNet,
|
||||
kind: "assembly",
|
||||
display: info.AssemblyName,
|
||||
sourceFile: relativePath,
|
||||
attributes: new Dictionary<string, string>
|
||||
{
|
||||
["target_framework"] = info.TargetFramework,
|
||||
["root_namespace"] = info.RootNamespace
|
||||
});
|
||||
builder.AddNode(
|
||||
symbolId: assemblySymbol,
|
||||
lang: SymbolId.Lang.DotNet,
|
||||
kind: "assembly",
|
||||
display: info.AssemblyName,
|
||||
sourceFile: relativePath,
|
||||
attributes: new Dictionary<string, string>
|
||||
{
|
||||
["target_framework"] = info.TargetFramework,
|
||||
["root_namespace"] = info.RootNamespace,
|
||||
["code_id"] = CodeId.ForDotNet(info.AssemblyName, info.AssemblyName, null)
|
||||
});
|
||||
|
||||
// Add namespace node
|
||||
if (!string.IsNullOrWhiteSpace(info.RootNamespace))
|
||||
@@ -348,7 +349,8 @@ public sealed partial class DotNetReachabilityLifter : IReachabilityLifter
|
||||
attributes: new Dictionary<string, string>
|
||||
{
|
||||
["version"] = version,
|
||||
["purl"] = $"pkg:nuget/{packageName}@{version}"
|
||||
["purl"] = $"pkg:nuget/{packageName}@{version}",
|
||||
["code_id"] = CodeId.ForDotNet(packageName, packageName, null)
|
||||
});
|
||||
|
||||
// Process dependencies
|
||||
|
||||
@@ -94,7 +94,8 @@ public sealed class NodeReachabilityLifter : IReachabilityLifter
|
||||
attributes: new Dictionary<string, string>
|
||||
{
|
||||
["version"] = pkgVersion ?? "0.0.0",
|
||||
["purl"] = $"pkg:npm/{EncodePackageName(pkgName)}@{pkgVersion}"
|
||||
["purl"] = $"pkg:npm/{EncodePackageName(pkgName)}@{pkgVersion}",
|
||||
["code_id"] = CodeId.ForNode(pkgName, "module")
|
||||
});
|
||||
|
||||
// Process entrypoints (main, module, exports)
|
||||
@@ -137,7 +138,11 @@ public sealed class NodeReachabilityLifter : IReachabilityLifter
|
||||
lang: SymbolId.Lang.Node,
|
||||
kind: "entrypoint",
|
||||
display: $"{pkgName}:{mainPath}",
|
||||
sourceFile: NormalizePath(mainPath));
|
||||
sourceFile: NormalizePath(mainPath),
|
||||
attributes: new Dictionary<string, string>
|
||||
{
|
||||
["code_id"] = CodeId.ForNode(pkgName, NormalizePath(mainPath))
|
||||
});
|
||||
|
||||
builder.AddEdge(
|
||||
from: moduleSymbol,
|
||||
@@ -162,7 +167,11 @@ public sealed class NodeReachabilityLifter : IReachabilityLifter
|
||||
lang: SymbolId.Lang.Node,
|
||||
kind: "entrypoint",
|
||||
display: $"{pkgName}:{modulePath} (ESM)",
|
||||
sourceFile: NormalizePath(modulePath));
|
||||
sourceFile: NormalizePath(modulePath),
|
||||
attributes: new Dictionary<string, string>
|
||||
{
|
||||
["code_id"] = CodeId.ForNode(pkgName, NormalizePath(modulePath))
|
||||
});
|
||||
|
||||
builder.AddEdge(
|
||||
from: moduleSymbol,
|
||||
@@ -219,7 +228,11 @@ public sealed class NodeReachabilityLifter : IReachabilityLifter
|
||||
kind: "binary",
|
||||
display: $"{binName} -> {binPath}",
|
||||
sourceFile: NormalizePath(binPath),
|
||||
attributes: new Dictionary<string, string> { ["bin_name"] = binName });
|
||||
attributes: new Dictionary<string, string>
|
||||
{
|
||||
["bin_name"] = binName,
|
||||
["code_id"] = CodeId.ForNode(pkgName, NormalizePath(binPath))
|
||||
});
|
||||
|
||||
builder.AddEdge(
|
||||
from: moduleSymbol,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
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>
|
||||
/// Packages richgraph-v1 JSON + meta into a deterministic zip and stores it in CAS.
|
||||
/// </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);
|
||||
|
||||
var folder = Path.GetDirectoryName(writeResult.GraphPath)!;
|
||||
var zipPath = Path.Combine(folder, "richgraph.zip");
|
||||
CreateDeterministicZip(folder, zipPath);
|
||||
|
||||
await using var stream = File.OpenRead(zipPath);
|
||||
var sha = ComputeSha256(zipPath);
|
||||
var casEntry = await cas.PutAsync(new FileCasPutRequest(sha, stream, leaveOpen: false), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new RichGraphPublishResult(writeResult.GraphHash, casEntry.RelativePath, writeResult.NodeCount, writeResult.EdgeCount);
|
||||
}
|
||||
|
||||
private static void CreateDeterministicZip(string sourceDir, string destinationZip)
|
||||
{
|
||||
if (File.Exists(destinationZip))
|
||||
{
|
||||
File.Delete(destinationZip);
|
||||
}
|
||||
|
||||
var files = Directory.EnumerateFiles(sourceDir, "*", SearchOption.TopDirectoryOnly)
|
||||
.OrderBy(f => f, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
using var zip = ZipFile.Open(destinationZip, ZipArchiveMode.Create);
|
||||
foreach (var file in files)
|
||||
{
|
||||
var entryName = Path.GetFileName(file);
|
||||
zip.CreateEntryFromFile(file, entryName, CompressionLevel.Optimal);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeSha256(string path)
|
||||
{
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
using var stream = File.OpenRead(path);
|
||||
return Convert.ToHexString(sha.ComputeHash(stream)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RichGraphPublishResult(
|
||||
string GraphHash,
|
||||
string RelativePath,
|
||||
int NodeCount,
|
||||
int EdgeCount);
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Scanner.Cache.Abstractions;
|
||||
using StellaOps.Scanner.Surface.Env;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
public interface IRichGraphPublisherService
|
||||
{
|
||||
Task<RichGraphPublishResult> PublishAsync(ReachabilityUnionGraph graph, string analysisId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service wrapper that builds richgraph-v1 from a union graph and stores it in CAS.
|
||||
/// </summary>
|
||||
public sealed class ReachabilityRichGraphPublisherService : IRichGraphPublisherService
|
||||
{
|
||||
private readonly ISurfaceEnvironment _environment;
|
||||
private readonly IFileContentAddressableStore _cas;
|
||||
private readonly IRichGraphPublisher _publisher;
|
||||
|
||||
public ReachabilityRichGraphPublisherService(
|
||||
ISurfaceEnvironment environment,
|
||||
IFileContentAddressableStore cas,
|
||||
IRichGraphPublisher publisher)
|
||||
{
|
||||
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
|
||||
_cas = cas ?? throw new ArgumentNullException(nameof(cas));
|
||||
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
|
||||
}
|
||||
|
||||
public Task<RichGraphPublishResult> PublishAsync(ReachabilityUnionGraph graph, string analysisId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var richGraph = RichGraphBuilder.FromUnion(graph, "scanner.reachability", "0.1.0");
|
||||
var workRoot = Path.Combine(_environment.Settings.CacheRoot.FullName, "reachability");
|
||||
Directory.CreateDirectory(workRoot);
|
||||
return _publisher.PublishAsync(richGraph, analysisId, _cas, workRoot, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical richgraph-v1 document used for CAS storage and Signals ingestion.
|
||||
/// </summary>
|
||||
public sealed record RichGraph(
|
||||
IReadOnlyList<RichGraphNode> Nodes,
|
||||
IReadOnlyList<RichGraphEdge> Edges,
|
||||
IReadOnlyList<RichGraphRoot> Roots,
|
||||
RichGraphAnalyzer Analyzer,
|
||||
string Schema = "richgraph-v1")
|
||||
{
|
||||
public RichGraph Trimmed()
|
||||
{
|
||||
var nodes = (Nodes ?? Array.Empty<RichGraphNode>())
|
||||
.Where(n => !string.IsNullOrWhiteSpace(n.Id))
|
||||
.Select(n => n.Trimmed())
|
||||
.OrderBy(n => n.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var edges = (Edges ?? Array.Empty<RichGraphEdge>())
|
||||
.Where(e => !string.IsNullOrWhiteSpace(e.From) && !string.IsNullOrWhiteSpace(e.To))
|
||||
.Select(e => e.Trimmed())
|
||||
.OrderBy(e => e.From, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.To, StringComparer.Ordinal)
|
||||
.ThenBy(e => e.Kind, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var roots = (Roots ?? Array.Empty<RichGraphRoot>())
|
||||
.Select(r => r.Trimmed())
|
||||
.OrderBy(r => r.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return this with { Nodes = nodes, Edges = edges, Roots = roots, Analyzer = Analyzer.Trimmed() };
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RichGraphNode(
|
||||
string Id,
|
||||
string SymbolId,
|
||||
string? CodeId,
|
||||
string? Purl,
|
||||
string Lang,
|
||||
string Kind,
|
||||
string? Display,
|
||||
string? BuildId,
|
||||
IReadOnlyList<string>? Evidence,
|
||||
IReadOnlyDictionary<string, string>? Attributes,
|
||||
string? SymbolDigest)
|
||||
{
|
||||
public RichGraphNode Trimmed()
|
||||
{
|
||||
return this with
|
||||
{
|
||||
Id = Id.Trim(),
|
||||
SymbolId = SymbolId.Trim(),
|
||||
CodeId = string.IsNullOrWhiteSpace(CodeId) ? null : CodeId.Trim(),
|
||||
Purl = string.IsNullOrWhiteSpace(Purl) ? null : Purl.Trim(),
|
||||
Lang = Lang.Trim(),
|
||||
Kind = Kind.Trim(),
|
||||
Display = string.IsNullOrWhiteSpace(Display) ? null : Display.Trim(),
|
||||
BuildId = string.IsNullOrWhiteSpace(BuildId) ? null : BuildId.Trim(),
|
||||
SymbolDigest = string.IsNullOrWhiteSpace(SymbolDigest) ? null : SymbolDigest.Trim(),
|
||||
Evidence = Evidence is null
|
||||
? Array.Empty<string>()
|
||||
: Evidence.Where(e => !string.IsNullOrWhiteSpace(e)).Select(e => e.Trim()).OrderBy(e => e, StringComparer.Ordinal).ToArray(),
|
||||
Attributes = Attributes is null
|
||||
? ImmutableDictionary<string, string>.Empty
|
||||
: Attributes.Where(kv => !string.IsNullOrWhiteSpace(kv.Key) && kv.Value is not null)
|
||||
.ToImmutableSortedDictionary(kv => kv.Key.Trim(), kv => kv.Value!.Trim(), StringComparer.Ordinal)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RichGraphEdge(
|
||||
string From,
|
||||
string To,
|
||||
string Kind,
|
||||
string? Purl,
|
||||
string? SymbolDigest,
|
||||
IReadOnlyList<string>? Evidence,
|
||||
double Confidence,
|
||||
IReadOnlyList<string>? Candidates)
|
||||
{
|
||||
public RichGraphEdge Trimmed()
|
||||
{
|
||||
return this with
|
||||
{
|
||||
From = From.Trim(),
|
||||
To = To.Trim(),
|
||||
Kind = string.IsNullOrWhiteSpace(Kind) ? "call" : Kind.Trim(),
|
||||
Purl = string.IsNullOrWhiteSpace(Purl) ? null : Purl.Trim(),
|
||||
SymbolDigest = string.IsNullOrWhiteSpace(SymbolDigest) ? null : SymbolDigest.Trim(),
|
||||
Evidence = Evidence is null
|
||||
? Array.Empty<string>()
|
||||
: Evidence.Where(e => !string.IsNullOrWhiteSpace(e)).Select(e => e.Trim()).OrderBy(e => e, StringComparer.Ordinal).ToArray(),
|
||||
Candidates = Candidates is null
|
||||
? Array.Empty<string>()
|
||||
: Candidates.Where(c => !string.IsNullOrWhiteSpace(c)).Select(c => c.Trim()).OrderBy(c => c, StringComparer.Ordinal).ToArray(),
|
||||
Confidence = ClampConfidence(Confidence)
|
||||
};
|
||||
}
|
||||
|
||||
private static double ClampConfidence(double value) => Math.Min(1.0, Math.Max(0.0, value));
|
||||
}
|
||||
|
||||
public sealed record RichGraphRoot(string Id, string Phase, string? Source)
|
||||
{
|
||||
public RichGraphRoot Trimmed()
|
||||
=> new(Id.Trim(), string.IsNullOrWhiteSpace(Phase) ? "runtime" : Phase.Trim(), string.IsNullOrWhiteSpace(Source) ? null : Source.Trim());
|
||||
}
|
||||
|
||||
public sealed record RichGraphAnalyzer(string Name, string Version, string? ToolchainDigest)
|
||||
{
|
||||
public RichGraphAnalyzer Trimmed()
|
||||
=> new(
|
||||
string.IsNullOrWhiteSpace(Name) ? "scanner.reachability" : Name.Trim(),
|
||||
string.IsNullOrWhiteSpace(Version) ? "0.1.0" : Version.Trim(),
|
||||
string.IsNullOrWhiteSpace(ToolchainDigest) ? null : ToolchainDigest.Trim());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transforms the union graph into a richgraph-v1 payload with purl/symbol digests.
|
||||
/// </summary>
|
||||
public static class RichGraphBuilder
|
||||
{
|
||||
public static RichGraph FromUnion(ReachabilityUnionGraph union, string analyzerName, string analyzerVersion)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(union);
|
||||
|
||||
var nodePurls = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var nodeDigests = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
var nodes = new List<RichGraphNode>();
|
||||
foreach (var node in union.Nodes ?? Array.Empty<ReachabilityUnionNode>())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(node.SymbolId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var symbolId = node.SymbolId.Trim();
|
||||
var purl = node.Attributes is not null && node.Attributes.TryGetValue("purl", out var p) ? p : null;
|
||||
if (!string.IsNullOrWhiteSpace(purl))
|
||||
{
|
||||
nodePurls[symbolId] = purl!;
|
||||
}
|
||||
|
||||
var symbolDigest = ComputeSymbolDigest(symbolId);
|
||||
nodeDigests[symbolId] = symbolDigest;
|
||||
|
||||
var codeId = node.Attributes is not null && node.Attributes.TryGetValue("code_id", out var cid)
|
||||
? cid
|
||||
: CodeId.FromSymbolId(symbolId);
|
||||
|
||||
nodes.Add(new RichGraphNode(
|
||||
Id: symbolId,
|
||||
SymbolId: symbolId,
|
||||
CodeId: codeId,
|
||||
Purl: purl,
|
||||
Lang: node.Lang,
|
||||
Kind: node.Kind,
|
||||
Display: node.Display,
|
||||
BuildId: node.Attributes is not null && node.Attributes.TryGetValue("build_id", out var bid) ? bid : null,
|
||||
Evidence: node.Source?.Evidence is null ? Array.Empty<string>() : new[] { node.Source.Evidence },
|
||||
Attributes: node.Attributes,
|
||||
SymbolDigest: symbolDigest));
|
||||
}
|
||||
|
||||
var edges = new List<RichGraphEdge>();
|
||||
foreach (var edge in union.Edges ?? Array.Empty<ReachabilityUnionEdge>())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(edge.From) || string.IsNullOrWhiteSpace(edge.To))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var toId = edge.To.Trim();
|
||||
nodePurls.TryGetValue(toId, out var edgePurl);
|
||||
nodeDigests.TryGetValue(toId, out var edgeDigest);
|
||||
|
||||
edges.Add(new RichGraphEdge(
|
||||
From: edge.From.Trim(),
|
||||
To: toId,
|
||||
Kind: edge.EdgeType,
|
||||
Purl: edgePurl ?? "pkg:unknown",
|
||||
SymbolDigest: edgeDigest,
|
||||
Evidence: edge.Source?.Evidence is null ? Array.Empty<string>() : new[] { edge.Source.Evidence },
|
||||
Confidence: ConfidenceToProbability(edge.Confidence),
|
||||
Candidates: edgePurl is null ? new[] { "pkg:unknown" } : Array.Empty<string>()));
|
||||
}
|
||||
|
||||
// include any synthetic roots if provided as attributes
|
||||
var roots = nodes
|
||||
.Where(n => n.Attributes is not null && n.Attributes.ContainsKey("root"))
|
||||
.Select(n => new RichGraphRoot(n.Id, "runtime", n.Attributes!["root"]))
|
||||
.ToList();
|
||||
|
||||
return new RichGraph(nodes, edges, roots, new RichGraphAnalyzer(analyzerName, analyzerVersion, null)).Trimmed();
|
||||
}
|
||||
|
||||
private static string ComputeSymbolDigest(string symbolId)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(symbolId));
|
||||
return $"sha256:{Convert.ToHexString(hash).ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
private static double ConfidenceToProbability(string? confidence)
|
||||
{
|
||||
return (confidence ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"certain" => 1.0,
|
||||
"high" => 0.9,
|
||||
"medium" => 0.6,
|
||||
"low" => 0.3,
|
||||
_ => 0.6
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using System;
|
||||
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;
|
||||
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
/// <summary>
|
||||
/// Writes richgraph-v1 documents to disk with canonical ordering and BLAKE3 hash.
|
||||
/// </summary>
|
||||
public sealed class RichGraphWriter
|
||||
{
|
||||
private static readonly JsonWriterOptions JsonOptions = new()
|
||||
{
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Indented = false,
|
||||
SkipValidation = false
|
||||
};
|
||||
|
||||
public async Task<RichGraphWriteResult> WriteAsync(
|
||||
RichGraph graph,
|
||||
string outputRoot,
|
||||
string analysisId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(graph);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputRoot);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(analysisId);
|
||||
|
||||
var trimmed = graph.Trimmed();
|
||||
var root = Path.Combine(outputRoot, "reachability_graphs", analysisId);
|
||||
Directory.CreateDirectory(root);
|
||||
|
||||
var graphPath = Path.Combine(root, "richgraph-v1.json");
|
||||
|
||||
await using (var stream = File.Create(graphPath))
|
||||
await using (var writer = new Utf8JsonWriter(stream, JsonOptions))
|
||||
{
|
||||
WriteGraph(writer, trimmed);
|
||||
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var bytes = await File.ReadAllBytesAsync(graphPath, cancellationToken).ConfigureAwait(false);
|
||||
var graphHash = ComputeSha256(bytes);
|
||||
|
||||
var metaPath = Path.Combine(root, "meta.json");
|
||||
await using (var stream = File.Create(metaPath))
|
||||
await using (var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true }))
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("schema", trimmed.Schema);
|
||||
writer.WriteString("graph_hash", graphHash);
|
||||
writer.WritePropertyName("files");
|
||||
writer.WriteStartArray();
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("path", graphPath);
|
||||
writer.WriteString("hash", graphHash);
|
||||
writer.WriteEndObject();
|
||||
writer.WriteEndArray();
|
||||
writer.WriteEndObject();
|
||||
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new RichGraphWriteResult(graphPath, metaPath, graphHash, trimmed.Nodes.Count, trimmed.Edges.Count);
|
||||
}
|
||||
|
||||
private static void WriteGraph(Utf8JsonWriter writer, RichGraph graph)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("schema", graph.Schema);
|
||||
|
||||
writer.WritePropertyName("analyzer");
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("name", graph.Analyzer.Name);
|
||||
writer.WriteString("version", graph.Analyzer.Version);
|
||||
if (!string.IsNullOrWhiteSpace(graph.Analyzer.ToolchainDigest))
|
||||
{
|
||||
writer.WriteString("toolchain_digest", graph.Analyzer.ToolchainDigest);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
|
||||
writer.WritePropertyName("nodes");
|
||||
writer.WriteStartArray();
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("id", node.Id);
|
||||
writer.WriteString("symbol_id", node.SymbolId);
|
||||
writer.WriteString("lang", node.Lang);
|
||||
writer.WriteString("kind", node.Kind);
|
||||
if (!string.IsNullOrWhiteSpace(node.Display)) writer.WriteString("display", node.Display);
|
||||
if (!string.IsNullOrWhiteSpace(node.CodeId)) writer.WriteString("code_id", node.CodeId);
|
||||
if (!string.IsNullOrWhiteSpace(node.Purl)) writer.WriteString("purl", node.Purl);
|
||||
if (!string.IsNullOrWhiteSpace(node.BuildId)) writer.WriteString("build_id", node.BuildId);
|
||||
if (!string.IsNullOrWhiteSpace(node.SymbolDigest)) writer.WriteString("symbol_digest", node.SymbolDigest);
|
||||
|
||||
if (node.Evidence is { Count: > 0 })
|
||||
{
|
||||
writer.WritePropertyName("evidence");
|
||||
writer.WriteStartArray();
|
||||
foreach (var e in node.Evidence) writer.WriteStringValue(e);
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
if (node.Attributes is { Count: > 0 })
|
||||
{
|
||||
writer.WritePropertyName("attributes");
|
||||
writer.WriteStartObject();
|
||||
foreach (var kv in node.Attributes)
|
||||
{
|
||||
writer.WriteString(kv.Key, kv.Value);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WritePropertyName("edges");
|
||||
writer.WriteStartArray();
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("from", edge.From);
|
||||
writer.WriteString("to", edge.To);
|
||||
writer.WriteString("kind", edge.Kind);
|
||||
if (!string.IsNullOrWhiteSpace(edge.Purl)) writer.WriteString("purl", edge.Purl);
|
||||
if (!string.IsNullOrWhiteSpace(edge.SymbolDigest)) writer.WriteString("symbol_digest", edge.SymbolDigest);
|
||||
writer.WriteNumber("confidence", edge.Confidence);
|
||||
|
||||
if (edge.Evidence is { Count: > 0 })
|
||||
{
|
||||
writer.WritePropertyName("evidence");
|
||||
writer.WriteStartArray();
|
||||
foreach (var e in edge.Evidence) writer.WriteStringValue(e);
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
if (edge.Candidates is { Count: > 0 })
|
||||
{
|
||||
writer.WritePropertyName("candidates");
|
||||
writer.WriteStartArray();
|
||||
foreach (var c in edge.Candidates) writer.WriteStringValue(c);
|
||||
writer.WriteEndArray();
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WritePropertyName("roots");
|
||||
writer.WriteStartArray();
|
||||
foreach (var root in graph.Roots)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
writer.WriteString("id", root.Id);
|
||||
writer.WriteString("phase", root.Phase);
|
||||
if (!string.IsNullOrWhiteSpace(root.Source)) writer.WriteString("source", root.Source);
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
writer.WriteEndArray();
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private static string ComputeSha256(IReadOnlyList<byte> bytes)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var hash = sha.ComputeHash(bytes.ToArray());
|
||||
return "sha256:" + Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RichGraphWriteResult(
|
||||
string GraphPath,
|
||||
string MetaPath,
|
||||
string GraphHash,
|
||||
int NodeCount,
|
||||
int EdgeCount);
|
||||
Reference in New Issue
Block a user