import { createHash } from "node:crypto"; import { createServer } from "node:http"; import { constants as fsConstants, copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, writeFile, } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { unzipSync } from "fflate"; import { verifyChecksummedEnginePack, writeEngineChecksums, } from "./checksummed-engine-pack.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); export const PERL_ENGINE_LOCK = Object.freeze({ bridgeAbi: 1, engineVersion: "5.28.1", perlVersion: "v5.28.1", webPerlVersion: "0.09-beta", sourceDateEpoch: 1_551_644_196, source: Object.freeze({ repository: "https://github.com/haukex/webperl", tag: "v0.09-beta", tagObject: "c5933d11666906328b238d03459707207b6ca2b4", commit: "6f2173d29a2c2e3536e1de75ff5d291ae96ab348", tree: "20e59a142ad2e355fc3ed03da974bdf183b79c66", license: "GPL-1.0-or-later OR Artistic-1.0-Perl", }), prebuilt: Object.freeze({ root: "webperl_prebuilt_v0.09-beta", archive: "https://github.com/haukex/webperl/releases/download/v0.09-beta/webperl_prebuilt_v0.09-beta.zip", archiveSha256: "5f441249217e90ab378c666f473d4206ab4f44907f6bb0aa8d70834bc38c40dc", archiveBytes: 3_936_557, files: Object.freeze({ "emperl.data": Object.freeze({ upstream: "emperl.data", sha256: "9529019418cf766a42cf2d25bd3fc97b47c9e689f5666cfc32dc11338d1b1e66", bytes: 12_021_691, }), "emperl.js": Object.freeze({ upstream: "emperl.js", sha256: "b60e3c04874c6ef5278001257b4c8a9f4c7e69ca3d6b268d9639723234844784", bytes: 303_013, }), "emperl.wasm": Object.freeze({ upstream: "emperl.wasm", sha256: "f1d49c4514c7332a57992c4a2444fd6a56ae3b5e6651b4fd484852a641e5e4ec", bytes: 3_734_063, }), "LICENSE-WebPerl-Artistic.txt": Object.freeze({ upstream: "LICENSE_artistic.txt", sha256: "435a788b2de9aaae9c640e7c0ab7b317b41323522e8641bcda000607892abf1f", bytes: 7_587, }), "LICENSE-WebPerl-GPL.txt": Object.freeze({ upstream: "LICENSE_gpl.txt", sha256: "0166b8b8e96a6b153da26ece157ebbd9ffad5423670f611fae80fefacb6da7f5", bytes: 14_468, }), }), }), supportingFiles: Object.freeze({ "LICENSE-Devel-StackTrace-Artistic-2.0.txt": Object.freeze({ source: "engines/perl/LICENSE-Devel-StackTrace-Artistic-2.0.txt", sha256: "e16dd93533bb65e25fad00d06e88840d9b5fd6bb80d551d8866126c52d89e1de", bytes: 9_048, }), "LICENSE-Emscripten.txt": Object.freeze({ source: "engines/perl/LICENSE-Emscripten.txt", sha256: "51aea7641f81d560eb039bc97ce35e2517e2656fe8731eb49ce6a18498eb22fe", bytes: 5_092, }), "NOTICE.txt": Object.freeze({ source: "engines/perl/NOTICE.txt", }), "perl-runtime.worker.js": Object.freeze({ source: "engines/perl/perl-runtime.worker.js", }), "regex_tools_bridge.pl": Object.freeze({ source: "engines/perl/regex_tools_bridge.pl", }), }), develStackTrace: Object.freeze({ version: "2.03", sourceArchive: "https://www.cpan.org/authors/id/D/DR/DROLSKY/Devel-StackTrace-2.03.tar.gz", sourceArchiveSha256: "7618cd4ebe24e254c17085f4b418784ab503cb4cb3baf8f48a7be894e59ba848", }), toolchain: Object.freeze({ emscriptenVersion: "1.38.28", target: "wasm32-unknown-emscripten", license: "MIT OR NCSA", }), limits: Object.freeze({ maximumPatternCharacters: 65_536, maximumSubjectBytes: 16_777_216, maximumReplacementCharacters: 65_536, maximumMatches: 10_000, maximumCaptureRows: 100_000, maximumCaptureGroups: 1_000, maximumOutputBytes: 67_108_864, maximumErrorCharacters: 1_024, maximumRequestJsonBytes: 101_466_112, maximumResponseJsonBytes: 8_388_608, outputChunkCharacters: 4_096, }), }); const SOURCE_FILES = Object.freeze([ "engines/perl/README.md", "engines/perl/perl-runtime.worker.js", "engines/perl/regex_tools_bridge.pl", ]); const PACK_FILES = Object.freeze( [ ...Object.keys(PERL_ENGINE_LOCK.prebuilt.files), ...Object.keys(PERL_ENGINE_LOCK.supportingFiles), "SHA256SUMS", "engine-metadata.json", ].sort(), ); const CHECKSUM_FILES = Object.freeze( PACK_FILES.filter((name) => name !== "SHA256SUMS"), ); const CONTENT_FILES = Object.freeze( PACK_FILES.filter( (name) => name !== "SHA256SUMS" && name !== "engine-metadata.json", ), ); function compareText(left, right) { return left < right ? -1 : left > right ? 1 : 0; } function sha256(value) { return createHash("sha256").update(value).digest("hex"); } async function sha256File(file) { return sha256(await readFile(file)); } async function assertRealDirectory(candidate, label) { const details = await lstat(candidate).catch(() => null); if (!details?.isDirectory() || details.isSymbolicLink()) { throw new Error(`${label} must be a real directory: ${candidate}`); } return realpath(candidate); } async function assertRegularFile(candidate, label) { const details = await lstat(candidate).catch(() => null); if (!details?.isFile() || details.isSymbolicLink()) { throw new Error( `${label} must be a regular, non-symlink file: ${candidate}`, ); } return realpath(candidate); } function assertSafeOutputTarget(target, repository) { const filesystemRoot = path.parse(target).root; const repositoryFromTarget = path.relative(target, repository); const targetContainsRepository = repositoryFromTarget === "" || (!repositoryFromTarget.startsWith("..") && !path.isAbsolute(repositoryFromTarget)); if (target === filesystemRoot || targetContainsRepository) { throw new Error( "Perl pack output may not be a filesystem root or contain the repository.", ); } } function assertBytes(value, descriptor, label) { const actualHash = sha256(value); if ( value.byteLength !== descriptor.bytes || actualHash !== descriptor.sha256 ) { throw new Error( `${label} does not match the pinned WebPerl release: expected ${descriptor.bytes} bytes and ${descriptor.sha256}, got ${value.byteLength} bytes and ${actualHash}.`, ); } } async function sourceRecords(projectRoot) { return Promise.all( SOURCE_FILES.map(async (relativePath) => { const value = await readFile(path.join(projectRoot, relativePath)); return { path: relativePath, sha256: sha256(value), bytes: value.byteLength, }; }), ); } async function fileRecord(directory, name) { const value = await readFile(path.join(directory, name)); return { path: name, sha256: sha256(value), bytes: value.byteLength }; } function dynamicExecutionAudit(loader, worker, harness) { const loaderEvalSites = loader.match(/\beval\s*\(/gu)?.length ?? 0; const loaderFunctionSites = loader.match(/\bnew\s+Function\b/gu)?.length ?? 0; if ( loaderEvalSites !== 1 || loaderFunctionSites !== 0 || !loader.includes("var rv=eval(code)") ) { throw new Error( "The generated WebPerl loader dynamic-code audit no longer matches the pinned release.", ); } if ( !worker.includes('var FIXED_INVOCATION = "RegexTools::run_request()"') || !worker.includes('var OUTPUT_PATH = "/tmp/regex-tools-output.bin"') || !worker.includes('new TextDecoder("utf-8", { fatal: true })') || !worker.includes("injectReplacementOutput(parsed") || !worker.includes("[FIXED_INVOCATION]") || !worker.includes("importScripts(") || /\beval\s*\(/u.test(worker) || /\bnew\s+Function\b/u.test(worker) ) { throw new Error( "The classic WebPerl worker no longer has a fixed, source-free invocation channel.", ); } if ( !harness.includes("no re 'eval';") || !harness.includes("sub _contains_code_assertion") || !harness.includes("sub _tokenize_replacement") || !harness.includes("sub _resolve_named_spans") || !harness.includes("sub _append_replacement_tokens") || !harness.includes("sub _append_span") || harness.includes("sub _capture_values") || harness.includes("sub _expand_replacement") || harness.includes("$response->{output}") || !harness.includes("code-assertion-disabled") || !harness.includes("use Cpanel::JSON::XS ();") || harness.includes("use WebPerl") || (harness.match(/\beval\s*\{/gu)?.length ?? 0) !== 3 || /\beval\s+(?:\\?["']|\$)/u.test(harness) ) { throw new Error( "The fixed Perl bridge no longer satisfies its no-user-source-evaluation contract.", ); } return { generatedLoaderJavaScriptEvalSites: loaderEvalSites, generatedLoaderNewFunctionSites: loaderFunctionSites, }; } async function verifyRepositorySources(projectRoot) { const repository = await assertRealDirectory( projectRoot, "Regex Tools repository", ); for (const relativePath of SOURCE_FILES) { await assertRegularFile( path.join(repository, relativePath), `Perl source ${relativePath}`, ); } for (const [name, descriptor] of Object.entries( PERL_ENGINE_LOCK.supportingFiles, )) { const source = await assertRegularFile( path.join(repository, descriptor.source), `Perl supporting source ${name}`, ); if ( descriptor.sha256 && ((await sha256File(source)) !== descriptor.sha256 || (await readFile(source)).byteLength !== descriptor.bytes) ) { throw new Error(`${name} does not match its pinned upstream licence.`); } } const loader = await readFile( path.join(repository, "public", "engines", "perl", "emperl.js"), "utf8", ).catch(() => ""); const worker = await readFile( path.join(repository, "engines", "perl", "perl-runtime.worker.js"), "utf8", ); const harness = await readFile( path.join(repository, "engines", "perl", "regex_tools_bridge.pl"), "utf8", ); if (loader) dynamicExecutionAudit(loader, worker, harness); return repository; } async function archiveInputs(archivePath) { const archive = await assertRegularFile( archivePath, "WebPerl v0.09-beta prebuilt archive", ); const encoded = await readFile(archive); if ( encoded.byteLength !== PERL_ENGINE_LOCK.prebuilt.archiveBytes || sha256(encoded) !== PERL_ENGINE_LOCK.prebuilt.archiveSha256 ) { throw new Error( "The local WebPerl archive does not match the pinned v0.09-beta prebuilt release.", ); } const entries = unzipSync(encoded); const result = new Map(); for (const [output, descriptor] of Object.entries( PERL_ENGINE_LOCK.prebuilt.files, )) { const entry = entries[`${PERL_ENGINE_LOCK.prebuilt.root}/${descriptor.upstream}`]; if (!entry) { throw new Error( `The pinned WebPerl archive is missing ${descriptor.upstream}.`, ); } const value = Buffer.from(entry); assertBytes(value, descriptor, `Archived ${descriptor.upstream}`); result.set(output, value); } return result; } async function locatePrebuiltDirectory(directory) { const candidate = await assertRealDirectory( directory, "Extracted WebPerl v0.09-beta directory", ); const direct = await lstat(path.join(candidate, "emperl.js")).catch( () => null, ); if (direct?.isFile() && !direct.isSymbolicLink()) return candidate; return assertRealDirectory( path.join(candidate, PERL_ENGINE_LOCK.prebuilt.root), "Extracted WebPerl v0.09-beta release root", ); } async function directoryInputs(directory) { const prebuilt = await locatePrebuiltDirectory(directory); const result = new Map(); for (const [output, descriptor] of Object.entries( PERL_ENGINE_LOCK.prebuilt.files, )) { const source = await assertRegularFile( path.join(prebuilt, descriptor.upstream), `Extracted WebPerl ${descriptor.upstream}`, ); const value = await readFile(source); assertBytes(value, descriptor, `Extracted ${descriptor.upstream}`); result.set(output, value); } return result; } async function expectedMetadata(projectRoot, pack) { return { schemaVersion: 1, engine: "perl", engineName: "Perl regular expressions via WebPerl", engineVersion: PERL_ENGINE_LOCK.engineVersion, semanticIdentity: "Perl v5.28.1 regular expressions via the legacy WebPerl v0.09-beta prebuilt runtime", status: "legacy-beta-compatibility-runtime", bridge: { abiVersion: PERL_ENGINE_LOCK.bridgeAbi, nativeOffsetUnit: "Unicode code points (Perl character offsets)", requestEncoding: "exact-key and byte-bounded JSON in a fixed MEMFS request file; no request value is interpolated into source", responseEncoding: "byte-bounded metadata JSON in a fixed MEMFS response file", workerTopology: "supervised ES module worker with a nested self-hosted classic WebPerl worker", evaluatorInvocation: "RegexTools::run_request()", userSourceEvaluation: false, regexCodeAssertionsEnabled: false, supportedApplicationFlags: [ "g", "i", "m", "s", "x", "n", "a", "d", "l", "u", ], replacement: "Pre-tokenized fixed grammar supporting literal text, $$, greedy $n, and ${name}; bounded UTF-8 chunks stream from native spans without a complete capture-value array, expansion, or output materialization", replacementParityOracle: "identity self-test compares fixed named and numbered fixtures with native Perl s/// output", replacementOutputTransport: "successful UTF-8 output uses a separate fixed MEMFS binary file; the classic worker validates exact length, bound, canonical UTF-8 and injects the decoded string for PortableEngineAdapter", captureNames: "Perl-native numbered participation and spans; syntax metadata annotates capture-row names; named replacement lookup uses native %+ and resolves the selected value back to a native span before output", limits: { ...PERL_ENGINE_LOCK.limits }, sourceFiles: await sourceRecords(projectRoot), }, securityAudit: { generatedLoaderJavaScriptEvalSites: 1, generatedLoaderNewFunctionSites: 0, evalSitePurpose: "optional WebPerl Perl-to-JavaScript interoperability import; not loaded or invoked by regex-tools", normalPathCsp: "script-src 'self' 'wasm-unsafe-eval'; no 'unsafe-eval'", runtimeRegexEval: "disabled with no re 'eval'", codeAssertionDefense: "fixed scanner rejects (?{...}) and (??{...}) before compilation", killability: "the supervised worker tree can be terminated by EngineSupervisor", boundedTransport: "request and response JSON have exact field/byte contracts; replacement output is not JSON-escaped", }, source: { project: "WebPerl", repository: PERL_ENGINE_LOCK.source.repository, tag: PERL_ENGINE_LOCK.source.tag, tagObjectSha1: PERL_ENGINE_LOCK.source.tagObject, commitSha1: PERL_ENGINE_LOCK.source.commit, treeSha1: PERL_ENGINE_LOCK.source.tree, license: PERL_ENGINE_LOCK.source.license, perl: { version: PERL_ENGINE_LOCK.engineVersion, repository: "https://github.com/Perl/perl5", tag: "v5.28.1", license: PERL_ENGINE_LOCK.source.license, }, }, prebuilt: { archive: PERL_ENGINE_LOCK.prebuilt.archive, archiveSha256: PERL_ENGINE_LOCK.prebuilt.archiveSha256, archiveBytes: PERL_ENGINE_LOCK.prebuilt.archiveBytes, runtimeAssetsCopiedByteForByte: true, }, toolchain: { emscriptenVersion: PERL_ENGINE_LOCK.toolchain.emscriptenVersion, target: PERL_ENGINE_LOCK.toolchain.target, license: PERL_ENGINE_LOCK.toolchain.license, }, bundledModules: [ { name: "Cpanel::JSON::XS", version: "4.09", license: PERL_ENGINE_LOCK.source.license, normalEnginePath: true, }, { name: "Devel::StackTrace", version: PERL_ENGINE_LOCK.develStackTrace.version, license: "Artistic-2.0", normalEnginePath: false, sourceArchive: PERL_ENGINE_LOCK.develStackTrace.sourceArchive, sourceArchiveSha256: PERL_ENGINE_LOCK.develStackTrace.sourceArchiveSha256, }, { name: "Future", version: "0.39", license: PERL_ENGINE_LOCK.source.license, normalEnginePath: false, }, ], sourceDateEpoch: PERL_ENGINE_LOCK.sourceDateEpoch, files: await Promise.all( CONTENT_FILES.map((name) => fileRecord(pack, name)), ), }; } async function assertClosedPack(pack) { const entries = (await readdir(pack, { withFileTypes: true })) .map((entry) => { if (!entry.isFile() || entry.isSymbolicLink()) { throw new Error( `Perl pack contains a non-regular entry: ${entry.name}.`, ); } return entry.name; }) .sort(compareText); if (JSON.stringify(entries) !== JSON.stringify(PACK_FILES)) { throw new Error(`Perl pack must contain only: ${PACK_FILES.join(", ")}.`); } } async function assertPinnedPackFiles(pack, projectRoot) { for (const [name, descriptor] of Object.entries( PERL_ENGINE_LOCK.prebuilt.files, )) { const value = await readFile(path.join(pack, name)); assertBytes(value, descriptor, `Packed ${name}`); } for (const [name, descriptor] of Object.entries( PERL_ENGINE_LOCK.supportingFiles, )) { const packed = await readFile(path.join(pack, name)); const source = await readFile(path.join(projectRoot, descriptor.source)); if (!packed.equals(source)) { throw new Error(`Packed ${name} differs from ${descriptor.source}.`); } } const loader = await readFile(path.join(pack, "emperl.js"), "utf8"); const worker = await readFile( path.join(pack, "perl-runtime.worker.js"), "utf8", ); const harness = await readFile( path.join(pack, "regex_tools_bridge.pl"), "utf8", ); dynamicExecutionAudit(loader, worker, harness); } const CSP = "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'"; function mediaType(name) { if (name.endsWith(".js")) return "text/javascript; charset=utf-8"; if (name.endsWith(".wasm")) return "application/wasm"; if (name.endsWith(".json")) return "application/json; charset=utf-8"; if (name.endsWith(".pl") || name.endsWith(".txt")) { return "text/plain; charset=utf-8"; } return "application/octet-stream"; } async function listen(server) { await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); const address = server.address(); if (!address || typeof address === "string") { throw new Error("The Perl smoke server did not bind a TCP port."); } return address.port; } async function closeServer(server) { server.closeAllConnections?.(); await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); } async function smokePerlPack(pack) { const server = createServer(async (request, response) => { try { const url = new URL(request.url ?? "/", "http://127.0.0.1"); if (url.pathname === "/") { response.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Content-Security-Policy": CSP, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", }); response.end( "Perl smoke", ); return; } const name = decodeURIComponent(url.pathname.slice(1)); if (!PACK_FILES.includes(name) || name.includes("/")) { response.writeHead(404).end("Not found"); return; } const value = await readFile(path.join(pack, name)); response.writeHead(200, { "Content-Type": mediaType(name), "Content-Security-Policy": CSP, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", }); response.end(value); } catch { response.writeHead(404).end("Not found"); } }); const port = await listen(server); let browser; try { const { chromium } = await import("@playwright/test"); browser = await chromium.launch({ headless: true }); const page = await browser.newPage(); const failures = []; page.on("pageerror", (error) => failures.push(error.message)); page.on("console", (message) => { if (message.type() === "error") failures.push(message.text()); }); const navigation = await page.goto(`http://127.0.0.1:${port}/`); const csp = navigation?.headers()["content-security-policy"] ?? ""; if ( !csp.includes("'wasm-unsafe-eval'") || /(?:^|[\s;])'unsafe-eval'(?:[\s;]|$)/u.test(csp) ) { throw new Error("The Perl browser smoke did not use the strict CSP."); } const result = await page.evaluate(async () => { const worker = new Worker("./perl-runtime.worker.js", { type: "classic", }); let requestId = 0; const send = (kind, request) => new Promise((resolve, reject) => { requestId += 1; const expected = requestId; const cleanup = () => { clearTimeout(timer); worker.removeEventListener("message", onMessage); worker.removeEventListener("error", onError); }; const onError = (event) => { cleanup(); reject( new Error( event.message || "The WebPerl smoke worker failed to load.", ), ); }; const onMessage = (event) => { if (event.data?.requestId !== expected) return; cleanup(); if (event.data.ok) resolve(event.data.payload); else reject(new Error(event.data.error?.message ?? "Worker error")); }; const timer = setTimeout(() => { cleanup(); reject(new Error("WebPerl smoke request timed out.")); }, 45_000); worker.addEventListener("message", onMessage); worker.addEventListener("error", onError); worker.postMessage({ protocolVersion: 1, requestId: expected, kind, ...(request === undefined ? {} : { request }), }); }); try { const identity = await send("load"); const base = { abi: 1, pattern: "(?😀+)", flags: "gu", options: {}, subject: "x😀😀 y😀", scanAll: false, maximumMatches: 10, maximumCaptureRows: 10, }; const execution = await send("run", { ...base, operation: "execute", }); const replacement = await send("run", { ...base, operation: "replace", replacement: "${word}-$1-$$", maximumOutputBytes: 1_024, }); const boundedMatches = await send("run", { abi: 1, operation: "replace", pattern: "(a)", flags: "gu", options: {}, subject: "a-a-a-tail", scanAll: true, maximumMatches: 2, maximumCaptureRows: 2, replacement: "[$1]", maximumOutputBytes: 1_024, }); const greedyCapture = await send("run", { abi: 1, operation: "replace", pattern: "(a)(b)", flags: "u", options: {}, subject: "ab", scanAll: false, maximumMatches: 10, maximumCaptureRows: 10, replacement: "$12|$1", maximumOutputBytes: 1_024, }); const adversarialExpansion = await send("run", { abi: 1, operation: "replace", pattern: "(é+)", flags: "u", options: {}, subject: "é".repeat(32_768), scanAll: false, maximumMatches: 1, maximumCaptureRows: 1, replacement: "$1".repeat(2_048), maximumOutputBytes: 31, }); const blocked = await send("run", { ...base, operation: "execute", pattern: "a(?{ die q(pwned) })", }); return { identity, execution, replacement, boundedMatches, greedyCapture, adversarialExpansion, blocked, }; } finally { worker.terminate(); } }); if ( result.identity?.ok !== true || result.identity.identity?.perlVersion !== PERL_ENGINE_LOCK.perlVersion || result.identity.identity?.webPerlVersion !== PERL_ENGINE_LOCK.webPerlVersion || result.identity.identity?.legacy !== true || result.identity.identity?.beta !== true || result.identity.identity?.selfTest !== true || result.identity.identity?.replacementTransport !== "fixed MEMFS binary output file" || result.identity.identity?.replacementParityOracle !== "fixed native s/// fixtures" ) { throw new Error("WebPerl failed its browser identity smoke test."); } if ( result.execution?.ok !== true || result.execution.groupCount !== 1 || JSON.stringify(result.execution.matches?.map(({ span }) => span)) !== JSON.stringify([ [1, 3], [5, 6], ]) || JSON.stringify( result.execution.matches?.map(({ captures }) => captures[0]), ) !== JSON.stringify([ [1, 3], [5, 6], ]) ) { throw new Error("WebPerl failed its native browser match smoke test."); } if ( result.replacement?.ok !== true || result.replacement.output !== "x😀😀-😀😀-$ y😀-😀-$" || result.replacement.outputBytes !== 33 || result.replacement.outputTruncated !== false ) { throw new Error( "WebPerl failed its bounded browser replacement smoke test.", ); } if ( result.boundedMatches?.ok !== true || result.boundedMatches.output !== "[a]-[a]-a-tail" || result.boundedMatches.outputBytes !== 14 || result.boundedMatches.outputTruncated !== false || result.boundedMatches.resultsTruncated !== true || result.boundedMatches.matches?.length !== 2 ) { throw new Error( "WebPerl replaced beyond its match/capture-row result bound.", ); } if ( result.greedyCapture?.ok !== true || result.greedyCapture.output !== "|a" || result.greedyCapture.outputBytes !== 2 || result.greedyCapture.outputTruncated !== false ) { throw new Error( "WebPerl failed its greedy numbered-capture replacement grammar.", ); } if ( result.adversarialExpansion?.ok !== true || result.adversarialExpansion.output !== "é".repeat(15) || result.adversarialExpansion.outputBytes !== 30 || result.adversarialExpansion.outputTruncated !== true || result.adversarialExpansion.matches?.length !== 1 ) { throw new Error( "WebPerl materialized or misbounded an adversarial replacement expansion.", ); } if ( result.blocked?.ok !== false || result.blocked.code !== "code-assertion-disabled" ) { throw new Error("WebPerl did not reject a runtime regex code assertion."); } if (failures.length > 0) { throw new Error(`WebPerl browser CSP failures: ${failures.join("; ")}`); } } finally { await browser?.close(); await closeServer(server); } } export async function verifyPerlPack( directory, projectRoot = root, { smoke = true } = {}, ) { const repository = await verifyRepositorySources(projectRoot); const pack = await assertRealDirectory(directory, "Perl engine pack"); await assertClosedPack(pack); const metadata = await verifyChecksummedEnginePack(pack, "perl"); await assertPinnedPackFiles(pack, repository); const expected = await expectedMetadata(repository, pack); if (JSON.stringify(metadata) !== JSON.stringify(expected)) { throw new Error( "Perl engine metadata does not match the pinned source and pack contract.", ); } if (smoke) await smokePerlPack(pack); return metadata; } async function replaceDirectory(stage, target, label) { const existing = await lstat(target).catch(() => null); if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) { throw new Error(`${label} must be a real directory.`); } if (!existing) { await rename(stage, target); return; } const backup = `${target}.backup-${process.pid}-${Date.now()}`; if (await lstat(backup).catch(() => null)) { throw new Error(`${label} backup path already exists.`); } await rename(target, backup); try { await rename(stage, target); } catch (error) { await rename(backup, target); throw error; } await rm(backup, { recursive: true }); } export async function buildPerlPack({ projectRoot = root, archive, prebuiltDirectory, output = path.join(root, ".engine-build", "perl"), force = false, } = {}) { const repository = await verifyRepositorySources(projectRoot); if (Boolean(archive) === Boolean(prebuiltDirectory)) { throw new Error( "Provide exactly one local --archive or --prebuilt WebPerl input.", ); } const upstream = archive ? await archiveInputs(path.resolve(archive)) : await directoryInputs(path.resolve(prebuiltDirectory)); const target = path.resolve(output); assertSafeOutputTarget(target, repository); const existing = await lstat(target).catch(() => null); if (existing && !force) { throw new Error(`${target} exists; pass --force to replace it.`); } if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) { throw new Error("Perl pack output must be a real directory."); } await mkdir(path.dirname(target), { recursive: true }); const stage = await mkdtemp(path.join(path.dirname(target), ".perl-pack-")); try { for (const [name, value] of upstream) { await writeFile(path.join(stage, name), value, { flag: "wx", mode: 0o644, }); } for (const [name, descriptor] of Object.entries( PERL_ENGINE_LOCK.supportingFiles, )) { await copyFile( path.join(repository, descriptor.source), path.join(stage, name), fsConstants.COPYFILE_EXCL, ); } const metadata = await expectedMetadata(repository, stage); await writeFile( path.join(stage, "engine-metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`, { flag: "wx", mode: 0o644 }, ); await writeEngineChecksums(stage); await verifyPerlPack(stage, repository); await replaceDirectory(stage, target, "Perl pack output"); return { output: target, metadata: await verifyPerlPack(target, repository, { smoke: false }), }; } catch (error) { await rm(stage, { recursive: true, force: true }); throw error; } } export async function installPerlPack(source, projectRoot = root) { const repository = await verifyRepositorySources(projectRoot); const sourcePath = await assertRealDirectory(source, "Perl staged pack"); const target = path.join(repository, "public", "engines", "perl"); if (sourcePath === (await realpath(target).catch(() => ""))) { throw new Error("Perl staged pack and installation target must differ."); } const metadata = await verifyPerlPack(sourcePath, repository); await mkdir(path.dirname(target), { recursive: true }); const stage = await mkdtemp( path.join(path.dirname(target), ".perl-install-"), ); try { for (const name of PACK_FILES) { await copyFile( path.join(sourcePath, name), path.join(stage, name), fsConstants.COPYFILE_EXCL, ); } await verifyPerlPack(stage, repository, { smoke: false }); await replaceDirectory(stage, target, "Installed Perl pack target"); } catch (error) { await rm(stage, { recursive: true, force: true }); throw error; } return { output: target, metadata }; } function parseBuildArguments(arguments_) { let archive; let prebuiltDirectory; let output = path.join(root, ".engine-build", "perl"); let force = false; for (let index = 0; index < arguments_.length; index += 1) { const argument = arguments_[index]; if (argument === "--force") { if (force) throw new Error("--force was provided more than once."); force = true; continue; } if ( argument === "--archive" || argument === "--prebuilt" || argument === "--output" ) { const value = arguments_[index + 1]; if (!value || value.startsWith("--")) { throw new Error(`${argument} requires a local path.`); } if (argument === "--archive") archive = path.resolve(root, value); if (argument === "--prebuilt") { prebuiltDirectory = path.resolve(root, value); } if (argument === "--output") output = path.resolve(root, value); index += 1; continue; } throw new Error(`Unknown Perl engine-pack argument: ${argument}`); } return { archive, prebuiltDirectory, output, force }; } async function main() { const arguments_ = process.argv.slice(2); if (arguments_[0] === "--verify") { if (arguments_.length > 2) { throw new Error( "Usage: node scripts/perl-engine-pack.mjs --verify [pack]", ); } const pack = path.resolve(root, arguments_[1] ?? "public/engines/perl"); const metadata = await verifyPerlPack(pack, root); console.log(`Verified ${metadata.semanticIdentity} pack at ${pack}.`); return; } const result = await buildPerlPack(parseBuildArguments(arguments_)); console.log( `Built verified ${result.metadata.semanticIdentity} pack at ${result.output}.`, ); } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { await main(); }