pompelmi 1.1.0 → 1.2.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.
@@ -39,7 +39,9 @@
39
39
  "Bash(node --test test/unit.test.js)",
40
40
  "Bash(echo \"exit:$?\")",
41
41
  "Read(//tmp/**)",
42
- "Bash(tee /Users/tommy/pompelmi/pompelmi/test_out.txt)"
42
+ "Bash(tee /Users/tommy/pompelmi/pompelmi/test_out.txt)",
43
+ "Bash(npm install:*)",
44
+ "Bash(npm ls:*)"
43
45
  ]
44
46
  }
45
47
  }
package/README.md CHANGED
@@ -15,7 +15,7 @@
15
15
 
16
16
  ---
17
17
 
18
- A minimal Node.js wrapper around [ClamAV](https://www.clamav.net/) that scans any file and returns a plain string: `"Clean"`, `"Malicious"`, or `"ScanError"`. No daemons. No cloud. No native bindings.
18
+ A minimal Node.js wrapper around [ClamAV](https://www.clamav.net/) that scans any file and returns a typed `Verdict` Symbol: `Verdict.Clean`, `Verdict.Malicious`, or `Verdict.ScanError`. No daemons. No cloud. No native bindings. Zero runtime dependencies.
19
19
 
20
20
  ## Table of contents
21
21
 
@@ -43,12 +43,11 @@ npm install pompelmi
43
43
  ```
44
44
 
45
45
  ```js
46
- const pompelmi = require('pompelmi');
46
+ const { scan, Verdict } = require('pompelmi');
47
47
 
48
- const result = await pompelmi.scan('/path/to/file.zip');
49
- // "Clean" | "Malicious" | "ScanError"
48
+ const result = await scan('/path/to/file.zip');
50
49
 
51
- if (result === 'Malicious') {
50
+ if (result === Verdict.Malicious) {
52
51
  throw new Error('File rejected: malware detected');
53
52
  }
54
53
  ```
@@ -66,21 +65,26 @@ No stdout parsing. No regex. No surprises.
66
65
  ### `pompelmi.scan(filePath, [options])`
67
66
 
68
67
  ```ts
69
- pompelmi.scan(filePath: string, options?: { host?: string; port?: number; timeout?: number }): Promise<"Clean" | "Malicious" | "ScanError">
68
+ scan(filePath: string, options?: { host?: string; port?: number; timeout?: number }): Promise<symbol>
69
+ // resolves to one of: Verdict.Clean | Verdict.Malicious | Verdict.ScanError
70
70
  ```
71
71
 
72
72
  | Parameter | Type | Description |
73
73
  |------------|----------|-----------------------------------------|
74
74
  | `filePath` | `string` | Absolute or relative path to the file. |
75
- | `options` | `object` | Optional. Omit to use the local `clamscan` CLI. Pass `host` / `port` to scan via a clamd TCP socket instead. See [docs/api.md](./docs/api.md) for the full reference. |
75
+ | `options` | `object` | Optional. Omit to use the local `clamscan` CLI. Pass `host` / `port` to scan via a clamd TCP socket instead. See [docs/api.html](./docs/api.html) for the full reference. |
76
76
 
77
77
  **Resolves** to one of:
78
78
 
79
- | Result | ClamAV exit code | Meaning |
80
- |---------------|:---:|------------------------------------------------------------------------------------------------------|
81
- | `"Clean"` | 0 | No threats found. |
82
- | `"Malicious"` | 1 | A known virus or malware signature was matched. |
83
- | `"ScanError"` | 2 | The scan itself failed (I/O error, encrypted archive, permission denied). File status is unknown — treat as untrusted. |
79
+ | Result | ClamAV exit code | Meaning |
80
+ |--------------------|:---:|------------------------------------------------------------------------------------------------------|
81
+ | `Verdict.Clean` | 0 | No threats found. |
82
+ | `Verdict.Malicious` | 1 | A known virus or malware signature was matched. |
83
+ | `Verdict.ScanError` | 2 | The scan itself failed (I/O error, encrypted archive, permission denied). File status is unknown — treat as untrusted. |
84
+
85
+ > **Reading the verdict as a string** — each Verdict Symbol carries a `.description` property
86
+ > (`Verdict.Clean.description === 'Clean'`) for logging or serialisation without comparing against
87
+ > raw strings in application logic.
84
88
 
85
89
  **Rejects** with an `Error` in these cases:
86
90
 
@@ -95,20 +99,20 @@ pompelmi.scan(filePath: string, options?: { host?: string; port?: number; timeou
95
99
  **Example — full error handling:**
96
100
 
97
101
  ```js
98
- const pompelmi = require('pompelmi');
102
+ const { scan, Verdict } = require('pompelmi');
99
103
  const path = require('path');
100
104
 
101
105
  async function safeScan(filePath) {
102
106
  try {
103
- const result = await pompelmi.scan(path.resolve(filePath));
107
+ const result = await scan(path.resolve(filePath));
104
108
 
105
- if (result === 'ScanError') {
109
+ if (result === Verdict.ScanError) {
106
110
  // The scan could not complete — treat the file as untrusted.
107
111
  console.warn('Scan incomplete, rejecting file as precaution.');
108
112
  return null;
109
113
  }
110
114
 
111
- return result; // "Clean" or "Malicious"
115
+ return result; // Verdict.Clean or Verdict.Malicious
112
116
  } catch (err) {
113
117
  console.error('Scan failed:', err.message);
114
118
  return null;
@@ -200,7 +204,7 @@ npm test
200
204
 
201
205
  The test suite has two parts:
202
206
 
203
- - **Unit tests** (`test/unit.test.js`) — run with Node's built-in test runner. Mock `cross-spawn` and platform dependencies; no ClamAV installation required.
207
+ - **Unit tests** (`test/unit.test.js`) — run with Node's built-in test runner. Mock `nativeSpawn` from `src/spawn.js` and platform dependencies via require-cache injection; no ClamAV installation required.
204
208
  - **Integration tests** (`test/scan.test.js`) — spawn real `clamscan` processes against EICAR test files. Skipped automatically if `clamscan` is not found in PATH.
205
209
 
206
210
  ## Contributing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pompelmi",
3
- "version": "1.1.0",
3
+ "version": "1.2.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",
@@ -27,9 +27,7 @@
27
27
  "test": "node --test test/unit.test.js && node test/scan.test.js",
28
28
  "lint": "eslint src/"
29
29
  },
30
- "dependencies": {
31
- "cross-spawn": "^7.0.6"
32
- },
30
+ "dependencies": {},
33
31
  "devDependencies": {
34
32
  "@eslint/js": "^10.0.1",
35
33
  "eslint": "^10.2.0",
@@ -1,4 +1,4 @@
1
- const spawn = require("cross-spawn");
1
+ const { nativeSpawn: spawn } = require('./spawn.js');
2
2
  const fs = require("fs");
3
3
  const { getUpdaterCommand } = require('./InstallerCommand.js');
4
4
  const { PLATFORM } = require('./constants.js');
@@ -1,4 +1,4 @@
1
- const spawn = require("cross-spawn");
1
+ const { nativeSpawn: spawn } = require('./spawn.js');
2
2
  const { execSync } = require("child_process");
3
3
  const { getInstallerCommand } = require('./InstallerCommand.js');
4
4
  const { PLATFORM } = require('./constants.js')
@@ -1,4 +1,4 @@
1
- const spawn = require("cross-spawn");
1
+ const { nativeSpawn: spawn } = require('./spawn.js');
2
2
  const fs = require("fs");
3
3
  const { SCAN_RESULTS } = require('./config.js');
4
4
  const { scanViaClamd } = require('./ClamdScanner.js');
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
- const net = require('net');
4
- const fs = require('fs');
3
+ const net = require('net');
4
+ const fs = require('fs');
5
+ const { Verdict } = require('./verdicts.js');
5
6
 
6
7
  // ClamAV INSTREAM protocol:
7
8
  // 1. Send "zINSTREAM\0"
@@ -13,9 +14,9 @@ const CHUNK_SIZE = 64 * 1024; // 64 KB — well within clamd's default Stre
13
14
 
14
15
  function parseClamdResponse(raw) {
15
16
  const text = raw.toString('utf8').trim();
16
- if (text === 'stream: OK') return 'Clean';
17
- if (text.endsWith(' FOUND')) return 'Malicious';
18
- return 'ScanError';
17
+ if (text === 'stream: OK') return Verdict.Clean;
18
+ if (text.endsWith(' FOUND')) return Verdict.Malicious;
19
+ return Verdict.ScanError;
19
20
  }
20
21
 
21
22
  /**
package/src/config.js CHANGED
@@ -1,3 +1,5 @@
1
+ const { Verdict } = require('./verdicts.js');
2
+
1
3
  module.exports = Object.freeze({
2
4
  INSTALLER_COMMANDS: Object.freeze({
3
5
  win32: ['choco', ['install', 'clamav', '-y']],
@@ -15,8 +17,8 @@ module.exports = Object.freeze({
15
17
  win32: 'C:\\ProgramData\\ClamAV\\main.cvd',
16
18
  }),
17
19
  SCAN_RESULTS: Object.freeze({
18
- 0: 'Clean',
19
- 1: 'Malicious',
20
- 2: 'ScanError'
20
+ 0: Verdict.Clean,
21
+ 1: Verdict.Malicious,
22
+ 2: Verdict.ScanError,
21
23
  }),
22
24
  });
package/src/index.js CHANGED
@@ -1,5 +1,4 @@
1
- const { scan } = require('./ClamAVScanner.js');
1
+ const { scan } = require('./ClamAVScanner.js');
2
+ const { Verdict } = require('./verdicts.js');
2
3
 
3
- const pompelmi = { scan };
4
-
5
- module.exports = pompelmi;
4
+ module.exports = { scan, Verdict };
package/src/spawn.js ADDED
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('child_process');
4
+
5
+ /**
6
+ * A thin wrapper around Node's built-in child_process.spawn that handles the
7
+ * one meaningful cross-platform difference: on Windows, .cmd/.bat launchers
8
+ * (e.g. choco, npm) cannot be resolved without the shell, so shell:true is
9
+ * required. On POSIX systems the shell is never involved.
10
+ *
11
+ * All callers in this codebase pass only hardcoded, trusted arguments, so
12
+ * enabling the shell on Windows introduces no injection risk.
13
+ *
14
+ * @param {string} cmd
15
+ * @param {string[]} args
16
+ * @param {object} [options] - Passed through to child_process.spawn.
17
+ * `shell` is always overridden by this wrapper.
18
+ * @returns {import('child_process').ChildProcess}
19
+ */
20
+ function nativeSpawn(cmd, args, options) {
21
+ return spawn(cmd, args, {
22
+ ...options,
23
+ shell: process.platform === 'win32',
24
+ });
25
+ }
26
+
27
+ module.exports = { nativeSpawn };
@@ -0,0 +1,23 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Opaque Symbol-based scan verdicts.
5
+ *
6
+ * Using Symbols instead of strings makes comparisons typo-proof: a misspelled
7
+ * string silently produces `false`; an unknown Symbol property is `undefined`
8
+ * and fails loudly at the point of use.
9
+ *
10
+ * Usage:
11
+ * const { Verdict } = require('pompelmi');
12
+ * const result = await pompelmi.scan(filePath);
13
+ * if (result === Verdict.Clean) { /* safe *\/ }
14
+ * if (result === Verdict.Malicious) { /* quarantine or reject *\/ }
15
+ * if (result === Verdict.ScanError) { /* clamscan returned exit code 2 *\/ }
16
+ */
17
+ const Verdict = Object.freeze({
18
+ Clean: Symbol('Clean'),
19
+ Malicious: Symbol('Malicious'),
20
+ ScanError: Symbol('ScanError'),
21
+ });
22
+
23
+ module.exports = { Verdict };