/** * Resolves the browser pieces of the closed detector for `bun run build:extension`. * * The rule engine is proprietary and ships as a prebuilt release (see * docs/ENGINE.md, "The closed detector"). The native archive that * `crates/core/build.rs` links and the browser bundle the extension vendors * come from the same `detector-v` release, and they resolve * the same three ways: * * 1. `IMPECCABLE_DETECTOR_LIB=` holding an `extension-detector/` * subdirectory (what the detector repo's `cargo xtask detector-archive` * writes for a local build). * 2. `~/.impeccable/detector//extension-detector/` * (`IMPECCABLE_HOME` moves the root). * 3. A download of `detector-browser-bundle.zip` from the release, verified * against its `.sha256` sidecar and extracted into that cache. * `IMPECCABLE_DETECTOR_BASE` overrides the release root; * `IMPECCABLE_DETECTOR_OFFLINE=1` refuses to download. * * The five pieces are generated by the detector repo's `cargo xtask bundle` * and are never tracked here; `extension/detector/` is gitignored. */ import crypto from 'node:crypto'; import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); export const DEFAULT_DETECTOR_BASE = 'https://github.com/pbakaus/impeccable/releases/download'; export const BROWSER_BUNDLE_ASSET = 'detector-browser-bundle.zip'; /** Where the pieces sit inside a detector-archive dir and inside the cache. */ export const BUNDLE_SUBDIR = 'extension-detector'; /** Where the pieces sit inside detector-browser-bundle.zip. */ export const ZIP_MEMBER_DIR = 'extension-src/detector'; /** The five generated pieces the extension shell loads. */ export const DETECTOR_PIECES = [ 'core.js', 'core_bg.wasm', 'snapshot.js', 'overlay.js', 'antipatterns.json', ]; export function readDetectorVersion(root = ROOT) { return fs.readFileSync(path.join(root, 'DETECTOR_VERSION'), 'utf-8').trim(); } /** `~/.impeccable`, or `IMPECCABLE_HOME` when set. */ export function detectorHome(env = process.env) { return env.IMPECCABLE_HOME ? path.resolve(env.IMPECCABLE_HOME) : path.join(os.homedir(), '.impeccable'); } /** The version-pinned cache directory the download extracts into. */ export function cacheBundleDir(version, env = process.env) { return path.join(detectorHome(env), 'detector', version, BUNDLE_SUBDIR); } export function assetUrl(version, asset, base) { return `${base.replace(/\/$/, '')}/detector-v${version}/${asset}`; } /** True when `dir` holds all five pieces. */ export function isCompleteBundleDir(dir) { return !!dir && DETECTOR_PIECES.every((piece) => fs.existsSync(path.join(dir, piece))); } function missingPieces(dir) { return DETECTOR_PIECES.filter((piece) => !fs.existsSync(path.join(dir, piece))); } /** A `.sha256` sidecar is either a bare hex digest or ` `. */ function parseSha256(text) { const match = String(text).trim().match(/\b([0-9a-fA-F]{64})\b/); return match ? match[1].toLowerCase() : null; } async function fetchBuffer(url, fetchImpl) { const res = await fetchImpl(url, { redirect: 'follow' }); if (!res.ok) throw new Error(`GET ${url} failed with HTTP ${res.status}`); return Buffer.from(await res.arrayBuffer()); } /** * Downloads `detector-browser-bundle.zip`, verifies it against its `.sha256` * sidecar, and extracts the five pieces into `destDir`. Refuses to install an * unverified archive. */ export async function downloadBundle({ version, base, destDir, fetchImpl = fetch, log = () => {}, unzip = defaultUnzip, } = {}) { const zipUrl = assetUrl(version, BROWSER_BUNDLE_ASSET, base); log(`Downloading ${BROWSER_BUNDLE_ASSET} from detector-v${version}`); let expected; try { const sidecar = await fetchBuffer(`${zipUrl}.sha256`, fetchImpl); expected = parseSha256(sidecar.toString('utf-8')); } catch (err) { throw new Error( `${BROWSER_BUNDLE_ASSET}.sha256 could not be read from ${zipUrl}.sha256 (${err.message}). ` + 'Refusing to install an unverified detector bundle.', ); } if (!expected) { throw new Error( `${zipUrl}.sha256 did not contain a SHA-256 digest. Refusing to install an unverified detector bundle.`, ); } const zip = await fetchBuffer(zipUrl, fetchImpl); const actual = crypto.createHash('sha256').update(zip).digest('hex'); if (actual !== expected) { throw new Error( `${BROWSER_BUNDLE_ASSET} checksum mismatch for detector-v${version}:\n` + ` expected ${expected}\n actual ${actual}\nRefusing to install it.`, ); } const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detector-')); try { const zipPath = path.join(staging, BROWSER_BUNDLE_ASSET); fs.writeFileSync(zipPath, zip); const extractDir = path.join(staging, 'out'); fs.mkdirSync(extractDir, { recursive: true }); unzip(zipPath, `${ZIP_MEMBER_DIR}/*`, extractDir); const missing = missingPieces(extractDir); if (missing.length) { throw new Error( `detector-v${version} ${BROWSER_BUNDLE_ASSET} is missing ${missing.join(', ')} under ${ZIP_MEMBER_DIR}/.`, ); } fs.mkdirSync(destDir, { recursive: true }); for (const piece of DETECTOR_PIECES) { fs.copyFileSync(path.join(extractDir, piece), path.join(destDir, piece)); } } finally { fs.rmSync(staging, { recursive: true, force: true }); } return destDir; } /** `unzip -o -j` flattens the member directory, so every piece lands in destDir. */ function defaultUnzip(zipPath, member, destDir) { try { execFileSync('unzip', ['-o', '-j', zipPath, member, '-d', destDir], { stdio: 'pipe' }); } catch (err) { const detail = err.stderr ? String(err.stderr).trim() : err.message; throw new Error(`unzip failed on ${path.basename(zipPath)}: ${detail}`); } } /** * Finds a directory holding the five detector pieces, downloading the release * bundle into the version cache if neither of the local options has them. * * @returns {Promise<{ dir: string, source: 'env' | 'cache' | 'download', version: string }>} */ export async function resolveDetectorBundle({ root = ROOT, version = readDetectorVersion(root), env = process.env, base = env.IMPECCABLE_DETECTOR_BASE || DEFAULT_DETECTOR_BASE, fetchImpl = fetch, log = () => {}, unzip = defaultUnzip, } = {}) { if (env.IMPECCABLE_DETECTOR_LIB) { const dir = path.join(path.resolve(env.IMPECCABLE_DETECTOR_LIB), BUNDLE_SUBDIR); if (isCompleteBundleDir(dir)) return { dir, source: 'env', version }; throw new Error( `IMPECCABLE_DETECTOR_LIB=${env.IMPECCABLE_DETECTOR_LIB} does not hold a complete ${BUNDLE_SUBDIR}/.\n` + ` looked in: ${dir}\n` + ` missing: ${(fs.existsSync(dir) ? missingPieces(dir) : DETECTOR_PIECES).join(', ')}\n` + " Run `cargo xtask detector-archive --out ` in the detector repo, or unset " + 'IMPECCABLE_DETECTOR_LIB to use the published release.', ); } const cached = cacheBundleDir(version, env); if (isCompleteBundleDir(cached)) return { dir: cached, source: 'cache', version }; if (env.IMPECCABLE_DETECTOR_OFFLINE === '1') { throw new Error( `No detector browser bundle for v${version} and IMPECCABLE_DETECTOR_OFFLINE=1 forbids downloading.\n` + ` cache: ${cached}\n` + ' Set IMPECCABLE_DETECTOR_LIB to a local `cargo xtask detector-archive` output, ' + 'or clear IMPECCABLE_DETECTOR_OFFLINE.', ); } try { await downloadBundle({ version, base, destDir: cached, fetchImpl, log, unzip }); } catch (err) { throw new Error( `Could not obtain the detector browser bundle for v${version}.\n` + ` ${err.message}\n` + ` release base: ${base}\n` + ' Options: point IMPECCABLE_DETECTOR_LIB at a local `cargo xtask detector-archive` output, ' + 'override the release root with IMPECCABLE_DETECTOR_BASE, or publish detector-v' + `${version} (see docs/ENGINE.md).`, ); } return { dir: cached, source: 'download', version }; } /** * Resolves the bundle and copies the five pieces into `destDir` * (`extension/detector/`). */ export async function vendorDetectorBundle({ destDir, ...options } = {}) { const resolved = await resolveDetectorBundle(options); fs.mkdirSync(destDir, { recursive: true }); const files = []; for (const piece of DETECTOR_PIECES) { const to = path.join(destDir, piece); fs.copyFileSync(path.join(resolved.dir, piece), to); files.push({ name: piece, bytes: fs.statSync(to).size }); } return { ...resolved, destDir, files }; }