mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +03:00
`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
186 lines
6.9 KiB
JavaScript
186 lines
6.9 KiB
JavaScript
import { describe, it, before, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import {
|
|
BUNDLE_SUBDIR,
|
|
DETECTOR_PIECES,
|
|
cacheBundleDir,
|
|
isCompleteBundleDir,
|
|
resolveDetectorBundle,
|
|
vendorDetectorBundle,
|
|
} from '../scripts/lib/detector-bundle.mjs';
|
|
|
|
const VERSION = '9.9.9';
|
|
|
|
let tmp;
|
|
before(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'detector-bundle-test-')); });
|
|
after(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
|
|
|
|
let seq = 0;
|
|
function scratch(name) {
|
|
const dir = path.join(tmp, `${name}-${seq++}`);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
return dir;
|
|
}
|
|
|
|
/** A directory holding the five pieces, each with recognizable content. */
|
|
function writeBundle(dir) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
for (const piece of DETECTOR_PIECES) {
|
|
fs.writeFileSync(path.join(dir, piece), piece === 'antipatterns.json' ? '[{"id":"a"}]' : `// ${piece}\n`);
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
/** A fetch that serves a zip body and its sidecar, recording every URL asked for. */
|
|
function fakeRelease({ zip, digest, sidecarStatus = 200, zipStatus = 200 }) {
|
|
const urls = [];
|
|
const fetchImpl = async (url) => {
|
|
urls.push(url);
|
|
if (url.endsWith('.sha256')) {
|
|
return {
|
|
ok: sidecarStatus === 200,
|
|
status: sidecarStatus,
|
|
arrayBuffer: async () => Buffer.from(`${digest} detector-browser-bundle.zip\n`),
|
|
};
|
|
}
|
|
return { ok: zipStatus === 200, status: zipStatus, arrayBuffer: async () => zip };
|
|
};
|
|
return { fetchImpl, urls };
|
|
}
|
|
|
|
describe('detector bundle resolution', () => {
|
|
it('uses IMPECCABLE_DETECTOR_LIB when its extension-detector/ is complete', async () => {
|
|
const lib = scratch('lib');
|
|
writeBundle(path.join(lib, BUNDLE_SUBDIR));
|
|
const result = await resolveDetectorBundle({
|
|
version: VERSION,
|
|
env: { IMPECCABLE_DETECTOR_LIB: lib },
|
|
fetchImpl: () => assert.fail('should not download'),
|
|
});
|
|
assert.equal(result.source, 'env');
|
|
assert.equal(result.dir, path.join(lib, BUNDLE_SUBDIR));
|
|
});
|
|
|
|
it('names the env var and the missing pieces when the local dir is incomplete', async () => {
|
|
const lib = scratch('lib-partial');
|
|
const dir = path.join(lib, BUNDLE_SUBDIR);
|
|
writeBundle(dir);
|
|
fs.rmSync(path.join(dir, 'core_bg.wasm'));
|
|
await assert.rejects(
|
|
() => resolveDetectorBundle({ version: VERSION, env: { IMPECCABLE_DETECTOR_LIB: lib } }),
|
|
(err) => {
|
|
assert.match(err.message, /IMPECCABLE_DETECTOR_LIB=/);
|
|
assert.match(err.message, /core_bg\.wasm/);
|
|
assert.match(err.message, /detector-archive/);
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
it('uses the version cache under IMPECCABLE_HOME without downloading', async () => {
|
|
const home = scratch('home');
|
|
const env = { IMPECCABLE_HOME: home };
|
|
writeBundle(cacheBundleDir(VERSION, env));
|
|
const result = await resolveDetectorBundle({
|
|
version: VERSION,
|
|
env,
|
|
fetchImpl: () => assert.fail('should not download'),
|
|
});
|
|
assert.equal(result.source, 'cache');
|
|
assert.equal(result.dir, cacheBundleDir(VERSION, env));
|
|
});
|
|
|
|
it('downloads and verifies the release bundle into the cache', async () => {
|
|
const home = scratch('home-download');
|
|
const env = { IMPECCABLE_HOME: home, IMPECCABLE_DETECTOR_BASE: 'https://example.test/dl' };
|
|
const zip = Buffer.from('pretend zip bytes');
|
|
const digest = crypto.createHash('sha256').update(zip).digest('hex');
|
|
const { fetchImpl, urls } = fakeRelease({ zip, digest });
|
|
|
|
// The extraction step is the `unzip` CLI in production; inject a stand-in
|
|
// that writes the flattened members the real one would.
|
|
const unzip = (zipPath, member, destDir) => {
|
|
assert.equal(fs.readFileSync(zipPath).toString(), zip.toString());
|
|
assert.equal(member, 'extension-src/detector/*');
|
|
writeBundle(destDir);
|
|
};
|
|
|
|
const result = await resolveDetectorBundle({ version: VERSION, env, fetchImpl, unzip });
|
|
assert.equal(result.source, 'download');
|
|
assert.equal(result.dir, cacheBundleDir(VERSION, env));
|
|
assert.ok(isCompleteBundleDir(result.dir));
|
|
assert.ok(
|
|
urls.includes(`https://example.test/dl/detector-v${VERSION}/detector-browser-bundle.zip`),
|
|
`asked for: ${urls.join(', ')}`,
|
|
);
|
|
assert.ok(urls.some((u) => u.endsWith('.sha256')), 'verifies against the sidecar');
|
|
});
|
|
|
|
it('refuses a bundle whose checksum does not match', async () => {
|
|
const home = scratch('home-mismatch');
|
|
const env = { IMPECCABLE_HOME: home };
|
|
const { fetchImpl } = fakeRelease({ zip: Buffer.from('bytes'), digest: 'f'.repeat(64) });
|
|
await assert.rejects(
|
|
() => resolveDetectorBundle({ version: VERSION, env, fetchImpl, unzip: () => assert.fail('never extracts') }),
|
|
/checksum mismatch/,
|
|
);
|
|
assert.equal(isCompleteBundleDir(cacheBundleDir(VERSION, env)), false);
|
|
});
|
|
|
|
it('refuses to install when the sidecar is unavailable', async () => {
|
|
const home = scratch('home-nosidecar');
|
|
const env = { IMPECCABLE_HOME: home };
|
|
const { fetchImpl } = fakeRelease({ zip: Buffer.from('bytes'), digest: 'x', sidecarStatus: 404 });
|
|
await assert.rejects(
|
|
() => resolveDetectorBundle({ version: VERSION, env, fetchImpl, unzip: () => assert.fail('never extracts') }),
|
|
/unverified detector bundle/,
|
|
);
|
|
});
|
|
|
|
it('names every option when nothing resolves and the download fails', async () => {
|
|
const home = scratch('home-empty');
|
|
const env = { IMPECCABLE_HOME: home };
|
|
const fetchImpl = async () => ({ ok: false, status: 404, arrayBuffer: async () => Buffer.alloc(0) });
|
|
await assert.rejects(
|
|
() => resolveDetectorBundle({ version: VERSION, env, fetchImpl }),
|
|
(err) => {
|
|
assert.match(err.message, /IMPECCABLE_DETECTOR_LIB/);
|
|
assert.match(err.message, /IMPECCABLE_DETECTOR_BASE/);
|
|
assert.match(err.message, new RegExp(`detector-v${VERSION}`));
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
it('refuses to download when IMPECCABLE_DETECTOR_OFFLINE=1', async () => {
|
|
const home = scratch('home-offline');
|
|
await assert.rejects(
|
|
() => resolveDetectorBundle({
|
|
version: VERSION,
|
|
env: { IMPECCABLE_HOME: home, IMPECCABLE_DETECTOR_OFFLINE: '1' },
|
|
fetchImpl: () => assert.fail('should not download'),
|
|
}),
|
|
/IMPECCABLE_DETECTOR_OFFLINE=1/,
|
|
);
|
|
});
|
|
|
|
it('copies all five pieces into the destination directory', async () => {
|
|
const lib = scratch('lib-vendor');
|
|
writeBundle(path.join(lib, BUNDLE_SUBDIR));
|
|
const dest = path.join(scratch('dest'), 'detector');
|
|
const result = await vendorDetectorBundle({
|
|
destDir: dest,
|
|
version: VERSION,
|
|
env: { IMPECCABLE_DETECTOR_LIB: lib },
|
|
});
|
|
assert.deepEqual(result.files.map((f) => f.name).sort(), [...DETECTOR_PIECES].sort());
|
|
assert.ok(isCompleteBundleDir(dest));
|
|
assert.ok(result.files.every((f) => f.bytes > 0));
|
|
});
|
|
});
|