diff --git a/services/domain/Big.Api/Program.cs b/services/domain/Big.Api/Program.cs index 466ce1d..f4a21f0 100644 --- a/services/domain/Big.Api/Program.cs +++ b/services/domain/Big.Api/Program.cs @@ -37,6 +37,10 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService() builder.Services.AddSingleton(sp => sp.GetRequiredService() .GetSection("Acl").Get() ?? throw new InvalidOperationException("Missing configuration section 'Acl'")); +// clamd defaults to the compose/chart service name; ClamAv__* overrides it (S-29, ADR-0036). +builder.Services.AddSingleton(sp => sp.GetRequiredService() + .GetSection("ClamAv").Get() ?? new ClamAvOptions()); +builder.Services.AddSingleton(); // The in-memory registration store is shared between the submit endpoint and the worker (ADR-0009). builder.Services.AddSingleton(); @@ -158,8 +162,8 @@ app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRequest bo // Provide documents (S-10a): the zorgprofessional supplies the documents their registration is parked // waiting for, completing the WachtOpDocumenten task so the process advances to beoordeling (ADR-0017). // Owner-scoped by the caller's bsn (the BFF forwards it from the DigiD token); unknown or not-the- -// caller's is 404 (indistinguishable). Idempotent — completing an already-left wait is a no-op. The -// real file upload + ZGW storage is S-10b; this endpoint is the trigger that unblocks the process. +// caller's is 404 (indistinguishable). Idempotent — completing an already-left wait is a no-op. Only a +// PDF that clamd scans clean is stored and unblocks the process (S-29). app.MapPost("/registrations/{id}/documents", async (string id, ProvideDocumentsRequest body, ProvideDocuments provide, CancellationToken ct) => { if (!Guid.TryParse(id, out var guid)) @@ -177,8 +181,16 @@ app.MapPost("/registrations/{id}/documents", async (string id, ProvideDocumentsR var command = new ProvideDocumentsCommand( new RegistrationId(guid), body.Bsn, content, body.FileName ?? "diploma.pdf", body.ContentType ?? "application/pdf"); - var outcome = await provide.HandleAsync(command, ct); - return outcome == ProvideDocumentsOutcome.Accepted ? Results.NoContent() : Results.NotFound(); + // A refused file is 422 with a machine-readable reason the portal words for the citizen; an + // unreachable scanner is 503 — retryable, and nothing was stored (S-29, ADR-0036). + return await provide.HandleAsync(command, ct) switch + { + ProvideDocumentsOutcome.Accepted => Results.NoContent(), + ProvideDocumentsOutcome.NotAPdf => Results.UnprocessableEntity(new { reason = "not-a-pdf" }), + ProvideDocumentsOutcome.Infected => Results.UnprocessableEntity(new { reason = "infected" }), + ProvideDocumentsOutcome.ScannerUnavailable => Results.StatusCode(StatusCodes.Status503ServiceUnavailable), + _ => Results.NotFound(), + }; }); // The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open diff --git a/services/domain/Big.Infrastructure/ClamdDocumentScanner.cs b/services/domain/Big.Infrastructure/ClamdDocumentScanner.cs index bc6fdc3..fe7666a 100644 --- a/services/domain/Big.Infrastructure/ClamdDocumentScanner.cs +++ b/services/domain/Big.Infrastructure/ClamdDocumentScanner.cs @@ -1,10 +1,48 @@ +using System.Buffers.Binary; +using System.Net.Sockets; +using System.Text; using Big.Application; namespace Big.Infrastructure; -/// Scans a document with clamd over its INSTREAM protocol (S-29, ADR-0036). +/// +/// Scans a document with clamd over its INSTREAM protocol (S-29, ADR-0036): zINSTREAM\0, the +/// document as one big-endian length-prefixed chunk, a zero-length terminator, then one reply — +/// stream: OK or stream: <signature> FOUND. Anything else (an ERROR reply, a refused +/// connection, a timeout) is , so the caller fails closed. +/// public sealed class ClamdDocumentScanner(ClamAvOptions options) : IDocumentScanner { - public Task ScanAsync(byte[] content, CancellationToken ct = default) - => Task.FromResult(ScanVerdict.Clean); + public async Task ScanAsync(byte[] content, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(content); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeout.CancelAfter(options.Timeout); + + string reply; + try + { + using var client = new TcpClient(); + await client.ConnectAsync(options.Host, options.Port, timeout.Token); + var stream = client.GetStream(); + + var length = new byte[4]; + BinaryPrimitives.WriteInt32BigEndian(length, content.Length); + await stream.WriteAsync("zINSTREAM\0"u8.ToArray(), timeout.Token); + await stream.WriteAsync(length, timeout.Token); + await stream.WriteAsync(content, timeout.Token); + await stream.WriteAsync(new byte[4], timeout.Token); + + using var reader = new StreamReader(stream, Encoding.ASCII); + reply = (await reader.ReadToEndAsync(timeout.Token)).TrimEnd('\0', '\n'); + } + catch (Exception e) when (e is SocketException or IOException + || (e is OperationCanceledException && !ct.IsCancellationRequested)) + { + return ScanVerdict.Unavailable; + } + + if (reply == "stream: OK") return ScanVerdict.Clean; + return reply.EndsWith(" FOUND", StringComparison.Ordinal) ? ScanVerdict.Infected : ScanVerdict.Unavailable; + } }