#!/usr/bin/env node import { spawnSync } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; const LOCKFILE_PATH = 'package-lock.json'; const POLICY_PATH = 'security/install-scripts-allowlist.json'; const SUPPORTED_VERIFIERS = new Set(['better-sqlite3-memory-db']); const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i; function packageNameFromPath(path) { const marker = "node_modules/"; const index = path.lastIndexOf(marker); return index === -1 ? "" : path.slice(index + marker.length); } export function validateRebuildEntries(lockfile, approvedEntries) { if (lockfile?.lockfileVersion !== 3 || typeof lockfile.packages !== 'object') { throw new Error('native rebuild requires npm lockfile version 3'); } if (!Array.isArray(approvedEntries)) { throw new Error('native rebuild allowlist must be an array'); } return approvedEntries.map((approval) => { for (const field of ['path', 'name', 'version', 'integrity', 'verifier']) { if (typeof approval?.[field] !== 'string' || approval[field] === '') { throw new Error(`native rebuild approval is missing ${field}`); } } if (!SUPPORTED_VERIFIERS.has(approval.verifier)) { throw new Error(`unsupported native rebuild verifier: ${approval.verifier}`); } if (!PACKAGE_NAME.test(approval.name)) { throw new Error("invalid package name for native rebuild: " + approval.name); } if (packageNameFromPath(approval.path) !== approval.name) { throw new Error("native rebuild name mismatch for " + approval.path); } const locked = lockfile.packages[approval.path]; if (!locked) throw new Error(`native rebuild path missing from lockfile: ${approval.path}`); if (!locked.hasInstallScript) { throw new Error(`native rebuild target has no install script: ${approval.path}`); } if (locked.version !== approval.version) { throw new Error(`native rebuild version mismatch for ${approval.path}`); } if (locked.integrity !== approval.integrity) { throw new Error(`native rebuild integrity mismatch for ${approval.path}`); } return { ...approval }; }); } function run(command, args) { const result = spawnSync(command, args, { env: { ...process.env, npm_config_audit: 'false' }, stdio: 'inherit', shell: false, }); if (result.error) throw result.error; if (result.status !== 0) { throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}`); } } /** * `npm rebuild` に渡す引数。 * * `--build-from-source` は、配布済みバイナリを取ってこさせず必ず手元で組ませる * ための指定(取得物を 1 つ減らすので安全側)。CI の runner にコンパイラ・make・ * python3 が要るようになる代わりなので、消すときはそこまで見ること。 * * `--` は、名前が `-` で始まっていても旗として解釈させないための区切り。名前は * すでに PACKAGE_NAME で縛ってあるので二重の守りだが、縛りのほうが緩んだときに * ここだけで止まる形にしておく。 */ export function rebuildArgs(name) { return ['rebuild', '--build-from-source', '--foreground-scripts', '--no-audit', '--', name]; } export function verify(verifier) { if (verifier === 'better-sqlite3-memory-db') { run(process.execPath, [ '-e', "const Database=require('better-sqlite3'); new Database(':memory:').close()", ]); return; } throw new Error(`unsupported native rebuild verifier: ${verifier}`); } export async function main() { const [lockfile, policy] = await Promise.all([ readFile(LOCKFILE_PATH, 'utf8').then(JSON.parse), readFile(POLICY_PATH, 'utf8').then(JSON.parse), ]); const approved = validateRebuildEntries(lockfile, policy.rebuild?.[LOCKFILE_PATH]); for (const entry of approved) { run('npm', rebuildArgs(entry.name)); verify(entry.verifier); } } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main().catch((error) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; }); }