pompelmi 1.14.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -89,11 +89,15 @@ Most integrations require parsing ClamAV's stdout with regex, managing a clamd d
89
89
  - Symbol-based verdicts (`Verdict.Clean` / `Verdict.Malicious` / `Verdict.ScanError`) — typo-proof comparisons
90
90
  - Full clamd support via the INSTREAM protocol — TCP (`host`/`port`) or UNIX socket (`socket`) with configurable timeout
91
91
  - Built-in helpers to install ClamAV and update virus definitions programmatically
92
- - Works with Express, Fastify, NestJS, and any other Node.js HTTP framework
92
+ - Works with Express, Fastify, NestJS, Hono, and any other Node.js HTTP framework
93
+ - Works with Node.js and Bun — uses `Bun.file()` for faster file reading when available
94
+ - Interactive demo at [pompelmi.app/demo](https://pompelmi.app/demo.html) — try before you install
93
95
  - Zero runtime dependencies — ships nothing but source code
94
96
  - Tested with EICAR standard antivirus test files
95
97
  - CommonJS module; TypeScript type declarations available inline
96
98
 
99
+ See [how pompelmi compares](./docs/comparison.html) to other Node.js ClamAV integrations.
100
+
97
101
  ---
98
102
 
99
103
  ## Framework Integrations
@@ -104,6 +108,8 @@ Official integration packages for popular frameworks:
104
108
  |---------|-----------|---------|
105
109
  | [@pompelmi/nestjs](./packages/nestjs/) | NestJS | `npm i @pompelmi/nestjs` |
106
110
  | [@pompelmi/fastify](./packages/fastify/) | Fastify | `npm i @pompelmi/fastify` |
111
+ | [@pompelmi/hono](./packages/hono/) | Hono | `npm i @pompelmi/hono` |
112
+ | [@pompelmi/testing](./packages/testing/) | Jest/Vitest/Node | `npm i -D @pompelmi/testing` |
107
113
 
108
114
  ### NestJS
109
115
 
@@ -132,11 +138,29 @@ const result = await fastify.pompelmi.scanBuffer(buffer);
132
138
  fastify.post('/upload', { preHandler: fastify.pompelmi.preHandler({ field: 'file' }) }, handler);
133
139
  ```
134
140
 
141
+ ### Hono (Node.js, Bun, Cloudflare Workers)
142
+
143
+ ```js
144
+ import { Hono } from 'hono'
145
+ import { pompelmiMiddleware } from '@pompelmi/hono'
146
+
147
+ const app = new Hono()
148
+
149
+ app.use('/upload/*', pompelmiMiddleware({
150
+ host: 'localhost',
151
+ port: 3310,
152
+ onInfected: (c, filename) => c.json({ error: 'Malware detected' }, 422),
153
+ }))
154
+
155
+ app.post('/upload', async (c) => c.json({ ok: true }))
156
+ ```
157
+
135
158
  ---
136
159
 
137
160
  ## Requirements
138
161
 
139
162
  - **Node.js** — any LTS release (no native addons, no C++ bindings)
163
+ - **Bun** — fully supported; uses `Bun.file()` for faster file reading
140
164
  - **ClamAV** — must be installed on the host or reachable over TCP
141
165
 
142
166
  pompelmi does not bundle or automatically download ClamAV. Install it once per machine (see [Installing ClamAV](#installing-clamav)).
@@ -156,6 +180,9 @@ yarn add pompelmi
156
180
 
157
181
  # pnpm
158
182
  pnpm add pompelmi
183
+
184
+ # bun
185
+ bun add pompelmi
159
186
  ```
160
187
 
161
188
  ### Docker
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pompelmi",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "ClamAV for humans — scan any file and get back Clean, Malicious, or ScanError. No daemons. No cloud. No native bindings.",
5
5
  "license": "ISC",
6
6
  "author": "pompelmi contributors",
@@ -37,7 +37,7 @@
37
37
  "pompelmi": "./bin/pompelmi.js"
38
38
  },
