/** * TASK-0102 §3: the ClamAV client protocol (unit, Docker-free — fake clamd * servers, no real daemon). * * - unix socket → LOCAL protocol: `zCONTSCAN \0` → `: OK\0` * (what C `clamdscan` sends, verified by strace — no INIC/INOK handshake) * - TCP → REMOTE protocol: `zINSTREAM\n` → INIC → INOK → base64 * → `INOC\n` → verdict * * Clean / infected / error verdicts are all classified; a timeout is a * structured error (retriable by the pipeline), never a silent pass. */ import { createServer, type Server } from "node:net"; import { mkdtemp, writeFile, unlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { getClamdVersion, isClamavConfigured, scanFileViaSocket, scanStreamViaTcp } from "../clamav.js"; const servers: Server[] = []; function freePort(): Promise { return new Promise((resolve, reject) => { const s = createServer(); s.once("error", reject); s.listen(0, "127.0.0.1", () => { const { port } = s.address() as { port: number }; s.close(() => resolve(port)); }); }); } // ── unix socket (local protocol) ──────────────────────────────────────────── describe("clamav unix socket (zCONTSCAN local protocol)", () => { let sockPath: string; let verdict: "ok" | "infected" | "error"; beforeAll(async () => { verdict = "ok"; const dir = await mkdtemp(path.join(tmpdir(), "clamd-sock-")); sockPath = path.join(dir, "clamd.sock"); const srv = createServer((s) => { let buf = Buffer.alloc(0); s.on("data", (c) => { buf = Buffer.concat([buf, c]); const nul = buf.indexOf(0x00); if (nul < 0) return; const req = buf.subarray(0, nul).toString(); const file = req.replace(/^zCONTSCAN\s+/, ""); if (verdict === "infected") s.write(`${file}: Eicar-Test-Signature FOUND\0`); else if (verdict === "error") s.write("ERROR: stream read failed\0"); else s.write(`${file}: OK\0`); s.end(); }); }); servers.push(srv); await new Promise((r) => srv.listen(sockPath, r)); }); afterAll(async () => { for (const s of servers) await new Promise((r) => s.close(() => r())); await unlink(sockPath).catch(() => {}); }); it("config gate: socket configured", () => { expect(isClamavConfigured({ socket: sockPath })).toBe(true); expect(isClamavConfigured({})).toBe(false); }); it("clean file → scanned clean", async () => { verdict = "ok"; const f = path.join(tmpdir(), "clamd-clean.txt"); await writeFile(f, "clean"); const r = await scanFileViaSocket(sockPath, f, 5_000); expect(r).toMatchObject({ engine: "clamav", scanned: true, infected: false }); await unlink(f).catch(() => {}); }); it("infected file → signature captured", async () => { verdict = "infected"; const f = path.join(tmpdir(), "clamd-eicar.txt"); await writeFile(f, "EICAR…"); const r = await scanFileViaSocket(sockPath, f, 5_000); expect(r.scanned).toBe(true); expect(r.infected).toBe(true); expect(r.signature).toContain("Eicar-Test-Signature FOUND"); await unlink(f).catch(() => {}); }); it("clam ERROR verdict → scanned:false + error (retriable upstream)", async () => { verdict = "error"; const f = path.join(tmpdir(), "clamd-err.txt"); await writeFile(f, "x"); const r = await scanFileViaSocket(sockPath, f, 5_000); expect(r).toMatchObject({ scanned: false, infected: false }); expect(r.error).toContain("ERROR"); await unlink(f).catch(() => {}); }); }); // ── TCP (remote INSTREAM protocol) ────────────────────────────────────────── describe("clamav TCP (zINSTREAM remote protocol)", () => { let port: number; let verdict: "ok" | "infected"; beforeAll(async () => { verdict = "ok"; port = await freePort(); const srv = createServer((s) => { let buf = Buffer.alloc(0); let state: "inic" | "inok" | "data" = "inic"; const b64 = Buffer.alloc(0); // accumulate s.on("data", (c) => { buf = Buffer.concat([buf, c]); if (state === "inic") { // client sent zINSTREAM\n s.write("INIC\n"); state = "inok"; } if (state === "inok") { const idx = buf.indexOf(0x0a); if (idx >= 0) { const line = buf.subarray(0, idx).toString(); buf = buf.subarray(idx + 1); if (line === "INOK") { state = "data"; } } } if (state === "data") { // base64 text until the line "INOC" const idx = buf.indexOf("INOC"); if (idx >= 0) { const b64text = buf.subarray(0, idx).toString(); s.write( verdict === "infected" ? "eicar.txt: Eicar-Test-Signature FOUND\n" : "OK\n" ); void b64text; s.end(); } } }); }); servers.push(srv); await new Promise((r) => srv.listen(port, "127.0.0.1", r)); }); afterAll(async () => { for (const s of servers) await new Promise((r) => s.close(() => r())); }); it("clean buffer → scanned clean", async () => { verdict = "ok"; const r = await scanStreamViaTcp("127.0.0.1", port, Buffer.from("hello clean"), 5_000); expect(r).toMatchObject({ engine: "clamav", scanned: true, infected: false }); }); it("infected buffer → signature captured", async () => { verdict = "infected"; const r = await scanStreamViaTcp("127.0.0.1", port, Buffer.from("EICAR"), 5_000); expect(r.infected).toBe(true); expect(r.signature).toContain("Eicar-Test-Signature FOUND"); }); it("timeout → structured error (never a silent clean)", async () => { // Point at a port with no clamd answering INIC: connection refused. const deadPort = await freePort(); const r = await scanStreamViaTcp("127.0.0.1", deadPort, Buffer.from("x"), 2_000); expect(r).toMatchObject({ scanned: false, infected: false }); expect(r.error).toBeTruthy(); }); }); // ── version (best effort) ─────────────────────────────────────────────────── describe("getClamdVersion (best effort, short timeout)", () => { it("resolves 'unknown' fast when no server answers", async () => { const deadPort = await freePort(); const t0 = Date.now(); const v = await getClamdVersion({ host: "127.0.0.1", port: deadPort }); expect(v).toBe("unknown"); expect(Date.now() - t0).toBeLessThan(5_000); }); it("resolves 'unknown' when unconfigured", async () => { expect(await getClamdVersion({})).toBe("unknown"); }); });