diff --git a/.gitignore b/.gitignore index 72aaa11..b54656b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ db.sqlite-wal db.sqlite-shm build/ uploads/ +.keys/ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c72434a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,38 @@ +{ + "name": "prometheus", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "prometheus", + "version": "0.1.0", + "license": "MPL-2.0", + "dependencies": { + "busboy": "^1.6.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + } + } +} diff --git a/package.json b/package.json index 3d88f3e..4dd9151 100644 --- a/package.json +++ b/package.json @@ -10,5 +10,8 @@ }, "scripts": { "start": "node service/server.js" + }, + "dependencies": { + "busboy": "^1.6.0" } } diff --git a/service/clamd.js b/service/clamd.js new file mode 100644 index 0000000..488252e --- /dev/null +++ b/service/clamd.js @@ -0,0 +1,222 @@ +/** + * A minimal client for clamd — ClamAV's long-running daemon. + * + * We speak the INSTREAM command over TCP. The protocol is small enough that + * reaching for a third-party clamd client would obscure what we do more than + * explain it: + * + * client → server: "zINSTREAM\0" + * client → server: <4-byte big-endian length> (repeated) + * client → server: <4-byte 0> (end-of-stream) + * client ← server: "stream: OK\0" (clean) + * or "stream: FOUND\0" (infected) + * or "ERROR: \0" (operational problem) + * + * Reference: https://docs.clamav.net/manual/Usage/Scanning.html#clamd-protocol + * + * The `MOCK_SCAN` env var short-circuits all of this for local development on a + * box without ClamAV installed. Values: `clean`, `infected`, `error`. + */ + +import { connect } from 'node:net'; +import { PassThrough } from 'node:stream'; + +const CLAMD_HOST = process.env.CLAMD_HOST || '127.0.0.1'; +const CLAMD_PORT = Number(process.env.CLAMD_PORT) || 3310; +const CLAMD_CHUNK = 65536; // 64 KiB per INSTREAM frame +const CLAMD_SOCKET_TIMEOUT = 60_000; // 60 s per scan — generous for big files + +/** + * Scan a stream through clamd and return the verdict. + * + * @param {NodeJS.ReadableStream} source the file bytes + * @returns {Promise<{verdict: 'clean'|'infected'|'error', engine: string, engine_version?: string, signatures: string[], raw: string, duration_ms: number}>} + */ +export async function scanStream(source) { + const mock = process.env.MOCK_SCAN; + if (mock) { + return mockScan(mock, source); + } + + const started = Date.now(); + + return new Promise((resolve, reject) => { + const sock = connect({ host: CLAMD_HOST, port: CLAMD_PORT }); + sock.setTimeout(CLAMD_SOCKET_TIMEOUT); + + let response = Buffer.alloc(0); + let settled = false; + + const finish = (result) => { + if (settled) return; + settled = true; + sock.destroy(); + result.duration_ms = Date.now() - started; + resolve(result); + }; + + const fail = (err) => { + if (settled) return; + settled = true; + sock.destroy(); + // Operational problems (daemon down, network error, timeout) don't make + // the file infected — they make the scan inconclusive. The caller decides + // what to do with that; we never pretend a scan happened when it didn't. + resolve({ + verdict: 'error', + engine: 'clamav', + signatures: [], + raw: '', + error: String(err?.message || err), + duration_ms: Date.now() - started, + }); + }; + + sock.on('error', fail); + sock.on('timeout', () => fail(new Error('clamd socket timeout'))); + + sock.on('data', (chunk) => { + response = Buffer.concat([response, chunk]); + // Response is a single null-terminated string. + if (response.includes(0)) { + const text = response.subarray(0, response.indexOf(0)).toString('utf8'); + finish(parseVerdict(text)); + } + }); + + sock.on('close', () => { + if (!settled) { + fail(new Error('clamd closed connection without a verdict')); + } + }); + + sock.on('connect', () => { + sock.write('zINSTREAM\0'); + + // Forward the source through the framing format clamd expects. + source.on('error', fail); + + source.on('data', (chunk) => { + if (settled) return; + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + // One source chunk may be bigger than our 64 KiB frame cap; slice it. + for (let i = 0; i < buf.length; i += CLAMD_CHUNK) { + const slice = buf.subarray(i, Math.min(i + CLAMD_CHUNK, buf.length)); + const header = Buffer.alloc(4); + header.writeUInt32BE(slice.length, 0); + sock.write(header); + sock.write(slice); + } + }); + + source.on('end', () => { + if (settled) return; + const terminator = Buffer.alloc(4); // 0 means end-of-stream + sock.write(terminator); + }); + }); + }); +} + +/* ---------------------------------------------------------------------- */ + +/** + * Translate clamd's response line into a structured verdict. + */ +function parseVerdict(text) { + const trimmed = text.trim(); + + if (/\bOK\b/.test(trimmed) && !/FOUND/i.test(trimmed)) { + return { verdict: 'clean', engine: 'clamav', signatures: [], raw: trimmed }; + } + + const foundMatch = trimmed.match(/stream:\s*(.+?)\s+FOUND/i); + if (foundMatch) { + return { + verdict: 'infected', + engine: 'clamav', + signatures: [foundMatch[1].trim()], + raw: trimmed, + }; + } + + // Anything that didn't match the two happy shapes is an operational error. + return { + verdict: 'error', + engine: 'clamav', + signatures: [], + raw: trimmed, + error: trimmed, + }; +} + +/** + * Local-dev short-circuit. Drains the source so the caller's upstream code + * (SHA computation, byte accounting) still gets the data. + */ +async function mockScan(mode, source) { + const started = Date.now(); + // Drain the source — the caller's hash/size pipeline needs the bytes to + // flow through end-to-end even when we aren't really scanning. + await new Promise((resolve, reject) => { + const sink = new PassThrough(); + source.on('error', reject); + sink.on('error', reject); + sink.on('finish', resolve); + source.pipe(sink); + }); + + const duration_ms = Date.now() - started; + if (mode === 'infected') { + return { + verdict: 'infected', + engine: 'mock', + signatures: ['Mock.Test.Sample'], + raw: 'MOCK_SCAN=infected', + duration_ms, + }; + } + if (mode === 'error') { + return { + verdict: 'error', + engine: 'mock', + signatures: [], + raw: 'MOCK_SCAN=error', + error: 'MOCK_SCAN=error forced an operational failure', + duration_ms, + }; + } + return { + verdict: 'clean', + engine: 'mock', + signatures: [], + raw: 'MOCK_SCAN=clean', + duration_ms, + }; +} + +/** + * One-shot "is clamd alive" check, for the healthcheck endpoint. + */ +export async function clamdPing() { + if (process.env.MOCK_SCAN) { + return { ok: true, engine: 'mock' }; + } + return new Promise((resolve) => { + const sock = connect({ host: CLAMD_HOST, port: CLAMD_PORT }); + sock.setTimeout(2000); + let data = ''; + const done = (ok, err = '') => { + sock.destroy(); + resolve(ok ? { ok: true, engine: 'clamav' } : { ok: false, error: err }); + }; + sock.on('connect', () => sock.write('zPING\0')); + sock.on('data', (c) => { + data += c.toString('utf8'); + if (data.includes('PONG')) done(true); + }); + sock.on('error', (e) => done(false, String(e?.message || e))); + sock.on('timeout', () => done(false, 'timeout')); + sock.on('close', () => done(data.includes('PONG'))); + }); +} diff --git a/service/pages.js b/service/pages.js index c443841..a1e29b4 100644 --- a/service/pages.js +++ b/service/pages.js @@ -364,32 +364,89 @@ export function submit() {
-
The submit UI is being wired right now. Upload - + scan is the next milestone (see - the publishers' walkthrough for the full flow). This - page becomes the first screen of the submit wizard once the upload endpoint - lands.
+
Milestone 1 of 4: upload + scan. This step is + live and testable. The next milestone wires the manifest and APS1 record + (see the publishers' walkthrough); payment and the + on-chain publish come after that.
  1. 1. Upload the binary. - Drag-and-drop or file picker. The server scans with ClamAV on the - way in and returns a signed scan report. Scan verdict and SHA-256 appear - on the next screen.
  2. + Pick a file (any file — we don't care what the format is, only + what ClamAV says about the bytes). The server scans it, hashes it, + and hands back a signed scan report.
  3. 2. Fill the manifest. Name, description, category, version, icon URL. Scan verdict is - attached automatically. Preview on the right as you type.
  4. + attached automatically. Not wired yet.
  5. 3. Sign with your BNS key. - The service shows the APS1 payload. Sign in Theseus (if your wallet - is there), paste a BIP-137 signature from any BCH wallet, or import your - name's mnemonic once in the page (wiped on close). The signed TX is yours - regardless of whether we broadcast it.
  6. + The service shows the APS1 payload. Sign in Theseus, paste a + BIP-137 signature from any BCH wallet, or import your name's mnemonic + once in the page (wiped on close). Not wired yet.
  7. 4. Pay the listing fee (eventually). - During the free wave: skip. Later: a BCH invoice for the per-listing - fee at the day's rate. Service broadcasts the APS1 TX on confirmation; if - the broadcast fails you still have the signed TX and can relay it yourself.
  8. + Free during the opening wave; a BCH invoice for the per-listing + fee once storage costs cross a threshold. Not wired yet.
+
+

Upload a file to try it

+

Pick any file to send through the scanner. The upload is + accepted, SHA-256'd, and either kept (clean) or discarded (infected or + error). Nothing is published; this is just the scan step.

+ +
+ + +
+
+ + +
+ + +
`, }; } diff --git a/service/server.js b/service/server.js index 7e96c37..7648d0c 100644 --- a/service/server.js +++ b/service/server.js @@ -13,16 +13,22 @@ */ import { createServer } from 'node:http'; -import { readFile } from 'node:fs/promises'; import { createReadStream, statSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { dirname, join, normalize, resolve } from 'node:path'; +import { dirname, normalize, resolve } from 'node:path'; import { layout } from './layout.js'; import * as pages from './pages.js'; +import { handleUpload } from './uploads.js'; +import { clamdPing } from './clamd.js'; +import { publicKeyBase64, loadServiceKey } from './signing.js'; const PORT = Number(process.env.PORT) || 3700; const BIND = process.env.BIND || '127.0.0.1'; +// Load the service key on startup so a missing-key problem shows up in the +// terminal when the operator reads the launch log, not under a POST later. +loadServiceKey(); + const SELF = fileURLToPath(import.meta.url); const SERVICE_DIR = dirname(SELF); const PUBLIC_DIR = resolve(SERVICE_DIR, '..', 'public'); @@ -65,6 +71,12 @@ const server = createServer(async (req, res) => { try { if (url.startsWith('/static/')) { await serveStatic(url.slice('/static/'.length), res); + } else if (url === '/api/upload') { + await handleUpload(req, res); + } else if (url === '/healthz') { + await serveHealth(res); + } else if (url === '/.well-known/prometheus-service-key.json') { + serveServiceKey(res); } else if (PAGE_ROUTES[url]) { servePage(PAGE_ROUTES[url], res); } else { @@ -118,6 +130,43 @@ function servePage(fn, res, status = 200, req = null) { res.end(html); } +/** + * `/healthz` — JSON probe for operators and uptime checks. Says whether + * clamd (or the mock) is reachable; the HTTP status alone is enough for + * an opinionated monitor. + */ +async function serveHealth(res) { + const scanner = await clamdPing(); + const ok = scanner.ok === true; + const body = JSON.stringify({ ok, scanner }); + res.writeHead(ok ? 200 : 503, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + 'content-length': Buffer.byteLength(body), + }); + res.end(body); +} + +/** + * `/.well-known/prometheus-service-key.json` — the public key callers use to + * verify scan reports. Lives at a well-known path so clients can cache it by + * origin. + */ +function serveServiceKey(res) { + const body = JSON.stringify({ + service: 'prometheus', + alg: 'ed25519', + pubkey: publicKeyBase64(), + issued_by: 'https://prometheus.x', + }, null, 2); + res.writeHead(200, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'public, max-age=3600', + 'content-length': Buffer.byteLength(body), + }); + res.end(body); +} + /** * Serve a file from PUBLIC_DIR by relative path. The caller passes a * path with no leading slash and no `..`; this double-checks via diff --git a/service/signing.js b/service/signing.js new file mode 100644 index 0000000..dbac23c --- /dev/null +++ b/service/signing.js @@ -0,0 +1,108 @@ +/** + * Service key — the identity that signs scan reports. + * + * This is NOT the publisher's BNS key and NOT the operator's BCH wallet. It + * is the service's own identity, used to attest "Prometheus's scanner saw + * this file and this is what it reported." Readers' clients verify the + * signature against the service's public key (published out of band — see + * `/.well-known/prometheus-service-key.json`) to tell a report that came + * from this service apart from one somebody fabricated. + * + * ed25519 was chosen over secp256k1 BIP-137 (the BCH convention) because the + * scan report isn't a Bitcoin thing. The key never signs value, never signs + * on behalf of a user, and never touches the chain. ed25519 keeps the surface + * small and the signatures short. + * + * The private key lives at `KEY_DIR/service.key` (defaults to a directory + * next to the service). It is generated on first start if absent. The matching + * public key is served alongside signed reports and at + * `/.well-known/prometheus-service-key.json` so clients can cache it. + */ + +import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as nodeSign } from 'node:crypto'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const KEY_DIR = process.env.PROMETHEUS_KEY_DIR + || resolve(dirname(fileURLToPath(import.meta.url)), '..', '.keys'); +const KEY_PATH = resolve(KEY_DIR, 'service.key'); + +let cachedPrivate = null; +let cachedPublic = null; +let cachedPublicBase64 = null; + +/** + * Load (or generate) the service key. Idempotent. + */ +export function loadServiceKey() { + if (cachedPrivate) return; + + if (!existsSync(KEY_PATH)) { + mkdirSync(KEY_DIR, { recursive: true }); + const { privateKey } = generateKeyPairSync('ed25519'); + const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }); + // Restrictive mode intentional — the file is a secret. + writeFileSync(KEY_PATH, pem, { mode: 0o600 }); + console.log(`[prometheus] generated new service key at ${KEY_PATH}`); + } + + const pem = readFileSync(KEY_PATH, 'utf8'); + cachedPrivate = createPrivateKey(pem); + cachedPublic = createPublicKey(cachedPrivate); + // Raw 32-byte Ed25519 public key, base64url-encoded. Matches the shape + // the client-side verifier wants. + const der = cachedPublic.export({ type: 'spki', format: 'der' }); + // The 32 bytes of the key sit at the end of the DER SPKI envelope. + const raw = der.subarray(der.length - 32); + cachedPublicBase64 = raw.toString('base64url'); +} + +/** + * The service's public key, base64url-encoded (raw 32-byte Ed25519). + */ +export function publicKeyBase64() { + loadServiceKey(); + return cachedPublicBase64; +} + +/** + * Sign a scan-report payload. The payload is a JSON object; we canonicalise + * it (keys sorted at every level, no whitespace) before signing so verifiers + * can reproduce the signed bytes without ambiguity. + * + * @param {object} payload + * @returns {{alg: 'ed25519', payload: string, signature: string, pubkey: string}} + */ +export function signReport(payload) { + loadServiceKey(); + const canonical = canonicalJson(payload); + const signature = nodeSign(null, Buffer.from(canonical, 'utf8'), cachedPrivate); + return { + alg: 'ed25519', + payload: Buffer.from(canonical, 'utf8').toString('base64url'), + signature: signature.toString('base64url'), + pubkey: cachedPublicBase64, + }; +} + +/** + * JSON with keys sorted at every level and no whitespace. Equivalent to RFC + * 8785's "JCS" shape for the subset we use (plain data: null / boolean / + * number / string / array / object). We don't carry anything exotic (dates + * are ISO strings before this is called, numbers are integers), so a short + * walker beats a 500 KB dependency. + */ +export function canonicalJson(value) { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return '[' + value.map(canonicalJson).join(',') + ']'; + } + const keys = Object.keys(value).sort(); + const body = keys + .map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k])) + .join(','); + return '{' + body + '}'; +} diff --git a/service/uploads.js b/service/uploads.js new file mode 100644 index 0000000..a32e076 --- /dev/null +++ b/service/uploads.js @@ -0,0 +1,246 @@ +/** + * POST /api/upload — accept a file, scan it, hand back a signed scan report. + * + * This endpoint is the floor of the submit path. It does not publish anything + * on chain, it does not collect payment, it does not need the publisher's + * identity. All it does is: + * + * 1. Receive the file bytes (multipart/form-data, `file` field). + * 2. Spool to a temp file while hashing to SHA-256. + * 3. Hand the file to clamd (or MOCK_SCAN) for a verdict. + * 4. Keep the file if clean, discard it otherwise. + * 5. Sign the verdict with the service key and return it. + * + * A clean upload leaves `uploads/.bin` on disk plus a + * `uploads/.json` sidecar with the metadata. The signed report in + * the response is the artifact the manifest-signing step will attach to the + * APS1 record later. + * + * The file-size cap is deliberately generous — apps can be big — but every + * byte over the cap is dropped and the upload rejected: letting one request + * fill the disk is worse than making a publisher try again. + */ + +import { createHash } from 'node:crypto'; +import { createReadStream, createWriteStream } from 'node:fs'; +import { mkdir, rename, unlink, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { pipeline } from 'node:stream/promises'; +import { PassThrough } from 'node:stream'; +import Busboy from 'busboy'; +import { scanStream } from './clamd.js'; +import { signReport } from './signing.js'; + +const UPLOAD_DIR = process.env.PROMETHEUS_UPLOAD_DIR + || resolve(dirname(fileURLToPath(import.meta.url)), '..', 'uploads'); + +// 2 GiB — the ceiling, not the typical size. Change via env rather than code +// so an operator can tighten it without a redeploy. +const MAX_FILE_BYTES = Number(process.env.PROMETHEUS_MAX_UPLOAD_BYTES) + || 2 * 1024 * 1024 * 1024; + +/** + * Express-style handler attached to `/api/upload`. + */ +export async function handleUpload(req, res) { + if (req.method !== 'POST') { + return respond(res, 405, { ok: false, error: 'method not allowed' }); + } + + const contentType = String(req.headers['content-type'] || ''); + if (!contentType.startsWith('multipart/form-data')) { + return respond(res, 415, { + ok: false, + error: 'content-type must be multipart/form-data with a `file` field', + }); + } + + await mkdir(UPLOAD_DIR, { recursive: true }); + + const tempId = randomUUID(); + const tempPath = resolve(UPLOAD_DIR, `.tmp-${tempId}`); + + let gotFile = false; + let truncated = false; + let originalName = ''; + let mimeType = 'application/octet-stream'; + + // We let busboy drive. The one 'file' event we care about forwards straight + // to a hashing + write pipeline; any extra fields are ignored. + const busboy = Busboy({ + headers: req.headers, + limits: { fileSize: MAX_FILE_BYTES, files: 1 }, + }); + + const hasher = createHash('sha256'); + let byteCount = 0; + + // We'd like to pipe the busboy file into TWO sinks: the temp file and the + // hasher. PassThrough splits a stream in one line: one upstream, two + // downstreams, no buffering we have to manage ourselves. + const toHash = new PassThrough(); + const toDisk = new PassThrough(); + toHash.on('data', (chunk) => { + hasher.update(chunk); + byteCount += chunk.length; + }); + + const writeToDisk = pipeline(toDisk, createWriteStream(tempPath)); + + const fileDone = new Promise((resolveFile, rejectFile) => { + busboy.on('file', (fieldname, file, info) => { + gotFile = true; + originalName = sanitize(info.filename || 'upload'); + mimeType = info.mimeType || mimeType; + + file.on('limit', () => { truncated = true; }); + + file.on('data', (chunk) => { + toHash.write(chunk); + toDisk.write(chunk); + }); + + file.on('end', () => { + toHash.end(); + toDisk.end(); + }); + + file.on('error', rejectFile); + }); + + busboy.on('finish', resolveFile); + busboy.on('error', rejectFile); + }); + + // Pump the request through busboy. If anything throws here we clean up and + // reply with 400 — we never leave a half-written temp file behind. + try { + req.pipe(busboy); + await fileDone; + await writeToDisk; + } catch (err) { + await safeUnlink(tempPath); + return respond(res, 400, { ok: false, error: String(err?.message || err) }); + } + + if (!gotFile) { + await safeUnlink(tempPath); + return respond(res, 400, { ok: false, error: 'no `file` field in request' }); + } + + if (truncated) { + await safeUnlink(tempPath); + return respond(res, 413, { + ok: false, + error: `file exceeded the ${MAX_FILE_BYTES}-byte cap`, + }); + } + + const sha256 = hasher.digest('hex'); + + // Scan from the on-disk copy. Scanning in parallel with receive would + // save latency on huge files, but doubles the error surface — we would + // need to coordinate two independent failure modes. On-disk-then-scan is + // what every mature scanning service does; we can revisit once we have + // a reason. + const scan = await scanStream(createReadStream(tempPath)); + + const now = new Date().toISOString(); + const upload = { + hash: sha256, + size: byteCount, + name: originalName, + mime: mimeType, + received_at: now, + }; + + let stored = false; + let finalPath = null; + + if (scan.verdict === 'clean') { + finalPath = resolve(UPLOAD_DIR, `${sha256}.bin`); + try { + await rename(tempPath, finalPath); + stored = true; + await writeFile( + resolve(UPLOAD_DIR, `${sha256}.json`), + JSON.stringify({ upload, scan }, null, 2), + 'utf8', + ); + } catch (err) { + // Rare: another concurrent upload of the same bytes won the race. + // The existing file is already correct; drop ours and move on. + await safeUnlink(tempPath); + stored = true; + finalPath = resolve(UPLOAD_DIR, `${sha256}.bin`); + } + } else { + // Infected or error — the bytes do not stay on our disk. + await safeUnlink(tempPath); + } + + // The signed report is what callers persist. It carries the upload metadata + // AND the scan outcome, so a downstream verifier can confirm this exact + // (hash, size, verdict) tuple came from this service and was not edited. + const reportPayload = { + v: 1, + kind: 'prometheus.scan-report', + service: 'prometheus', + issued_at: now, + upload: { + hash: upload.hash, + size: upload.size, + name: upload.name, + mime: upload.mime, + }, + scan: { + verdict: scan.verdict, + engine: scan.engine, + signatures: scan.signatures, + duration_ms: scan.duration_ms, + }, + stored, + }; + const report = signReport(reportPayload); + + return respond(res, scan.verdict === 'error' ? 502 : 200, { + ok: scan.verdict !== 'error', + upload, + scan: { + verdict: scan.verdict, + engine: scan.engine, + signatures: scan.signatures, + duration_ms: scan.duration_ms, + ...(scan.error ? { error: scan.error } : {}), + }, + stored, + report, + }); +} + +/* ----------------------- helpers ----------------------- */ + +function respond(res, status, body) { + const json = JSON.stringify(body); + res.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'content-length': Buffer.byteLength(json), + 'cache-control': 'no-store', + }); + res.end(json); +} + +function sanitize(name) { + // Keep the publisher's filename for display, but strip path separators and + // control characters — nothing we write to disk is derived from this; it is + // just metadata for the UI. + return String(name) + .replace(/[\\/\x00-\x1f]/g, '_') + .slice(0, 200); +} + +async function safeUnlink(path) { + try { await unlink(path); } catch { /* already gone */ } +}