test(domain): clamd INSTREAM adapter maps replies to scan verdicts (refs #192)
Red: the stub adapter never connects and always answers Clean. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
{
|
||||
/// <summary>A one-shot fake clamd: reads one INSTREAM request to its zero-length terminator,
|
||||
/// records it, and answers <paramref name="reply"/>.</summary>
|
||||
private sealed class FakeClamd : IDisposable
|
||||
{
|
||||
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
|
||||
public Task<byte[]> Received { get; }
|
||||
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
|
||||
|
||||
public FakeClamd(string reply)
|
||||
{
|
||||
_listener.Start();
|
||||
Received = Serve(reply);
|
||||
}
|
||||
|
||||
private async Task<byte[]> 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]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user