diff --git a/services/domain/Big.Infrastructure/ClamdDocumentScanner.cs b/services/domain/Big.Infrastructure/ClamdDocumentScanner.cs new file mode 100644 index 0000000..bc6fdc3 --- /dev/null +++ b/services/domain/Big.Infrastructure/ClamdDocumentScanner.cs @@ -0,0 +1,10 @@ +using Big.Application; + +namespace Big.Infrastructure; + +/// Scans a document with clamd over its INSTREAM protocol (S-29, ADR-0036). +public sealed class ClamdDocumentScanner(ClamAvOptions options) : IDocumentScanner +{ + public Task ScanAsync(byte[] content, CancellationToken ct = default) + => Task.FromResult(ScanVerdict.Clean); +} diff --git a/services/domain/Big.Infrastructure/Options.cs b/services/domain/Big.Infrastructure/Options.cs index ccbe7b1..73a1cfc 100644 --- a/services/domain/Big.Infrastructure/Options.cs +++ b/services/domain/Big.Infrastructure/Options.cs @@ -27,3 +27,12 @@ public sealed class AclOptions { public Uri BaseUrl { get; set; } = null!; } + +/// Where clamd listens (S-29, ADR-0036). bounds one whole scan; a scan that +/// takes longer counts as the scanner being unavailable. +public sealed class ClamAvOptions +{ + public string Host { get; set; } = "clamav"; + public int Port { get; set; } = 3310; + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); +} diff --git a/services/domain/Big.Tests/ClamdDocumentScannerTests.cs b/services/domain/Big.Tests/ClamdDocumentScannerTests.cs new file mode 100644 index 0000000..8ee42a7 --- /dev/null +++ b/services/domain/Big.Tests/ClamdDocumentScannerTests.cs @@ -0,0 +1,85 @@ +using System.Net; +using System.Net.Sockets; +using Big.Application; +using Big.Infrastructure; + +namespace Big.Tests; + +// S-29 (#192, ADR-0036): the clamd INSTREAM adapter, against a fake clamd on a loopback socket. The live +// engine (EICAR → FOUND) is verified by verify-clamav. +public class ClamdDocumentScannerTests +{ + /// A one-shot fake clamd: reads one INSTREAM request to its zero-length terminator, + /// records it, and answers . + private sealed class FakeClamd : IDisposable + { + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + public Task Received { get; } + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + + public FakeClamd(string reply) + { + _listener.Start(); + Received = Serve(reply); + } + + private async Task Serve(string reply) + { + using var client = await _listener.AcceptTcpClientAsync(); + var stream = client.GetStream(); + var received = new MemoryStream(); + var buffer = new byte[4096]; + while (!EndsWithTerminator(received)) + { + var n = await stream.ReadAsync(buffer); + if (n == 0) break; + received.Write(buffer, 0, n); + } + await stream.WriteAsync(System.Text.Encoding.ASCII.GetBytes(reply + "\0")); + return received.ToArray(); + } + + // The request is "zINSTREAM\0" + chunks + a 4-byte zero length; it is complete once it ends in it. + private static bool EndsWithTerminator(MemoryStream s) + => s.Length > 14 && s.ToArray()[^4..].All(b => b == 0); + + public void Dispose() => _listener.Stop(); + } + + private static ClamdDocumentScanner ScannerFor(int port) => + new(new ClamAvOptions { Host = "127.0.0.1", Port = port, Timeout = TimeSpan.FromSeconds(5) }); + + [Fact] + public async Task Sends_the_document_as_one_length_prefixed_instream_chunk() + { + using var clamd = new FakeClamd("stream: OK"); + + await ScannerFor(clamd.Port).ScanAsync([1, 2, 3]); + + Assert.Equal("zINSTREAM\0"u8.ToArray().Concat(new byte[] { 0, 0, 0, 3, 1, 2, 3, 0, 0, 0, 0 }), + await clamd.Received.WaitAsync(TimeSpan.FromSeconds(5))); + } + + [Theory] + [InlineData("stream: OK", ScanVerdict.Clean)] + [InlineData("stream: Eicar-Test-Signature FOUND", ScanVerdict.Infected)] + [InlineData("INSTREAM size limit exceeded. ERROR", ScanVerdict.Unavailable)] + public async Task Maps_the_clamd_reply_to_a_verdict(string reply, ScanVerdict expected) + { + using var clamd = new FakeClamd(reply); + + Assert.Equal(expected, await ScannerFor(clamd.Port).ScanAsync([1, 2, 3])); + } + + [Fact] + public async Task An_unreachable_clamd_is_unavailable_not_an_exception() + { + // Grab a free port, then close it, so nothing listens there. + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + + Assert.Equal(ScanVerdict.Unavailable, await ScannerFor(port).ScanAsync([1, 2, 3])); + } +}