up
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
api-governance / spectral-lint (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-24 07:52:25 +02:00
parent 5970f0d9bd
commit 150b3730ef
215 changed files with 8119 additions and 740 deletions

View File

@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.EntryTrace;
using StellaOps.Scanner.Reachability;
namespace StellaOps.Scanner.Worker.Processing.Reachability;
/// <summary>
/// Builds a reachability union graph from the EntryTrace graph if available.
/// </summary>
public sealed class ReachabilityBuildStageExecutor : IScanStageExecutor
{
private readonly ILogger<ReachabilityBuildStageExecutor> _logger;
public ReachabilityBuildStageExecutor(ILogger<ReachabilityBuildStageExecutor> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string StageName => ScanStageNames.ComposeArtifacts;
public ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
if (!context.Analysis.TryGet<EntryTraceGraph>(ScanAnalysisKeys.EntryTraceGraph, out var entryTrace) || entryTrace is null)
{
_logger.LogDebug("No EntryTrace graph present; reachability union graph not built.");
return ValueTask.CompletedTask;
}
var nodeMap = entryTrace.Nodes.ToDictionary(n => n.Id);
var unionNodes = new List<ReachabilityUnionNode>(entryTrace.Nodes.Length);
foreach (var node in entryTrace.Nodes)
{
var symbolId = ComputeSymbolId("shell", node.DisplayName, node.Kind.ToString());
var source = node.Evidence is null
? null
: new ReachabilitySource("static", "entrytrace", node.Evidence.Path);
unionNodes.Add(new ReachabilityUnionNode(
SymbolId: symbolId,
Lang: "shell",
Kind: node.Kind.ToString().ToLowerInvariant(),
Display: node.DisplayName,
Source: source));
}
var unionEdges = new List<ReachabilityUnionEdge>(entryTrace.Edges.Length);
foreach (var edge in entryTrace.Edges)
{
if (!nodeMap.TryGetValue(edge.FromNodeId, out var fromNode) || !nodeMap.TryGetValue(edge.ToNodeId, out var toNode))
{
continue;
}
var fromId = ComputeSymbolId("shell", fromNode.DisplayName, fromNode.Kind.ToString());
var toId = ComputeSymbolId("shell", toNode.DisplayName, toNode.Kind.ToString());
unionEdges.Add(new ReachabilityUnionEdge(
From: fromId,
To: toId,
EdgeType: "call",
Confidence: "high",
Source: new ReachabilitySource("static", "entrytrace", edge.Relationship)));
}
var unionGraph = new ReachabilityUnionGraph(unionNodes, unionEdges);
context.Analysis.Set(ScanAnalysisKeys.ReachabilityUnionGraph, unionGraph);
_logger.LogInformation("Reachability union graph built from EntryTrace: nodes={NodeCount} edges={EdgeCount}", unionNodes.Count, unionEdges.Count);
return ValueTask.CompletedTask;
}
private static string ComputeSymbolId(string lang, string display, string kind)
{
using var sha = SHA256.Create();
var input = Encoding.UTF8.GetBytes((display ?? string.Empty) + "|" + (kind ?? string.Empty));
var hash = sha.ComputeHash(input);
var base64 = Convert.ToBase64String(hash)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
return $"sym:{lang}:{base64}";
}
}

View File

@@ -0,0 +1,43 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Core.Contracts;
using StellaOps.Scanner.Reachability;
namespace StellaOps.Scanner.Worker.Processing.Reachability;
/// <summary>
/// Emits reachability union graphs to CAS during the EmitReports stage when present in the analysis store.
/// </summary>
public sealed class ReachabilityPublishStageExecutor : IScanStageExecutor
{
private readonly IReachabilityUnionPublisherService _publisher;
private readonly ILogger<ReachabilityPublishStageExecutor> _logger;
public ReachabilityPublishStageExecutor(
IReachabilityUnionPublisherService publisher,
ILogger<ReachabilityPublishStageExecutor> logger)
{
_publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string StageName => ScanStageNames.EmitReports;
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
if (!context.Analysis.TryGet<ReachabilityUnionGraph>(ScanAnalysisKeys.ReachabilityUnionGraph, out var graph) || graph is null)
{
_logger.LogDebug("No reachability union graph present; skipping publish.");
return;
}
var publishResult = await _publisher.PublishAsync(graph, context.ScanId, cancellationToken).ConfigureAwait(false);
context.Analysis.Set(ScanAnalysisKeys.ReachabilityUnionCas, publishResult);
_logger.LogInformation("Published reachability union graph to CAS: sha256={Sha} records={Records}", publishResult.Sha256, publishResult.Records);
}
}

View File

@@ -1,21 +1,28 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Scanner.Reachability;
namespace StellaOps.Scanner.Worker.Processing;
public sealed class ScanJobProcessor
{
private readonly IReadOnlyDictionary<string, IScanStageExecutor> _executors;
private readonly ScanProgressReporter _progressReporter;
private readonly ILogger<ScanJobProcessor> _logger;
public ScanJobProcessor(IEnumerable<IScanStageExecutor> executors, ScanProgressReporter progressReporter, ILogger<ScanJobProcessor> logger)
{
_progressReporter = progressReporter ?? throw new ArgumentNullException(nameof(progressReporter));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
private readonly IReadOnlyDictionary<string, IScanStageExecutor> _executors;
private readonly ScanProgressReporter _progressReporter;
private readonly ILogger<ScanJobProcessor> _logger;
private readonly IReachabilityUnionPublisherService _reachabilityPublisher;
public ScanJobProcessor(
IEnumerable<IScanStageExecutor> executors,
ScanProgressReporter progressReporter,
IReachabilityUnionPublisherService reachabilityPublisher,
ILogger<ScanJobProcessor> logger)
{
_progressReporter = progressReporter ?? throw new ArgumentNullException(nameof(progressReporter));
_reachabilityPublisher = reachabilityPublisher ?? throw new ArgumentNullException(nameof(reachabilityPublisher));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
var map = new Dictionary<string, IScanStageExecutor>(StringComparer.OrdinalIgnoreCase);
foreach (var executor in executors ?? Array.Empty<IScanStageExecutor>())
@@ -42,12 +49,14 @@ public sealed class ScanJobProcessor
_executors = map;
}
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
foreach (var stage in ScanStageNames.Ordered)
{
public async ValueTask ExecuteAsync(ScanJobContext context, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(context);
// Placeholder: reachability publisher will be fed once lifter outputs are routed here.
_ = _reachabilityPublisher;
foreach (var stage in ScanStageNames.Ordered)
{
cancellationToken.ThrowIfCancellationRequested();
if (!_executors.TryGetValue(stage, out var executor))
@@ -55,11 +64,11 @@ public sealed class ScanJobProcessor
continue;
}
await _progressReporter.ExecuteStageAsync(
context,
stage,
executor.ExecuteAsync,
cancellationToken).ConfigureAwait(false);
}
}
}
await _progressReporter.ExecuteStageAsync(
context,
stage,
executor.ExecuteAsync,
cancellationToken).ConfigureAwait(false);
}
}
}

