Resolve Concelier/Excititor merge conflicts

This commit is contained in:
root
2025-10-20 14:19:25 +03:00
2687 changed files with 212646 additions and 85913 deletions

View File

@@ -0,0 +1,112 @@
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.WebService.Diagnostics;
using StellaOps.Scanner.WebService.Options;
namespace StellaOps.Scanner.WebService.Endpoints;
internal static class HealthEndpoints
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public static void MapHealthEndpoints(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);
var group = endpoints.MapGroup("/");
group.MapGet("/healthz", HandleHealth)
.WithName("scanner.health")
.Produces<HealthDocument>(StatusCodes.Status200OK)
.AllowAnonymous();
group.MapGet("/readyz", HandleReady)
.WithName("scanner.ready")
.Produces<ReadyDocument>(StatusCodes.Status200OK)
.AllowAnonymous();
}
private static IResult HandleHealth(
ServiceStatus status,
IOptions<ScannerWebServiceOptions> options,
HttpContext context)
{
ApplyNoCache(context.Response);
var snapshot = status.CreateSnapshot();
var uptimeSeconds = Math.Max((snapshot.CapturedAt - snapshot.StartedAt).TotalSeconds, 0d);
var telemetry = new TelemetrySnapshot(
Enabled: options.Value.Telemetry.Enabled,
Logging: options.Value.Telemetry.EnableLogging,
Metrics: options.Value.Telemetry.EnableMetrics,
Tracing: options.Value.Telemetry.EnableTracing);
var document = new HealthDocument(
Status: "healthy",
StartedAt: snapshot.StartedAt,
CapturedAt: snapshot.CapturedAt,
UptimeSeconds: uptimeSeconds,
Telemetry: telemetry);
return Json(document, StatusCodes.Status200OK);
}
private static async Task<IResult> HandleReady(
ServiceStatus status,
HttpContext context,
CancellationToken cancellationToken)
{
ApplyNoCache(context.Response);
await Task.CompletedTask;
status.RecordReadyCheck(success: true, latency: TimeSpan.Zero, error: null);
var snapshot = status.CreateSnapshot();
var ready = snapshot.Ready;
var document = new ReadyDocument(
Status: ready.IsReady ? "ready" : "unready",
CheckedAt: ready.CheckedAt,
LatencyMs: ready.Latency?.TotalMilliseconds,
Error: ready.Error);
return Json(document, StatusCodes.Status200OK);
}
private static void ApplyNoCache(HttpResponse response)
{
response.Headers.CacheControl = "no-store, no-cache, max-age=0, must-revalidate";
response.Headers.Pragma = "no-cache";
response.Headers["Expires"] = "0";
}
private static IResult Json<T>(T value, int statusCode)
{
var payload = JsonSerializer.Serialize(value, JsonOptions);
return Results.Content(payload, "application/json", Encoding.UTF8, statusCode);
}
internal sealed record TelemetrySnapshot(
bool Enabled,
bool Logging,
bool Metrics,
bool Tracing);
internal sealed record HealthDocument(
string Status,
DateTimeOffset StartedAt,
DateTimeOffset CapturedAt,
double UptimeSeconds,
TelemetrySnapshot Telemetry);
internal sealed record ReadyDocument(
string Status,
DateTimeOffset CheckedAt,
double? LatencyMs,
string? Error);
}

View File

