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]));
+ }
+}