View File

@@ -8,6 +8,7 @@ using Microsoft.Extensions.Options;
using StellaOps.Auth.Client;
using StellaOps.Configuration;
using StellaOps.Scanner.Cache;
using StellaOps.Scanner.Reachability;
using StellaOps.Scanner.Analyzers.OS.Plugin;
using StellaOps.Scanner.Analyzers.Lang.Plugin;
using StellaOps.Scanner.EntryTrace;
@@ -24,6 +25,7 @@ using StellaOps.Scanner.Worker.Processing;
using StellaOps.Scanner.Worker.Processing.Surface;
using StellaOps.Scanner.Storage.Extensions;
using StellaOps.Scanner.Storage;
using Reachability = StellaOps.Scanner.Worker.Processing.Reachability;
var builder = Host.CreateApplicationBuilder(args);
@@ -56,6 +58,9 @@ builder.Services.AddSingleton<IDelayScheduler, SystemDelayScheduler>();
builder.Services.AddEntryTraceAnalyzer();
builder.Services.AddSingleton<IEntryTraceExecutionService, EntryTraceExecutionService>();
builder.Services.AddSingleton<ReachabilityUnionWriter>();
builder.Services.AddSingleton<ReachabilityUnionPublisher>();
builder.Services.AddSingleton<IReachabilityUnionPublisherService, ReachabilityUnionPublisherService>();
var storageSection = builder.Configuration.GetSection("ScannerStorage");
var connectionString = storageSection.GetValue<string>("Mongo:ConnectionString");
@@ -78,6 +83,8 @@ builder.Services.AddSingleton<ILanguageAnalyzerPluginCatalog, LanguageAnalyzerPl
builder.Services.AddSingleton<IScanAnalyzerDispatcher, CompositeScanAnalyzerDispatcher>();
builder.Services.AddSingleton<IScanStageExecutor, RegistrySecretStageExecutor>();
builder.Services.AddSingleton<IScanStageExecutor, AnalyzerStageExecutor>();
builder.Services.AddSingleton<IScanStageExecutor, Reachability.ReachabilityBuildStageExecutor>();
builder.Services.AddSingleton<IScanStageExecutor, Reachability.ReachabilityPublishStageExecutor>();
builder.Services.AddSingleton<ScannerWorkerHostedService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ScannerWorkerHostedService>());

View File

@@ -21,6 +21,7 @@
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Analyzers.Lang/StellaOps.Scanner.Analyzers.Lang.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.EntryTrace/StellaOps.Scanner.EntryTrace.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Cache/StellaOps.Scanner.Cache.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Reachability/StellaOps.Scanner.Reachability.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Env/StellaOps.Scanner.Surface.Env.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Validation/StellaOps.Scanner.Surface.Validation.csproj" />
<ProjectReference Include="../__Libraries/StellaOps.Scanner.Surface.Secrets/StellaOps.Scanner.Surface.Secrets.csproj" />

View File

@@ -151,6 +151,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Analyzers
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Analyzers.Lang.Php.Tests", "__Tests\StellaOps.Scanner.Analyzers.Lang.Php.Tests\StellaOps.Scanner.Analyzers.Lang.Php.Tests.csproj", "{F4A239E0-AC66-4105-8423-4805B2029ABE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Reachability.Tests", "__Tests\StellaOps.Scanner.Reachability.Tests\StellaOps.Scanner.Reachability.Tests.csproj", "{01F66FFA-8399-480E-A463-BB2B456C8814}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Scanner.Reachability", "__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj", "{D31CFFE3-72B3-48D7-A284-710B14380062}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.Replay.Core", "..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj", "{F812FD49-2D45-4503-A367-ABA55153D9B3}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -1013,6 +1019,42 @@ Global
{F4A239E0-AC66-4105-8423-4805B2029ABE}.Release|x64.Build.0 = Release|Any CPU
{F4A239E0-AC66-4105-8423-4805B2029ABE}.Release|x86.ActiveCfg = Release|Any CPU
{F4A239E0-AC66-4105-8423-4805B2029ABE}.Release|x86.Build.0 = Release|Any CPU
{01F66FFA-8399-480E-A463-BB2B456C8814}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{01F66FFA-8399-480E-A463-BB2B456C8814}.Debug|Any CPU.Build.0 = Debug|Any CPU
{01F66FFA-8399-480E-A463-BB2B456C8814}.Debug|x64.ActiveCfg = Debug|Any CPU
{01F66FFA-8399-480E-A463-BB2B456C8814}.Debug|x64.Build.0 = Debug|Any CPU
{01F66FFA-8399-480E-A463-BB2B456C8814}.Debug|x86.ActiveCfg = Debug|Any CPU
{01F66FFA-8399-480E-A463-BB2B456C8814}.Debug|x86.Build.0 = Debug|Any CPU
{01F66FFA-8399-480E-A463-BB2B456C8814}.Release|Any CPU.ActiveCfg = Release|Any CPU
{01F66FFA-8399-480E-A463-BB2B456C8814}.Release|Any CPU.Build.0 = Release|Any CPU
{01F66FFA-8399-480E-A463-BB2B456C8814}.Release|x64.ActiveCfg = Release|Any CPU
{01F66FFA-8399-480E-A463-BB2B456C8814}.Release|x64.Build.0 = Release|Any CPU
{01F66FFA-8399-480E-A463-BB2B456C8814}.Release|x86.ActiveCfg = Release|Any CPU
{01F66FFA-8399-480E-A463-BB2B456C8814}.Release|x86.Build.0 = Release|Any CPU
{D31CFFE3-72B3-48D7-A284-710B14380062}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D31CFFE3-72B3-48D7-A284-710B14380062}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D31CFFE3-72B3-48D7-A284-710B14380062}.Debug|x64.ActiveCfg = Debug|Any CPU
{D31CFFE3-72B3-48D7-A284-710B14380062}.Debug|x64.Build.0 = Debug|Any CPU
{D31CFFE3-72B3-48D7-A284-710B14380062}.Debug|x86.ActiveCfg = Debug|Any CPU
{D31CFFE3-72B3-48D7-A284-710B14380062}.Debug|x86.Build.0 = Debug|Any CPU
{D31CFFE3-72B3-48D7-A284-710B14380062}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D31CFFE3-72B3-48D7-A284-710B14380062}.Release|Any CPU.Build.0 = Release|Any CPU
{D31CFFE3-72B3-48D7-A284-710B14380062}.Release|x64.ActiveCfg = Release|Any CPU
{D31CFFE3-72B3-48D7-A284-710B14380062}.Release|x64.Build.0 = Release|Any CPU
{D31CFFE3-72B3-48D7-A284-710B14380062}.Release|x86.ActiveCfg = Release|Any CPU
{D31CFFE3-72B3-48D7-A284-710B14380062}.Release|x86.Build.0 = Release|Any CPU
{F812FD49-2D45-4503-A367-ABA55153D9B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F812FD49-2D45-4503-A367-ABA55153D9B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F812FD49-2D45-4503-A367-ABA55153D9B3}.Debug|x64.ActiveCfg = Debug|Any CPU
{F812FD49-2D45-4503-A367-ABA55153D9B3}.Debug|x64.Build.0 = Debug|Any CPU
{F812FD49-2D45-4503-A367-ABA55153D9B3}.Debug|x86.ActiveCfg = Debug|Any CPU
{F812FD49-2D45-4503-A367-ABA55153D9B3}.Debug|x86.Build.0 = Debug|Any CPU
{F812FD49-2D45-4503-A367-ABA55153D9B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F812FD49-2D45-4503-A367-ABA55153D9B3}.Release|Any CPU.Build.0 = Release|Any CPU
{F812FD49-2D45-4503-A367-ABA55153D9B3}.Release|x64.ActiveCfg = Release|Any CPU
{F812FD49-2D45-4503-A367-ABA55153D9B3}.Release|x64.Build.0 = Release|Any CPU
{F812FD49-2D45-4503-A367-ABA55153D9B3}.Release|x86.ActiveCfg = Release|Any CPU
{F812FD49-2D45-4503-A367-ABA55153D9B3}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -1062,5 +1104,7 @@ Global
{E0104A8E-2C39-48C1-97EC-66C171310944} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{0262C376-6C43-4A69-86EA-74C228BC0F36} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
{F4A239E0-AC66-4105-8423-4805B2029ABE} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{01F66FFA-8399-480E-A463-BB2B456C8814} = {56BCE1BF-7CBA-7CE8-203D-A88051F1D642}
{D31CFFE3-72B3-48D7-A284-710B14380062} = {41F15E67-7190-CF23-3BC4-77E87134CADD}
EndGlobalSection
EndGlobal

