Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.WebService/Endpoints/CallGraphEndpoints.cs
master 4391f35d8a Refactor SurfaceCacheValidator to simplify oldest entry calculation
Add global using for Xunit in test project

Enhance ImportValidatorTests with async validation and quarantine checks

Implement FileSystemQuarantineServiceTests for quarantine functionality

Add integration tests for ImportValidator to check monotonicity

Create BundleVersionTests to validate version parsing and comparison logic

Implement VersionMonotonicityCheckerTests for monotonicity checks and activation logic
2025-12-16 10:44:00 +02:00

245 lines
8.8 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Scanner.WebService.Constants;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Domain;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
namespace StellaOps.Scanner.WebService.Endpoints;
internal static class CallGraphEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
private static readonly HashSet<string> SupportedLanguages = new(StringComparer.OrdinalIgnoreCase)
{
"dotnet", "java", "node", "python", "go", "rust", "binary", "ruby", "php"
};
public static void MapCallGraphEndpoints(this RouteGroupBuilder scansGroup)
{
ArgumentNullException.ThrowIfNull(scansGroup);
// POST /scans/{scanId}/callgraphs
scansGroup.MapPost("/{scanId}/callgraphs", HandleSubmitCallGraphAsync)
.WithName("scanner.scans.callgraphs.submit")
.WithTags("CallGraphs")
.Produces<CallGraphAcceptedResponseDto>(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status413PayloadTooLarge)
.RequireAuthorization(ScannerPolicies.CallGraphIngest);
}
private static async Task<IResult> HandleSubmitCallGraphAsync(
string scanId,
CallGraphV1Dto request,
IScanCoordinator coordinator,
ICallGraphIngestionService ingestionService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(coordinator);
ArgumentNullException.ThrowIfNull(ingestionService);
ArgumentNullException.ThrowIfNull(request);
if (!ScanId.TryParse(scanId, out var parsed))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid scan identifier",
StatusCodes.Status400BadRequest,
detail: "Scan identifier is required.");
}
// Validate Content-Digest header for idempotency
var contentDigest = context.Request.Headers["Content-Digest"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(contentDigest))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Missing Content-Digest header",
StatusCodes.Status400BadRequest,
detail: "Content-Digest header is required for idempotent call graph submission.");
}
// Verify scan exists
var snapshot = await coordinator.GetAsync(parsed, cancellationToken).ConfigureAwait(false);
if (snapshot is null)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.NotFound,
"Scan not found",
StatusCodes.Status404NotFound,
detail: "Requested scan could not be located.");
}
// Validate call graph schema
var validationResult = ValidateCallGraph(request);
if (!validationResult.IsValid)
{
var extensions = new Dictionary<string, object?>
{
["errors"] = validationResult.Errors
};
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid call graph",
StatusCodes.Status400BadRequest,
detail: "Call graph validation failed.",
extensions: extensions);
}
// Check for duplicate submission (idempotency)
var existing = await ingestionService.FindByDigestAsync(parsed, contentDigest, cancellationToken)
.ConfigureAwait(false);
if (existing is not null)
{
var conflictExtensions = new Dictionary<string, object?>
{
["callgraphId"] = existing.Id,
["digest"] = existing.Digest
};
return ProblemResultFactory.Create(
context,
ProblemTypes.Conflict,
"Duplicate call graph",
StatusCodes.Status409Conflict,
detail: "Call graph with this Content-Digest already submitted.",
extensions: conflictExtensions);
}
// Ingest the call graph
var result = await ingestionService.IngestAsync(parsed, request, contentDigest, cancellationToken)
.ConfigureAwait(false);
var response = new CallGraphAcceptedResponseDto(
CallgraphId: result.CallgraphId,
NodeCount: result.NodeCount,
EdgeCount: result.EdgeCount,
Digest: result.Digest);
context.Response.Headers.Location = $"/api/scans/{scanId}/callgraphs/{result.CallgraphId}";
return Json(response, StatusCodes.Status202Accepted);
}
private static CallGraphValidationResult ValidateCallGraph(CallGraphV1Dto callGraph)
{
var errors = new List<string>();
// Validate schema version
if (string.IsNullOrWhiteSpace(callGraph.Schema))
{
errors.Add("Schema version is required.");
}
else if (!string.Equals(callGraph.Schema, "stella.callgraph.v1", StringComparison.Ordinal))
{
errors.Add($"Unsupported schema '{callGraph.Schema}'. Expected 'stella.callgraph.v1'.");
}
// Validate scan key
if (string.IsNullOrWhiteSpace(callGraph.ScanKey))
{
errors.Add("ScanKey is required.");
}
// Validate language
if (string.IsNullOrWhiteSpace(callGraph.Language))
{
errors.Add("Language is required.");
}
else if (!SupportedLanguages.Contains(callGraph.Language))
{
errors.Add($"Unsupported language '{callGraph.Language}'. Supported: {string.Join(", ", SupportedLanguages)}.");
}
// Validate nodes
if (callGraph.Nodes is null || callGraph.Nodes.Count == 0)
{
errors.Add("At least one node is required.");
}
else
{
var nodeIds = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < callGraph.Nodes.Count; i++)
{
var node = callGraph.Nodes[i];
if (string.IsNullOrWhiteSpace(node.NodeId))
{
errors.Add($"nodes[{i}].nodeId is required.");
}
else if (!nodeIds.Add(node.NodeId))
{
errors.Add($"Duplicate nodeId '{node.NodeId}'.");
}
if (string.IsNullOrWhiteSpace(node.SymbolKey))
{
errors.Add($"nodes[{i}].symbolKey is required.");
}
}
}
// Validate edges
if (callGraph.Edges is null || callGraph.Edges.Count == 0)
{
errors.Add("At least one edge is required.");
}
else
{
var nodeIds = callGraph.Nodes?
.Where(n => !string.IsNullOrWhiteSpace(n.NodeId))
.Select(n => n.NodeId)
.ToHashSet(StringComparer.Ordinal) ?? new HashSet<string>();
for (var i = 0; i < callGraph.Edges.Count; i++)
{
var edge = callGraph.Edges[i];
if (string.IsNullOrWhiteSpace(edge.From))
{
errors.Add($"edges[{i}].from is required.");
}
else if (nodeIds.Count > 0 && !nodeIds.Contains(edge.From))
{
errors.Add($"edges[{i}].from references unknown node '{edge.From}'.");
}
if (string.IsNullOrWhiteSpace(edge.To))
{
errors.Add($"edges[{i}].to is required.");
}
else if (nodeIds.Count > 0 && !nodeIds.Contains(edge.To))
{
errors.Add($"edges[{i}].to references unknown node '{edge.To}'.");
}
}
}
return errors.Count > 0
? CallGraphValidationResult.Failure(errors.ToArray())
: CallGraphValidationResult.Success();
}
private static IResult Json<T>(T value, int statusCode)
{
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", System.Text.Encoding.UTF8, statusCode);
}
}