1
13import { createServer, type Server } from "node:net";
14import { mkdtemp, writeFile, unlink } from "node:fs/promises";
15import { tmpdir } from "node:os";
16import path from "node:path";
17import { afterAll, beforeAll, describe, expect, it } from "vitest";
18import {
19 getClamdVersion,
20 isClamavConfigured,
21 scanFileViaSocket,
22 scanStreamViaTcp
23} from "../clamav.js";
24
25const servers: Server[] = [];
26
27function freePort(): Promise<number> {
28 return new Promise((resolve, reject) => {
29 const s = createServer();
30 s.once("error", reject);
31 s.listen(0, "127.0.0.1", () => {
32 const { port } = s.address() as { port: number };
33 s.close(() => resolve(port));
34 });
35 });
36}
37
38
39
40describe("clamav unix socket (zCONTSCAN local protocol)", () => {
41 let sockPath: string;
42 let verdict: "ok" | "infected" | "error";
43
44 beforeAll(async () => {
45 verdict = "ok";
46 const dir = await mkdtemp(path.join(tmpdir(), "clamd-sock-"));
47 sockPath = path.join(dir, "clamd.sock");
48 const srv = createServer((s) => {
49 let buf = Buffer.alloc(0);
50 s.on("data", (c) => {
51 buf = Buffer.concat([buf, c]);
52 const nul = buf.indexOf(0x00);
53 if (nul < 0) return;
54 const req = buf.subarray(0, nul).toString();
55 const file = req.replace(/^zCONTSCAN\s+/, "");
56 if (verdict === "infected") s.write(`${file}: Eicar-Test-Signature FOUND\0`);
57 else if (verdict === "error") s.write("ERROR: stream read failed\0");
58 else s.write(`${file}: OK\0`);
59 s.end();
60 });
61 });
62 servers.push(srv);
63 await new Promise<void>((r) => srv.listen(sockPath, r));
64 });
65 afterAll(async () => {
66 for (const s of servers) await new Promise<void>((r) => s.close(() => r()));
67 await unlink(sockPath).catch(() => {});
68 });
69
70 it("config gate: socket configured", () => {
71 expect(isClamavConfigured({ socket: sockPath })).toBe(true);
72 expect(isClamavConfigured({})).toBe(false);
73 });
74
75 it("clean file → scanned clean", async () => {
76 verdict = "ok";
77 const f = path.join(tmpdir(), "clamd-clean.txt");
78 await writeFile(f, "clean");
79 const r = await scanFileViaSocket(sockPath, f, 5_000);
80 expect(r).toMatchObject({ engine: "clamav", scanned: true, infected: false });
81 await unlink(f).catch(() => {});
82 });
83
84 it("infected file → signature captured", async () => {
85 verdict = "infected";
86 const f = path.join(tmpdir(), "clamd-eicar.txt");
87 await writeFile(f, "EICAR…");
88 const r = await scanFileViaSocket(sockPath, f, 5_000);
89 expect(r.scanned).toBe(true);
90 expect(r.infected).toBe(true);
91 expect(r.signature).toContain("Eicar-Test-Signature FOUND");
92 await unlink(f).catch(() => {});
93 });
94
95 it("clam ERROR verdict → scanned:false + error (retriable upstream)", async () => {
96 verdict = "error";
97 const f = path.join(tmpdir(), "clamd-err.txt");
98 await writeFile(f, "x");
99 const r = await scanFileViaSocket(sockPath, f, 5_000);
100 expect(r).toMatchObject({ scanned: false, infected: false });
101 expect(r.error).toContain("ERROR");
102 await unlink(f).catch(() => {});
103 });
104});
105
106
107
108describe("clamav TCP (zINSTREAM remote protocol)", () => {
109 let port: number;
110 let verdict: "ok" | "infected";
111
112 beforeAll(async () => {
113 verdict = "ok";
114 port = await freePort();
115 const srv = createServer((s) => {
116 let buf = Buffer.alloc(0);
117 let state: "inic" | "inok" | "data" = "inic";
118 const b64 = Buffer.alloc(0);
119 s.on("data", (c) => {
120 buf = Buffer.concat([buf, c]);
121 if (state === "inic") {
122
123 s.write("INIC\n");
124 state = "inok";
125 }
126 if (state === "inok") {
127 const idx = buf.indexOf(0x0a);
128 if (idx >= 0) {
129 const line = buf.subarray(0, idx).toString();
130 buf = buf.subarray(idx + 1);
131 if (line === "INOK") {
132 state = "data";
133 }
134 }
135 }
136 if (state === "data") {
137
138 const idx = buf.indexOf("INOC");
139 if (idx >= 0) {
140 const b64text = buf.subarray(0, idx).toString();
141 s.write(
142 verdict === "infected"
143 ? "eicar.txt: Eicar-Test-Signature FOUND\n"
144 : "OK\n"
145 );
146 void b64text;
147 s.end();
148 }
149 }
150 });
151 });
152 servers.push(srv);
153 await new Promise<void>((r) => srv.listen(port, "127.0.0.1", r));
154 });
155 afterAll(async () => {
156 for (const s of servers) await new Promise<void>((r) => s.close(() => r()));
157 });
158
159 it("clean buffer → scanned clean", async () => {
160 verdict = "ok";
161 const r = await scanStreamViaTcp("127.0.0.1", port, Buffer.from("hello clean"), 5_000);
162 expect(r).toMatchObject({ engine: "clamav", scanned: true, infected: false });
163 });
164
165 it("infected buffer → signature captured", async () => {
166 verdict = "infected";
167 const r = await scanStreamViaTcp("127.0.0.1", port, Buffer.from("EICAR"), 5_000);
168 expect(r.infected).toBe(true);
169 expect(r.signature).toContain("Eicar-Test-Signature FOUND");
170 });
171
172 it("timeout → structured error (never a silent clean)", async () => {
173
174 const deadPort = await freePort();
175 const r = await scanStreamViaTcp("127.0.0.1", deadPort, Buffer.from("x"), 2_000);
176 expect(r).toMatchObject({ scanned: false, infected: false });
177 expect(r.error).toBeTruthy();
178 });
179});
180
181
182
183describe("getClamdVersion (best effort, short timeout)", () => {
184 it("resolves 'unknown' fast when no server answers", async () => {
185 const deadPort = await freePort();
186 const t0 = Date.now();
187 const v = await getClamdVersion({ host: "127.0.0.1", port: deadPort });
188 expect(v).toBe("unknown");
189 expect(Date.now() - t0).toBeLessThan(5_000);
190 });
191
192 it("resolves 'unknown' when unconfigured", async () => {
193 expect(await getClamdVersion({})).toBe("unknown");
194 });
195});
196