View File

@@ -386,8 +386,8 @@ public sealed class JavaLanguageAnalyzer : ILanguageAnalyzer
LanguageEvidenceKind.File,
evidenceSource,
locator,
value: null,
sha256: null));
null,
null));
}
private static void AddConfigHint(
@@ -412,8 +412,8 @@ public sealed class JavaLanguageAnalyzer : ILanguageAnalyzer
LanguageEvidenceKind.File,
"framework-config",
locator,
value: null,
sha256: sha256));
null,
sha256));
}
private static string? TryComputeSha256(JavaArchive archive, JavaArchiveEntry entry)
@@ -585,37 +585,45 @@ public sealed class JavaLanguageAnalyzer : ILanguageAnalyzer
string? version = null;
string? vendor = null;
while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
var separatorIndex = line.IndexOf(':');
if (separatorIndex <= 0)
{
continue;
}
var key = line[..separatorIndex].Trim();
var value = line[(separatorIndex + 1)..].Trim();
if (key.Equals("Implementation-Title", StringComparison.OrdinalIgnoreCase))
{
title ??= value;
}
else if (key.Equals("Implementation-Version", StringComparison.OrdinalIgnoreCase))
{
version ??= value;
}
else if (key.Equals("Implementation-Vendor", StringComparison.OrdinalIgnoreCase))
{
vendor ??= value;
while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
var separatorIndex = line.IndexOf(':');
if (separatorIndex <= 0)
{
continue;
}
var key = line[..separatorIndex].Trim();
var value = line[(separatorIndex + 1)..].Trim();
if (key.Equals("Implementation-Title", StringComparison.OrdinalIgnoreCase))
{
title ??= value;
}
else if (key.Equals("Implementation-Version", StringComparison.OrdinalIgnoreCase))
{
version ??= value;
}
else if (key.Equals("Implementation-Vendor", StringComparison.OrdinalIgnoreCase))
{
vendor ??= value;
}
}
if (title is null && version is null && vendor is null)
{
return null;
}
return new ManifestMetadata(title, version, vendor);
}
}
internal sealed record FrameworkConfigSummary(
IReadOnlyDictionary<string, string> Metadata,
@@ -624,13 +632,6 @@ internal sealed record FrameworkConfigSummary(
internal sealed record JniHintSummary(
IReadOnlyDictionary<string, string> Metadata,
IReadOnlyCollection<LanguageComponentEvidence> Evidence);
if (title is null && version is null && vendor is null)
{
return null;
}
return new ManifestMetadata(title, version, vendor);
}
private static string BuildPurl(string groupId, string artifactId, string version, string? packaging)
{

View File

@@ -1,5 +1,3 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Esprima;
using Esprima.Ast;
using EsprimaNode = Esprima.Ast.Node;
@@ -19,12 +17,8 @@ internal static class NodeImportWalker
Script script;
try
{
script = new JavaScriptParser(content, new ParserOptions
{
Tolerant = true,
AdaptRegexp = true,
Source = sourcePath
}).ParseScript();
var parser = new JavaScriptParser();
script = parser.ParseScript(content, sourcePath, true);
}
catch (ParserException)
{
@@ -43,13 +37,13 @@ internal static class NodeImportWalker
switch (node)
{
case ImportDeclaration importDecl when !string.IsNullOrWhiteSpace(importDecl.Source?.StringValue):
edges.Add(new NodeImportEdge(sourcePath, importDecl.Source.StringValue!, "import", BuildEvidence(importDecl.Loc)));
edges.Add(new NodeImportEdge(sourcePath, importDecl.Source.StringValue!, "import", string.Empty));
break;
case CallExpression call when IsRequire(call) && call.Arguments.FirstOrDefault() is Literal { Value: string target }:
edges.Add(new NodeImportEdge(sourcePath, target, "require", BuildEvidence(call.Loc)));
edges.Add(new NodeImportEdge(sourcePath, target, "require", string.Empty));
break;
case ImportExpression importExp when importExp.Source is Literal { Value: string importTarget }:
edges.Add(new NodeImportEdge(sourcePath, importTarget, "import()", BuildEvidence(importExp.Loc)));
edges.Add(new NodeImportEdge(sourcePath, importTarget, "import()", string.Empty));
break;
}
@@ -64,29 +58,4 @@ internal static class NodeImportWalker
return call.Callee is Identifier id && string.Equals(id.Name, "require", StringComparison.Ordinal)
&& call.Arguments.Count == 1 && call.Arguments[0] is Literal { Value: string };
}
private static string BuildEvidence(Location? loc)
{
if (loc is null)
{
return string.Empty;
}
var json = new JsonObject
{
["start"] = BuildPosition(loc.Start),
["end"] = BuildPosition(loc.End)
};
return json.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
}
private static JsonObject BuildPosition(Position pos)
{
return new JsonObject
{
["line"] = pos.Line,
["column"] = pos.Column
};
}
}

View File

@@ -145,17 +145,17 @@ internal sealed class NodePackage
"package.json:entrypoint",
locator,
content,
sha256: null));
null));
}
foreach (var importEdge in _imports.OrderBy(static e => e.ComparisonKey, StringComparer.Ordinal))
{
evidence.Add(new LanguageComponentEvidence(
LanguageEvidenceKind.Source,
LanguageEvidenceKind.File,
"node.import",
importEdge.SourceFile,
importEdge.TargetSpecifier,
sha256: null));
null));
}
return evidence

