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; /// The answer, or null to accept the request and never answer (a hung clamd). 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); } if (reply is null) await Task.Delay(TimeSpan.FromSeconds(10)); // long past any test timeout else 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])); } [Fact] public async Task A_clamd_that_never_answers_is_unavailable_after_the_timeout() { using var clamd = new FakeClamd(reply: null); var scanner = new ClamdDocumentScanner( new ClamAvOptions { Host = "127.0.0.1", Port = clamd.Port, Timeout = TimeSpan.FromMilliseconds(300) }); Assert.Equal(ScanVerdict.Unavailable, await scanner.ScanAsync([1, 2, 3])); } [Fact] public async Task A_cancelled_request_is_cancelled_not_reported_unavailable() { // The caller giving up is not a scanner outage: it propagates instead of becoming a 503. using var clamd = new FakeClamd(reply: null); using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); await Assert.ThrowsAnyAsync(() => ScannerFor(clamd.Port).ScanAsync([1, 2, 3], cts.Token)); } [Fact] public async Task Rejects_null_content() => await Assert.ThrowsAsync(() => ScannerFor(1).ScanAsync(null!)); [Fact] public void Defaults_to_the_clamav_service_on_the_clamd_port() => Assert.Equal(("clamav", 3310), (new ClamAvOptions().Host, new ClamAvOptions().Port)); }