242 lines
8.3 KiB
C#
242 lines
8.3 KiB
C#
|
|
using StellaOps.Scanner.Contracts;
|
|
using System.Collections.Immutable;
|
|
|
|
namespace StellaOps.Scanner.CallGraph;
|
|
|
|
/// <summary>
|
|
/// Analyzes call graph reachability from entrypoints to sinks using BFS traversal.
|
|
/// Provides deterministically-ordered paths suitable for witness generation.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Sprint: SPRINT_3700_0001_0001 (WIT-007A, WIT-007B)
|
|
/// Contract: Paths are ordered by (SinkId ASC, EntrypointId ASC, PathLength ASC).
|
|
/// Node IDs within paths are ordered from entrypoint to sink (caller → callee).
|
|
/// </remarks>
|
|
public sealed class ReachabilityAnalyzer
|
|
{
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly ReachabilityAnalysisOptions _options;
|
|
|
|
/// <summary>
|
|
/// Creates a new ReachabilityAnalyzer with default options.
|
|
/// </summary>
|
|
public ReachabilityAnalyzer(TimeProvider? timeProvider = null, int maxDepth = 256)
|
|
: this(timeProvider, new ReachabilityAnalysisOptions { MaxDepth = maxDepth })
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a new ReachabilityAnalyzer with specified options.
|
|
/// </summary>
|
|
public ReachabilityAnalyzer(TimeProvider? timeProvider, ReachabilityAnalysisOptions options)
|
|
{
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
_options = (options ?? ReachabilityAnalysisOptions.Default).Validated();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Analyzes reachability using default options.
|
|
/// </summary>
|
|
public ReachabilityAnalysisResult Analyze(CallGraphSnapshot snapshot)
|
|
=> Analyze(snapshot, _options);
|
|
|
|
/// <summary>
|
|
/// Analyzes reachability with explicit options for this invocation.
|
|
/// </summary>
|
|
/// <param name="snapshot">The call graph snapshot to analyze.</param>
|
|
/// <param name="options">Options controlling limits and output format.</param>
|
|
/// <returns>Analysis result with deterministically-ordered paths.</returns>
|
|
public ReachabilityAnalysisResult Analyze(CallGraphSnapshot snapshot, ReachabilityAnalysisOptions options)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(snapshot);
|
|
var opts = (options ?? _options).Validated();
|
|
var trimmed = snapshot.Trimmed();
|
|
|
|
var adjacency = BuildAdjacency(trimmed);
|
|
|
|
var entrypoints = trimmed.EntrypointIds;
|
|
if (entrypoints.IsDefaultOrEmpty)
|
|
{
|
|
return EmptyResult(trimmed);
|
|
}
|
|
|
|
var origins = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
var parents = new Dictionary<string, string?>(StringComparer.Ordinal);
|
|
var depths = new Dictionary<string, int>(StringComparer.Ordinal);
|
|
var queue = new Queue<string>();
|
|
|
|
foreach (var entry in entrypoints.OrderBy(e => e, StringComparer.Ordinal))
|
|
{
|
|
origins[entry] = entry;
|
|
parents[entry] = null;
|
|
depths[entry] = 0;
|
|
queue.Enqueue(entry);
|
|
}
|
|
|
|
while (queue.Count > 0)
|
|
{
|
|
var current = queue.Dequeue();
|
|
if (!depths.TryGetValue(current, out var depth))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (depth >= opts.MaxDepth)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!adjacency.TryGetValue(current, out var neighbors))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
foreach (var next in neighbors)
|
|
{
|
|
if (origins.ContainsKey(next))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
origins[next] = origins[current];
|
|
parents[next] = current;
|
|
depths[next] = depth + 1;
|
|
queue.Enqueue(next);
|
|
}
|
|
}
|
|
|
|
var reachableNodes = origins.Keys.OrderBy(id => id, StringComparer.Ordinal).ToImmutableArray();
|
|
|
|
// WIT-007B: Use explicit sinks if specified, otherwise use snapshot sinks
|
|
var targetSinks = opts.ExplicitSinks.HasValue && !opts.ExplicitSinks.Value.IsDefaultOrEmpty
|
|
? opts.ExplicitSinks.Value
|
|
: trimmed.SinkIds;
|
|
|
|
var reachableSinks = targetSinks
|
|
.Where(origins.ContainsKey)
|
|
.OrderBy(id => id, StringComparer.Ordinal)
|
|
.ToImmutableArray();
|
|
|
|
var paths = BuildPaths(reachableSinks, origins, parents, opts);
|
|
|
|
var computedAt = _timeProvider.GetUtcNow();
|
|
var provisional = new ReachabilityAnalysisResult(
|
|
ScanId: trimmed.ScanId,
|
|
GraphDigest: trimmed.GraphDigest,
|
|
Language: trimmed.Language,
|
|
ComputedAt: computedAt,
|
|
ReachableNodeIds: reachableNodes,
|
|
ReachableSinkIds: reachableSinks,
|
|
Paths: paths,
|
|
ResultDigest: string.Empty);
|
|
|
|
var resultDigest = CallGraphDigests.ComputeResultDigest(provisional);
|
|
return provisional with { ResultDigest = resultDigest };
|
|
}
|
|
|
|
private static Dictionary<string, ImmutableArray<string>> BuildAdjacency(CallGraphSnapshot snapshot)
|
|
{
|
|
var map = new Dictionary<string, List<string>>(StringComparer.Ordinal);
|
|
foreach (var edge in snapshot.Edges)
|
|
{
|
|
if (!map.TryGetValue(edge.SourceId, out var list))
|
|
{
|
|
list = new List<string>();
|
|
map[edge.SourceId] = list;
|
|
}
|
|
list.Add(edge.TargetId);
|
|
}
|
|
|
|
return map.ToDictionary(
|
|
kvp => kvp.Key,
|
|
kvp => kvp.Value
|
|
.Where(v => !string.IsNullOrWhiteSpace(v))
|
|
.Distinct(StringComparer.Ordinal)
|
|
.OrderBy(v => v, StringComparer.Ordinal)
|
|
.ToImmutableArray(),
|
|
StringComparer.Ordinal);
|
|
}
|
|
|
|
private static ReachabilityAnalysisResult EmptyResult(CallGraphSnapshot snapshot)
|
|
{
|
|
var computedAt = TimeProvider.System.GetUtcNow();
|
|
var provisional = new ReachabilityAnalysisResult(
|
|
ScanId: snapshot.ScanId,
|
|
GraphDigest: snapshot.GraphDigest,
|
|
Language: snapshot.Language,
|
|
ComputedAt: computedAt,
|
|
ReachableNodeIds: ImmutableArray<string>.Empty,
|
|
ReachableSinkIds: ImmutableArray<string>.Empty,
|
|
Paths: ImmutableArray<ReachabilityPath>.Empty,
|
|
ResultDigest: string.Empty);
|
|
|
|
return provisional with { ResultDigest = CallGraphDigests.ComputeResultDigest(provisional) };
|
|
}
|
|
|
|
private static ImmutableArray<ReachabilityPath> BuildPaths(
|
|
ImmutableArray<string> reachableSinks,
|
|
Dictionary<string, string> origins,
|
|
Dictionary<string, string?> parents,
|
|
ReachabilityAnalysisOptions options)
|
|
{
|
|
var paths = new List<ReachabilityPath>(reachableSinks.Length);
|
|
var pathCountPerSink = new Dictionary<string, int>(StringComparer.Ordinal);
|
|
|
|
foreach (var sinkId in reachableSinks)
|
|
{
|
|
if (!origins.TryGetValue(sinkId, out var origin))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Enforce per-sink limit
|
|
pathCountPerSink.TryGetValue(sinkId, out var currentCount);
|
|
if (currentCount >= options.MaxPathsPerSink)
|
|
{
|
|
continue;
|
|
}
|
|
pathCountPerSink[sinkId] = currentCount + 1;
|
|
|
|
var nodeIds = ReconstructPathNodeIds(sinkId, parents);
|
|
paths.Add(new ReachabilityPath(origin, sinkId, nodeIds));
|
|
|
|
// Enforce total path limit
|
|
if (paths.Count >= options.MaxTotalPaths)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Deterministic ordering: SinkId ASC, EntrypointId ASC, PathLength ASC
|
|
return paths
|
|
.OrderBy(p => p.SinkId, StringComparer.Ordinal)
|
|
.ThenBy(p => p.EntrypointId, StringComparer.Ordinal)
|
|
.ThenBy(p => p.NodeIds.Length)
|
|
.ToImmutableArray();
|
|
}
|
|
|
|
private static ImmutableArray<string> ReconstructPathNodeIds(string sinkId, Dictionary<string, string?> parents)
|
|
{
|
|
var stack = new Stack<string>();
|
|
var cursor = sinkId;
|
|
while (true)
|
|
{
|
|
stack.Push(cursor);
|
|
if (!parents.TryGetValue(cursor, out var parent) || parent is null)
|
|
{
|
|
break;
|
|
}
|
|
cursor = parent;
|
|
}
|
|
|
|
var builder = ImmutableArray.CreateBuilder<string>(stack.Count);
|
|
while (stack.Count > 0)
|
|
{
|
|
builder.Add(stack.Pop());
|
|
}
|
|
return builder.ToImmutable();
|
|
}
|
|
}
|
|
|