View File

@@ -509,7 +509,8 @@ internal static class NodePackageCollector
var lockLocator = BuildLockLocator(lockEntry);
var lockSource = lockEntry?.Source;
var isWorkspaceMember = workspaceIndex?.TryGetMember(relativeDirectory, out var workspaceRoot) == true;
string? workspaceRoot = null;
var isWorkspaceMember = workspaceIndex?.TryGetMember(relativeDirectory, out workspaceRoot) == true;
var workspaceRootValue = isWorkspaceMember && workspaceIndex is not null ? workspaceRoot : null;
var workspaceTargets = workspaceIndex is null ? Array.Empty<string>() : ExtractWorkspaceTargets(relativeDirectory, root, workspaceIndex);
var workspaceLink = workspaceIndex is not null && !isWorkspaceMember && workspaceIndex.TryGetWorkspacePathByName(name, out var workspacePathByName)

View File

@@ -25,4 +25,7 @@ public static class ScanAnalysisKeys
public const string DenoRuntimePayload = "analysis.lang.deno.runtime";
public const string RubyObservationPayload = "analysis.lang.ruby.observation";
public const string ReachabilityUnionGraph = "analysis.reachability.union.graph";
public const string ReachabilityUnionCas = "analysis.reachability.union.cas";
}

View File

