1019 lines
32 KiB
JavaScript
1019 lines
32 KiB
JavaScript
import { spawn } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
access,
|
|
constants as fsConstants,
|
|
copyFile,
|
|
lstat,
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
readdir,
|
|
realpath,
|
|
rename,
|
|
rm,
|
|
stat,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
export const PCRE2_LOCK = Object.freeze({
|
|
bridgeAbi: 3,
|
|
maximumPatternBytes: 1_048_576,
|
|
maximumSubjectBytes: 16_777_216,
|
|
maximumReplacementBytes: 262_144,
|
|
maximumMatches: 10_000,
|
|
maximumCaptureRows: 100_000,
|
|
maximumCaptureGroups: 1_000,
|
|
maximumTraceEvents: 50_000,
|
|
maximumTraceBytes: 10_485_760,
|
|
maximumTraceMarkBytes: 1_024,
|
|
sourceDateEpoch: 1_760_997_684,
|
|
source: Object.freeze({
|
|
repository: "https://github.com/PCRE2Project/pcre2.git",
|
|
tag: "pcre2-10.47",
|
|
tagObject: "cd007b4466798f66d479d1442a407099e7c40050",
|
|
commit: "f454e231fe5006dd7ff8f4693fd2b8eb94333429",
|
|
tree: "81a83a3552bd68d0ea7b7004f8bb6e7892f583ba",
|
|
version: "10.47 2025-10-21",
|
|
license: "BSD-3-Clause WITH PCRE2-exception",
|
|
criticalFiles: Object.freeze({
|
|
"CMakeLists.txt":
|
|
"74f65935e2b1120e7d7aeb4be81755e0f7a875732467ae64a9cc6ee7174fa5ba",
|
|
"LICENCE.md":
|
|
"197d8a73ffee0d6b09adba2f9c677b5f5aede24edf89258a68e48248d010d811",
|
|
"src/pcre2.h.generic":
|
|
"058462dc030e845ee627e19ac36347f561f5cc44d89eaf1c0010f2b363ab68d5",
|
|
}),
|
|
}),
|
|
emscripten: Object.freeze({
|
|
version: "6.0.4",
|
|
compilerRevision: "fe5be6afdff43ad58860d821fcc8572a23f92d19",
|
|
repositoryCommit: "224ec5f9f2f72f09f9ce0e26d66bae7dbd8b692f",
|
|
emccSha256:
|
|
"498eba9b6aacffca1c358ba340fc7b37015d7867cb211c7f2d45784ee9310f03",
|
|
emcmakeSha256:
|
|
"18c8397cf60835533e0db88d1172e04fa0b753dea68e978149d923a9e6378af3",
|
|
}),
|
|
cmakeVersion: "4.3.4",
|
|
ninjaVersion: "1.13.2",
|
|
});
|
|
|
|
const PACK_FILES = Object.freeze([
|
|
"LICENSE.txt",
|
|
"SHA256SUMS",
|
|
"engine-metadata.json",
|
|
"pcre2.mjs",
|
|
"pcre2.wasm",
|
|
]);
|
|
const CHECKSUM_FILES = Object.freeze([
|
|
"LICENSE.txt",
|
|
"engine-metadata.json",
|
|
"pcre2.mjs",
|
|
"pcre2.wasm",
|
|
]);
|
|
const BRIDGE_SOURCE_FILES = Object.freeze([
|
|
"engines/pcre2/CMakeLists.txt",
|
|
"engines/pcre2/pcre2_bridge.c",
|
|
"engines/pcre2/pcre2_bridge.h",
|
|
]);
|
|
const EXPORTED_FUNCTIONS = Object.freeze([
|
|
"_free",
|
|
"_malloc",
|
|
"_regex_pcre2_bridge_abi_version",
|
|
"_regex_pcre2_compile_probe",
|
|
"_regex_pcre2_config_flags",
|
|
"_regex_pcre2_error_message",
|
|
"_regex_pcre2_execute",
|
|
"_regex_pcre2_self_test",
|
|
"_regex_pcre2_substitute",
|
|
"_regex_pcre2_trace",
|
|
"_regex_pcre2_version",
|
|
]);
|
|
|
|
function sha256(data) {
|
|
return createHash("sha256").update(data).digest("hex");
|
|
}
|
|
|
|
async function sha256File(file) {
|
|
return sha256(await readFile(file));
|
|
}
|
|
|
|
function commandLabel(command, arguments_) {
|
|
return [command, ...arguments_]
|
|
.map((part) =>
|
|
/^[A-Za-z0-9_./:=,+-]+$/u.test(part) ? part : JSON.stringify(part),
|
|
)
|
|
.join(" ");
|
|
}
|
|
|
|
async function run(command, arguments_, options = {}) {
|
|
const child = spawn(command, arguments_, {
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
const stdout = [];
|
|
const stderr = [];
|
|
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
|
|
const result = await new Promise((resolve, reject) => {
|
|
child.once("error", reject);
|
|
child.once("close", (code, signal) => resolve({ code, signal }));
|
|
});
|
|
const output = Buffer.concat(stdout).toString("utf8");
|
|
const errorOutput = Buffer.concat(stderr).toString("utf8");
|
|
if (result.code !== 0) {
|
|
const detail = [output, errorOutput].filter(Boolean).join("\n").trim();
|
|
throw new Error(
|
|
`${commandLabel(command, arguments_)} failed with ${
|
|
result.signal ? `signal ${result.signal}` : `exit code ${result.code}`
|
|
}${detail ? `:\n${detail}` : "."}`,
|
|
);
|
|
}
|
|
return { stdout: output, stderr: errorOutput };
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
async function assertHash(file, expected, label) {
|
|
const actual = await sha256File(file);
|
|
if (actual !== expected) {
|
|
throw new Error(
|
|
`${label} SHA-256 mismatch: expected ${expected}, got ${actual}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function resolveExecutable(name, environment = process.env) {
|
|
const searchPath = environment.PATH ?? "";
|
|
for (const directory of searchPath.split(path.delimiter)) {
|
|
if (!directory) continue;
|
|
const candidate = path.join(directory, name);
|
|
try {
|
|
await access(candidate, fsConstants.X_OK);
|
|
const details = await stat(candidate);
|
|
if (details.isFile()) return realpath(candidate);
|
|
} catch {
|
|
// Continue through PATH.
|
|
}
|
|
}
|
|
throw new Error(`${name} is required on PATH for the explicit PCRE2 build.`);
|
|
}
|
|
|
|
function singleVersionLine(output) {
|
|
return output.trim().split(/\r?\n/u)[0] ?? "";
|
|
}
|
|
|
|
async function verifyPcre2Source(sourceDirectory) {
|
|
const source = await assertRealDirectory(sourceDirectory, "PCRE2 source");
|
|
const git = await resolveExecutable("git");
|
|
const status = await run(
|
|
git,
|
|
["status", "--porcelain=v1", "--untracked-files=all"],
|
|
{ cwd: source },
|
|
);
|
|
if (status.stdout !== "") {
|
|
throw new Error("The PCRE2 source checkout must be completely clean.");
|
|
}
|
|
|
|
const checks = [
|
|
[["rev-parse", "HEAD"], PCRE2_LOCK.source.commit, "PCRE2 commit"],
|
|
[["rev-parse", "HEAD^{tree}"], PCRE2_LOCK.source.tree, "PCRE2 source tree"],
|
|
[
|
|
["rev-parse", `refs/tags/${PCRE2_LOCK.source.tag}^{tag}`],
|
|
PCRE2_LOCK.source.tagObject,
|
|
"PCRE2 signed tag object",
|
|
],
|
|
[
|
|
["rev-parse", `refs/tags/${PCRE2_LOCK.source.tag}^{commit}`],
|
|
PCRE2_LOCK.source.commit,
|
|
"PCRE2 tag target",
|
|
],
|
|
];
|
|
for (const [arguments_, expected, label] of checks) {
|
|
const { stdout } = await run(git, arguments_, { cwd: source });
|
|
const actual = stdout.trim();
|
|
if (actual !== expected) {
|
|
throw new Error(
|
|
`${label} mismatch: expected ${expected}, got ${actual}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const [relativePath, expected] of Object.entries(
|
|
PCRE2_LOCK.source.criticalFiles,
|
|
)) {
|
|
await assertHash(
|
|
path.join(source, relativePath),
|
|
expected,
|
|
`PCRE2 ${relativePath}`,
|
|
);
|
|
}
|
|
return source;
|
|
}
|
|
|
|
async function verifyEmscripten(emccFile) {
|
|
const emcc = await assertRegularFile(emccFile, "emcc");
|
|
const emcmake = await assertRegularFile(
|
|
path.join(path.dirname(emcc), "emcmake"),
|
|
"emcmake",
|
|
);
|
|
await assertHash(emcc, PCRE2_LOCK.emscripten.emccSha256, "emcc");
|
|
await assertHash(emcmake, PCRE2_LOCK.emscripten.emcmakeSha256, "emcmake");
|
|
|
|
const version = await run(emcc, ["--version"]);
|
|
const expectedVersion =
|
|
`emcc (Emscripten gcc/clang-like replacement + linker emulating GNU ld) ` +
|
|
`${PCRE2_LOCK.emscripten.version} (${PCRE2_LOCK.emscripten.compilerRevision})`;
|
|
if (singleVersionLine(version.stdout) !== expectedVersion) {
|
|
throw new Error(
|
|
`Emscripten identity mismatch: expected ${expectedVersion}, got ${singleVersionLine(
|
|
version.stdout,
|
|
)}.`,
|
|
);
|
|
}
|
|
|
|
const git = await resolveExecutable("git");
|
|
const repository = (
|
|
await run(git, ["rev-parse", "--show-toplevel"], {
|
|
cwd: path.dirname(emcc),
|
|
})
|
|
).stdout.trim();
|
|
const commit = (
|
|
await run(git, ["rev-parse", "HEAD"], { cwd: repository })
|
|
).stdout.trim();
|
|
if (commit !== PCRE2_LOCK.emscripten.repositoryCommit) {
|
|
throw new Error(
|
|
`Emscripten repository mismatch: expected ${PCRE2_LOCK.emscripten.repositoryCommit}, got ${commit}.`,
|
|
);
|
|
}
|
|
const tagTarget = (
|
|
await run(
|
|
git,
|
|
["rev-parse", `refs/tags/${PCRE2_LOCK.emscripten.version}^{commit}`],
|
|
{
|
|
cwd: repository,
|
|
},
|
|
)
|
|
).stdout.trim();
|
|
if (tagTarget !== PCRE2_LOCK.emscripten.repositoryCommit) {
|
|
throw new Error(
|
|
"The Emscripten 6.0.4 tag does not resolve to the pinned commit.",
|
|
);
|
|
}
|
|
const status = await run(
|
|
git,
|
|
["status", "--porcelain=v1", "--untracked-files=all"],
|
|
{ cwd: repository },
|
|
);
|
|
if (status.stdout !== "") {
|
|
throw new Error("The Emscripten checkout must be completely clean.");
|
|
}
|
|
return { emcc, emcmake };
|
|
}
|
|
|
|
async function verifyNativeBuildTools() {
|
|
const cmake = await resolveExecutable("cmake");
|
|
const ninja = await resolveExecutable("ninja");
|
|
const cmakeOutput = await run(cmake, ["--version"]);
|
|
const ninjaOutput = await run(ninja, ["--version"]);
|
|
if (
|
|
singleVersionLine(cmakeOutput.stdout) !==
|
|
`cmake version ${PCRE2_LOCK.cmakeVersion}`
|
|
) {
|
|
throw new Error(
|
|
`The PCRE2 pack requires CMake ${PCRE2_LOCK.cmakeVersion}.`,
|
|
);
|
|
}
|
|
if (ninjaOutput.stdout.trim() !== PCRE2_LOCK.ninjaVersion) {
|
|
throw new Error(
|
|
`The PCRE2 pack requires Ninja ${PCRE2_LOCK.ninjaVersion}.`,
|
|
);
|
|
}
|
|
return { cmake, ninja };
|
|
}
|
|
|
|
function parseValue(argv, index, option) {
|
|
const value = argv[index + 1];
|
|
if (!value || value.startsWith("--")) {
|
|
throw new Error(`${option} requires a path.`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function parsePcre2BuildArguments(argv) {
|
|
if (argv.length === 0) return { mode: "ecmascript" };
|
|
if (argv.length === 1 && argv[0] === "--help") return { mode: "help" };
|
|
|
|
let pcre2 = false;
|
|
let sourceDirectory;
|
|
let emccFile;
|
|
let force = false;
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const argument = argv[index];
|
|
if (argument === "--pcre2") {
|
|
if (pcre2) throw new Error("--pcre2 may be supplied only once.");
|
|
pcre2 = true;
|
|
} else if (argument === "--source-dir") {
|
|
if (sourceDirectory)
|
|
throw new Error("--source-dir may be supplied only once.");
|
|
sourceDirectory = parseValue(argv, index, argument);
|
|
index += 1;
|
|
} else if (argument === "--emcc") {
|
|
if (emccFile) throw new Error("--emcc may be supplied only once.");
|
|
emccFile = parseValue(argv, index, argument);
|
|
index += 1;
|
|
} else if (argument === "--force") {
|
|
if (force) throw new Error("--force may be supplied only once.");
|
|
force = true;
|
|
} else {
|
|
throw new Error(`Unknown engine-build argument: ${argument}`);
|
|
}
|
|
}
|
|
if (!pcre2) {
|
|
throw new Error(
|
|
"External engine options require the explicit --pcre2 gate.",
|
|
);
|
|
}
|
|
if (!sourceDirectory || !emccFile) {
|
|
throw new Error(
|
|
"The offline PCRE2 build requires both --source-dir and --emcc; it never downloads them.",
|
|
);
|
|
}
|
|
return { mode: "pcre2", sourceDirectory, emccFile, force };
|
|
}
|
|
|
|
async function bridgeSourceFiles(root) {
|
|
return Promise.all(
|
|
BRIDGE_SOURCE_FILES.map(async (relativePath) => {
|
|
const contents = await readFile(path.join(root, relativePath));
|
|
return {
|
|
path: relativePath,
|
|
sha256: sha256(contents),
|
|
bytes: contents.byteLength,
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function describedFile(directory, name) {
|
|
const contents = await readFile(path.join(directory, name));
|
|
return { path: name, sha256: sha256(contents), bytes: contents.byteLength };
|
|
}
|
|
|
|
async function expectedMetadata(root, packDirectory) {
|
|
const files = await Promise.all(
|
|
["LICENSE.txt", "pcre2.mjs", "pcre2.wasm"].map((name) =>
|
|
describedFile(packDirectory, name),
|
|
),
|
|
);
|
|
return {
|
|
schemaVersion: 1,
|
|
engine: "pcre2",
|
|
engineVersion: PCRE2_LOCK.source.version,
|
|
status: "production-runtime",
|
|
bridge: {
|
|
abiVersion: PCRE2_LOCK.bridgeAbi,
|
|
maximumPatternBytes: PCRE2_LOCK.maximumPatternBytes,
|
|
maximumSubjectBytes: PCRE2_LOCK.maximumSubjectBytes,
|
|
maximumReplacementBytes: PCRE2_LOCK.maximumReplacementBytes,
|
|
maximumMatches: PCRE2_LOCK.maximumMatches,
|
|
maximumCaptureRows: PCRE2_LOCK.maximumCaptureRows,
|
|
maximumCaptureGroups: PCRE2_LOCK.maximumCaptureGroups,
|
|
maximumTraceEvents: PCRE2_LOCK.maximumTraceEvents,
|
|
maximumTraceBytes: PCRE2_LOCK.maximumTraceBytes,
|
|
maximumTraceMarkBytes: PCRE2_LOCK.maximumTraceMarkBytes,
|
|
sourceFiles: await bridgeSourceFiles(root),
|
|
exports: [...EXPORTED_FUNCTIONS],
|
|
},
|
|
source: {
|
|
repository: PCRE2_LOCK.source.repository,
|
|
tag: PCRE2_LOCK.source.tag,
|
|
tagObjectSha1: PCRE2_LOCK.source.tagObject,
|
|
commitSha1: PCRE2_LOCK.source.commit,
|
|
treeSha1: PCRE2_LOCK.source.tree,
|
|
license: PCRE2_LOCK.source.license,
|
|
},
|
|
toolchain: {
|
|
emscriptenVersion: PCRE2_LOCK.emscripten.version,
|
|
emscriptenCompilerRevision: PCRE2_LOCK.emscripten.compilerRevision,
|
|
emsdkCommitSha1: PCRE2_LOCK.emscripten.repositoryCommit,
|
|
cmakeVersion: PCRE2_LOCK.cmakeVersion,
|
|
ninjaVersion: PCRE2_LOCK.ninjaVersion,
|
|
},
|
|
configuration: {
|
|
codeUnitWidth: 8,
|
|
unicode: true,
|
|
jit: false,
|
|
threads: false,
|
|
filesystem: false,
|
|
initialMemoryBytes: 16_777_216,
|
|
maximumMemoryBytes: 268_435_456,
|
|
},
|
|
sourceDateEpoch: PCRE2_LOCK.sourceDateEpoch,
|
|
files,
|
|
};
|
|
}
|
|
|
|
function deterministicJson(value) {
|
|
return `${JSON.stringify(value, null, 2)}\n`;
|
|
}
|
|
|
|
async function expectedChecksums(packDirectory) {
|
|
const entries = await Promise.all(
|
|
CHECKSUM_FILES.map(
|
|
async (name) =>
|
|
`${await sha256File(path.join(packDirectory, name))} ${name}`,
|
|
),
|
|
);
|
|
return `${entries.join("\n")}\n`;
|
|
}
|
|
|
|
function equalJson(left, right) {
|
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
}
|
|
|
|
async function smokePcre2Pack(packDirectory) {
|
|
const wasm = await readFile(path.join(packDirectory, "pcre2.wasm"));
|
|
if (
|
|
wasm.byteLength < 8 ||
|
|
!wasm.subarray(0, 4).equals(Buffer.from([0x00, 0x61, 0x73, 0x6d])) ||
|
|
!WebAssembly.validate(wasm)
|
|
) {
|
|
throw new Error("pcre2.wasm is not a valid WebAssembly module.");
|
|
}
|
|
|
|
const moduleUrl = `${pathToFileURL(path.join(packDirectory, "pcre2.mjs")).href}?sha256=${sha256(
|
|
await readFile(path.join(packDirectory, "pcre2.mjs")),
|
|
)}`;
|
|
const imported = await import(moduleUrl);
|
|
if (typeof imported.default !== "function") {
|
|
throw new Error("pcre2.mjs does not export its Emscripten factory.");
|
|
}
|
|
const diagnostics = [];
|
|
const module = await imported.default({
|
|
locateFile(name) {
|
|
return path.join(packDirectory, name);
|
|
},
|
|
print() {},
|
|
printErr(line) {
|
|
diagnostics.push(String(line));
|
|
},
|
|
});
|
|
|
|
if (module._regex_pcre2_bridge_abi_version() !== PCRE2_LOCK.bridgeAbi) {
|
|
throw new Error("The WebAssembly bridge ABI version is incorrect.");
|
|
}
|
|
if (module._regex_pcre2_config_flags() !== 1) {
|
|
throw new Error(
|
|
"The WebAssembly pack must have Unicode enabled and JIT disabled.",
|
|
);
|
|
}
|
|
if (
|
|
module.UTF8ToString(module._regex_pcre2_version()) !==
|
|
PCRE2_LOCK.source.version
|
|
) {
|
|
throw new Error("The WebAssembly PCRE2 version is incorrect.");
|
|
}
|
|
if (module._regex_pcre2_self_test() !== 0) {
|
|
throw new Error(
|
|
`The WebAssembly bridge self-test failed: ${diagnostics.join("\n")}`,
|
|
);
|
|
}
|
|
|
|
const pattern = new TextEncoder().encode("(?<word>\\p{L}+)-(?<number>\\d+)");
|
|
const patternPointer = module._malloc(pattern.byteLength);
|
|
const resultPointer = module._malloc(20);
|
|
if (!patternPointer || !resultPointer) {
|
|
if (patternPointer) module._free(patternPointer);
|
|
if (resultPointer) module._free(resultPointer);
|
|
throw new Error("The WebAssembly smoke test could not allocate memory.");
|
|
}
|
|
try {
|
|
module.HEAPU8.set(pattern, patternPointer);
|
|
module.HEAPU8.fill(0, resultPointer, resultPointer + 20);
|
|
const status = module._regex_pcre2_compile_probe(
|
|
patternPointer,
|
|
pattern.byteLength,
|
|
0x20 | 0x40,
|
|
resultPointer,
|
|
);
|
|
const result = new DataView(module.HEAPU8.buffer, resultPointer, 20);
|
|
if (
|
|
status !== 0 ||
|
|
result.getInt32(0, true) !== 0 ||
|
|
result.getUint32(8, true) !== 2 ||
|
|
result.getUint32(12, true) !== 2
|
|
) {
|
|
throw new Error("The WebAssembly compile-probe result is incorrect.");
|
|
}
|
|
|
|
module.HEAPU8.fill(0, resultPointer, resultPointer + 20);
|
|
const unsupportedStatus = module._regex_pcre2_compile_probe(
|
|
patternPointer,
|
|
pattern.byteLength,
|
|
0x80000000,
|
|
resultPointer,
|
|
);
|
|
if (unsupportedStatus !== -10002 || result.getInt32(0, true) !== -10002) {
|
|
throw new Error("The WebAssembly bridge accepted an unknown option bit.");
|
|
}
|
|
|
|
module.HEAPU8[patternPointer] = 0x28;
|
|
module.HEAPU8.fill(0, resultPointer, resultPointer + 20);
|
|
const invalidStatus = module._regex_pcre2_compile_probe(
|
|
patternPointer,
|
|
1,
|
|
0,
|
|
resultPointer,
|
|
);
|
|
if (
|
|
invalidStatus <= 0 ||
|
|
result.getInt32(0, true) !== invalidStatus ||
|
|
result.getUint32(4, true) > 1
|
|
) {
|
|
throw new Error(
|
|
"The WebAssembly bridge did not preserve the compile error.",
|
|
);
|
|
}
|
|
|
|
if (
|
|
module._regex_pcre2_compile_probe(0, 1, 0, resultPointer) !== -10001 ||
|
|
module._regex_pcre2_compile_probe(
|
|
patternPointer,
|
|
PCRE2_LOCK.maximumPatternBytes + 1,
|
|
0,
|
|
resultPointer,
|
|
) !== -10003
|
|
) {
|
|
throw new Error("The WebAssembly bridge input limits are not enforced.");
|
|
}
|
|
} finally {
|
|
module._free(resultPointer);
|
|
module._free(patternPointer);
|
|
}
|
|
|
|
const runPattern = new TextEncoder().encode("(?<emoji>😀)|(?<word>\\p{L}+)");
|
|
const runSubject = new TextEncoder().encode("x😀 Grüße");
|
|
const runPointers = [];
|
|
const allocate = (bytes) => {
|
|
const pointer = module._malloc(Math.max(1, bytes));
|
|
if (!pointer) throw new Error("The PCRE2 runtime smoke allocation failed.");
|
|
runPointers.push(pointer);
|
|
return pointer;
|
|
};
|
|
try {
|
|
const runPatternPointer = allocate(runPattern.byteLength);
|
|
const runSubjectPointer = allocate(runSubject.byteLength);
|
|
const limitsPointer = allocate(20);
|
|
const recordsPointer = allocate(20 * 16);
|
|
const namesPointer = allocate(10 * 12);
|
|
const nameBytesPointer = allocate(256);
|
|
const runResultPointer = allocate(60);
|
|
module.HEAPU8.set(runPattern, runPatternPointer);
|
|
module.HEAPU8.set(runSubject, runSubjectPointer);
|
|
let view = new DataView(module.HEAPU8.buffer);
|
|
view.setUint32(limitsPointer, 10, true);
|
|
view.setUint32(limitsPointer + 4, 100, true);
|
|
view.setUint32(limitsPointer + 8, 1_000_000, true);
|
|
view.setUint32(limitsPointer + 12, 1_000, true);
|
|
view.setUint32(limitsPointer + 16, 32_768, true);
|
|
module.HEAPU8.fill(0, runResultPointer, runResultPointer + 60);
|
|
const runStatus = module._regex_pcre2_execute(
|
|
runPatternPointer,
|
|
runPattern.byteLength,
|
|
runSubjectPointer,
|
|
runSubject.byteLength,
|
|
0x20 | 0x40 | 0x100,
|
|
limitsPointer,
|
|
recordsPointer,
|
|
20,
|
|
namesPointer,
|
|
10,
|
|
nameBytesPointer,
|
|
256,
|
|
runResultPointer,
|
|
);
|
|
view = new DataView(module.HEAPU8.buffer);
|
|
if (
|
|
runStatus !== 0 ||
|
|
view.getInt32(runResultPointer, true) !== 0 ||
|
|
view.getUint32(runResultPointer + 12, true) !== 2 ||
|
|
view.getUint32(runResultPointer + 20, true) !== 3 ||
|
|
view.getUint32(runResultPointer + 24, true) !== 9 ||
|
|
view.getUint32(recordsPointer + 3 * 16 + 8, true) !== 1 ||
|
|
view.getUint32(recordsPointer + 3 * 16 + 12, true) !== 5
|
|
) {
|
|
throw new Error("The WebAssembly bounded-match smoke result is wrong.");
|
|
}
|
|
|
|
const replacementPattern = new TextEncoder().encode("(?<word>\\p{L}+)");
|
|
const replacementSubject = new TextEncoder().encode("Grüße 42");
|
|
const replacement = new TextEncoder().encode("${word}!");
|
|
const replacementPatternPointer = allocate(replacementPattern.byteLength);
|
|
const replacementSubjectPointer = allocate(replacementSubject.byteLength);
|
|
const replacementPointer = allocate(replacement.byteLength);
|
|
const outputPointer = allocate(65);
|
|
module.HEAPU8.set(replacementPattern, replacementPatternPointer);
|
|
module.HEAPU8.set(replacementSubject, replacementSubjectPointer);
|
|
module.HEAPU8.set(replacement, replacementPointer);
|
|
module.HEAPU8.fill(0, runResultPointer, runResultPointer + 60);
|
|
const substituteStatus = module._regex_pcre2_substitute(
|
|
replacementPatternPointer,
|
|
replacementPattern.byteLength,
|
|
replacementSubjectPointer,
|
|
replacementSubject.byteLength,
|
|
replacementPointer,
|
|
replacement.byteLength,
|
|
0x20 | 0x40 | 0x100,
|
|
limitsPointer,
|
|
recordsPointer,
|
|
20,
|
|
namesPointer,
|
|
10,
|
|
nameBytesPointer,
|
|
256,
|
|
outputPointer,
|
|
64,
|
|
runResultPointer,
|
|
);
|
|
view = new DataView(module.HEAPU8.buffer);
|
|
const outputLength = view.getUint32(runResultPointer + 48, true);
|
|
const output = new TextDecoder().decode(
|
|
module.HEAPU8.slice(outputPointer, outputPointer + outputLength),
|
|
);
|
|
if (
|
|
substituteStatus !== 0 ||
|
|
output !== "Grüße! 42" ||
|
|
view.getUint32(runResultPointer + 52, true) !== 1 ||
|
|
view.getUint32(runResultPointer + 56, true) !== 0
|
|
) {
|
|
throw new Error(
|
|
"The WebAssembly bounded-substitution smoke result is wrong.",
|
|
);
|
|
}
|
|
module.HEAPU8.fill(0, runResultPointer, runResultPointer + 60);
|
|
const missingOutputStatus = module._regex_pcre2_substitute(
|
|
replacementPatternPointer,
|
|
replacementPattern.byteLength,
|
|
replacementSubjectPointer,
|
|
replacementSubject.byteLength,
|
|
replacementPointer,
|
|
replacement.byteLength,
|
|
0x20 | 0x40,
|
|
limitsPointer,
|
|
recordsPointer,
|
|
20,
|
|
namesPointer,
|
|
10,
|
|
nameBytesPointer,
|
|
256,
|
|
0,
|
|
0,
|
|
runResultPointer,
|
|
);
|
|
if (
|
|
missingOutputStatus !== -10001 ||
|
|
view.getInt32(runResultPointer, true) !== -10001
|
|
) {
|
|
throw new Error(
|
|
"The WebAssembly substitution bridge accepted a missing NUL byte.",
|
|
);
|
|
}
|
|
|
|
const traceEventsPointer = allocate(64 * 32);
|
|
const traceMarksPointer = allocate(256);
|
|
const traceResultPointer = allocate(52);
|
|
view.setUint32(limitsPointer, 64, true);
|
|
view.setUint32(limitsPointer + 4, 4_096, true);
|
|
module.HEAPU8.fill(0, traceResultPointer, traceResultPointer + 52);
|
|
const traceStatus = module._regex_pcre2_trace(
|
|
runPatternPointer,
|
|
runPattern.byteLength,
|
|
runSubjectPointer,
|
|
runSubject.byteLength,
|
|
0x20 | 0x40,
|
|
limitsPointer,
|
|
traceEventsPointer,
|
|
64,
|
|
traceMarksPointer,
|
|
256,
|
|
traceResultPointer,
|
|
);
|
|
view = new DataView(module.HEAPU8.buffer);
|
|
const traceEventCount = view.getUint32(traceResultPointer + 20, true);
|
|
if (
|
|
traceStatus !== 0 ||
|
|
view.getInt32(traceResultPointer, true) !== 0 ||
|
|
view.getInt32(traceResultPointer + 12, true) <= 0 ||
|
|
traceEventCount === 0 ||
|
|
view.getUint32(traceResultPointer + 24, true) !== traceEventCount ||
|
|
view.getUint32(traceResultPointer + 36, true) !== 0 ||
|
|
view.getUint32(traceResultPointer + 44, true) !== traceEventCount - 1 ||
|
|
view.getUint32(traceResultPointer + 48, true) !== 1
|
|
) {
|
|
throw new Error("The WebAssembly automatic-callout trace is wrong.");
|
|
}
|
|
|
|
view.setUint32(limitsPointer, 1, true);
|
|
module.HEAPU8.fill(0, traceResultPointer, traceResultPointer + 52);
|
|
const boundedTraceStatus = module._regex_pcre2_trace(
|
|
runPatternPointer,
|
|
runPattern.byteLength,
|
|
runSubjectPointer,
|
|
runSubject.byteLength,
|
|
0x20 | 0x40,
|
|
limitsPointer,
|
|
traceEventsPointer,
|
|
64,
|
|
traceMarksPointer,
|
|
256,
|
|
traceResultPointer,
|
|
);
|
|
if (
|
|
boundedTraceStatus !== 0 ||
|
|
view.getInt32(traceResultPointer, true) !== 0 ||
|
|
view.getInt32(traceResultPointer + 12, true) !== -37 ||
|
|
view.getUint32(traceResultPointer + 20, true) !== 1 ||
|
|
view.getUint32(traceResultPointer + 24, true) !== 2 ||
|
|
view.getUint32(traceResultPointer + 36, true) !== 1 ||
|
|
view.getUint32(traceResultPointer + 44, true) !== 0
|
|
) {
|
|
throw new Error("The WebAssembly trace cap is not authoritative.");
|
|
}
|
|
} finally {
|
|
for (const pointer of runPointers.reverse()) module._free(pointer);
|
|
}
|
|
}
|
|
|
|
export async function verifyPcre2Pack(packDirectory, root) {
|
|
const repositoryRoot = await assertRealDirectory(
|
|
root,
|
|
"Regex Tools repository",
|
|
);
|
|
const pack = await assertRealDirectory(packDirectory, "PCRE2 pack");
|
|
const entries = (await readdir(pack, { withFileTypes: true })).sort(
|
|
(left, right) =>
|
|
left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
|
|
);
|
|
if (
|
|
entries.length !== PACK_FILES.length ||
|
|
entries.some(
|
|
(entry, index) =>
|
|
entry.name !== PACK_FILES[index] ||
|
|
!entry.isFile() ||
|
|
entry.isSymbolicLink(),
|
|
)
|
|
) {
|
|
throw new Error(
|
|
`The staged PCRE2 pack must contain only: ${PACK_FILES.join(", ")}.`,
|
|
);
|
|
}
|
|
|
|
await assertHash(
|
|
path.join(pack, "LICENSE.txt"),
|
|
PCRE2_LOCK.source.criticalFiles["LICENCE.md"],
|
|
"staged PCRE2 licence",
|
|
);
|
|
const moduleText = await readFile(path.join(pack, "pcre2.mjs"), "utf8");
|
|
if (
|
|
moduleText.includes("sourceMappingURL") ||
|
|
moduleText.includes("/mnt/") ||
|
|
moduleText.includes("/home/")
|
|
) {
|
|
throw new Error("pcre2.mjs leaks a build path or source-map reference.");
|
|
}
|
|
|
|
let metadata;
|
|
try {
|
|
metadata = JSON.parse(
|
|
await readFile(path.join(pack, "engine-metadata.json"), "utf8"),
|
|
);
|
|
} catch (error) {
|
|
throw new Error("engine-metadata.json is not valid JSON.", {
|
|
cause: error,
|
|
});
|
|
}
|
|
const expected = await expectedMetadata(repositoryRoot, pack);
|
|
if (!equalJson(metadata, expected)) {
|
|
throw new Error(
|
|
"The staged PCRE2 metadata does not match the pinned build contract.",
|
|
);
|
|
}
|
|
const checksums = await readFile(path.join(pack, "SHA256SUMS"), "utf8");
|
|
if (checksums !== (await expectedChecksums(pack))) {
|
|
throw new Error("The staged PCRE2 SHA256SUMS file is incorrect.");
|
|
}
|
|
await smokePcre2Pack(pack);
|
|
return metadata;
|
|
}
|
|
|
|
export async function installPcre2Pack(packDirectory, root) {
|
|
const repositoryRoot = await assertRealDirectory(
|
|
root,
|
|
"Regex Tools repository",
|
|
);
|
|
const sourcePack = await assertRealDirectory(
|
|
packDirectory,
|
|
"verified PCRE2 pack",
|
|
);
|
|
const metadata = await verifyPcre2Pack(sourcePack, repositoryRoot);
|
|
const engineRoot = await assertRealDirectory(
|
|
path.join(repositoryRoot, "public", "engines"),
|
|
"public engine directory",
|
|
);
|
|
const target = path.join(engineRoot, "pcre2");
|
|
const stage = await mkdtemp(path.join(engineRoot, ".pcre2-install-"));
|
|
const targetDetails = await lstat(target).catch(() => null);
|
|
if (
|
|
targetDetails &&
|
|
(!targetDetails.isDirectory() || targetDetails.isSymbolicLink())
|
|
) {
|
|
await rm(stage, { recursive: true, force: true });
|
|
throw new Error("public/engines/pcre2 must be a real directory.");
|
|
}
|
|
try {
|
|
for (const name of PACK_FILES) {
|
|
await copyFile(
|
|
path.join(sourcePack, name),
|
|
path.join(stage, name),
|
|
fsConstants.COPYFILE_EXCL,
|
|
);
|
|
}
|
|
await verifyPcre2Pack(stage, repositoryRoot);
|
|
if (targetDetails) {
|
|
const backup = path.join(engineRoot, `.pcre2-replaced-${process.pid}`);
|
|
await rename(target, backup);
|
|
try {
|
|
await rename(stage, target);
|
|
} catch (error) {
|
|
await rename(backup, target);
|
|
throw error;
|
|
}
|
|
await rm(backup, { recursive: true });
|
|
} else {
|
|
await rename(stage, target);
|
|
}
|
|
return { output: target, metadata };
|
|
} finally {
|
|
await rm(stage, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function cleanBuildEnvironment() {
|
|
const environment = { ...process.env };
|
|
for (const variable of [
|
|
"CFLAGS",
|
|
"CPPFLAGS",
|
|
"CXXFLAGS",
|
|
"DESTDIR",
|
|
"EMCC_CFLAGS",
|
|
"EMMAKEN_CFLAGS",
|
|
"LDFLAGS",
|
|
]) {
|
|
delete environment[variable];
|
|
}
|
|
environment.LANG = "C";
|
|
environment.LC_ALL = "C";
|
|
environment.SOURCE_DATE_EPOCH = String(PCRE2_LOCK.sourceDateEpoch);
|
|
environment.TZ = "UTC";
|
|
environment.ZERO_AR_DATE = "1";
|
|
return environment;
|
|
}
|
|
|
|
async function ensurePrivateBuildRoot(root) {
|
|
const buildRoot = path.join(root, ".engine-build");
|
|
const existing = await lstat(buildRoot).catch(() => null);
|
|
if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) {
|
|
throw new Error(".engine-build must be a real directory.");
|
|
}
|
|
await mkdir(buildRoot, { recursive: true });
|
|
return realpath(buildRoot);
|
|
}
|
|
|
|
function assertGeneratedChild(buildRoot, candidate) {
|
|
const relative = path.relative(buildRoot, candidate);
|
|
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
throw new Error(
|
|
`Refusing to remove a path outside .engine-build: ${candidate}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function buildPcre2Pack(options, root) {
|
|
const repositoryRoot = await assertRealDirectory(
|
|
root,
|
|
"Regex Tools repository",
|
|
);
|
|
const source = await verifyPcre2Source(options.sourceDirectory);
|
|
const { emcmake } = await verifyEmscripten(options.emccFile);
|
|
const { cmake, ninja } = await verifyNativeBuildTools();
|
|
const buildRoot = await ensurePrivateBuildRoot(repositoryRoot);
|
|
const output = path.join(buildRoot, "pcre2");
|
|
const outputDetails = await lstat(output).catch(() => null);
|
|
if (outputDetails && !options.force) {
|
|
throw new Error(
|
|
`${output} already exists; pass --force to replace that exact staged pack.`,
|
|
);
|
|
}
|
|
|
|
const work = await mkdtemp(path.join(buildRoot, ".pcre2-work-"));
|
|
const stage = await mkdtemp(path.join(buildRoot, ".pcre2-stage-"));
|
|
assertGeneratedChild(buildRoot, work);
|
|
assertGeneratedChild(buildRoot, stage);
|
|
const environment = cleanBuildEnvironment();
|
|
try {
|
|
await run(
|
|
emcmake,
|
|
[
|
|
cmake,
|
|
"-S",
|
|
path.join(repositoryRoot, "engines", "pcre2"),
|
|
"-B",
|
|
work,
|
|
"-G",
|
|
"Ninja",
|
|
"-DCMAKE_BUILD_TYPE=Release",
|
|
`-DCMAKE_MAKE_PROGRAM=${ninja}`,
|
|
`-DPCRE2_SOURCE_DIR=${source}`,
|
|
`-DREGEX_PCRE2_OUTPUT_DIR=${stage}`,
|
|
],
|
|
{ cwd: repositoryRoot, env: environment },
|
|
);
|
|
await run(
|
|
cmake,
|
|
["--build", work, "--target", "regex-pcre2-pack", "--parallel", "1"],
|
|
{
|
|
cwd: repositoryRoot,
|
|
env: environment,
|
|
},
|
|
);
|
|
|
|
const generatedEntries = (await readdir(stage)).sort();
|
|
if (
|
|
generatedEntries.length !== 2 ||
|
|
generatedEntries[0] !== "pcre2.mjs" ||
|
|
generatedEntries[1] !== "pcre2.wasm"
|
|
) {
|
|
throw new Error(
|
|
`Emscripten produced unexpected files: ${generatedEntries.join(", ") || "(none)"}.`,
|
|
);
|
|
}
|
|
await copyFile(
|
|
path.join(source, "LICENCE.md"),
|
|
path.join(stage, "LICENSE.txt"),
|
|
fsConstants.COPYFILE_EXCL,
|
|
);
|
|
const metadata = await expectedMetadata(repositoryRoot, stage);
|
|
await writeFile(
|
|
path.join(stage, "engine-metadata.json"),
|
|
deterministicJson(metadata),
|
|
{ encoding: "utf8", flag: "wx" },
|
|
);
|
|
await writeFile(
|
|
path.join(stage, "SHA256SUMS"),
|
|
await expectedChecksums(stage),
|
|
{ encoding: "utf8", flag: "wx" },
|
|
);
|
|
await verifyPcre2Pack(stage, repositoryRoot);
|
|
|
|
if (outputDetails) {
|
|
const backup = path.join(buildRoot, `.pcre2-replaced-${process.pid}`);
|
|
assertGeneratedChild(buildRoot, backup);
|
|
await rename(output, backup);
|
|
try {
|
|
await rename(stage, output);
|
|
} catch (error) {
|
|
await rename(backup, output);
|
|
throw error;
|
|
}
|
|
await rm(backup, { recursive: true });
|
|
} else {
|
|
await rename(stage, output);
|
|
}
|
|
return { output, metadata };
|
|
} finally {
|
|
await rm(work, { recursive: true, force: true });
|
|
await rm(stage, { recursive: true, force: true });
|
|
}
|
|
}
|