partly or unimplemented features - now implemented
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using StellaOps.Reachability.Core;
|
||||
|
||||
namespace StellaOps.ReachGraph.WebService.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Unified Reachability Query API - facade for static, runtime, and hybrid queries.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("v1/reachability")]
|
||||
[Produces("application/json")]
|
||||
public class ReachabilityController : ControllerBase
|
||||
{
|
||||
private readonly IReachabilityIndex _reachabilityIndex;
|
||||
private readonly ILogger<ReachabilityController> _logger;
|
||||
|
||||
public ReachabilityController(
|
||||
IReachabilityIndex reachabilityIndex,
|
||||
ILogger<ReachabilityController> logger)
|
||||
{
|
||||
_reachabilityIndex = reachabilityIndex;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query static reachability from call graph analysis.
|
||||
/// </summary>
|
||||
/// <param name="request">Static query request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Static reachability result.</returns>
|
||||
[HttpPost("static")]
|
||||
[EnableRateLimiting("reachgraph-read")]
|
||||
[ProducesResponseType(typeof(StaticReachabilityResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> QueryStaticAsync(
|
||||
[FromBody] StaticQueryRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (request.Symbol is null)
|
||||
{
|
||||
return BadRequest(new { error = "Symbol is required" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
|
||||
{
|
||||
return BadRequest(new { error = "ArtifactDigest is required" });
|
||||
}
|
||||
|
||||
var tenantId = GetTenantId();
|
||||
_logger.LogDebug(
|
||||
"Static reachability query for {Symbol} in {Artifact}, tenant={Tenant}",
|
||||
BuildSymbolKey(request.Symbol),
|
||||
request.ArtifactDigest,
|
||||
tenantId);
|
||||
|
||||
var result = await _reachabilityIndex.QueryStaticAsync(
|
||||
request.Symbol,
|
||||
request.ArtifactDigest,
|
||||
ct);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query runtime reachability from observed execution facts.
|
||||
/// </summary>
|
||||
/// <param name="request">Runtime query request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Runtime reachability result.</returns>
|
||||
[HttpPost("runtime")]
|
||||
[EnableRateLimiting("reachgraph-read")]
|
||||
[ProducesResponseType(typeof(RuntimeReachabilityResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> QueryRuntimeAsync(
|
||||
[FromBody] RuntimeQueryRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (request.Symbol is null)
|
||||
{
|
||||
return BadRequest(new { error = "Symbol is required" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
|
||||
{
|
||||
return BadRequest(new { error = "ArtifactDigest is required" });
|
||||
}
|
||||
|
||||
var observationWindow = request.ObservationWindow ?? TimeSpan.FromDays(7);
|
||||
|
||||
var tenantId = GetTenantId();
|
||||
_logger.LogDebug(
|
||||
"Runtime reachability query for {Symbol} in {Artifact}, window={Window}, tenant={Tenant}",
|
||||
BuildSymbolKey(request.Symbol),
|
||||
request.ArtifactDigest,
|
||||
observationWindow,
|
||||
tenantId);
|
||||
|
||||
var result = await _reachabilityIndex.QueryRuntimeAsync(
|
||||
request.Symbol,
|
||||
request.ArtifactDigest,
|
||||
observationWindow,
|
||||
ct);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Query hybrid reachability combining static analysis and runtime evidence.
|
||||
/// </summary>
|
||||
/// <param name="request">Hybrid query request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Hybrid reachability result with verdict recommendation.</returns>
|
||||
[HttpPost("hybrid")]
|
||||
[EnableRateLimiting("reachgraph-read")]
|
||||
[ProducesResponseType(typeof(HybridReachabilityResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> QueryHybridAsync(
|
||||
[FromBody] HybridQueryRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (request.Symbol is null)
|
||||
{
|
||||
return BadRequest(new { error = "Symbol is required" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
|
||||
{
|
||||
return BadRequest(new { error = "ArtifactDigest is required" });
|
||||
}
|
||||
|
||||
var options = new HybridQueryOptions
|
||||
{
|
||||
IncludeStatic = request.IncludeStatic ?? true,
|
||||
IncludeRuntime = request.IncludeRuntime ?? true,
|
||||
ObservationWindow = request.ObservationWindow ?? TimeSpan.FromDays(7),
|
||||
ConfidenceThreshold = request.ConfidenceThreshold ?? 0.8
|
||||
};
|
||||
|
||||
var tenantId = GetTenantId();
|
||||
_logger.LogDebug(
|
||||
"Hybrid reachability query for {Symbol} in {Artifact}, static={Static}, runtime={Runtime}, tenant={Tenant}",
|
||||
BuildSymbolKey(request.Symbol),
|
||||
request.ArtifactDigest,
|
||||
options.IncludeStatic,
|
||||
options.IncludeRuntime,
|
||||
tenantId);
|
||||
|
||||
var result = await _reachabilityIndex.QueryHybridAsync(
|
||||
request.Symbol,
|
||||
request.ArtifactDigest,
|
||||
options,
|
||||
ct);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch query for multiple symbols (CVE vulnerability analysis).
|
||||
/// </summary>
|
||||
/// <param name="request">Batch query request.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Results for all symbols.</returns>
|
||||
[HttpPost("batch")]
|
||||
[EnableRateLimiting("reachgraph-read")]
|
||||
[ProducesResponseType(typeof(BatchQueryResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> QueryBatchAsync(
|
||||
[FromBody] BatchQueryRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (request.Symbols is null || request.Symbols.Count == 0)
|
||||
{
|
||||
return BadRequest(new { error = "At least one symbol is required" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ArtifactDigest))
|
||||
{
|
||||
return BadRequest(new { error = "ArtifactDigest is required" });
|
||||
}
|
||||
|
||||
var options = new HybridQueryOptions
|
||||
{
|
||||
IncludeStatic = request.IncludeStatic ?? true,
|
||||
IncludeRuntime = request.IncludeRuntime ?? true,
|
||||
ObservationWindow = request.ObservationWindow ?? TimeSpan.FromDays(7),
|
||||
ConfidenceThreshold = request.ConfidenceThreshold ?? 0.8
|
||||
};
|
||||
|
||||
var tenantId = GetTenantId();
|
||||
_logger.LogInformation(
|
||||
"Batch reachability query for {Count} symbols in {Artifact}, tenant={Tenant}",
|
||||
request.Symbols.Count,
|
||||
request.ArtifactDigest,
|
||||
tenantId);
|
||||
|
||||
var results = await _reachabilityIndex.QueryBatchAsync(
|
||||
request.Symbols,
|
||||
request.ArtifactDigest,
|
||||
options,
|
||||
ct);
|
||||
|
||||
var response = new BatchQueryResponse
|
||||
{
|
||||
ArtifactDigest = request.ArtifactDigest,
|
||||
TotalSymbols = request.Symbols.Count,
|
||||
Results = results.ToList()
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
private string? GetTenantId()
|
||||
{
|
||||
return User.FindFirst("tenant")?.Value
|
||||
?? Request.Headers["X-Tenant-ID"].FirstOrDefault();
|
||||
}
|
||||
|
||||
private static string BuildSymbolKey(SymbolRef symbol)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrEmpty(symbol.Namespace)) parts.Add(symbol.Namespace);
|
||||
if (!string.IsNullOrEmpty(symbol.TypeName)) parts.Add(symbol.TypeName);
|
||||
if (!string.IsNullOrEmpty(symbol.MemberName)) parts.Add(symbol.MemberName);
|
||||
return string.Join(".", parts);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for static reachability query.
|
||||
/// </summary>
|
||||
public record StaticQueryRequest
|
||||
{
|
||||
/// <summary>Symbol to query.</summary>
|
||||
public SymbolRef? Symbol { get; init; }
|
||||
|
||||
/// <summary>Target artifact digest (sha256:...).</summary>
|
||||
public string? ArtifactDigest { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for runtime reachability query.
|
||||
/// </summary>
|
||||
public record RuntimeQueryRequest
|
||||
{
|
||||
/// <summary>Symbol to query.</summary>
|
||||
public SymbolRef? Symbol { get; init; }
|
||||
|
||||
/// <summary>Target artifact digest.</summary>
|
||||
public string? ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>Observation window to consider. Default: 7 days.</summary>
|
||||
public TimeSpan? ObservationWindow { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for hybrid reachability query.
|
||||
/// </summary>
|
||||
public record HybridQueryRequest
|
||||
{
|
||||
/// <summary>Symbol to query.</summary>
|
||||
public SymbolRef? Symbol { get; init; }
|
||||
|
||||
/// <summary>Target artifact digest.</summary>
|
||||
public string? ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>Include static analysis. Default: true.</summary>
|
||||
public bool? IncludeStatic { get; init; }
|
||||
|
||||
/// <summary>Include runtime evidence. Default: true.</summary>
|
||||
public bool? IncludeRuntime { get; init; }
|
||||
|
||||
/// <summary>Observation window for runtime. Default: 7 days.</summary>
|
||||
public TimeSpan? ObservationWindow { get; init; }
|
||||
|
||||
/// <summary>Confidence threshold for verdict. Default: 0.8.</summary>
|
||||
public double? ConfidenceThreshold { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for batch reachability query.
|
||||
/// </summary>
|
||||
public record BatchQueryRequest
|
||||
{
|
||||
/// <summary>Symbols to query.</summary>
|
||||
public IReadOnlyList<SymbolRef>? Symbols { get; init; }
|
||||
|
||||
/// <summary>Target artifact digest.</summary>
|
||||
public string? ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>Include static analysis. Default: true.</summary>
|
||||
public bool? IncludeStatic { get; init; }
|
||||
|
||||
/// <summary>Include runtime evidence. Default: true.</summary>
|
||||
public bool? IncludeRuntime { get; init; }
|
||||
|
||||
/// <summary>Observation window for runtime. Default: 7 days.</summary>
|
||||
public TimeSpan? ObservationWindow { get; init; }
|
||||
|
||||
/// <summary>Confidence threshold for verdict. Default: 0.8.</summary>
|
||||
public double? ConfidenceThreshold { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for batch reachability query.
|
||||
/// </summary>
|
||||
public record BatchQueryResponse
|
||||
{
|
||||
/// <summary>Artifact digest queried.</summary>
|
||||
public required string ArtifactDigest { get; init; }
|
||||
|
||||
/// <summary>Total symbols queried.</summary>
|
||||
public int TotalSymbols { get; init; }
|
||||
|
||||
/// <summary>Results for each symbol.</summary>
|
||||
public required IReadOnlyList<HybridReachabilityResult> Results { get; init; }
|
||||
}
|
||||
@@ -71,6 +71,12 @@ builder.Services.AddScoped<IReachGraphStoreService, ReachGraphStoreService>();
|
||||
builder.Services.AddScoped<IReachGraphSliceService, ReachGraphSliceService>();
|
||||
builder.Services.AddScoped<IReachGraphReplayService, ReachGraphReplayService>();
|
||||
|
||||
// Reachability Core adapters and unified query interface
|
||||
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
|
||||
builder.Services.AddScoped<StellaOps.Reachability.Core.IReachGraphAdapter, ReachGraphStoreAdapter>();
|
||||
builder.Services.AddSingleton<StellaOps.Reachability.Core.ISignalsAdapter, InMemorySignalsAdapter>();
|
||||
builder.Services.AddScoped<StellaOps.Reachability.Core.IReachabilityIndex, StellaOps.Reachability.Core.ReachabilityIndex>();
|
||||
|
||||
// Rate limiting
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Reachability.Core;
|
||||
|
||||
namespace StellaOps.ReachGraph.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="ISignalsAdapter"/> for runtime observation facts.
|
||||
/// Production deployments should integrate with the actual Signals runtime service.
|
||||
/// </summary>
|
||||
public sealed class InMemorySignalsAdapter : ISignalsAdapter
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, List<ObservedSymbol>> _observations = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="InMemorySignalsAdapter"/>.
|
||||
/// </summary>
|
||||
public InMemorySignalsAdapter(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<RuntimeReachabilityResult> QueryAsync(
|
||||
SymbolRef symbol,
|
||||
string artifactDigest,
|
||||
TimeSpan observationWindow,
|
||||
string? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(symbol);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var windowStart = now - observationWindow;
|
||||
|
||||
var key = GetKey(artifactDigest, tenantId);
|
||||
var symbolFqn = BuildSymbolFqn(symbol);
|
||||
|
||||
if (!_observations.TryGetValue(key, out var observations))
|
||||
{
|
||||
return Task.FromResult(CreateNotFoundResult(symbol, artifactDigest, observationWindow, windowStart, now));
|
||||
}
|
||||
|
||||
var matches = observations
|
||||
.Where(o => MatchesSymbol(o, symbol) && o.ObservedAt >= windowStart && o.ObservedAt <= now)
|
||||
.ToList();
|
||||
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
return Task.FromResult(CreateNotFoundResult(symbol, artifactDigest, observationWindow, windowStart, now));
|
||||
}
|
||||
|
||||
var firstSeen = matches.Min(o => o.ObservedAt);
|
||||
var lastSeen = matches.Max(o => o.ObservedAt);
|
||||
var hitCount = matches.Sum(o => o.HitCount);
|
||||
|
||||
var contexts = matches
|
||||
.Select(o => new ExecutionContext
|
||||
{
|
||||
Environment = o.Environment ?? "production",
|
||||
Service = o.ServiceName,
|
||||
TraceId = o.TraceId,
|
||||
ObservedAt = o.ObservedAt
|
||||
})
|
||||
.Distinct()
|
||||
.Take(10)
|
||||
.ToImmutableArray();
|
||||
|
||||
var evidenceUris = matches
|
||||
.Where(o => !string.IsNullOrEmpty(o.EvidenceUri))
|
||||
.Select(o => o.EvidenceUri!)
|
||||
.Distinct()
|
||||
.ToImmutableArray();
|
||||
|
||||
var result = new RuntimeReachabilityResult
|
||||
{
|
||||
Symbol = symbol,
|
||||
ArtifactDigest = artifactDigest,
|
||||
WasObserved = true,
|
||||
ObservationWindow = observationWindow,
|
||||
WindowStart = windowStart,
|
||||
WindowEnd = now,
|
||||
HitCount = hitCount,
|
||||
FirstSeen = firstSeen,
|
||||
LastSeen = lastSeen,
|
||||
Contexts = contexts,
|
||||
EvidenceUris = evidenceUris
|
||||
};
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<bool> HasFactsAsync(string artifactDigest, string? tenantId, CancellationToken ct)
|
||||
{
|
||||
var key = GetKey(artifactDigest, tenantId);
|
||||
return Task.FromResult(_observations.ContainsKey(key) && _observations[key].Count > 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<SignalsMetadata?> GetMetadataAsync(string artifactDigest, string? tenantId, CancellationToken ct)
|
||||
{
|
||||
var key = GetKey(artifactDigest, tenantId);
|
||||
|
||||
if (!_observations.TryGetValue(key, out var observations) || observations.Count == 0)
|
||||
{
|
||||
return Task.FromResult<SignalsMetadata?>(null);
|
||||
}
|
||||
|
||||
var environments = observations
|
||||
.Where(o => !string.IsNullOrEmpty(o.Environment))
|
||||
.Select(o => o.Environment!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var metadata = new SignalsMetadata
|
||||
{
|
||||
ArtifactDigest = artifactDigest,
|
||||
TenantId = tenantId,
|
||||
EarliestObservation = observations.Min(o => o.ObservedAt),
|
||||
LatestObservation = observations.Max(o => o.ObservedAt),
|
||||
SymbolCount = observations.Select(o => o.SymbolRef).Distinct().Count(),
|
||||
TotalObservations = observations.Sum(o => o.HitCount),
|
||||
Environments = environments,
|
||||
AgentVersion = "signals-inmemory@v1"
|
||||
};
|
||||
|
||||
return Task.FromResult<SignalsMetadata?>(metadata);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an observation for testing purposes.
|
||||
/// </summary>
|
||||
public void RecordObservation(
|
||||
string artifactDigest,
|
||||
string? tenantId,
|
||||
SymbolRef symbol,
|
||||
DateTimeOffset observedAt,
|
||||
long hitCount = 1,
|
||||
string? environment = null,
|
||||
string? serviceName = null,
|
||||
string? traceId = null)
|
||||
{
|
||||
var key = GetKey(artifactDigest, tenantId);
|
||||
var symbolFqn = BuildSymbolFqn(symbol);
|
||||
|
||||
var observation = new ObservedSymbol
|
||||
{
|
||||
SymbolRef = symbolFqn,
|
||||
Symbol = symbol,
|
||||
ObservedAt = observedAt,
|
||||
HitCount = hitCount,
|
||||
Environment = environment ?? "production",
|
||||
ServiceName = serviceName,
|
||||
TraceId = traceId,
|
||||
EvidenceUri = EvidenceUriBuilder.Build("signals", artifactDigest, $"symbol:{symbolFqn}")
|
||||
};
|
||||
|
||||
var list = _observations.GetOrAdd(key, _ => new List<ObservedSymbol>());
|
||||
lock (list)
|
||||
{
|
||||
list.Add(observation);
|
||||
}
|
||||
}
|
||||
|
||||
private static RuntimeReachabilityResult CreateNotFoundResult(
|
||||
SymbolRef symbol,
|
||||
string artifactDigest,
|
||||
TimeSpan observationWindow,
|
||||
DateTimeOffset windowStart,
|
||||
DateTimeOffset windowEnd)
|
||||
{
|
||||
return new RuntimeReachabilityResult
|
||||
{
|
||||
Symbol = symbol,
|
||||
ArtifactDigest = artifactDigest,
|
||||
WasObserved = false,
|
||||
ObservationWindow = observationWindow,
|
||||
WindowStart = windowStart,
|
||||
WindowEnd = windowEnd,
|
||||
HitCount = 0,
|
||||
FirstSeen = null,
|
||||
LastSeen = null,
|
||||
Contexts = ImmutableArray<ExecutionContext>.Empty,
|
||||
EvidenceUris = ImmutableArray<string>.Empty
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetKey(string artifactDigest, string? tenantId) =>
|
||||
$"{tenantId ?? "default"}:{artifactDigest}".ToLowerInvariant();
|
||||
|
||||
private static string BuildSymbolFqn(SymbolRef symbol)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrEmpty(symbol.Namespace)) parts.Add(symbol.Namespace);
|
||||
if (!string.IsNullOrEmpty(symbol.TypeName)) parts.Add(symbol.TypeName);
|
||||
if (!string.IsNullOrEmpty(symbol.MemberName)) parts.Add(symbol.MemberName);
|
||||
return string.Join(".", parts);
|
||||
}
|
||||
|
||||
private static bool MatchesSymbol(ObservedSymbol observation, SymbolRef symbol)
|
||||
{
|
||||
if (observation.Symbol is not null)
|
||||
{
|
||||
return observation.Symbol.Equals(symbol);
|
||||
}
|
||||
|
||||
var targetFqn = BuildSymbolFqn(symbol);
|
||||
return observation.SymbolRef.Equals(targetFqn, StringComparison.OrdinalIgnoreCase) ||
|
||||
observation.SymbolRef.Contains(targetFqn, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed class ObservedSymbol
|
||||
{
|
||||
public required string SymbolRef { get; init; }
|
||||
public SymbolRef? Symbol { get; init; }
|
||||
public required DateTimeOffset ObservedAt { get; init; }
|
||||
public long HitCount { get; init; }
|
||||
public string? Environment { get; init; }
|
||||
public string? ServiceName { get; init; }
|
||||
public string? TraceId { get; init; }
|
||||
public string? EvidenceUri { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using StellaOps.Reachability.Core;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
|
||||
namespace StellaOps.ReachGraph.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Adapter implementation that wires <see cref="IReachGraphAdapter"/> to <see cref="IReachGraphStoreService"/>.
|
||||
/// </summary>
|
||||
public sealed class ReachGraphStoreAdapter : IReachGraphAdapter
|
||||
{
|
||||
private readonly IReachGraphStoreService _storeService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly string _tenantId;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="ReachGraphStoreAdapter"/>.
|
||||
/// </summary>
|
||||
public ReachGraphStoreAdapter(
|
||||
IReachGraphStoreService storeService,
|
||||
TimeProvider timeProvider,
|
||||
string tenantId = "default")
|
||||
{
|
||||
_storeService = storeService ?? throw new ArgumentNullException(nameof(storeService));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_tenantId = tenantId;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<StaticReachabilityResult> QueryAsync(
|
||||
SymbolRef symbol,
|
||||
string artifactDigest,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(symbol);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
|
||||
var graphs = await _storeService.ListByArtifactAsync(artifactDigest, _tenantId, 1, ct);
|
||||
if (graphs.Count == 0)
|
||||
{
|
||||
return CreateNotFoundResult(symbol, artifactDigest);
|
||||
}
|
||||
|
||||
var summary = graphs[0];
|
||||
var graph = await _storeService.GetByDigestAsync(summary.Digest, _tenantId, ct);
|
||||
if (graph is null)
|
||||
{
|
||||
return CreateNotFoundResult(symbol, artifactDigest);
|
||||
}
|
||||
|
||||
// Search for the symbol in the graph
|
||||
var (isReachable, pathCount, shortestPath, entrypoints) = SearchSymbolInGraph(graph, symbol);
|
||||
|
||||
return new StaticReachabilityResult
|
||||
{
|
||||
Symbol = symbol,
|
||||
ArtifactDigest = artifactDigest,
|
||||
IsReachable = isReachable,
|
||||
PathCount = pathCount,
|
||||
ShortestPathLength = shortestPath,
|
||||
Entrypoints = entrypoints,
|
||||
Guards = ImmutableArray<GuardCondition>.Empty,
|
||||
EvidenceUris = CreateEvidenceUris(graph, symbol),
|
||||
AnalyzedAt = _timeProvider.GetUtcNow(),
|
||||
AnalyzerVersion = graph.SchemaVersion
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> HasGraphAsync(string artifactDigest, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
|
||||
var graphs = await _storeService.ListByArtifactAsync(artifactDigest, _tenantId, 1, ct);
|
||||
return graphs.Count > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<ReachGraphMetadata?> GetMetadataAsync(string artifactDigest, CancellationToken ct)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(artifactDigest);
|
||||
|
||||
var graphs = await _storeService.ListByArtifactAsync(artifactDigest, _tenantId, 10, ct);
|
||||
if (graphs.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var summary = graphs[0];
|
||||
var graph = await _storeService.GetByDigestAsync(summary.Digest, _tenantId, ct);
|
||||
if (graph is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Count entrypoints from scope
|
||||
var entrypointCount = graph.Scope.Entrypoints?.Length ?? 0;
|
||||
|
||||
return new ReachGraphMetadata
|
||||
{
|
||||
ArtifactDigest = artifactDigest,
|
||||
GraphDigest = summary.Digest,
|
||||
CreatedAt = summary.CreatedAt,
|
||||
NodeCount = graph.Nodes.Length,
|
||||
EdgeCount = graph.Edges.Length,
|
||||
EntrypointCount = entrypointCount,
|
||||
Version = graph.SchemaVersion
|
||||
};
|
||||
}
|
||||
|
||||
private StaticReachabilityResult CreateNotFoundResult(SymbolRef symbol, string artifactDigest)
|
||||
{
|
||||
return new StaticReachabilityResult
|
||||
{
|
||||
Symbol = symbol,
|
||||
ArtifactDigest = artifactDigest,
|
||||
IsReachable = false,
|
||||
PathCount = 0,
|
||||
ShortestPathLength = null,
|
||||
Entrypoints = ImmutableArray<string>.Empty,
|
||||
Guards = ImmutableArray<GuardCondition>.Empty,
|
||||
EvidenceUris = ImmutableArray<string>.Empty,
|
||||
AnalyzedAt = _timeProvider.GetUtcNow(),
|
||||
AnalyzerVersion = null
|
||||
};
|
||||
}
|
||||
|
||||
private static (bool isReachable, int pathCount, int? shortestPath, ImmutableArray<string> entrypoints) SearchSymbolInGraph(
|
||||
ReachGraphMinimal graph,
|
||||
SymbolRef symbol)
|
||||
{
|
||||
if (graph.Nodes.Length == 0)
|
||||
{
|
||||
return (false, 0, null, ImmutableArray<string>.Empty);
|
||||
}
|
||||
|
||||
// Find the node matching the symbol
|
||||
var targetNode = graph.Nodes.FirstOrDefault(n => MatchesSymbol(n, symbol));
|
||||
|
||||
if (targetNode is null)
|
||||
{
|
||||
return (false, 0, null, ImmutableArray<string>.Empty);
|
||||
}
|
||||
|
||||
// Build adjacency list for BFS
|
||||
var adjacency = new Dictionary<string, List<string>>();
|
||||
foreach (var node in graph.Nodes)
|
||||
{
|
||||
adjacency[node.Id] = new List<string>();
|
||||
}
|
||||
foreach (var edge in graph.Edges)
|
||||
{
|
||||
if (adjacency.ContainsKey(edge.Source))
|
||||
{
|
||||
adjacency[edge.Source].Add(edge.Target);
|
||||
}
|
||||
}
|
||||
|
||||
// Get entrypoints from scope
|
||||
var entrypoints = graph.Scope.Entrypoints ?? ImmutableArray<string>.Empty;
|
||||
if (entrypoints.Length == 0)
|
||||
{
|
||||
// If no entrypoints defined, try to find nodes with no incoming edges
|
||||
var hasIncoming = new HashSet<string>(graph.Edges.Select(e => e.Target));
|
||||
entrypoints = graph.Nodes
|
||||
.Where(n => !hasIncoming.Contains(n.Id))
|
||||
.Select(n => n.Id)
|
||||
.ToImmutableArray();
|
||||
}
|
||||
|
||||
// BFS from each entrypoint to find paths to target
|
||||
var reachableFrom = new List<string>();
|
||||
var shortestPath = int.MaxValue;
|
||||
var pathCount = 0;
|
||||
|
||||
foreach (var entrypoint in entrypoints)
|
||||
{
|
||||
var (canReach, distance) = BfsToTarget(adjacency, entrypoint, targetNode.Id);
|
||||
if (canReach)
|
||||
{
|
||||
reachableFrom.Add(entrypoint);
|
||||
pathCount++;
|
||||
if (distance < shortestPath)
|
||||
{
|
||||
shortestPath = distance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reachableFrom.Count == 0)
|
||||
{
|
||||
return (false, 0, null, ImmutableArray<string>.Empty);
|
||||
}
|
||||
|
||||
return (true, pathCount, shortestPath, reachableFrom.ToImmutableArray());
|
||||
}
|
||||
|
||||
private static bool MatchesSymbol(ReachGraphNode node, SymbolRef symbol)
|
||||
{
|
||||
// Match by reference or node ID
|
||||
var symbolFqn = BuildSymbolFqn(symbol);
|
||||
|
||||
// Check against node's Ref (PURL for package, path for file, symbol for function)
|
||||
if (node.Ref.Contains(symbolFqn, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also check against node ID
|
||||
if (node.Id.Contains(symbolFqn, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check individual parts
|
||||
if (!string.IsNullOrEmpty(symbol.MemberName) &&
|
||||
node.Ref.Contains(symbol.MemberName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string BuildSymbolFqn(SymbolRef symbol)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrEmpty(symbol.Namespace)) parts.Add(symbol.Namespace);
|
||||
if (!string.IsNullOrEmpty(symbol.TypeName)) parts.Add(symbol.TypeName);
|
||||
if (!string.IsNullOrEmpty(symbol.MemberName)) parts.Add(symbol.MemberName);
|
||||
return string.Join(".", parts);
|
||||
}
|
||||
|
||||
private static (bool canReach, int distance) BfsToTarget(
|
||||
Dictionary<string, List<string>> adjacency,
|
||||
string start,
|
||||
string target)
|
||||
{
|
||||
if (start == target) return (true, 0);
|
||||
|
||||
var visited = new HashSet<string> { start };
|
||||
var queue = new Queue<(string node, int depth)>();
|
||||
queue.Enqueue((start, 0));
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var (current, depth) = queue.Dequeue();
|
||||
|
||||
if (!adjacency.TryGetValue(current, out var neighbors))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var neighbor in neighbors)
|
||||
{
|
||||
if (neighbor == target)
|
||||
{
|
||||
return (true, depth + 1);
|
||||
}
|
||||
|
||||
if (visited.Add(neighbor))
|
||||
{
|
||||
queue.Enqueue((neighbor, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (false, -1);
|
||||
}
|
||||
|
||||
private static ImmutableArray<string> CreateEvidenceUris(ReachGraphMinimal graph, SymbolRef symbol)
|
||||
{
|
||||
var artifactDigest = graph.Artifact.Digest ?? "unknown";
|
||||
var symbolFqn = BuildSymbolFqn(symbol);
|
||||
var evidenceUri = EvidenceUriBuilder.Build("reachgraph", artifactDigest, $"symbol:{symbolFqn}");
|
||||
|
||||
return ImmutableArray.Create(evidenceUri);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Reachability.Core;
|
||||
using StellaOps.ReachGraph.WebService.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReachGraph.WebService.Tests;
|
||||
|
||||
public class InMemorySignalsAdapterTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider = new(new DateTimeOffset(2025, 6, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ReturnsNotObserved_WhenNoFacts()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new InMemorySignalsAdapter(_timeProvider);
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "System",
|
||||
TypeName = "String",
|
||||
MemberName = "Trim"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await adapter.QueryAsync(
|
||||
symbol,
|
||||
"sha256:test",
|
||||
TimeSpan.FromDays(7),
|
||||
"tenant1",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.WasObserved.Should().BeFalse();
|
||||
result.HitCount.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ReturnsObserved_WhenFactsExist()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new InMemorySignalsAdapter(_timeProvider);
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "Service",
|
||||
MemberName = "Process"
|
||||
};
|
||||
|
||||
adapter.RecordObservation(
|
||||
"sha256:test",
|
||||
"tenant1",
|
||||
symbol,
|
||||
_timeProvider.GetUtcNow().AddHours(-1),
|
||||
hitCount: 100,
|
||||
environment: "production",
|
||||
serviceName: "api-gateway");
|
||||
|
||||
// Act
|
||||
var result = await adapter.QueryAsync(
|
||||
symbol,
|
||||
"sha256:test",
|
||||
TimeSpan.FromDays(7),
|
||||
"tenant1",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.WasObserved.Should().BeTrue();
|
||||
result.HitCount.Should().Be(100);
|
||||
result.FirstSeen.Should().NotBeNull();
|
||||
result.LastSeen.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ReturnsNotObserved_WhenOutsideWindow()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new InMemorySignalsAdapter(_timeProvider);
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "Service",
|
||||
MemberName = "Process"
|
||||
};
|
||||
|
||||
// Record observation 10 days ago
|
||||
adapter.RecordObservation(
|
||||
"sha256:test",
|
||||
"tenant1",
|
||||
symbol,
|
||||
_timeProvider.GetUtcNow().AddDays(-10),
|
||||
hitCount: 50);
|
||||
|
||||
// Act - query with 7-day window
|
||||
var result = await adapter.QueryAsync(
|
||||
symbol,
|
||||
"sha256:test",
|
||||
TimeSpan.FromDays(7),
|
||||
"tenant1",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.WasObserved.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_AggregatesMultipleObservations()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new InMemorySignalsAdapter(_timeProvider);
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "Service",
|
||||
MemberName = "Process"
|
||||
};
|
||||
|
||||
adapter.RecordObservation(
|
||||
"sha256:test",
|
||||
"tenant1",
|
||||
symbol,
|
||||
_timeProvider.GetUtcNow().AddHours(-2),
|
||||
hitCount: 50);
|
||||
|
||||
adapter.RecordObservation(
|
||||
"sha256:test",
|
||||
"tenant1",
|
||||
symbol,
|
||||
_timeProvider.GetUtcNow().AddHours(-1),
|
||||
hitCount: 75);
|
||||
|
||||
// Act
|
||||
var result = await adapter.QueryAsync(
|
||||
symbol,
|
||||
"sha256:test",
|
||||
TimeSpan.FromDays(7),
|
||||
"tenant1",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.WasObserved.Should().BeTrue();
|
||||
result.HitCount.Should().Be(125); // 50 + 75
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_IncludesContexts()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new InMemorySignalsAdapter(_timeProvider);
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "Service",
|
||||
MemberName = "Process"
|
||||
};
|
||||
|
||||
adapter.RecordObservation(
|
||||
"sha256:test",
|
||||
"tenant1",
|
||||
symbol,
|
||||
_timeProvider.GetUtcNow().AddMinutes(-30),
|
||||
hitCount: 10,
|
||||
environment: "production",
|
||||
serviceName: "api-gateway",
|
||||
traceId: "trace-001");
|
||||
|
||||
// Act
|
||||
var result = await adapter.QueryAsync(
|
||||
symbol,
|
||||
"sha256:test",
|
||||
TimeSpan.FromDays(7),
|
||||
"tenant1",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Contexts.Should().NotBeEmpty();
|
||||
result.Contexts[0].Environment.Should().Be("production");
|
||||
result.Contexts[0].Service.Should().Be("api-gateway");
|
||||
result.Contexts[0].TraceId.Should().Be("trace-001");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_IsolatesByTenant()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new InMemorySignalsAdapter(_timeProvider);
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "Service",
|
||||
MemberName = "Process"
|
||||
};
|
||||
|
||||
adapter.RecordObservation(
|
||||
"sha256:test",
|
||||
"tenant1",
|
||||
symbol,
|
||||
_timeProvider.GetUtcNow().AddMinutes(-30),
|
||||
hitCount: 100);
|
||||
|
||||
// Act - query different tenant
|
||||
var result = await adapter.QueryAsync(
|
||||
symbol,
|
||||
"sha256:test",
|
||||
TimeSpan.FromDays(7),
|
||||
"tenant2",
|
||||
CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.WasObserved.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasFactsAsync_ReturnsTrue_WhenFactsExist()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new InMemorySignalsAdapter(_timeProvider);
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "Service",
|
||||
MemberName = "Process"
|
||||
};
|
||||
|
||||
adapter.RecordObservation(
|
||||
"sha256:test",
|
||||
"tenant1",
|
||||
symbol,
|
||||
_timeProvider.GetUtcNow(),
|
||||
hitCount: 1);
|
||||
|
||||
// Act
|
||||
var result = await adapter.HasFactsAsync("sha256:test", "tenant1", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasFactsAsync_ReturnsFalse_WhenNoFacts()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new InMemorySignalsAdapter(_timeProvider);
|
||||
|
||||
// Act
|
||||
var result = await adapter.HasFactsAsync("sha256:test", "tenant1", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMetadataAsync_ReturnsMetadata_WhenFactsExist()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new InMemorySignalsAdapter(_timeProvider);
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "Service",
|
||||
MemberName = "Process"
|
||||
};
|
||||
|
||||
adapter.RecordObservation(
|
||||
"sha256:test",
|
||||
"tenant1",
|
||||
symbol,
|
||||
_timeProvider.GetUtcNow().AddDays(-3),
|
||||
hitCount: 50,
|
||||
environment: "production");
|
||||
|
||||
adapter.RecordObservation(
|
||||
"sha256:test",
|
||||
"tenant1",
|
||||
symbol,
|
||||
_timeProvider.GetUtcNow().AddDays(-1),
|
||||
hitCount: 100,
|
||||
environment: "staging");
|
||||
|
||||
// Act
|
||||
var metadata = await adapter.GetMetadataAsync("sha256:test", "tenant1", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
metadata.Should().NotBeNull();
|
||||
metadata!.ArtifactDigest.Should().Be("sha256:test");
|
||||
metadata.TotalObservations.Should().Be(150);
|
||||
metadata.Environments.Should().Contain("production");
|
||||
metadata.Environments.Should().Contain("staging");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMetadataAsync_ReturnsNull_WhenNoFacts()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = new InMemorySignalsAdapter(_timeProvider);
|
||||
|
||||
// Act
|
||||
var metadata = await adapter.GetMetadataAsync("sha256:test", "tenant1", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
metadata.Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Licensed to StellaOps under the BUSL-1.1 license.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using StellaOps.Reachability.Core;
|
||||
using StellaOps.ReachGraph.Schema;
|
||||
using StellaOps.ReachGraph.WebService.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.ReachGraph.WebService.Tests;
|
||||
|
||||
public class ReachGraphStoreAdapterTests
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider = new(DateTimeOffset.UtcNow);
|
||||
private readonly InMemoryReachGraphStoreService _storeService = new();
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ReturnsNotReachable_WhenGraphNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = CreateAdapter();
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "System",
|
||||
TypeName = "String",
|
||||
MemberName = "Trim"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await adapter.QueryAsync(symbol, "sha256:notfound", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.IsReachable.Should().BeFalse();
|
||||
result.Symbol.Should().Be(symbol);
|
||||
result.ArtifactDigest.Should().Be("sha256:notfound");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ReturnsReachable_WhenSymbolFoundInGraph()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateTestGraph("sha256:test123");
|
||||
await _storeService.UpsertAsync(graph, "tenant1", CancellationToken.None);
|
||||
|
||||
var adapter = CreateAdapter();
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "MyApp",
|
||||
TypeName = "VulnerableClass",
|
||||
MemberName = "Execute"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await adapter.QueryAsync(symbol, "sha256:test123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.IsReachable.Should().BeTrue();
|
||||
result.DistanceFromEntrypoint.Should().BeGreaterThanOrEqualTo(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueryAsync_ReturnsNotReachable_WhenSymbolNotInGraph()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateTestGraph("sha256:test123");
|
||||
await _storeService.UpsertAsync(graph, "tenant1", CancellationToken.None);
|
||||
|
||||
var adapter = CreateAdapter();
|
||||
var symbol = new SymbolRef
|
||||
{
|
||||
Namespace = "NonExistent",
|
||||
TypeName = "Class",
|
||||
MemberName = "Method"
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await adapter.QueryAsync(symbol, "sha256:test123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.IsReachable.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasGraphAsync_ReturnsTrue_WhenGraphExists()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateTestGraph("sha256:exists123");
|
||||
await _storeService.UpsertAsync(graph, "tenant1", CancellationToken.None);
|
||||
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.HasGraphAsync("sha256:exists123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasGraphAsync_ReturnsFalse_WhenGraphNotExists()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
// Act
|
||||
var result = await adapter.HasGraphAsync("sha256:doesnotexist", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMetadataAsync_ReturnsMetadata_WhenGraphExists()
|
||||
{
|
||||
// Arrange
|
||||
var graph = CreateTestGraph("sha256:metadata123");
|
||||
await _storeService.UpsertAsync(graph, "tenant1", CancellationToken.None);
|
||||
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
// Act
|
||||
var metadata = await adapter.GetMetadataAsync("sha256:metadata123", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
metadata.Should().NotBeNull();
|
||||
metadata!.ArtifactDigest.Should().Be("sha256:metadata123");
|
||||
metadata.NodeCount.Should().BeGreaterThan(0);
|
||||
metadata.EdgeCount.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMetadataAsync_ReturnsNull_WhenGraphNotExists()
|
||||
{
|
||||
// Arrange
|
||||
var adapter = CreateAdapter();
|
||||
|
||||
// Act
|
||||
var metadata = await adapter.GetMetadataAsync("sha256:notfound", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
metadata.Should().BeNull();
|
||||
}
|
||||
|
||||
private ReachGraphStoreAdapter CreateAdapter()
|
||||
{
|
||||
return new ReachGraphStoreAdapter(
|
||||
_storeService,
|
||||
_timeProvider,
|
||||
NullLogger<ReachGraphStoreAdapter>.Instance);
|
||||
}
|
||||
|
||||
private static ReachGraphMinimal CreateTestGraph(string artifactDigest)
|
||||
{
|
||||
var entrypoint = new ReachGraphNode
|
||||
{
|
||||
Id = "entry-main",
|
||||
Ref = "MyApp.Program.Main",
|
||||
Kind = "method",
|
||||
Depth = 0
|
||||
};
|
||||
|
||||
var vulnerableClass = new ReachGraphNode
|
||||
{
|
||||
Id = "vulnerable-class",
|
||||
Ref = "MyApp.VulnerableClass.Execute",
|
||||
Kind = "method",
|
||||
Depth = 1
|
||||
};
|
||||
|
||||
var otherNode = new ReachGraphNode
|
||||
{
|
||||
Id = "other-node",
|
||||
Ref = "MyApp.OtherClass.DoWork",
|
||||
Kind = "method",
|
||||
Depth = 2
|
||||
};
|
||||
|
||||
var edges = ImmutableArray.Create(
|
||||
new ReachGraphEdge
|
||||
{
|
||||
Source = "entry-main",
|
||||
Target = "vulnerable-class"
|
||||
},
|
||||
new ReachGraphEdge
|
||||
{
|
||||
Source = "entry-main",
|
||||
Target = "other-node"
|
||||
});
|
||||
|
||||
return new ReachGraphMinimal
|
||||
{
|
||||
Artifact = new ReachGraphArtifact
|
||||
{
|
||||
Name = "test-artifact",
|
||||
Digest = artifactDigest,
|
||||
Env = "test"
|
||||
},
|
||||
Scope = new ReachGraphScope
|
||||
{
|
||||
Entrypoints = ImmutableArray.Create("entry-main"),
|
||||
Selectors = ImmutableArray<string>.Empty,
|
||||
Cves = null
|
||||
},
|
||||
Signature = null,
|
||||
Nodes = ImmutableArray.Create(entrypoint, vulnerableClass, otherNode),
|
||||
Edges = edges
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of IReachGraphStoreService for testing.
|
||||
/// </summary>
|
||||
internal sealed class InMemoryReachGraphStoreService : IReachGraphStoreService
|
||||
{
|
||||
private readonly Dictionary<string, ReachGraphMinimal> _graphs = new();
|
||||
|
||||
public Task<ReachGraphStoreResult> UpsertAsync(
|
||||
ReachGraphMinimal graph,
|
||||
string? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var digest = graph.Artifact.Digest;
|
||||
var created = !_graphs.ContainsKey(digest);
|
||||
_graphs[digest] = graph;
|
||||
|
||||
return Task.FromResult(new ReachGraphStoreResult
|
||||
{
|
||||
Digest = digest,
|
||||
ArtifactDigest = digest,
|
||||
Created = created,
|
||||
NodeCount = graph.Nodes.Length,
|
||||
EdgeCount = graph.Edges.Length,
|
||||
StoredAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
public Task<ReachGraphMinimal?> GetByDigestAsync(
|
||||
string digest,
|
||||
string? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_graphs.TryGetValue(digest, out var graph);
|
||||
return Task.FromResult(graph);
|
||||
}
|
||||
|
||||
public Task<ReachGraphMinimal?> GetByArtifactAsync(
|
||||
string artifactDigest,
|
||||
string? tenantId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var graph = _graphs.Values.FirstOrDefault(g => g.Artifact.Digest == artifactDigest);
|
||||
return Task.FromResult(graph);
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string digest, string? tenantId, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_graphs.ContainsKey(digest));
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string digest, string? tenantId, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_graphs.Remove(digest));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user