@@ -54,6 +54,21 @@ public sealed class ReachabilityGraphBuilder
return JsonSerializer.Serialize(payload, options);
}
public ReachabilityUnionGraph ToUnionGraph(string language)
{
ArgumentException.ThrowIfNullOrWhiteSpace(language);
var nodeList = nodes
.Select(id => new ReachabilityUnionNode(id, language, "symbol"))
.ToList();
var edgeList = edges
.Select(edge => new ReachabilityUnionEdge(edge.From, edge.To, edge.Kind))
.ToList();
return new ReachabilityUnionGraph(nodeList, edgeList);
}
public static ReachabilityGraphBuilder FromFixture(string variantPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(variantPath);

View File

@@ -0,0 +1,82 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.Cache.Abstractions;
namespace StellaOps.Scanner.Reachability;
/// <summary>
/// Packages a reachability union graph into a deterministic zip, stores it in CAS, and returns the CAS reference.
/// </summary>
public sealed class ReachabilityUnionPublisher
{
private readonly ReachabilityUnionWriter writer;
public ReachabilityUnionPublisher(ReachabilityUnionWriter writer)
{
this.writer = writer ?? throw new ArgumentNullException(nameof(writer));
}
public async Task<ReachabilityUnionPublishResult> PublishAsync(
ReachabilityUnionGraph graph,
IFileContentAddressableStore cas,
string workRoot,
string analysisId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentNullException.ThrowIfNull(cas);
ArgumentException.ThrowIfNullOrWhiteSpace(workRoot);
ArgumentException.ThrowIfNullOrWhiteSpace(analysisId);
var result = await writer.WriteAsync(graph, workRoot, analysisId, cancellationToken).ConfigureAwait(false);
var folder = Path.GetDirectoryName(result.MetaPath)!;
var zipPath = Path.Combine(folder, "reachability.zip");
CreateZip(folder, zipPath);
var sha = ComputeSha256(zipPath);
await using var zipStream = File.OpenRead(zipPath);
var casEntry = await cas.PutAsync(new FileCasPutRequest(sha, zipStream, leaveOpen: false), cancellationToken).ConfigureAwait(false);
return new ReachabilityUnionPublishResult(
Sha256: sha,
RelativePath: casEntry.RelativePath,
Records: result.Nodes.RecordCount + result.Edges.RecordCount + (result.Facts?.RecordCount ?? 0));
}
private static void CreateZip(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 = SHA256.Create();
using var stream = File.OpenRead(path);
return Convert.ToHexString(sha.ComputeHash(stream)).ToLowerInvariant();
}
}
public sealed record ReachabilityUnionPublishResult(
string Sha256,
string RelativePath,
int Records);

View File

@@ -0,0 +1,40 @@
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 IReachabilityUnionPublisherService
{
Task<ReachabilityUnionPublishResult> PublishAsync(ReachabilityUnionGraph graph, string analysisId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Default service that writes a union graph to CAS using the worker surface cache root.
/// </summary>
public sealed class ReachabilityUnionPublisherService : IReachabilityUnionPublisherService
{
private readonly ISurfaceEnvironment environment;
private readonly IFileContentAddressableStore cas;
private readonly ReachabilityUnionPublisher publisher;
public ReachabilityUnionPublisherService(
ISurfaceEnvironment environment,
IFileContentAddressableStore cas,
ReachabilityUnionPublisher publisher)
{
this.environment = environment ?? throw new ArgumentNullException(nameof(environment));
this.cas = cas ?? throw new ArgumentNullException(nameof(cas));
this.publisher = publisher ?? throw new ArgumentNullException(nameof(publisher));
}
public Task<ReachabilityUnionPublishResult> PublishAsync(ReachabilityUnionGraph graph, string analysisId, CancellationToken cancellationToken = default)
{
var workRoot = Path.Combine(environment.Settings.CacheRoot.FullName, "reachability");
Directory.CreateDirectory(workRoot);
return publisher.PublishAsync(graph, cas, workRoot, analysisId, cancellationToken);
}
}

View File

@@ -0,0 +1,6 @@
namespace StellaOps.Scanner.Reachability;
public static class ReachabilityUnionSchemas
{
public const string UnionSchema = "reachability-union@0.1";
}

View File

@@ -0,0 +1,390 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
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>
/// Serializes reachability graphs (static + runtime) into the union NDJSON layout
/// described in docs/reachability/runtime-static-union-schema.md.
/// </summary>
public sealed class ReachabilityUnionWriter
{
private static readonly JsonWriterOptions JsonOptions = new()
{
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Indented = false,
SkipValidation = false
};
public async Task<ReachabilityUnionWriteResult> WriteAsync(
ReachabilityUnionGraph graph,
string outputRoot,
string analysisId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(graph);
ArgumentException.ThrowIfNullOrWhiteSpace(outputRoot);
ArgumentException.ThrowIfNullOrWhiteSpace(analysisId);
var root = Path.Combine(outputRoot, "reachability_graphs", analysisId);
Directory.CreateDirectory(root);
var normalized = Normalize(graph);
var nodesPath = Path.Combine(root, "nodes.ndjson");
var edgesPath = Path.Combine(root, "edges.ndjson");
var factsPath = Path.Combine(root, "facts_runtime.ndjson");
var metaPath = Path.Combine(root, "meta.json");
var nodesInfo = await WriteNdjsonAsync(nodesPath, normalized.Nodes, WriteNodeAsync, cancellationToken).ConfigureAwait(false);
var edgesInfo = await WriteNdjsonAsync(edgesPath, normalized.Edges, WriteEdgeAsync, cancellationToken).ConfigureAwait(false);
FileHashInfo? factsInfo = null;
if (normalized.RuntimeFacts.Count > 0)
{
factsInfo = await WriteNdjsonAsync(factsPath, normalized.RuntimeFacts, WriteRuntimeFactAsync, cancellationToken).ConfigureAwait(false);
}
else if (File.Exists(factsPath))
{
File.Delete(factsPath);
}
await WriteMetaAsync(metaPath, nodesInfo, edgesInfo, factsInfo, cancellationToken).ConfigureAwait(false);
return new ReachabilityUnionWriteResult(nodesInfo.ToPublic(), edgesInfo.ToPublic(), factsInfo?.ToPublic(), metaPath);
}
private static NormalizedGraph Normalize(ReachabilityUnionGraph graph)
{
var nodes = graph.Nodes
.Where(n => !string.IsNullOrWhiteSpace(n.SymbolId))
.Select(n => n with
{
SymbolId = Trim(n.SymbolId) ?? string.Empty,
Lang = Trim(n.Lang) ?? string.Empty,
Kind = Trim(n.Kind) ?? string.Empty,
Display = Trim(n.Display),
Source = n.Source?.Trimmed(),
Attributes = (n.Attributes ?? ImmutableDictionary<string, string>.Empty)
.Where(kv => !string.IsNullOrWhiteSpace(kv.Key) && kv.Value is not null)
.ToImmutableSortedDictionary(kv => kv.Key.Trim(), kv => kv.Value!.Trim())
})
.OrderBy(n => n.SymbolId, StringComparer.Ordinal)
.ThenBy(n => n.Kind, StringComparer.Ordinal)
.ToList();
var edges = graph.Edges
.Where(e => !string.IsNullOrWhiteSpace(e.From) && !string.IsNullOrWhiteSpace(e.To))
.Select(e => e with
{
From = Trim(e.From)!,
To = Trim(e.To)!,
EdgeType = Trim(e.EdgeType) ?? "call",
Confidence = Trim(e.Confidence) ?? "certain",
Source = e.Source?.Trimmed()
})
.OrderBy(e => e.From, StringComparer.Ordinal)
.ThenBy(e => e.To, StringComparer.Ordinal)
.ThenBy(e => e.EdgeType, StringComparer.Ordinal)
.ToList();
var facts = (graph.RuntimeFacts ?? Enumerable.Empty<ReachabilityRuntimeFact>())
.Where(f => !string.IsNullOrWhiteSpace(f.SymbolId))
.Select(f => f with
{
SymbolId = Trim(f.SymbolId)!,
Samples = f.Samples?.Trimmed() ?? new ReachabilityRuntimeSamples(0, null, null),
Env = f.Env?.Trimmed() ?? ReachabilityRuntimeEnv.Empty
})
.OrderBy(f => f.SymbolId, StringComparer.Ordinal)
.ToList();
return new NormalizedGraph(nodes, edges, facts);
}
private static async Task<FileHashInfo> WriteNdjsonAsync<T>(
string path,
IReadOnlyCollection<T> items,
Func<T, StreamWriter, Task> writer,
CancellationToken cancellationToken)
{
await using (var stream = File.Create(path))
await using (var textWriter = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)))
{
foreach (var item in items)
{
await writer(item, textWriter).ConfigureAwait(false);
await textWriter.WriteLineAsync().ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
}
}
var sha = ComputeSha256(path);
return new FileHashInfo(path, sha, items.Count);
}
private static async Task WriteNodeAsync(ReachabilityUnionNode node, StreamWriter writer)
{
await using var json = new MemoryStream();
await using (var jw = new Utf8JsonWriter(json, JsonOptions))
{
jw.WriteStartObject();
jw.WriteString("symbol_id", node.SymbolId);
jw.WriteString("lang", node.Lang);
jw.WriteString("kind", node.Kind);
if (!string.IsNullOrWhiteSpace(node.Display))
{
jw.WriteString("display", node.Display);
}
if (node.Source is not null)
{
jw.WritePropertyName("source");
WriteSource(jw, node.Source);
}
if (node.Attributes is not null && node.Attributes.Count > 0)
{
jw.WritePropertyName("attributes");
jw.WriteStartObject();
foreach (var kv in node.Attributes)
{
jw.WriteString(kv.Key, kv.Value);
}
jw.WriteEndObject();
}
jw.WriteEndObject();
}
await writer.WriteAsync(Encoding.UTF8.GetString(json.ToArray())).ConfigureAwait(false);
}
private static async Task WriteEdgeAsync(ReachabilityUnionEdge edge, StreamWriter writer)
{
await using var json = new MemoryStream();
await using (var jw = new Utf8JsonWriter(json, JsonOptions))
{
jw.WriteStartObject();
jw.WriteString("from", edge.From);
jw.WriteString("to", edge.To);
jw.WriteString("edge_type", edge.EdgeType);
jw.WriteString("confidence", edge.Confidence);
if (edge.Source is not null)
{
jw.WritePropertyName("source");
WriteSource(jw, edge.Source);
}
jw.WriteEndObject();
}
await writer.WriteAsync(Encoding.UTF8.GetString(json.ToArray())).ConfigureAwait(false);
}
private static async Task WriteRuntimeFactAsync(ReachabilityRuntimeFact fact, StreamWriter writer)
{
await using var json = new MemoryStream();
await using (var jw = new Utf8JsonWriter(json, JsonOptions))
{
jw.WriteStartObject();
jw.WriteString("symbol_id", fact.SymbolId);
jw.WritePropertyName("samples");
jw.WriteStartObject();
jw.WriteNumber("call_count", fact.Samples?.CallCount ?? 0);
if (fact.Samples?.FirstSeenUtc is not null)
{
jw.WriteString("first_seen_utc", fact.Samples.FirstSeenUtc.Value.ToUniversalTime().ToString("O"));
}
if (fact.Samples?.LastSeenUtc is not null)
{
jw.WriteString("last_seen_utc", fact.Samples.LastSeenUtc.Value.ToUniversalTime().ToString("O"));
}
jw.WriteEndObject();
jw.WritePropertyName("env");
jw.WriteStartObject();
if (fact.Env?.Pid is not null)
{
jw.WriteNumber("pid", fact.Env.Pid.Value);
}
if (!string.IsNullOrWhiteSpace(fact.Env?.Image))
{
jw.WriteString("image", fact.Env!.Image);
}
if (!string.IsNullOrWhiteSpace(fact.Env?.Entrypoint))
{
jw.WriteString("entrypoint", fact.Env!.Entrypoint);
}
if (fact.Env?.Tags is { Count: > 0 })
{
jw.WritePropertyName("tags");
jw.WriteStartArray();
foreach (var tag in fact.Env!.Tags)
{
jw.WriteStringValue(tag);
}
jw.WriteEndArray();
}
jw.WriteEndObject();
jw.WriteEndObject();
}
await writer.WriteAsync(Encoding.UTF8.GetString(json.ToArray())).ConfigureAwait(false);
}
private static void WriteSource(Utf8JsonWriter jw, ReachabilitySource source)
{
jw.WriteStartObject();
jw.WriteString("origin", source.Origin ?? "static");
if (!string.IsNullOrWhiteSpace(source.Provenance))
{
jw.WriteString("provenance", source.Provenance);
}
if (!string.IsNullOrWhiteSpace(source.Evidence))
{
jw.WriteString("evidence", source.Evidence);
}
jw.WriteEndObject();
}
private static string ComputeSha256(string path)
{
using var sha = SHA256.Create();
using var stream = File.OpenRead(path);
var hash = sha.ComputeHash(stream);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static string? Trim(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private sealed record FileHashInfo(string Path, string Sha256, int RecordCount)
{
public ReachabilityUnionFileInfo ToPublic() => new(Path, Sha256, RecordCount);
}
private sealed record NormalizedGraph(
IReadOnlyList<ReachabilityUnionNode> Nodes,
IReadOnlyList<ReachabilityUnionEdge> Edges,
IReadOnlyList<ReachabilityRuntimeFact> RuntimeFacts);
private static async Task WriteMetaAsync(
string path,
FileHashInfo nodes,
FileHashInfo edges,
FileHashInfo? facts,
CancellationToken cancellationToken)
{
await using var stream = File.Create(path);
await using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
writer.WriteStartObject();
writer.WriteString("schema", "reachability-union@0.1");
writer.WriteString("generated_at", DateTimeOffset.UtcNow.ToString("O"));
writer.WritePropertyName("files");
writer.WriteStartArray();
WriteMetaFile(writer, nodes);
WriteMetaFile(writer, edges);
if (facts is not null)
{
WriteMetaFile(writer, facts);
}
writer.WriteEndArray();
writer.WriteEndObject();
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
}
private static void WriteMetaFile(Utf8JsonWriter writer, FileHashInfo info)
{
writer.WriteStartObject();
writer.WriteString("path", info.Path);
writer.WriteString("sha256", info.Sha256);
writer.WriteNumber("records", info.RecordCount);
writer.WriteEndObject();
}
}
public sealed record ReachabilityUnionGraph(
IReadOnlyCollection<ReachabilityUnionNode> Nodes,
IReadOnlyCollection<ReachabilityUnionEdge> Edges,
IReadOnlyCollection<ReachabilityRuntimeFact>? RuntimeFacts = null);
public sealed record ReachabilityUnionNode(
string SymbolId,
string Lang,
string Kind,
string? Display = null,
ReachabilitySource? Source = null,
IReadOnlyDictionary<string, string>? Attributes = null);
public sealed record ReachabilityUnionEdge(
string From,
string To,
string EdgeType,
string? Confidence = "certain",
ReachabilitySource? Source = null);
public sealed record ReachabilityRuntimeFact(
string SymbolId,
ReachabilityRuntimeSamples? Samples,
ReachabilityRuntimeEnv? Env);
public sealed record ReachabilityRuntimeSamples(
long CallCount,
DateTimeOffset? FirstSeenUtc,
DateTimeOffset? LastSeenUtc)
{
public ReachabilityRuntimeSamples Trimmed()
=> new(CallCount, FirstSeenUtc?.ToUniversalTime(), LastSeenUtc?.ToUniversalTime());
}
public sealed record ReachabilityRuntimeEnv(
int? Pid,
string? Image,
string? Entrypoint,
IReadOnlyList<string> Tags)
{
public static ReachabilityRuntimeEnv Empty { get; } = new(null, null, null, Array.Empty<string>());
public ReachabilityRuntimeEnv Trimmed()
=> new(
Pid,
string.IsNullOrWhiteSpace(Image) ? null : Image.Trim(),
string.IsNullOrWhiteSpace(Entrypoint) ? null : Entrypoint.Trim(),
(Tags ?? Array.Empty<string>()).Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).OrderBy(t => t, StringComparer.Ordinal).ToArray());
}
public sealed record ReachabilitySource(
string? Origin,
string? Provenance,
string? Evidence)
{
public ReachabilitySource Trimmed()
=> new(
string.IsNullOrWhiteSpace(Origin) ? "static" : Origin.Trim(),
string.IsNullOrWhiteSpace(Provenance) ? null : Provenance.Trim(),
string.IsNullOrWhiteSpace(Evidence) ? null : Evidence.Trim());
}
public sealed record ReachabilityUnionWriteResult(
ReachabilityUnionFileInfo Nodes,
ReachabilityUnionFileInfo Edges,
ReachabilityUnionFileInfo? Facts,
string MetaPath);
public sealed record ReachabilityUnionFileInfo(
string Path,
string Sha256,
int RecordCount);

View File

@@ -5,9 +5,8 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" Version="10.0.0-preview.7.25380.108" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Scanner.Cache\StellaOps.Scanner.Cache.csproj" />
<ProjectReference Include="..\StellaOps.Scanner.Surface.Env\StellaOps.Scanner.Surface.Env.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.Replay.Core\StellaOps.Replay.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,41 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.Cache.Abstractions;
namespace StellaOps.Scanner.Core.Tests.Fakes;
internal sealed class FakeFileContentAddressableStore : IFileContentAddressableStore
{
private readonly ConcurrentDictionary<string, byte[]> store = new();
public ValueTask<FileCasEntry?> TryGetAsync(string sha256, CancellationToken cancellationToken = default)
{
if (store.TryGetValue(sha256, out var bytes))
{
return ValueTask.FromResult<FileCasEntry?>(new FileCasEntry(sha256, bytes.LongLength, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, sha256 + ".zip"));
}
return ValueTask.FromResult<FileCasEntry?>(null);
}
public Task<FileCasEntry> PutAsync(FileCasPutRequest request, CancellationToken cancellationToken = default)
{
using var ms = new MemoryStream();
request.Content.CopyTo(ms);
store[request.Sha256] = ms.ToArray();
return Task.FromResult(new FileCasEntry(request.Sha256, ms.Length, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, request.Sha256 + ".zip"));
}
public Task<bool> RemoveAsync(string sha256, CancellationToken cancellationToken = default)
{
return Task.FromResult(store.TryRemove(sha256, out _));
}
public Task<int> EvictExpiredAsync(CancellationToken cancellationToken = default) => Task.FromResult(0);
public Task<int> ExportAsync(string destinationDirectory, CancellationToken cancellationToken = default) => Task.FromResult(0);
public Task<int> ImportAsync(string sourceDirectory, CancellationToken cancellationToken = default) => Task.FromResult(0);
public Task<int> CompactAsync(CancellationToken cancellationToken = default) => Task.FromResult(0);
}

View File

@@ -0,0 +1,39 @@
using System.Threading.Tasks;
using StellaOps.Scanner.Reachability;
using Xunit;
namespace StellaOps.Scanner.Core.Tests;
public class ReachabilityGraphBuilderUnionTests
{
[Fact]
public async Task ConvertsBuilderToUnionGraphAndWritesNdjson()
{
var builder = new ReachabilityGraphBuilder()
.AddNode("sym:dotnet:A")
.AddNode("sym:dotnet:B")
.AddEdge("sym:dotnet:A", "sym:dotnet:B", "call");
var graph = builder.ToUnionGraph("dotnet");
var writer = new ReachabilityUnionWriter();
using var temp = new TempDir();
var result = await writer.WriteAsync(graph, temp.Path, "analysis-graph-1");
Assert.Equal(2, result.Nodes.RecordCount);
Assert.Equal(1, result.Edges.RecordCount);
Assert.True(System.IO.File.Exists(result.MetaPath));
}
private sealed class TempDir : System.IDisposable
{
public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "reach-union-" + System.Guid.NewGuid().ToString("N"));
public TempDir() => System.IO.Directory.CreateDirectory(Path);
public void Dispose()
{
try { System.IO.Directory.Delete(Path, recursive: true); } catch { /* ignore */ }
}
}
}

View File

@@ -0,0 +1,30 @@
using System.Threading.Tasks;
using StellaOps.Scanner.Core.Tests.Fakes;
using StellaOps.Scanner.Reachability;
using Xunit;
namespace StellaOps.Scanner.Core.Tests;
public class ReachabilityUnionPublisherTests
{
[Fact]
public async Task PublishesZipToCas()
{
var graph = new ReachabilityUnionGraph(
Nodes: new[] { new ReachabilityUnionNode("sym:dotnet:A", "dotnet", "method") },
Edges: new ReachabilityUnionEdge[0]);
var cas = new FakeFileContentAddressableStore();
using var temp = new TempDir();
var publisher = new ReachabilityUnionPublisher(new ReachabilityUnionWriter());
var result = await publisher.PublishAsync(graph, cas, temp.Path, "analysis-pub-1");
Assert.False(string.IsNullOrWhiteSpace(result.Sha256));
Assert.Equal(1, result.Records);
var entry = await cas.TryGetAsync(result.Sha256);
Assert.NotNull(entry);
Assert.True(entry!.Value.SizeBytes > 0);
}
}

View File

@@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using StellaOps.Scanner.Reachability;
using Xunit;
namespace StellaOps.Scanner.Core.Tests;
public class ReachabilityUnionWriterTests
{
[Fact]
public async Task WritesDeterministicFilesAndHashes()
{
var writer = new ReachabilityUnionWriter();
using var temp = new TempDir();
var graph = new ReachabilityUnionGraph(
Nodes: new[]
{
new ReachabilityUnionNode("sym:dotnet:B", "dotnet", "method", display: "B"),
new ReachabilityUnionNode("sym:dotnet:A", "dotnet", "method", display: "A",
Source: new ReachabilitySource("static", "il", "file.cs:10"),
Attributes: new Dictionary<string, string> { { "visibility", "public" } }),
},
Edges: new[]
{
new ReachabilityUnionEdge("sym:dotnet:B", "sym:dotnet:A", "call", confidence: "high"),
new ReachabilityUnionEdge("sym:dotnet:A", "sym:dotnet:B", "call", confidence: "high"),
},
RuntimeFacts: new[]
{
new ReachabilityRuntimeFact(
"sym:dotnet:A",
new ReachabilityRuntimeSamples(2, DateTimeOffset.Parse("2025-11-20T12:00:00Z"), DateTimeOffset.Parse("2025-11-20T12:00:02Z")),
new ReachabilityRuntimeEnv(1234, "sha256:deadbeef", "Program.Main", new [] {"sealed", "offline"}))
});
var result = await writer.WriteAsync(graph, temp.Path, "analysis-1");
// Files exist
Assert.True(File.Exists(result.Nodes.Path));
Assert.True(File.Exists(result.Edges.Path));
Assert.NotNull(result.Facts);
Assert.True(File.Exists(result.MetaPath));
// Nodes sorted by symbol_id
var nodeLines = await File.ReadAllLinesAsync(result.Nodes.Path);
Assert.Equal(2, nodeLines.Length);
Assert.Contains("sym:dotnet:A", nodeLines[0]);
Assert.Contains("sym:dotnet:B", nodeLines[1]);
// Hashes recorded in meta match content
var meta = await JsonDocument.ParseAsync(File.OpenRead(result.MetaPath));
var files = meta.RootElement.GetProperty("files").EnumerateArray().ToList();
Assert.Contains(files, f => f.GetProperty("path").GetString() == result.Nodes.Path && f.GetProperty("sha256").GetString() == result.Nodes.Sha256);
Assert.Contains(files, f => f.GetProperty("path").GetString() == result.Edges.Path && f.GetProperty("sha256").GetString() == result.Edges.Sha256);
// Determinism: re-run with shuffled inputs yields identical hashes
var shuffled = new ReachabilityUnionGraph(
Nodes: graph.Nodes.Reverse().ToArray(),
Edges: graph.Edges.Reverse().ToArray(),
RuntimeFacts: graph.RuntimeFacts);
var second = await writer.WriteAsync(shuffled, temp.Path, "analysis-1");
Assert.Equal(result.Nodes.Sha256, second.Nodes.Sha256);
Assert.Equal(result.Edges.Sha256, second.Edges.Sha256);
Assert.Equal(result.Facts!.Sha256, second.Facts!.Sha256);
}
private sealed class TempDir : IDisposable
{
public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "reach-union-" + Guid.NewGuid().ToString("N"));
public TempDir() => Directory.CreateDirectory(Path);
public void Dispose()
{
try { Directory.Delete(Path, recursive: true); } catch { /* best effort */ }
}
}
}

