Files
pbakaus_impeccable/scripts/lib/detector-bundle.mjs
T
Paul BakausandClaude Fable 5.1 f2c9aeab5b build:extension: ship the wasm-core extension shell and vendor its detector from the detector release
`bun run build:extension` was broken on this branch: it still imported the
deleted JS engine (cli/engine/registry/antipatterns.mjs,
scripts/lib/browser-detector-bundle.js).

The shipped shell now matches the new design. The content script only
snapshots the DOM; an extension-owned offscreen document runs the
WebAssembly rule core over that snapshot, so the scanned page's CSP no
longer matters. That replaces the old approach of injecting a JS rules
bundle into the page. New files: extension/offscreen/offscreen.html, plus
the "offscreen" permission and a 'wasm-unsafe-eval' extension_pages CSP in
the manifest.

The manifest version stays at 1.3.3. The shell's own manifest carried
2.0.0; feature branches never bump versions, so the bump is a release step.

The five generated detector pieces (core.js, core_bg.wasm, snapshot.js,
overlay.js, antipatterns.json) are vendored at build time into the
gitignored extension/detector/ by the new scripts/lib/detector-bundle.mjs,
which resolves them the same three ways crates/core/build.rs resolves the
native archive: IMPECCABLE_DETECTOR_LIB/extension-detector/, the
~/.impeccable/detector/<DETECTOR_VERSION>/ cache, then a checksum-verified
download of detector-browser-bundle.zip from the detector release.
antipatterns.json is no longer regenerated here.

The zip packaging is unchanged. The Firefox variant still builds so
`web-ext lint` keeps covering the shared shell, but it cannot scan: Gecko
has no chrome.offscreen API. The build prints a one-line warning saying so.

Also here: a referenced-path check that fails the build when the manifest
or the service worker points at a file that is not in extension/, a
resolver unit test wired into the core suite, and the detector rule count
in the READMEs synced to the 61 the vendored registry carries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
2026-09-01 16:20:54 -07:00

231 lines
8.6 KiB
JavaScript

/**
* 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<DETECTOR_VERSION>` release, and they resolve
* the same three ways:
*
* 1. `IMPECCABLE_DETECTOR_LIB=<dir>` holding an `extension-detector/`
* subdirectory (what the detector repo's `cargo xtask detector-archive`
* writes for a local build).
* 2. `~/.impeccable/detector/<DETECTOR_VERSION>/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 `<digest> <filename>`. */
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 <dir>` 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 };
}