mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-19 01:26:29 +03:00
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
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
836516a7a0
commit
f2c9aeab5b
@@ -0,0 +1,185 @@
|
||||
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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user