View File

@@ -7,10 +7,12 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Core/StellaOps.Scanner.Core.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Reachability/StellaOps.Scanner.Reachability.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Scanner.Cache/StellaOps.Scanner.Cache.csproj" />
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="../../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Fixtures\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
</Project>

View File

@@ -0,0 +1,39 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Scanner.Cache.Abstractions;
namespace StellaOps.Scanner.Reachability.Tests;
internal sealed class FakeFileContentAddressableStore : IFileContentAddressableStore
{
private readonly ConcurrentDictionary<string, byte[]> store = new();
public ValueTask<FileCasEntry?> TryGetAsync(string sha256, CancellationToken cancellationToken = default)
{
if (store.TryGetValue(sha256, out var bytes))
{
return ValueTask.FromResult<FileCasEntry?>(new FileCasEntry(sha256, bytes.LongLength, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, sha256 + ".zip"));
}
return ValueTask.FromResult<FileCasEntry?>(null);
}
public Task<FileCasEntry> PutAsync(FileCasPutRequest request, CancellationToken cancellationToken = default)
{
using var ms = new MemoryStream();
request.Content.CopyTo(ms);
store[request.Sha256] = ms.ToArray();
return Task.FromResult(new FileCasEntry(request.Sha256, ms.Length, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, request.Sha256 + ".zip"));
}
public Task<bool> RemoveAsync(string sha256, CancellationToken cancellationToken = default)
=> Task.FromResult(store.TryRemove(sha256, out _));
public Task<int> EvictExpiredAsync(CancellationToken cancellationToken = default) => Task.FromResult(0);
public Task<int> ExportAsync(string destinationDirectory, CancellationToken cancellationToken = default) => Task.FromResult(0);
public Task<int> ImportAsync(string sourceDirectory, CancellationToken cancellationToken = default) => Task.FromResult(0);
public Task<int> CompactAsync(CancellationToken cancellationToken = default) => Task.FromResult(0);
}

