Files
regex-tools/scripts/perl-engine-pack.mjs

1028 lines
34 KiB
JavaScript

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(
"<!doctype html><meta charset=utf-8><title>Perl smoke</title>",
);
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: "(?<word>😀+)",
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();
}