@@ -0,0 +1,175 @@
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Policy;
using StellaOps.Scanner.WebService.Constants;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
namespace StellaOps.Scanner.WebService.Endpoints;
internal static class PolicyEndpoints
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static void MapPolicyEndpoints(this RouteGroupBuilder apiGroup, string policySegment)
{
ArgumentNullException.ThrowIfNull(apiGroup);
var policyGroup = apiGroup
.MapGroup(NormalizeSegment(policySegment))
.WithTags("Policy");
policyGroup.MapGet("/schema", HandleSchemaAsync)
.WithName("scanner.policy.schema")
.Produces(StatusCodes.Status200OK)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Retrieve the embedded policy JSON schema.";
operation.Description = "Returns the policy schema (`policy-schema@1`) used to validate YAML or JSON rulesets.";
return operation;
});
policyGroup.MapPost("/diagnostics", HandleDiagnosticsAsync)
.WithName("scanner.policy.diagnostics")
.Produces<PolicyDiagnosticsResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Run policy diagnostics.";
operation.Description = "Accepts YAML or JSON policy content and returns normalization issues plus recommendations (ignore rules, VEX include/exclude, vendor precedence).";
return operation;
});
policyGroup.MapPost("/preview", HandlePreviewAsync)
.WithName("scanner.policy.preview")
.Produces<PolicyPreviewResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Preview policy impact against findings.";
operation.Description = "Evaluates the supplied findings against the active or proposed policy, returning diffs, quieted verdicts, and actionable validation messages.";
return operation;
});
}
private static IResult HandleSchemaAsync(HttpContext context)
{
var schema = PolicySchemaResource.ReadSchemaJson();
return Results.Text(schema, "application/schema+json", Encoding.UTF8);
}
private static IResult HandleDiagnosticsAsync(
PolicyDiagnosticsRequestDto request,
TimeProvider timeProvider,
HttpContext context)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(timeProvider);
if (request.Policy is null || string.IsNullOrWhiteSpace(request.Policy.Content))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy diagnostics request",
StatusCodes.Status400BadRequest,
detail: "Policy content is required for diagnostics.");
}
var format = PolicyDtoMapper.ParsePolicyFormat(request.Policy.Format);
var binding = PolicyBinder.Bind(request.Policy.Content, format);
var diagnostics = PolicyDiagnostics.Create(binding, timeProvider);
var response = new PolicyDiagnosticsResponseDto
{
Success = diagnostics.ErrorCount == 0,
Version = diagnostics.Version,
RuleCount = diagnostics.RuleCount,
ErrorCount = diagnostics.ErrorCount,
WarningCount = diagnostics.WarningCount,
GeneratedAt = diagnostics.GeneratedAt,
Issues = diagnostics.Issues.Select(PolicyDtoMapper.ToIssueDto).ToImmutableArray(),
Recommendations = diagnostics.Recommendations
};
return Json(response);
}
private static async Task<IResult> HandlePreviewAsync(
PolicyPreviewRequestDto request,
PolicyPreviewService previewService,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(previewService);
if (string.IsNullOrWhiteSpace(request.ImageDigest))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy preview request",
StatusCodes.Status400BadRequest,
detail: "imageDigest is required.");
}
if (!request.ImageDigest.Contains(':', StringComparison.Ordinal))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy preview request",
StatusCodes.Status400BadRequest,
detail: "imageDigest must include algorithm prefix (e.g. sha256:...).");
}
if (request.Findings is not null)
{
var missingIds = request.Findings.Any(f => string.IsNullOrWhiteSpace(f.Id));
if (missingIds)
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid policy preview request",
StatusCodes.Status400BadRequest,
detail: "All findings must include an id value.");
}
}
var domainRequest = PolicyDtoMapper.ToDomain(request);
var response = await previewService.PreviewAsync(domainRequest, cancellationToken).ConfigureAwait(false);
var payload = PolicyDtoMapper.ToDto(response);
return Json(payload);
}
private static string NormalizeSegment(string segment)
{
if (string.IsNullOrWhiteSpace(segment))
{
return "/policy";
}
var trimmed = segment.Trim('/');
return "/" + trimmed;
}
private static IResult Json<T>(T value)
{
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", Encoding.UTF8);
}
}

View File