View File

@@ -0,0 +1,27 @@
using System.Threading.Tasks;
using StellaOps.Scanner.Reachability;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests;
public class ReachabilityUnionPublisherTests
{
[Fact]
public async Task PublishesZipToCas()
{
var graph = new ReachabilityUnionGraph(
Nodes: new[] { new ReachabilityUnionNode("sym:dotnet:A", "dotnet", "method") },
Edges: new ReachabilityUnionEdge[0]);
using var temp = new TempDir();
var cas = new FakeFileContentAddressableStore();
var publisher = new ReachabilityUnionPublisher(new ReachabilityUnionWriter());
var result = await publisher.PublishAsync(graph, cas, temp.Path, "analysis-pub-1");
Assert.False(string.IsNullOrWhiteSpace(result.Sha256));
var entry = await cas.TryGetAsync(result.Sha256);
Assert.NotNull(entry);
Assert.True(entry!.SizeBytes > 0);
}
}

View File

@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using StellaOps.Scanner.Reachability;
using Xunit;
namespace StellaOps.Scanner.Reachability.Tests;
public class ReachabilityUnionWriterTests
{
[Fact]
public async Task WritesDeterministicNdjson()
{
var writer = new ReachabilityUnionWriter();
using var temp = new TempDir();
var graph = new ReachabilityUnionGraph(
Nodes: new[]
{
new ReachabilityUnionNode("sym:dotnet:B", "dotnet", "method"),
new ReachabilityUnionNode("sym:dotnet:A", "dotnet", "method")
},
Edges: new[]
{
new ReachabilityUnionEdge("sym:dotnet:A", "sym:dotnet:B", "call")
});
var result = await writer.WriteAsync(graph, temp.Path, "analysis-x");
var meta = await JsonDocument.ParseAsync(File.OpenRead(result.MetaPath));
var files = meta.RootElement.GetProperty("files").EnumerateArray().ToList();
Assert.Equal(2, files.Count); // nodes + edges
// Deterministic order
var nodeLines = await File.ReadAllLinesAsync(Path.Combine(temp.Path, "reachability_graphs/analysis-x/nodes.ndjson"));
Assert.Contains(nodeLines, l => l.Contains("sym:dotnet:A"));
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<UseConcelierTestInfra>false</UseConcelierTestInfra>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Reachability\StellaOps.Scanner.Reachability.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.Scanner.Cache\StellaOps.Scanner.Cache.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,27 @@
using System;
using System.IO;
namespace StellaOps.Scanner.Reachability.Tests;
internal sealed class TempDir : IDisposable
{
public string Path { get; }
public TempDir()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "reach-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path);
}
public void Dispose()
{
try
{
Directory.Delete(Path, recursive: true);
}
catch
{
// best-effort cleanup only
}
}
}