39
39
  "scripts": {
40
- "test": "node --test test/unit.test.js && node --test packages/nestjs/test/index.test.js && node --test packages/fastify/test/index.test.js && node --test packages/nextjs/test/index.test.js && node test/scan.test.js",
40
+ "test": "node --test test/unit.test.js && node --test packages/nestjs/test/index.test.js && node --test packages/fastify/test/index.test.js && node --test packages/nextjs/test/index.test.js && node --test packages/hono/test/index.test.js && node --test packages/testing/test/index.test.js && node test/scan.test.js",
41
41
  "lint": "eslint src/"
42
42
  },
43
43
  "publishConfig": {
@@ -0,0 +1,160 @@
1
+ # @pompelmi/hono
2
+
3
+ Hono middleware for [pompelmi](https://pompelmi.app) — in-process ClamAV virus scanning with zero extra dependencies.
4
+
5
+ Works on **Node.js**, **Bun**, and **Cloudflare Workers** (simulation mode).
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @pompelmi/hono pompelmi
11
+ # or
12
+ bun add @pompelmi/hono pompelmi
13
+ ```
14
+
15
+ ## Quick Start
16
+
17
+ ```js
18
+ import { Hono } from 'hono'
19
+ import { pompelmiMiddleware } from '@pompelmi/hono'
20
+
21
+ const app = new Hono()
22
+
23
+ app.use('/upload/*', pompelmiMiddleware({
24
+ host: 'localhost',
25
+ port: 3310,
26
+ }))
27
+
28
+ app.post('/upload', async (c) => {
29
+ // file is guaranteed clean here
30
+ return c.json({ ok: true })
31
+ })
32
+
33
+ export default app
34
+ ```
35
+
36
+ ## Custom infected handler
37
+
38
+ ```js
39
+ app.use('/upload/*', pompelmiMiddleware({
40
+ host: 'localhost',
41
+ port: 3310,
42
+ field: 'file',
43
+ onInfected: (c, filename) => {
44
+ console.warn(`Blocked malicious upload: ${filename}`)
45
+ return c.json({ error: 'Malware detected', filename }, 422)
46
+ },
47
+ }))
48
+ ```
49
+
50
+ ## Hono on Node.js
51
+
52
+ ```js
53
+ const { serve } = require('@hono/node-server')
54
+ const { Hono } = require('hono')
55
+ const { pompelmiMiddleware } = require('@pompelmi/hono')
56
+
57
+ const app = new Hono()
58
+
59
+ app.use('/upload/*', pompelmiMiddleware({
60
+ host: '127.0.0.1',
61
+ port: 3310,
62
+ }))
63
+
64
+ app.post('/upload', async (c) => {
65
+ const body = await c.req.parseBody()
66
+ const file = body['file']
67
+ return c.json({ name: file.name, size: file.size, ok: true })
68
+ })
69
+
70
+ serve({ fetch: app.fetch, port: 3000 }, () => {
71
+ console.log('Server running on http://localhost:3000')
72
+ })
73
+ ```
74
+
75
+ ## Hono on Bun
76
+
77
+ ```ts
78
+ import { Hono } from 'hono'
79
+ import { pompelmiMiddleware } from '@pompelmi/hono'
80
+
81
+ const app = new Hono()
82
+
83
+ app.use('/upload/*', pompelmiMiddleware({
84
+ socket: '/run/clamav/clamd.sock', // UNIX socket — faster on Bun
85
+ }))
86
+
87
+ app.post('/upload', async (c) => {
88
+ return c.json({ ok: true })
89
+ })
90
+
91
+ export default {
92
+ port: 3000,
93
+ fetch: app.fetch,
94
+ }
95
+ ```
96
+
97
+ ## Hono on Cloudflare Workers
98
+
99
+ > Note: clamd is not available inside Workers. Use pompelmi in a Node.js / Bun sidecar
100
+ > service and call it over HTTP, or use the UNIX socket approach with a co-located daemon.
101
+ >
102
+ > For Workers deployments without a sidecar, the middleware skips scanning gracefully
103
+ > and calls `next()` — you can gate the behaviour with an environment variable.
104
+
105
+ ```ts
106
+ import { Hono } from 'hono'
107
+ import { pompelmiMiddleware } from '@pompelmi/hono'
108
+
109
+ const app = new Hono<{ Bindings: { SCAN_HOST: string; SCAN_PORT: string } }>()
110
+
111
+ app.use('/upload/*', async (c, next) => {
112
+ if (!c.env.SCAN_HOST) return next() // skip if no clamd configured
113
+ return pompelmiMiddleware({
114
+ host: c.env.SCAN_HOST,
115
+ port: Number(c.env.SCAN_PORT) || 3310,
116
+ })(c, next)
117
+ })
118
+
119
+ app.post('/upload', async (c) => {
120
+ return c.json({ ok: true })
121
+ })
122
+
123
+ export default app
124
+ ```
125
+
126
+ ## Configuration Reference
127
+
128
+ All options are forwarded to pompelmi's `ScanOptions`:
129
+
130
+ | Option | Type | Default | Description |
131
+ |--------|------|---------|-------------|
132
+ | `field` | `string` | `'file'` | Form field name containing the uploaded file |
133
+ | `host` | `string` | — | clamd hostname (enables TCP mode) |
134
+ | `port` | `number` | `3310` | clamd port |
135
+ | `socket` | `string` | — | UNIX domain socket path |
136
+ | `timeout` | `number` | `15000` | Socket idle timeout in ms |
137
+ | `retries` | `number` | `0` | Number of retry attempts |
138
+ | `retryDelay` | `number` | `1000` | Delay between retries in ms |
139
+ | `onInfected` | `Function` | — | Called with `(c, filename)` when malware is detected |
140
+
141
+ ## TypeScript
142
+
143
+ ```ts
144
+ import { Hono } from 'hono'
145
+ import { pompelmiMiddleware } from '@pompelmi/hono'
146
+ import { Verdict } from 'pompelmi'
147
+
148
+ const app = new Hono()
149
+
150
+ app.use('/upload/*', pompelmiMiddleware({
151
+ host: 'localhost',
152
+ port: 3310,
153
+ onInfected: (c, filename) =>
154
+ c.json({ error: `${filename} is infected` }, 422),
155
+ }))
156
+ ```
157
+
158
+ ## License
159
+
160
+ ISC — see root [LICENSE](../../LICENSE).
@@ -0,0 +1,29 @@
1
+ import type { MiddlewareHandler, Context } from 'hono';
2
+ import type { ScanOptions } from 'pompelmi';
3
+
4
+ export interface PompelmiHonoOptions extends ScanOptions {
5
+ /** Form field name that contains the uploaded file (default: 'file') */
6
+ field?: string;
7
+ /**
8
+ * Called with (c, filename) when a malicious file is detected.
9
+ * Must return a Response (or Promise<Response>).
10
+ * If omitted, responds with HTTP 422 { error: 'Malware detected' }.
11
+ */
12
+ onInfected?: (c: Context, filename: string) => Response | Promise<Response>;
13
+ }
14
+
15
+ /**
16
+ * Hono middleware that scans an uploaded file before the route handler runs.
17
+ * Reads the file from the parsed multipart body field (default: 'file').
18
+ *
19
+ * @example
20
+ * import { Hono } from 'hono'
21
+ * import { pompelmiMiddleware } from '@pompelmi/hono'
22
+ *
23
+ * const app = new Hono()
24
+ * app.use('/upload/*', pompelmiMiddleware({ host: 'localhost', port: 3310 }))
25
+ * app.post('/upload', (c) => c.json({ ok: true }))
26
+ */
27
+ export function pompelmiMiddleware(options?: PompelmiHonoOptions): MiddlewareHandler;
28
+
29
+ export { Verdict } from 'pompelmi';
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+
3
+ const { scanBuffer, Verdict } = require('pompelmi');
4
+
5
+ const SCAN_KEYS = ['host', 'port', 'socket', 'timeout', 'retries', 'retryDelay'];
6
+
7
+ function buildScanOptions(options) {
8
+ const out = {};
9
+ for (const k of SCAN_KEYS) {
10
+ if (options[k] !== undefined) out[k] = options[k];
11
+ }
12
+ return out;
13
+ }
14
+
15
+ async function toBuffer(raw) {
16
+ if (Buffer.isBuffer(raw)) return raw;
17
+ if (raw instanceof Uint8Array) return Buffer.from(raw);
18
+ // Web API File / Blob (Hono on Bun, Cloudflare Workers, Node 20+)
19
+ if (raw && typeof raw.arrayBuffer === 'function') {
20
+ return Buffer.from(await raw.arrayBuffer());
21
+ }
22
+ if (typeof raw === 'string') return Buffer.from(raw);
23
+ return null;
24
+ }
25
+
26
+ /**
27
+ * Hono middleware that scans an uploaded file with pompelmi before the route
28
+ * handler runs. The file is read from the parsed multipart body.
29
+ *
30
+ * @param {object} [options]
31
+ * @param {string} [options.field='file'] - Form field name containing the file.
32
+ * @param {string} [options.host] - clamd hostname (enables TCP mode).
33
+ * @param {number} [options.port=3310] - clamd port.
34
+ * @param {string} [options.socket] - UNIX domain socket path.
35
+ * @param {number} [options.timeout=15000] - Socket idle timeout in ms.
36
+ * @param {number} [options.retries=0] - Number of retry attempts.
37
+ * @param {number} [options.retryDelay=1000] - Delay between retries in ms.
38
+ * @param {Function} [options.onInfected] - Called with (c, filename) when malware
39
+ * is detected; must return a Response.
40
+ * Defaults to 422 JSON error.
41
+ */
42
+ function pompelmiMiddleware(options) {
43
+ const { field = 'file', onInfected } = options || {};
44
+ const scanOptions = buildScanOptions(options || {});
45
+
46
+ return async function pompelmiHonoMiddleware(c, next) {
47
+ let body;
48
+ try {
49
+ body = await c.req.parseBody();
50
+ } catch {
51
+ return next();
52
+ }
53
+
54
+ const raw = body[field];
55
+ if (raw == null) return next();
56
+
57
+ const buffer = await toBuffer(raw);
58
+ if (!buffer || buffer.length === 0) return next();
59
+
60
+ const result = await scanBuffer(buffer, scanOptions);
61
+
62
+ if (result === Verdict.Malicious) {
63
+ const filename = (raw && typeof raw.name === 'string') ? raw.name : field;
64
+ if (typeof onInfected === 'function') {
65
+ return onInfected(c, filename);
66
+ }
67
+ return c.json({ error: 'Malware detected' }, 422);
68
+ }
69
+
70
+ return next();
71
+ };
72
+ }
73
+
74
+ module.exports = { pompelmiMiddleware, Verdict };
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@pompelmi/hono",
3
+ "version": "1.0.0",
4
+ "description": "Hono middleware for pompelmi — in-process ClamAV virus scanning with zero extra dependencies",
5
+ "license": "ISC",
6
+ "author": "pompelmi contributors",
7
+ "homepage": "https://pompelmi.app",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/pompelmi/pompelmi.git",
11
+ "directory": "packages/hono"
12
+ },
13
+ "keywords": [
14
+ "hono",
15
+ "middleware",
16
+ "clamav",
17
+ "antivirus",
18
+ "virus-scan",
19
+ "malware",
20
+ "file-upload",
21
+ "security",
22
+ "pompelmi",
23
+ "bun",
24
+ "edge",
25
+ "cloudflare-workers"
26
+ ],
27
+ "main": "./index.js",
28
+ "types": "./index.d.ts",
29
+ "scripts": {
30
+ "test": "node --test test/index.test.js"
31
+ },
32
+ "peerDependencies": {
33
+ "hono": ">=3",
34
+ "pompelmi": ">=1.14.0"
35
+ },
36
+ "publishConfig": {
37
+ "registry": "https://registry.npmjs.org/",
38
+ "access": "public"
39
+ }
40
+ }
@@ -0,0 +1,126 @@
1
+ # @pompelmi/testing
2
+
3
+ Mock utilities for unit-testing applications that use [pompelmi](https://pompelmi.app).
4
+
5
+ Compatible with **Jest**, **Vitest**, and the **Node.js built-in test runner**.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install --save-dev @pompelmi/testing pompelmi
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ### `mockClean()` / `mockInfected()` / `mockScanError()`
16
+
17
+ ```js
18
+ const { mockClean, mockInfected, mockScanError, Verdict } = require('@pompelmi/testing')
19
+
20
+ it('passes clean files through', async () => {
21
+ const scanner = mockClean()
22
+ const result = await scanner.scanBuffer(Buffer.from('hello'))
23
+ assert.equal(result, Verdict.Clean)
24
+ })
25
+
26
+ it('rejects infected files', async () => {
27
+ const scanner = mockInfected('Win.Malware.Test')
28
+ const result = await scanner.scanBuffer(Buffer.from('eicar'))
29
+ assert.equal(result, Verdict.Malicious)
30
+ assert.equal(scanner.virusName, 'Win.Malware.Test')
31
+ })
32
+
33
+ it('handles scan errors', async () => {
34
+ const scanner = mockScanError()
35
+ const result = await scanner.scanBuffer(Buffer.from('data'))
36
+ assert.equal(result, Verdict.ScanError)
37
+ })
38
+ ```
39
+
40
+ ### `createMockScanner(defaultVerdict)`
41
+
42
+ Creates a scanner that resolves every scan call to the given verdict.
43
+
44
+ ```js
45
+ const { createMockScanner, Verdict } = require('@pompelmi/testing')
46
+
47
+ const scanner = createMockScanner(Verdict.Malicious)
48
+ // scanner.scan(), scanner.scanBuffer(), scanner.scanStream() all → Verdict.Malicious
49
+ ```
50
+
51
+ ### `withMockedPompelmi(verdict, fn)`
52
+
53
+ Convenience wrapper that creates the mock and passes it to your test function.
54
+
55
+ ```js
56
+ const { withMockedPompelmi, Verdict } = require('@pompelmi/testing')
57
+
58
+ it('rejects infected files', () =>
59
+ withMockedPompelmi(Verdict.Malicious, async (scanner) => {
60
+ const result = await scanner.scanBuffer(Buffer.from('eicar'))
61
+ assert.equal(result, Verdict.Malicious)
62
+ })
63
+ )
64
+ ```
65
+
66
+ ## Jest Example
67
+
68
+ ```js
69
+ import { mockInfected, Verdict } from '@pompelmi/testing'
70
+
71
+ // Manually inject the mock scanner into a module under test
72
+ jest.mock('pompelmi', () => mockInfected('Eicar'))
73
+
74
+ test('upload handler rejects infected files', async () => {
75
+ const { handleUpload } = await import('./upload-handler')
76
+ const response = await handleUpload(evilBuffer)
77
+ expect(response.status).toBe(422)
78
+ })
79
+ ```
80
+
81
+ ## Vitest Example
82
+
83
+ ```ts
84
+ import { mockClean, Verdict } from '@pompelmi/testing'
85
+ import { vi, it, expect } from 'vitest'
86
+
87
+ vi.mock('pompelmi', () => mockClean())
88
+
89
+ it('allows clean files', async () => {
90
+ const { handleUpload } = await import('./upload-handler')
91
+ const res = await handleUpload(cleanBuffer)
92
+ expect(res.status).toBe(200)
93
+ })
94
+ ```
95
+
96
+ ## Node.js built-in test runner
97
+
98
+ ```js
99
+ const { describe, it } = require('node:test')
100
+ const assert = require('node:assert/strict')
101
+ const { mockInfected, Verdict } = require('@pompelmi/testing')
102
+
103
+ describe('upload handler', () => {
104
+ it('rejects infected files', async () => {
105
+ const scanner = mockInfected('Win.Malware.Agent')
106
+ const result = await scanner.scanBuffer(Buffer.from('eicar'))
107
+ assert.equal(result, Verdict.Malicious)
108
+ assert.equal(scanner.virusName, 'Win.Malware.Agent')
109
+ })
110
+ })
111
+ ```
112
+
113
+ ## API
114
+
115
+ | Export | Description |
116
+ |--------|-------------|
117
+ | `createMockScanner(verdict)` | Returns a mock scanner that resolves to `verdict` |
118
+ | `mockClean()` | Shorthand for `createMockScanner(Verdict.Clean)` |
119
+ | `mockInfected(virusName?)` | Shorthand for `createMockScanner(Verdict.Malicious)` |
120
+ | `mockScanError()` | Shorthand for `createMockScanner(Verdict.ScanError)` |
121
+ | `withMockedPompelmi(verdict, fn)` | Runs `fn` with a mock scanner, returns a Promise |
122
+ | `Verdict` | Re-exported from `pompelmi` |
123
+
124
+ ## License
125
+
126
+ ISC — see root [LICENSE](../../LICENSE).
@@ -0,0 +1,44 @@
1
+
2
+ export interface MockScanner {
3
+ Verdict: { readonly Clean: symbol; readonly Malicious: symbol; readonly ScanError: symbol };
4
+ scan(filePath: string): Promise<symbol>;
5
+ scanBuffer(buffer: Buffer | Uint8Array): Promise<symbol>;
6
+ scanStream(stream: NodeJS.ReadableStream): Promise<symbol>;
7
+ scanS3(opts: Record<string, unknown>): Promise<symbol>;
8
+ /** The verdict this scanner always resolves to */
9
+ _verdict: symbol;
10
+ /** Virus name label — only present on mockInfected() scanners */
11
+ virusName?: string;
12
+ }
13
+
14
+ /**
15
+ * Creates a mock pompelmi scanner where every scan method resolves to
16
+ * `defaultVerdict`.
17
+ */
18
+ export function createMockScanner(defaultVerdict: symbol): MockScanner;
19
+
20
+ /** Returns a mock scanner that always resolves to Verdict.Clean */
21
+ export function mockClean(): MockScanner;
22
+
23
+ /**
24
+ * Returns a mock scanner that always resolves to Verdict.Malicious.
25
+ * The `virusName` is stored as `scanner.virusName` for test assertions.
26
+ */
27
+ export function mockInfected(virusName?: string): MockScanner;
28
+
29
+ /** Returns a mock scanner that always resolves to Verdict.ScanError */
30
+ export function mockScanError(): MockScanner;
31
+
32
+ /**
33
+ * Wraps a test function with a mock scanner.
34
+ * The scanner is passed as the first argument to `fn`.
35
+ *
36
+ * @example
37
+ * await withMockedPompelmi(Verdict.Malicious, async (scanner) => {
38
+ * const result = await scanner.scanBuffer(buffer)
39
+ * expect(result).toBe(Verdict.Malicious)
40
+ * })
41
+ */
42
+ export function withMockedPompelmi(verdict: symbol, fn: (scanner: MockScanner) => unknown): Promise<void>;
43
+
44
+ export { Verdict } from 'pompelmi';
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ const { Verdict } = require('pompelmi');
4
+
5
+ /**
6
+ * Creates a mock pompelmi scanner where all scan methods resolve to
7
+ * the given verdict.
8
+ *
9
+ * @param {symbol} defaultVerdict - One of Verdict.Clean, Verdict.Malicious, or Verdict.ScanError.
10
+ * @returns {{ scan, scanBuffer, scanStream, Verdict }}
11
+ */
12
+ function createMockScanner(defaultVerdict) {
13
+ if (defaultVerdict !== Verdict.Clean &&
14
+ defaultVerdict !== Verdict.Malicious &&
15
+ defaultVerdict !== Verdict.ScanError) {
16
+ throw new TypeError('defaultVerdict must be one of Verdict.Clean, Verdict.Malicious, or Verdict.ScanError');
17
+ }
18
+
19
+ return {
20
+ Verdict,
21
+ scan: () => Promise.resolve(defaultVerdict),
22
+ scanBuffer: () => Promise.resolve(defaultVerdict),
23
+ scanStream: () => Promise.resolve(defaultVerdict),
24
+ scanS3: () => Promise.resolve(defaultVerdict),
25
+ _verdict: defaultVerdict,
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Shorthand: returns a mock scanner that always resolves to Verdict.Clean.
31
+ */
32
+ function mockClean() {
33
+ return createMockScanner(Verdict.Clean);
34
+ }
35
+
36
+ /**
37
+ * Shorthand: returns a mock scanner that always resolves to Verdict.Malicious.
38
+ * The virusName parameter is stored on the scanner for inspection in tests.
39
+ *
40
+ * @param {string} [virusName='Win.Malware.Test'] - Virus name label (stored as scanner.virusName).
41
+ */
42
+ function mockInfected(virusName) {
43
+ const scanner = createMockScanner(Verdict.Malicious);
44
+ scanner.virusName = virusName || 'Win.Malware.Test';
45
+ return scanner;
46
+ }
47
+
48
+ /**
49
+ * Shorthand: returns a mock scanner that always resolves to Verdict.ScanError.
50
+ */
51
+ function mockScanError() {
52
+ return createMockScanner(Verdict.ScanError);
53
+ }
54
+
55
+ /**
56
+ * Wraps a test function with a mocked pompelmi scanner.
57
+ * The scanner is passed as the first argument to fn.
58
+ *
59
+ * This is a lightweight helper — for full module-level mocking in Jest/Vitest,
60
+ * use jest.mock() / vi.mock() directly.
61
+ *
62
+ * @param {symbol} verdict - Verdict to mock.
63
+ * @param {Function} fn - Test function that receives the mock scanner.
64
+ * @returns {Promise}
65
+ */
66
+ function withMockedPompelmi(verdict, fn) {
67
+ const scanner = createMockScanner(verdict);
68
+ try {
69
+ return Promise.resolve(fn(scanner));
70
+ } catch (err) {
71
+ return Promise.reject(err);
72
+ }
73
+ }
74
+
75
+ module.exports = {
76
+ createMockScanner,
77
+ mockClean,
78
+ mockInfected,
79
+ mockScanError,
80
+ withMockedPompelmi,
81
+ Verdict,
82
+ };
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@pompelmi/testing",
3
+ "version": "1.0.0",
4
+ "description": "Mock utilities for unit-testing applications that use pompelmi — Jest, Vitest, and Node test runner",
5
+ "license": "ISC",
6
+ "author": "pompelmi contributors",
7
+ "homepage": "https://pompelmi.app",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/pompelmi/pompelmi.git",
11
+ "directory": "packages/testing"
12
+ },
13
+ "keywords": [
14
+ "pompelmi",
15
+ "testing",
16
+ "mock",
17
+ "jest",
18
+ "vitest",
19
+ "clamav",
20
+ "antivirus",
21
+ "unit-test"
22
+ ],
23
+ "main": "./index.js",
24
+ "types": "./index.d.ts",
25
+ "scripts": {
26
+ "test": "node --test test/index.test.js"
27
+ },
28
+ "peerDependencies": {
29
+ "pompelmi": ">=1.14.0"
30
+ },
31
+ "publishConfig": {
32
+ "registry": "https://registry.npmjs.org/",
33
+ "access": "public"
34
+ }
35
+ }
@@ -3,6 +3,9 @@
3
3
  const net = require('net');
4
4
  const { Verdict } = require('./verdicts.js');
5
5
 
6
+ // net.createConnection is polyfilled by Bun — no code changes needed
7
+ const isBun = typeof Bun !== 'undefined'; // eslint-disable-line no-unused-vars
8
+
6
9
  const CLAMD_INSTREAM = Buffer.from('zINSTREAM\0');
7
10
  const CHUNK_SIZE = 64 * 1024;
8
11
 
@@ -4,6 +4,8 @@ const net = require('net');
4
4
  const fs = require('fs');
5
5
  const { Verdict } = require('./verdicts.js');
6
6
 
7
+ const isBun = typeof Bun !== 'undefined';
8
+
7
9
  // ClamAV INSTREAM protocol:
8
10
  // 1. Send "zINSTREAM\0"
9
11
  // 2. Send N chunks, each prefixed with a 4-byte big-endian length
@@ -63,24 +65,46 @@ function scanViaClamd(filePath, options = {}) {
63
65
  conn.on('data', (chunk) => chunks.push(chunk));
64
66
  conn.on('end', () => settle(resolve, parseClamdResponse(Buffer.concat(chunks))));
65
67
 
66
- conn.on('connect', () => {
68
+ conn.on('connect', async () => {
67
69
  conn.write(CLAMD_INSTREAM);
68
70
 
69
- const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
70
-
71
- fileStream.on('error', (err) => settle(reject, err));
72
-
73
- fileStream.on('data', (chunk) => {
74
- const header = Buffer.allocUnsafe(4);
75
- header.writeUInt32BE(chunk.length, 0);
76
- conn.write(header);
77
- conn.write(chunk);
78
- });
79
-
80
- fileStream.on('end', () => {
71
+ if (isBun) {
72
+ // Bun.file() is faster than fs.createReadStream on Bun
73
+ let fileData;
74
+ try {
75
+ fileData = await Bun.file(filePath).bytes();
76
+ } catch (err) {
77
+ return settle(reject, err);
78
+ }
79
+ const buf = Buffer.from(fileData);
80
+ let offset = 0;
81
+ while (offset < buf.length) {
82
+ const chunk = buf.slice(offset, offset + CHUNK_SIZE);
83
+ const header = Buffer.allocUnsafe(4);
84
+ header.writeUInt32BE(chunk.length, 0);
85
+ conn.write(header);
86
+ conn.write(chunk);
87
+ offset += chunk.length;
88
+ }
81
89
  conn.write(Buffer.alloc(4)); // terminating zero-length chunk
82
90
  conn.end();
83
- });
91
+ } else {
92
+ const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
93
+
94
+ fileStream.on('error', (err) => settle(reject, err));
95
+
96
+ fileStream.on('data', (chunk) => {
97
+ const header = Buffer.allocUnsafe(4);
98
+ header.writeUInt32BE(chunk.length, 0);
99
+ conn.write(header);
100
+ conn.write(chunk);
101
+ });
102
+
103
+ fileStream.on('end', () => {
104
+ conn.write(Buffer.alloc(4)); // terminating zero-length chunk
105
+ conn.end();
106
+ });
107
+ }
84
108
  });
85
109
  });
86
110
  }
@@ -3,6 +3,9 @@
3
3
  const net = require('net');
4
4
  const { Verdict } = require('./verdicts.js');
5
5
 
6
+ // net.createConnection is polyfilled by Bun — no code changes needed
7
+ const isBun = typeof Bun !== 'undefined'; // eslint-disable-line no-unused-vars
8
+
6
9
  const CLAMD_INSTREAM = Buffer.from('zINSTREAM\0');
7
10
 
8
11
  function parseClamdResponse(raw) {