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 +28 -1
- package/package.json +2 -2
- package/packages/hono/README.md +160 -0
- package/packages/hono/index.d.ts +29 -0
- package/packages/hono/index.js +74 -0
- package/packages/hono/package.json +40 -0
- package/packages/testing/README.md +126 -0
- package/packages/testing/index.d.ts +44 -0
- package/packages/testing/index.js +82 -0
- package/packages/testing/package.json +35 -0
- package/src/BufferScanner.js +3 -0
- package/src/ClamdScanner.js +38 -14
- package/src/StreamScanner.js +3 -0
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.
|
|
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
|
+
}
|
package/src/BufferScanner.js
CHANGED
|
@@ -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
|
|
package/src/ClamdScanner.js
CHANGED
|
@@ -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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
}
|
package/src/StreamScanner.js
CHANGED
|
@@ -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) {
|