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
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:
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>());
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StellaOps.Scanner.Reachability;
|
||||
|
||||
public static class ReachabilityUnionSchemas
|
||||
{
|
||||
public const string UnionSchema = "reachability-union@0.1";
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user