1028 lines
34 KiB
JavaScript
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();
|
|
}
|