using System.Collections.Generic; using System.IO.Pipelines; using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; using System.Text; 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 ScanEndpoints { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } }; public static void MapScanEndpoints(this RouteGroupBuilder apiGroup, string scansSegment) { ArgumentNullException.ThrowIfNull(apiGroup); var scans = apiGroup.MapGroup(NormalizeSegment(scansSegment)); scans.MapPost("/", HandleSubmitAsync) .WithName("scanner.scans.submit") .Produces(StatusCodes.Status202Accepted) .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status409Conflict) .RequireAuthorization(ScannerPolicies.ScansEnqueue); scans.MapGet("/{scanId}", HandleStatusAsync) .WithName("scanner.scans.status") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); scans.MapGet("/{scanId}/events", HandleProgressStreamAsync) .WithName("scanner.scans.events") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status404NotFound) .RequireAuthorization(ScannerPolicies.ScansRead); } private static async Task HandleSubmitAsync( ScanSubmitRequest request, IScanCoordinator coordinator, LinkGenerator links, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(coordinator); ArgumentNullException.ThrowIfNull(links); if (request.Image is null) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid scan submission", StatusCodes.Status400BadRequest, detail: "Request image descriptor is required."); } var reference = request.Image.Reference; var digest = request.Image.Digest; if (string.IsNullOrWhiteSpace(reference) && string.IsNullOrWhiteSpace(digest)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid scan submission", StatusCodes.Status400BadRequest, detail: "Either image.reference or image.digest must be provided."); } if (!string.IsNullOrWhiteSpace(digest) && !digest.Contains(':', StringComparison.Ordinal)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid scan submission", StatusCodes.Status400BadRequest, detail: "Image digest must include algorithm prefix (e.g. sha256:...)."); } var target = new ScanTarget(reference, digest).Normalize(); var metadata = NormalizeMetadata(request.Metadata); var submission = new ScanSubmission( Target: target, Force: request.Force, ClientRequestId: request.ClientRequestId?.Trim(), Metadata: metadata); ScanSubmissionResult result; try { result = await coordinator.SubmitAsync(submission, context.RequestAborted).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } var statusText = result.Snapshot.Status.ToString(); var location = links.GetPathByName( httpContext: context, endpointName: "scanner.scans.status", values: new { scanId = result.Snapshot.ScanId.Value }); if (!string.IsNullOrWhiteSpace(location)) { context.Response.Headers.Location = location; } var response = new ScanSubmitResponse( ScanId: result.Snapshot.ScanId.Value, Status: statusText, Location: location, Created: result.Created); return Json(response, StatusCodes.Status202Accepted); } private static async Task HandleStatusAsync( string scanId, IScanCoordinator coordinator, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(coordinator); if (!ScanId.TryParse(scanId, out var parsed)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid scan identifier", StatusCodes.Status400BadRequest, detail: "Scan identifier is required."); } var snapshot = await coordinator.GetAsync(parsed, context.RequestAborted).ConfigureAwait(false); if (snapshot is null) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Scan not found", StatusCodes.Status404NotFound, detail: "Requested scan could not be located."); } var response = new ScanStatusResponse( ScanId: snapshot.ScanId.Value, Status: snapshot.Status.ToString(), Image: new ScanStatusTarget(snapshot.Target.Reference, snapshot.Target.Digest), CreatedAt: snapshot.CreatedAt, UpdatedAt: snapshot.UpdatedAt, FailureReason: snapshot.FailureReason); return Json(response, StatusCodes.Status200OK); } private static async Task HandleProgressStreamAsync( string scanId, string? format, IScanProgressReader progressReader, HttpContext context, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(progressReader); if (!ScanId.TryParse(scanId, out var parsed)) { return ProblemResultFactory.Create( context, ProblemTypes.Validation, "Invalid scan identifier", StatusCodes.Status400BadRequest, detail: "Scan identifier is required."); } if (!progressReader.Exists(parsed)) { return ProblemResultFactory.Create( context, ProblemTypes.NotFound, "Scan not found", StatusCodes.Status404NotFound, detail: "Requested scan could not be located."); } var streamFormat = string.Equals(format, "jsonl", StringComparison.OrdinalIgnoreCase) ? "jsonl" : "sse"; context.Response.StatusCode = StatusCodes.Status200OK; context.Response.Headers.CacheControl = "no-store"; context.Response.Headers["X-Accel-Buffering"] = "no"; context.Response.Headers["Connection"] = "keep-alive"; if (streamFormat == "jsonl") { context.Response.ContentType = "application/x-ndjson"; } else { context.Response.ContentType = "text/event-stream"; } await foreach (var progressEvent in progressReader.SubscribeAsync(parsed, context.RequestAborted).WithCancellation(context.RequestAborted)) { var payload = new { scanId = progressEvent.ScanId.Value, sequence = progressEvent.Sequence, state = progressEvent.State, message = progressEvent.Message, timestamp = progressEvent.Timestamp, correlationId = progressEvent.CorrelationId, data = progressEvent.Data }; if (streamFormat == "jsonl") { await WriteJsonLineAsync(context.Response.BodyWriter, payload, cancellationToken).ConfigureAwait(false); } else { await WriteSseAsync(context.Response.BodyWriter, payload, progressEvent, cancellationToken).ConfigureAwait(false); } await context.Response.BodyWriter.FlushAsync(cancellationToken).ConfigureAwait(false); } return Results.Empty; } private static IReadOnlyDictionary NormalizeMetadata(IDictionary metadata) { if (metadata is null || metadata.Count == 0) { return new Dictionary(); } var normalized = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var pair in metadata) { if (string.IsNullOrWhiteSpace(pair.Key)) { continue; } var key = pair.Key.Trim(); var value = pair.Value?.Trim() ?? string.Empty; normalized[key] = value; } return normalized; } private static async Task WriteJsonLineAsync(PipeWriter writer, object payload, CancellationToken cancellationToken) { var json = JsonSerializer.Serialize(payload, SerializerOptions); var jsonBytes = Encoding.UTF8.GetBytes(json); await writer.WriteAsync(jsonBytes, cancellationToken).ConfigureAwait(false); await writer.WriteAsync(new[] { (byte)'\n' }, cancellationToken).ConfigureAwait(false); } private static async Task WriteSseAsync(PipeWriter writer, object payload, ScanProgressEvent progressEvent, CancellationToken cancellationToken) { var json = JsonSerializer.Serialize(payload, SerializerOptions); var eventName = progressEvent.State.ToLowerInvariant(); var builder = new StringBuilder(); builder.Append("id: ").Append(progressEvent.Sequence).Append('\n'); builder.Append("event: ").Append(eventName).Append('\n'); builder.Append("data: ").Append(json).Append('\n'); builder.Append('\n'); var bytes = Encoding.UTF8.GetBytes(builder.ToString()); await writer.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); } private static IResult Json(T value, int statusCode) { var payload = JsonSerializer.Serialize(value, SerializerOptions); return Results.Content(payload, "application/json", System.Text.Encoding.UTF8, statusCode); } private static string NormalizeSegment(string segment) { if (string.IsNullOrWhiteSpace(segment)) { return "/scans"; } var trimmed = segment.Trim('/'); return "/" + trimmed; } }