@@ -0,0 +1,266 @@
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Policy;
using StellaOps.Scanner.WebService.Constants;
using StellaOps.Scanner.WebService.Contracts;
using StellaOps.Scanner.WebService.Infrastructure;
using StellaOps.Scanner.WebService.Security;
using StellaOps.Scanner.WebService.Services;
namespace StellaOps.Scanner.WebService.Endpoints;
internal static class ReportEndpoints
{
private const string PayloadType = "application/vnd.stellaops.report+json";
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
public static void MapReportEndpoints(this RouteGroupBuilder apiGroup, string reportsSegment)
{
ArgumentNullException.ThrowIfNull(apiGroup);
var reports = apiGroup
.MapGroup(NormalizeSegment(reportsSegment))
.WithTags("Reports");
reports.MapPost("/", HandleCreateReportAsync)
.WithName("scanner.reports.create")
.Produces<ReportResponseDto>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status503ServiceUnavailable)
.RequireAuthorization(ScannerPolicies.Reports)
.WithOpenApi(operation =>
{
operation.Summary = "Assemble a signed scan report.";
operation.Description = "Aggregates latest findings with the active policy snapshot, returning verdicts plus an optional DSSE envelope.";
return operation;
});
}
private static async Task<IResult> HandleCreateReportAsync(
ReportRequestDto request,
PolicyPreviewService previewService,
IReportSigner signer,
TimeProvider timeProvider,
IReportEventDispatcher eventDispatcher,
HttpContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentNullException.ThrowIfNull(previewService);
ArgumentNullException.ThrowIfNull(signer);
ArgumentNullException.ThrowIfNull(timeProvider);
ArgumentNullException.ThrowIfNull(eventDispatcher);
if (string.IsNullOrWhiteSpace(request.ImageDigest))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid report request",
StatusCodes.Status400BadRequest,
detail: "imageDigest is required.");
}
if (!request.ImageDigest.Contains(':', StringComparison.Ordinal))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid report request",
StatusCodes.Status400BadRequest,
detail: "imageDigest must include algorithm prefix (e.g. sha256:...).");
}
if (request.Findings is not null && request.Findings.Any(f => string.IsNullOrWhiteSpace(f.Id)))
{
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Invalid report request",
StatusCodes.Status400BadRequest,
detail: "All findings must include an id value.");
}
var previewDto = new PolicyPreviewRequestDto
{
ImageDigest = request.ImageDigest,
Findings = request.Findings,
Baseline = request.Baseline,
Policy = null
};
var domainRequest = PolicyDtoMapper.ToDomain(previewDto) with { ProposedPolicy = null };
var preview = await previewService.PreviewAsync(domainRequest, cancellationToken).ConfigureAwait(false);
if (!preview.Success)
{
var issues = preview.Issues.Select(PolicyDtoMapper.ToIssueDto).ToArray();
var extensions = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["issues"] = issues
};
return ProblemResultFactory.Create(
context,
ProblemTypes.Validation,
"Unable to assemble report",
StatusCodes.Status503ServiceUnavailable,
detail: "No policy snapshot is available or validation failed.",
extensions: extensions);
}
var projectedVerdicts = preview.Diffs
.Select(diff => PolicyDtoMapper.ToVerdictDto(diff.Projected))
.ToArray();
var issuesDto = preview.Issues.Select(PolicyDtoMapper.ToIssueDto).ToArray();
var summary = BuildSummary(projectedVerdicts);
var verdict = ComputeVerdict(projectedVerdicts);
var reportId = CreateReportId(request.ImageDigest!, preview.PolicyDigest);
var generatedAt = timeProvider.GetUtcNow();
var document = new ReportDocumentDto
{
ReportId = reportId,
ImageDigest = request.ImageDigest!,
GeneratedAt = generatedAt,
Verdict = verdict,
Policy = new ReportPolicyDto
{
RevisionId = preview.RevisionId,
Digest = preview.PolicyDigest
},
Summary = summary,
Verdicts = projectedVerdicts,
Issues = issuesDto
};
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(document, SerializerOptions);
var signature = signer.Sign(payloadBytes);
DsseEnvelopeDto? envelope = null;
if (signature is not null)
{
envelope = new DsseEnvelopeDto
{
PayloadType = PayloadType,
Payload = Convert.ToBase64String(payloadBytes),
Signatures = new[]
{
new DsseSignatureDto
{
KeyId = signature.KeyId,
Algorithm = signature.Algorithm,
Signature = signature.Signature
}
}
};
}
var response = new ReportResponseDto
{
Report = document,
Dsse = envelope
};
await eventDispatcher
.PublishAsync(request, preview, document, envelope, context, cancellationToken)
.ConfigureAwait(false);
return Json(response);
}
private static ReportSummaryDto BuildSummary(IReadOnlyList<PolicyPreviewVerdictDto> verdicts)
{
if (verdicts.Count == 0)
{
return new ReportSummaryDto { Total = 0 };
}
var blocked = verdicts.Count(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Blocked), StringComparison.OrdinalIgnoreCase));
var warned = verdicts.Count(v =>
string.Equals(v.Status, nameof(PolicyVerdictStatus.Warned), StringComparison.OrdinalIgnoreCase)
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.Deferred), StringComparison.OrdinalIgnoreCase)
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.RequiresVex), StringComparison.OrdinalIgnoreCase)
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.Escalated), StringComparison.OrdinalIgnoreCase));
var ignored = verdicts.Count(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Ignored), StringComparison.OrdinalIgnoreCase));
var quieted = verdicts.Count(v => v.Quiet is true);
return new ReportSummaryDto
{
Total = verdicts.Count,
Blocked = blocked,
Warned = warned,
Ignored = ignored,
Quieted = quieted
};
}
private static string ComputeVerdict(IReadOnlyList<PolicyPreviewVerdictDto> verdicts)
{
if (verdicts.Count == 0)
{
return "unknown";
}
if (verdicts.Any(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Blocked), StringComparison.OrdinalIgnoreCase)))
{
return "blocked";
}
if (verdicts.Any(v => string.Equals(v.Status, nameof(PolicyVerdictStatus.Escalated), StringComparison.OrdinalIgnoreCase)))
{
return "escalated";
}
if (verdicts.Any(v =>
string.Equals(v.Status, nameof(PolicyVerdictStatus.Warned), StringComparison.OrdinalIgnoreCase)
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.Deferred), StringComparison.OrdinalIgnoreCase)
|| string.Equals(v.Status, nameof(PolicyVerdictStatus.RequiresVex), StringComparison.OrdinalIgnoreCase)))
{
return "warn";
}
return "pass";
}
private static string CreateReportId(string imageDigest, string policyDigest)
{
var builder = new StringBuilder();
builder.Append(imageDigest.Trim());
builder.Append('|');
builder.Append(policyDigest ?? string.Empty);
using var sha256 = SHA256.Create();
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(builder.ToString()));
var hex = Convert.ToHexString(hash.AsSpan(0, 10)).ToLowerInvariant();
return $"report-{hex}";
}
private static string NormalizeSegment(string segment)
{
if (string.IsNullOrWhiteSpace(segment))
{
return "/reports";
}
var trimmed = segment.Trim('/');
return "/" + trimmed;
}
private static IResult Json<T>(T value)
{
var payload = JsonSerializer.Serialize(value, SerializerOptions);
return Results.Content(payload, "application/json", Encoding.UTF8);
}
}

View File

@@ -0,0 +1,309 @@
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<ScanSubmitResponse>(StatusCodes.Status202Accepted)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status409Conflict)
.RequireAuthorization(ScannerPolicies.ScansEnqueue);
scans.MapGet("/{scanId}", HandleStatusAsync)
.WithName("scanner.scans.status")
.Produces<ScanStatusResponse>(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<IResult> 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<IResult> 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<IResult> 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<string, string> NormalizeMetadata(IDictionary<string, string> metadata)
{
if (metadata is null || metadata.Count == 0)
{
return new Dictionary<string, string>();
}
var normalized = new Dictionary<string, string>(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>(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;
}
}