pompelmi 1.14.0 → 1.16.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 +64 -1
- package/llms.txt +12 -0
- package/package.json +6 -3
- 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/remix/README.md +142 -0
- package/packages/remix/index.d.ts +50 -0
- package/packages/remix/index.js +96 -0
- package/packages/remix/package.json +38 -0
- package/packages/sveltekit/README.md +138 -0
- package/packages/sveltekit/index.d.ts +59 -0
- package/packages/sveltekit/index.js +100 -0
- package/packages/sveltekit/package.json +38 -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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { scanBuffer, Verdict } = require('pompelmi');
|
|
4
|
+
|
|
5
|
+
// File is a global in Node 20+; on Node 18 it lives in node:buffer
|
|
6
|
+
const NodeFile = globalThis.File ?? require('node:buffer').File;
|
|
7
|
+
|
|
8
|
+
const SCAN_KEYS = ['host', 'port', 'socket', 'timeout', 'retries', 'retryDelay'];
|
|
9
|
+
|
|
10
|
+
function buildScanOptions(options) {
|
|
11
|
+
const out = {};
|
|
12
|
+
for (const k of SCAN_KEYS) {
|
|
13
|
+
if (options[k] !== undefined) out[k] = options[k];
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function collectStream(data) {
|
|
19
|
+
const chunks = [];
|
|
20
|
+
for await (const chunk of data) {
|
|
21
|
+
chunks.push(chunk instanceof Buffer ? chunk : Buffer.from(chunk));
|
|
22
|
+
}
|
|
23
|
+
return Buffer.concat(chunks);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates a Remix UploadHandler that scans each uploaded file with pompelmi
|
|
28
|
+
* before passing it to an optional inner handler.
|
|
29
|
+
*
|
|
30
|
+
* Usage in a Remix action:
|
|
31
|
+
*
|
|
32
|
+
* import { unstable_parseMultipartFormData } from '@remix-run/node'
|
|
33
|
+
* import { pompelmiUploadHandler } from '@pompelmi/remix'
|
|
34
|
+
*
|
|
35
|
+
* export async function action({ request }) {
|
|
36
|
+
* const formData = await unstable_parseMultipartFormData(
|
|
37
|
+
* request,
|
|
38
|
+
* pompelmiUploadHandler({ host: 'localhost', port: 3310 })
|
|
39
|
+
* )
|
|
40
|
+
* // file is guaranteed clean here
|
|
41
|
+
* }
|
|
42
|
+
*
|
|
43
|
+
* @param {object} [options]
|
|
44
|
+
* @param {string} [options.field] - Only scan this field; pass others through (default: scan all files).
|
|
45
|
+
* @param {Function} [options.inner] - Inner UploadHandler for clean files. Defaults to storing as File.
|
|
46
|
+
* @param {string} [options.host] - clamd hostname.
|
|
47
|
+
* @param {number} [options.port=3310] - clamd port.
|
|
48
|
+
* @param {string} [options.socket] - UNIX socket path.
|
|
49
|
+
* @param {number} [options.timeout=15000] - Socket idle timeout in ms.
|
|
50
|
+
* @param {number} [options.retries=0] - Retry attempts.
|
|
51
|
+
* @param {number} [options.retryDelay=1000] - Delay between retries in ms.
|
|
52
|
+
* @param {Function} [options.onInfected] - Called with ({ name, filename }) when malware is detected.
|
|
53
|
+
* Must throw or return a Response. Default throws HTTP 422.
|
|
54
|
+
*/
|
|
55
|
+
function pompelmiUploadHandler(options) {
|
|
56
|
+
const { field, inner, onInfected } = options || {};
|
|
57
|
+
const scanOptions = buildScanOptions(options || {});
|
|
58
|
+
|
|
59
|
+
return async function remixUploadHandler({ name, filename, contentType, data }) {
|
|
60
|
+
// Skip non-file fields (no filename) or fields not targeted
|
|
61
|
+
if (!filename || (field && name !== field)) {
|
|
62
|
+
if (typeof inner === 'function') {
|
|
63
|
+
return inner({ name, filename, contentType, data });
|
|
64
|
+
}
|
|
65
|
+
const buf = await collectStream(data);
|
|
66
|
+
// Text field (no filename) → return as string; file field → return as File
|
|
67
|
+
if (!filename) return buf.toString('utf8');
|
|
68
|
+
return new NodeFile([buf], filename, { type: contentType });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const buffer = await collectStream(data);
|
|
72
|
+
|
|
73
|
+
if (buffer.length > 0) {
|
|
74
|
+
const result = await scanBuffer(buffer, scanOptions);
|
|
75
|
+
if (result === Verdict.Malicious) {
|
|
76
|
+
if (typeof onInfected === 'function') {
|
|
77
|
+
return onInfected({ name, filename });
|
|
78
|
+
}
|
|
79
|
+
throw new Response(
|
|
80
|
+
JSON.stringify({ error: 'Malware detected', filename }),
|
|
81
|
+
{ status: 422, headers: { 'Content-Type': 'application/json' } }
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (typeof inner === 'function') {
|
|
87
|
+
// Replay the buffer through the inner handler via an async generator
|
|
88
|
+
async function* replay() { yield buffer; }
|
|
89
|
+
return inner({ name, filename, contentType, data: replay() });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return new NodeFile([buffer], filename, { type: contentType });
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { pompelmiUploadHandler, Verdict };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pompelmi/remix",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Remix upload handler 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/remix"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"remix",
|
|
15
|
+
"remix-run",
|
|
16
|
+
"upload-handler",
|
|
17
|
+
"clamav",
|
|
18
|
+
"antivirus",
|
|
19
|
+
"virus-scan",
|
|
20
|
+
"malware",
|
|
21
|
+
"file-upload",
|
|
22
|
+
"security",
|
|
23
|
+
"pompelmi",
|
|
24
|
+
"fullstack"
|
|
25
|
+
],
|
|
26
|
+
"main": "./index.js",
|
|
27
|
+
"types": "./index.d.ts",
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node --test test/index.test.js"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"pompelmi": ">=1.15.0"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"registry": "https://registry.npmjs.org/",
|
|
36
|
+
"access": "public"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# @pompelmi/sveltekit
|
|
2
|
+
|
|
3
|
+
SvelteKit helper for [pompelmi](https://pompelmi.app) — in-process ClamAV virus scanning with zero extra dependencies.
|
|
4
|
+
|
|
5
|
+
Works with both **+page.server.ts actions** and **+server.ts API routes** on Node.js.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @pompelmi/sveltekit pompelmi
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
> **Requires** the [`@sveltejs/adapter-node`](https://kit.svelte.dev/docs/adapter-node) adapter. ClamAV is a Node.js process and cannot run on edge runtimes.
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
### Form action (+page.server.ts)
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { scanUpload } from '@pompelmi/sveltekit'
|
|
21
|
+
import type { Actions } from './$types'
|
|
22
|
+
|
|
23
|
+
export const actions: Actions = {
|
|
24
|
+
default: async ({ request }) => {
|
|
25
|
+
const formData = await request.formData()
|
|
26
|
+
const file = formData.get('file') as File
|
|
27
|
+
|
|
28
|
+
// Throws HTTP 422 Response automatically if malware is detected
|
|
29
|
+
await scanUpload(file, { host: 'localhost', port: 3310 })
|
|
30
|
+
|
|
31
|
+
// File is guaranteed clean here — save to storage, etc.
|
|
32
|
+
return { success: true, name: file.name }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
If a malicious file is uploaded, `scanUpload` throws a `Response` with HTTP **422** and a JSON body `{ error: 'Malware detected', filename }`. SvelteKit's error boundary catches it automatically.
|
|
38
|
+
|
|
39
|
+
### API route (+server.ts)
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { scanUpload } from '@pompelmi/sveltekit'
|
|
43
|
+
import { json } from '@sveltejs/kit'
|
|
44
|
+
import type { RequestHandler } from './$types'
|
|
45
|
+
|
|
46
|
+
export const POST: RequestHandler = async ({ request }) => {
|
|
47
|
+
const formData = await request.formData()
|
|
48
|
+
const file = formData.get('file') as File
|
|
49
|
+
|
|
50
|
+
await scanUpload(file, { host: 'localhost', port: 3310 })
|
|
51
|
+
|
|
52
|
+
return json({ ok: true, name: file.name, size: file.size })
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Scan all files in FormData
|
|
57
|
+
|
|
58
|
+
Use `scanFormData` to scan every file field at once. Throws on the first malicious file:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { scanFormData } from '@pompelmi/sveltekit'
|
|
62
|
+
|
|
63
|
+
export const actions: Actions = {
|
|
64
|
+
default: async ({ request }) => {
|
|
65
|
+
const formData = await request.formData()
|
|
66
|
+
|
|
67
|
+
// Scans all File/Blob values; throws 422 if any is malicious
|
|
68
|
+
await scanFormData(formData, { host: 'localhost', port: 3310 })
|
|
69
|
+
|
|
70
|
+
return { success: true }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Custom error handling
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { scanUpload } from '@pompelmi/sveltekit'
|
|
79
|
+
import { fail } from '@sveltejs/kit'
|
|
80
|
+
|
|
81
|
+
export const actions: Actions = {
|
|
82
|
+
default: async ({ request }) => {
|
|
83
|
+
const formData = await request.formData()
|
|
84
|
+
const file = formData.get('file') as File
|
|
85
|
+
|
|
86
|
+
await scanUpload(file, {
|
|
87
|
+
host: 'localhost',
|
|
88
|
+
port: 3310,
|
|
89
|
+
onInfected: ({ filename }) => {
|
|
90
|
+
console.warn(`Blocked malicious upload: ${filename}`)
|
|
91
|
+
// Use SvelteKit's fail() to return a form error
|
|
92
|
+
throw fail(422, { error: `${filename} contains malware` })
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
return { success: true }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Route component example (full)
|
|
102
|
+
|
|
103
|
+
```svelte
|
|
104
|
+
<!-- src/routes/upload/+page.svelte -->
|
|
105
|
+
<script lang="ts">
|
|
106
|
+
import { enhance } from '$app/forms'
|
|
107
|
+
import type { ActionData } from './$types'
|
|
108
|
+
|
|
109
|
+
export let form: ActionData
|
|
110
|
+
</script>
|
|
111
|
+
|
|
112
|
+
<form method="POST" enctype="multipart/form-data" use:enhance>
|
|
113
|
+
<input type="file" name="file" required />
|
|
114
|
+
<button type="submit">Upload & Scan</button>
|
|
115
|
+
|
|
116
|
+
{#if form?.error}
|
|
117
|
+
<p style="color: red">{form.error}</p>
|
|
118
|
+
{:else if form?.success}
|
|
119
|
+
<p>Uploaded: {form.name}</p>
|
|
120
|
+
{/if}
|
|
121
|
+
</form>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Configuration Reference
|
|
125
|
+
|
|
126
|
+
| Option | Type | Default | Description |
|
|
127
|
+
|--------|------|---------|-------------|
|
|
128
|
+
| `host` | `string` | — | clamd hostname (enables TCP mode) |
|
|
129
|
+
| `port` | `number` | `3310` | clamd port |
|
|
130
|
+
| `socket` | `string` | — | UNIX domain socket path |
|
|
131
|
+
| `timeout` | `number` | `15000` | Socket idle timeout in ms |
|
|
132
|
+
| `retries` | `number` | `0` | Retry attempts |
|
|
133
|
+
| `retryDelay` | `number` | `1000` | Delay between retries in ms |
|
|
134
|
+
| `onInfected` | `Function` | — | Called with `{ filename }` when malware detected; must throw |
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
ISC — see root [LICENSE](../../LICENSE).
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ScanOptions } from 'pompelmi';
|
|
2
|
+
|
|
3
|
+
export interface PompelmiSvelteKitOptions extends ScanOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Called with `{ filename }` when a malicious file is detected.
|
|
6
|
+
* Must throw (e.g. throw error(422, { message: 'Malware detected' })).
|
|
7
|
+
* If omitted, throws an HTTP 422 Response automatically.
|
|
8
|
+
*/
|
|
9
|
+
onInfected?: (args: { filename: string }) => never | Promise<never>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Scans an uploaded file with pompelmi inside a SvelteKit server action or
|
|
14
|
+
* API route (+page.server.ts / +server.ts). Throws HTTP 422 on malicious files.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* // +page.server.ts
|
|
18
|
+
* import { scanUpload } from '@pompelmi/sveltekit'
|
|
19
|
+
* import type { Actions } from './$types'
|
|
20
|
+
*
|
|
21
|
+
* export const actions: Actions = {
|
|
22
|
+
* default: async ({ request }) => {
|
|
23
|
+
* const formData = await request.formData()
|
|
24
|
+
* await scanUpload(formData.get('file') as File, { host: 'localhost', port: 3310 })
|
|
25
|
+
* return { success: true }
|
|
26
|
+
* }
|
|
27
|
+
* }
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* // +server.ts (API route)
|
|
31
|
+
* import { scanUpload } from '@pompelmi/sveltekit'
|
|
32
|
+
* import { json } from '@sveltejs/kit'
|
|
33
|
+
* import type { RequestHandler } from './$types'
|
|
34
|
+
*
|
|
35
|
+
* export const POST: RequestHandler = async ({ request }) => {
|
|
36
|
+
* const formData = await request.formData()
|
|
37
|
+
* await scanUpload(formData.get('file') as File, { host: 'localhost', port: 3310 })
|
|
38
|
+
* return json({ ok: true })
|
|
39
|
+
* }
|
|
40
|
+
*/
|
|
41
|
+
export function scanUpload(
|
|
42
|
+
file: File | Blob | Buffer | null | undefined,
|
|
43
|
+
options?: PompelmiSvelteKitOptions
|
|
44
|
+
): Promise<symbol>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Scans all File/Blob values in a FormData object.
|
|
48
|
+
* Throws HTTP 422 on the first malicious file found.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* const formData = await request.formData()
|
|
52
|
+
* await scanFormData(formData, { host: 'localhost', port: 3310 })
|
|
53
|
+
*/
|
|
54
|
+
export function scanFormData(
|
|
55
|
+
formData: FormData,
|
|
56
|
+
options?: PompelmiSvelteKitOptions
|
|
57
|
+
): Promise<void>;
|
|
58
|
+
|
|
59
|
+
export { Verdict } from 'pompelmi';
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { scanBuffer, Verdict } = require('pompelmi');
|
|
4
|
+
|
|
5
|
+
// File is a global in Node 20+; on Node 18 it lives in node:buffer
|
|
6
|
+
const NodeFile = globalThis.File ?? require('node:buffer').File;
|
|
7
|
+
|
|
8
|
+
const SCAN_KEYS = ['host', 'port', 'socket', 'timeout', 'retries', 'retryDelay'];
|
|
9
|
+
|
|
10
|
+
function buildScanOptions(options) {
|
|
11
|
+
const out = {};
|
|
12
|
+
for (const k of SCAN_KEYS) {
|
|
13
|
+
if (options[k] !== undefined) out[k] = options[k];
|
|
14
|
+
}
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function toBuffer(file) {
|
|
19
|
+
if (Buffer.isBuffer(file)) return file;
|
|
20
|
+
if (file instanceof Uint8Array) return Buffer.from(file);
|
|
21
|
+
// Web API File / Blob
|
|
22
|
+
if (file && typeof file.arrayBuffer === 'function') {
|
|
23
|
+
return Buffer.from(await file.arrayBuffer());
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Scans an uploaded file with pompelmi inside a SvelteKit server action or
|
|
30
|
+
* API route. Throws an HTTP 422 Response automatically on malicious files.
|
|
31
|
+
*
|
|
32
|
+
* Use in a +page.server.ts action or +server.ts route:
|
|
33
|
+
*
|
|
34
|
+
* import { scanUpload } from '@pompelmi/sveltekit'
|
|
35
|
+
*
|
|
36
|
+
* export const actions = {
|
|
37
|
+
* default: async ({ request }) => {
|
|
38
|
+
* const formData = await request.formData()
|
|
39
|
+
* await scanUpload(formData.get('file'), { host: 'localhost', port: 3310 })
|
|
40
|
+
* // file is guaranteed clean here
|
|
41
|
+
* return { success: true }
|
|
42
|
+
* }
|
|
43
|
+
* }
|
|
44
|
+
*
|
|
45
|
+
* @param {File|Blob|Buffer} file - The uploaded file to scan.
|
|
46
|
+
* @param {object} [options]
|
|
47
|
+
* @param {string} [options.host] - clamd hostname (enables TCP mode).
|
|
48
|
+
* @param {number} [options.port=3310] - clamd port.
|
|
49
|
+
* @param {string} [options.socket] - UNIX domain socket path.
|
|
50
|
+
* @param {number} [options.timeout] - Socket idle timeout in ms.
|
|
51
|
+
* @param {number} [options.retries] - Retry attempts.
|
|
52
|
+
* @param {number} [options.retryDelay] - Delay between retries in ms.
|
|
53
|
+
* @param {Function} [options.onInfected] - Called with ({ filename }) when malware
|
|
54
|
+
* is detected. Must throw. Defaults to
|
|
55
|
+
* throwing HTTP 422 Response.
|
|
56
|
+
* @returns {Promise<symbol>} Verdict.Clean or Verdict.ScanError (never returns Verdict.Malicious).
|
|
57
|
+
*/
|
|
58
|
+
async function scanUpload(file, options) {
|
|
59
|
+
if (!file) return Verdict.Clean;
|
|
60
|
+
|
|
61
|
+
const scanOptions = buildScanOptions(options || {});
|
|
62
|
+
const buffer = await toBuffer(file);
|
|
63
|
+
|
|
64
|
+
if (!buffer || buffer.length === 0) return Verdict.Clean;
|
|
65
|
+
|
|
66
|
+
const result = await scanBuffer(buffer, scanOptions);
|
|
67
|
+
|
|
68
|
+
if (result === Verdict.Malicious) {
|
|
69
|
+
const filename = (file && typeof file.name === 'string') ? file.name : 'upload';
|
|
70
|
+
const onInfected = options && options.onInfected;
|
|
71
|
+
if (typeof onInfected === 'function') {
|
|
72
|
+
return onInfected({ filename });
|
|
73
|
+
}
|
|
74
|
+
throw new Response(
|
|
75
|
+
JSON.stringify({ error: 'Malware detected', filename }),
|
|
76
|
+
{ status: 422, headers: { 'Content-Type': 'application/json' } }
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Scans all File/Blob values in a FormData object. Throws on the first
|
|
85
|
+
* malicious file found.
|
|
86
|
+
*
|
|
87
|
+
* @param {FormData} formData - The parsed form data.
|
|
88
|
+
* @param {object} [options] - Same options as scanUpload.
|
|
89
|
+
* @returns {Promise<void>}
|
|
90
|
+
*/
|
|
91
|
+
async function scanFormData(formData, options) {
|
|
92
|
+
if (!formData || typeof formData.entries !== 'function') return;
|
|
93
|
+
for (const [, value] of formData.entries()) {
|
|
94
|
+
if (value instanceof NodeFile || (value && typeof value.arrayBuffer === 'function')) {
|
|
95
|
+
await scanUpload(value, options);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { scanUpload, scanFormData, Verdict };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pompelmi/sveltekit",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "SvelteKit helper 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/sveltekit"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"sveltekit",
|
|
15
|
+
"svelte",
|
|
16
|
+
"server-action",
|
|
17
|
+
"api-route",
|
|
18
|
+
"clamav",
|
|
19
|
+
"antivirus",
|
|
20
|
+
"virus-scan",
|
|
21
|
+
"malware",
|
|
22
|
+
"file-upload",
|
|
23
|
+
"security",
|
|
24
|
+
"pompelmi"
|
|
25
|
+
],
|
|
26
|
+
"main": "./index.js",
|
|
27
|
+
"types": "./index.d.ts",
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "node --test test/index.test.js"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"pompelmi": ">=1.15.0"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"registry": "https://registry.npmjs.org/",
|
|
36
|
+
"access": "public"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -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';
|