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( "