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

1121 lines
38 KiB
JavaScript

import { createHash } from "node:crypto";
import {
constants as fsConstants,
copyFile,
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
realpath,
rename,
rm,
writeFile,
} from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { PHP, loadPHPRuntime } from "@php-wasm/universal";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
export const PHP_ENGINE_LOCK = Object.freeze({
bridgeAbi: 1,
phpVersion: "8.5.8",
pcreVersion: "10.44",
packageVersion: "3.1.46",
asyncMode: "asyncify",
platform: "wasm32-emscripten",
loaderBytes: 270_942,
wasmBytes: 21_019_221,
maximumPatternUtf16: 65_536,
maximumSubjectBytes: 16_777_216,
maximumReplacementUtf16: 65_536,
maximumOutputBytes: 67_108_864,
maximumMatches: 10_000,
maximumCaptureRows: 100_000,
maximumCaptureGroups: 1_000,
npm: Object.freeze({
web: Object.freeze({
name: "@php-wasm/web-8-5",
version: "3.1.46",
resolved:
"https://registry.npmjs.org/@php-wasm/web-8-5/-/web-8-5-3.1.46.tgz",
integrity:
"sha512-j9wwlY6aNXuKBr4JPJzqE+vkM3Onk+byJsxLqaJs4U0gYDUBLr8CuKuVr+p13apSTUosConePYTAs0214aNxYA==",
packageJsonSha256:
"bc69e9a9f88ed9c2e2c3ccd06ce7f842007cc0ef8edd4496a91b0131afdef1e4",
}),
universal: Object.freeze({
name: "@php-wasm/universal",
version: "3.1.46",
resolved:
"https://registry.npmjs.org/@php-wasm/universal/-/universal-3.1.46.tgz",
integrity:
"sha512-sx0KyN5kcsCMzwy+XJAyoOukSA5TEZYd0LK3l9kRP3I0CSd5h6JItkf5eZ42WltB+wD+aTRdqSwc/Mvkc7qBjQ==",
packageJsonSha256:
"051ffd543901513ad76b5f935882b99988622e67086df16df48fef8f7c12a116",
}),
util: Object.freeze({
name: "@php-wasm/util",
version: "3.1.46",
resolved: "https://registry.npmjs.org/@php-wasm/util/-/util-3.1.46.tgz",
integrity:
"sha512-Yh9HNbx+rB2R1tScUvvAsbJVIB7msjWceWYogyR2YvAOqlDL0ymZyaQ65+K3QcHh/8HostTRwwgQh5oK4DLDrA==",
packageJsonSha256:
"4d2d42e0495a92e1fed76ac6273cf807d84466fe6dbd4718c5602012968d6e78",
}),
logger: Object.freeze({
name: "@php-wasm/logger",
version: "3.1.46",
resolved:
"https://registry.npmjs.org/@php-wasm/logger/-/logger-3.1.46.tgz",
integrity:
"sha512-PfnDq5ammh5FXxkGX1TGEck9gmW3i76IvBCqtX4D45xnSBIuCeu8uf2oWuty2j/c8wTEO8vyRDdvCj5oow7bXw==",
packageJsonSha256:
"4e04ad8620452b48b1f225431c8143f4e14888408db2e3a166e02e4ac6863fc9",
}),
compression: Object.freeze({
name: "@php-wasm/stream-compression",
version: "3.1.46",
resolved:
"https://registry.npmjs.org/@php-wasm/stream-compression/-/stream-compression-3.1.46.tgz",
integrity:
"sha512-+oUw4CPX52FgYXEh5C+MgJAg7Cy5OeaVuh7HnQR6oxb69nU63/r8tHTLV/WrWRyjBYh321uJzLV7Gg29qmiUtg==",
packageJsonSha256:
"5a2b0ca2dff1466bdeb38c9dca93d2fb0154cf9fef254a7c475d8cfe32344b72",
}),
progress: Object.freeze({
name: "@php-wasm/progress",
version: "3.1.46",
resolved:
"https://registry.npmjs.org/@php-wasm/progress/-/progress-3.1.46.tgz",
integrity:
"sha512-WmmgEm6yz322M3pzgAUjxckyhpVxr2WWlHyVYDyaMZWU6UIDUHfiRZRPzADohNyYvV3g0/zMNaDGmxz+TTXwQQ==",
packageJsonSha256:
"0408ea4cdb2d251ad357090e9532f50769e9ca7c135aa823ccad9e7b95950a36",
}),
ini: Object.freeze({
name: "ini",
version: "4.1.2",
resolved: "https://registry.npmjs.org/ini/-/ini-4.1.2.tgz",
integrity:
"sha512-AMB1mvwR1pyBFY/nSevUX6y8nJWS63/SzUKD3JyQn97s4xgIdgQPT75IRouIiBAN4yLQBUShNYVW0+UG25daCw==",
packageJsonSha256:
"0bc65f73405054313cab76d1d5f89b888ad2c0e1261d3a97644728852a5df8dc",
}),
featureDetect: Object.freeze({
name: "wasm-feature-detect",
version: "1.8.0",
resolved:
"https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
integrity:
"sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
packageJsonSha256:
"f511a8499a742e364d5bc9cdb23fb09108b12c7683fe06fb9e2bc324832b3b5b",
}),
}),
sourceAssets: Object.freeze({
loader: "0ed44eae7cd040826775b3dcd5376bb86a14257ae9fe1ff5cda1b7daf7572088",
wasm: "ed97ea5422dbf23e0687c98203cc8c6d7f2772a3d71b03d4991d2fdd93a048f0",
nativeInventory:
"c0da167d0a43b0d85f72ca17fed7d823c84b85718e4c8abf5f120f61f7c8654a",
phpWasmLicense:
"8177f97513213526df2cf6184d8ff986c675afb514d4e68a404010521b880643",
iniLicense:
"4ec3d4c66cd87f5c8d8ad911b10f99bf27cb00cdfcff82621956e379186b016b",
featureDetectLicense:
"bcf29b4fd3ec2cb5f9d40a0866da446f6da62170d2ccedf4aeca9cf9406dd20c",
}),
legalAssets: Object.freeze({
"LICENSE.Emscripten-4.0.19.txt":
"620a78084fc7ca97c0b5dea9abf891f3ffcadfdbf305276f099c9c4e12fc1d86",
"LICENSE.Oniguruma-6.9.10.txt":
"70ba5469ea0bab6e18a32d7009068f996503168d27be57747e08da34337ff26f",
"LICENSE.OpenSSL-1.1.1t.txt":
"c32913b33252e71190af2066f08115c69bc9fddadf3bf29296e20c835389841c",
"LICENSE.PCRE2-10.44.txt":
"030087e2e8dd7c1bdd26057d25d4ded8f45bbf01ad458d68665ad04b8b0fbedf",
"LICENSE.PHP-4.0.txt":
"e66a38e0a83f49b6ad6ec72f6c0d7600cfa35ba95ee1a67eeeae676cd4416789",
"LICENSE.PHP-CLI-http-parser-MIT.txt":
"8539e2b4bd4e874b0f7d94a25b8f1b445d46ae22c8373d5a69f801a1a12f3265",
"LICENSE.PHP-Lexbor-Apache-2.0.txt":
"0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594",
"LICENSE.PHP-Zend-2.0.txt":
"509aeb3be64e428d7e0004ce224620ab01bf94adaacefbb449611939df56bc5b",
"LICENSE.PHP-bcmath-LGPL-2.1.txt":
"7586d794d062a2a4a16513d95358aecaee4450e2062f7e64ce5ce63ac7212fd6",
"LICENSE.PHP-libavifinfo-BSD-2-Clause.txt":
"5d40dcb72f934c3d08e10ddb8ba4844de17bf6f9aabc1a382aacf8a8bf4567f8",
"LICENSE.PHP-libmagic-BSD.txt":
"0bfa856a9930bddadbef95d1be1cf4e163c0be618e76ea3275caaf255283e274",
"LICENSE.PHP-libmbfl-LGPL-2.1.txt":
"31d9cadcc63c15b79fc59c70f043956104a34c75f23f0ac0ada53c1edfeb88ca",
"LICENSE.PHP-timelib-MIT.txt":
"2120d029e7c67b9d08c1f665f1c3ed581206f00970b664cccdebe1419bf566e9",
"LICENSE.PHP-uriparser-BSD-3-Clause.txt":
"287f09e6546a9610f949f89e8fb937cacfeabd7bfaa8c8a0c18312193bf04ad3",
"LICENSE.curl-7.69.1.txt":
"db3c4a3b3695a0f317a0c5176acd2f656d18abc45b3ee78e50935a78eb1e132e",
"LICENSE.ini-ISC.txt":
"4ec3d4c66cd87f5c8d8ad911b10f99bf27cb00cdfcff82621956e379186b016b",
"LICENSE.libaom-3.12.1.txt":
"4764a286d8b2faeaf42f4418e7d7a28d58fc8fd4d00a3d0a7f44b0a4099de7f2",
"LICENSE.libavif-1.3.0.txt":
"165abf92cc04b39e80d29cadea7a6a7e8fddf59407d4ad2616507a7ebe8216f9",
"LICENSE.libcxx.txt":
"539dd7aed86e8a4f12cbdd0e6c50c189c7d74847e4fecc64ce2c6ee3a01da38b",
"LICENSE.libcxxabi.txt":
"e2b35be49f7284a45b7baca8fc7b3ab7440e7902392b2528a457816b5bb2a15c",
"LICENSE.libgd-2.3.3.txt":
"005f4b6b0141d1bd11d371bbf7d4f67947f85a4906b7f5465f942204cf918ba3",
"LICENSE.libiconv-1.17-LGPL-2.1.txt":
"dc626520dcd53a22f727af3ee42c770e56c97a64fe3adb063799d8ab032fe551",
"LICENSE.libjpeg-turbo-3.0.3.txt":
"e548c96c0c09397c94b5c5531836c02ff2c403f1f2b130cad8e3ec9439b5a7cc",
"LICENSE.libpng-1.6.39.txt":
"dfe5a536b0e5a531f844fb9c101a3089aca60772a503893b8e15f9457e369960",
"LICENSE.libwebp.txt":
"5aec868f669e384a22372a4e8a1a6cd7d44c64cd451f960ca69cc170d1e13acf",
"LICENSE.libxml2-2.9.10.txt":
"c5c63674f8a83c4d2e385d96d1c670a03cb871ba2927755467017317878574bd",
"LICENSE.libyuv.txt":
"2b2cc1180c7e6988328ad2033b04b80117419db9c4c584918bbb3cfec7e9364f",
"LICENSE.libzip-1.9.2.txt":
"01c022eca6d566e2e8792fd0f091a28653b2a608319922bcd4de91c49d1438e1",
"LICENSE.llvm-compiler-rt.txt":
"1a8f1058753f1ba890de984e48f0242a3a5c29a6a8f2ed9fd813f36985387e8d",
"LICENSE.musl.txt":
"f9bc4423732350eb0b3f7ed7e91d530298476f8fec0c6c427a1c04ade22655af",
"LICENSE.php-wasm-GPL-2.0-or-later.txt":
"8177f97513213526df2cf6184d8ff986c675afb514d4e68a404010521b880643",
"LICENSE.wasm-feature-detect-Apache-2.0.txt":
"bcf29b4fd3ec2cb5f9d40a0866da446f6da62170d2ccedf4aeca9cf9406dd20c",
"LICENSE.zlib-1.2.13.txt":
"845efc77857d485d91fb3e0b884aaa929368c717ae8186b66fe1ed2495753243",
"NOTICE.IANA-TZDATA-2026.1.txt":
"8a19c6e511386301fa99ac61fec34c8be57be30255b29dad3ab294fe6b5da7c5",
"NOTICE.PHP-Lexbor.txt":
"b87f965fd2eba846a0a502d633dd7e7a680b93de5c514c404c948ccf1e5c9dc7",
"NOTICE.PHP-REDIST-BINS.txt":
"9159e8c784edf5ab9069c573bdc64a7a406ebe718768d2c25a0b243449b7665b",
"NOTICE.PHP-public-domain-hash-code.txt":
"1fc21940918bd2027d7b9ffccd23d45d35b33d6ca5b42a6fb481e75809763a98",
"NOTICE.SQLite-3.51.0.txt":
"b8262a33944a8937e6f38f573df2d945101b4bcb45de1b150c7e3a6ebd23b19b",
"NOTICE.dlmalloc-2.8.6.txt":
"ea9de40ffcf0d3536a71e22af9c33647c517256aa2a43167e933e74535569594",
"PATENTS.PHP-libavifinfo.txt":
"aef98cc6105f279463c37ec8c46dec5f2d133b6a91531ae9ec274e80fece9a88",
"PATENTS.libaom-3.12.1.txt":
"661fb8e504744e95587b556b94a58343448300606a41bea8c7a9b97125696e61",
"PATENTS.libwebp.txt":
"cc3273e0694ea5896145e0677699b53471b03ea43021ddc50e7923fbb9f5023c",
}),
});
const PACK_FILES = Object.freeze(
[
...Object.keys(PHP_ENGINE_LOCK.legalAssets),
"SHA256SUMS",
"engine-metadata.json",
"native-components.json",
"php-loader.mjs",
"php.wasm",
].sort(),
);
const CHECKSUM_FILES = Object.freeze(
PACK_FILES.filter((name) => name !== "SHA256SUMS"),
);
const LOADER_IMPORT = "import dependencyFilename from './8_5_8/php_8_5.wasm';";
const PATCHED_LOADER =
'const dependencyFilename = new URL("./php.wasm", import.meta.url).href;';
const PHP_NATIVE_COMPONENT_IDS = Object.freeze([
"wordpress-playground-php-wasm",
"php",
"pcre2",
"php-libbcmath",
"timelib",
"iana-tzdata",
"php-libmagic",
"lexbor",
"libmbfl",
"php-uriparser",
"php-libavifinfo",
"zlib",
"libzip",
"libxml2",
"sqlite",
"libgd",
"libjpeg-turbo",
"libpng",
"libwebp-and-libsharpyuv",
"libavif",
"libaom",
"libyuv",
"openssl",
"curl",
"libiconv",
"oniguruma",
"emscripten-runtime",
"musl-libc",
"llvm-compiler-rt",
"llvm-libcxx",
"llvm-libcxxabi",
"dlmalloc",
]);
const PHP_EXCLUDED_COMPONENT_IDS = Object.freeze([
"gmp",
"libsodium",
"tidy",
"intl-and-icu",
"freetype",
"imagemagick-and-imagick",
"standalone-libaom-3.13.1-recipe",
]);
const PHP_LOADED_EXTENSIONS = Object.freeze(
[
"Core",
"date",
"lexbor",
"openssl",
"pcre",
"sqlite3",
"zlib",
"bcmath",
"calendar",
"ctype",
"curl",
"dns_polyfill",
"uri",
"json",
"mbstring",
"SPL",
"fileinfo",
"filter",
"gd",
"hash",
"iconv",
"session",
"Zend OPcache",
"standard",
"libxml",
"PDO",
"pdo_sqlite",
"Phar",
"post_message_to_js",
"random",
"Reflection",
"exif",
"SimpleXML",
"soap",
"tokenizer",
"dom",
"wasm_memory_storage",
"xml",
"xmlreader",
"xmlwriter",
"zip",
].sort(compareText),
);
const PHP_BINARY_MARKERS = Object.freeze([
"AOMedia Project AV1 Encoder v3.12.1",
"AOMedia Project AV1 Decoder v3.12.1",
"libjpeg-turbo version 3.0.3 (build 20250321)",
]);
const PHP_EXCLUDED_BINARY_MARKERS = Object.freeze([
"AOMedia Project AV1 Encoder v3.13.1",
"AOMedia Project AV1 Decoder v3.13.1",
]);
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);
}
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}.`,
);
}
}
function assertExactJson(actual, expected, label) {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`${label} drifted from the pinned PHP runtime inventory.`);
}
}
function validateNativeInventory(inventory) {
if (
inventory?.schemaVersion !== 1 ||
inventory.scope?.assetSha256 !== PHP_ENGINE_LOCK.sourceAssets.wasm ||
inventory.scope?.assetBytes !== PHP_ENGINE_LOCK.wasmBytes ||
inventory.scope?.phpVersion !== PHP_ENGINE_LOCK.phpVersion ||
inventory.scope?.platform !== PHP_ENGINE_LOCK.platform ||
inventory.buildProvenance?.wordpressPlayground?.release !==
`v${PHP_ENGINE_LOCK.packageVersion}` ||
inventory.buildProvenance?.wordpressPlayground?.commit !==
"581c7c172428159eb4e6c5309054a568cd39a97a" ||
inventory.buildProvenance?.php?.commit !==
"26b97507444c4fbda072f57dda1820f7b7d5e467" ||
inventory.buildProvenance?.emscripten?.version !== "4.0.19" ||
inventory.buildProvenance?.emscripten?.commit !==
"08e2de1031913e4ba7963b1c56f35f036a7d4d56"
) {
throw new Error("PHP native-component provenance identity drifted.");
}
const componentIds = inventory.linkedComponents?.map(({ id }) => id);
const excludedIds = inventory.excludedDetections?.map(({ id }) => id);
assertExactJson(
componentIds,
PHP_NATIVE_COMPONENT_IDS,
"PHP linked-component list",
);
assertExactJson(
excludedIds,
PHP_EXCLUDED_COMPONENT_IDS,
"PHP excluded-detection list",
);
const referencedLegalFiles = new Set();
for (const component of inventory.linkedComponents) {
if (
!Array.isArray(component.legalFiles) ||
component.legalFiles.length === 0
) {
throw new Error(
`PHP native component ${component.id} has no legal-file route.`,
);
}
for (const name of component.legalFiles) {
if (!Object.hasOwn(PHP_ENGINE_LOCK.legalAssets, name)) {
throw new Error(
`PHP native component ${component.id} references an unpinned legal file: ${name}.`,
);
}
referencedLegalFiles.add(name);
}
}
for (const name of Object.keys(PHP_ENGINE_LOCK.legalAssets)) {
if (
name !== "LICENSE.ini-ISC.txt" &&
name !== "LICENSE.wasm-feature-detect-Apache-2.0.txt" &&
!referencedLegalFiles.has(name)
) {
throw new Error(
`Pinned PHP native legal file is not routed from the inventory: ${name}.`,
);
}
}
return inventory;
}
async function verifyPackage(projectRoot, descriptor) {
const directory = path.join(
projectRoot,
"node_modules",
...descriptor.name.split("/"),
);
await assertRealDirectory(directory, `${descriptor.name} npm package`);
const packageFile = path.join(directory, "package.json");
await assertRegularFile(packageFile, `${descriptor.name} package.json`);
await assertHash(
packageFile,
descriptor.packageJsonSha256,
`${descriptor.name} package.json`,
);
const packageJson = JSON.parse(await readFile(packageFile, "utf8"));
if (
packageJson.name !== descriptor.name ||
packageJson.version !== descriptor.version
) {
throw new Error(`${descriptor.name} npm package identity drifted.`);
}
return directory;
}
async function verifySources(projectRoot) {
const lock = JSON.parse(
await readFile(path.join(projectRoot, "package-lock.json"), "utf8"),
);
const rootEntry = lock.packages?.[""];
for (const descriptor of Object.values(PHP_ENGINE_LOCK.npm)) {
const entry = lock.packages?.[`node_modules/${descriptor.name}`];
if (
entry?.version !== descriptor.version ||
entry?.resolved !== descriptor.resolved ||
entry?.integrity !== descriptor.integrity
) {
throw new Error(
`${descriptor.name}@${descriptor.version} is not exactly pinned in package-lock.json.`,
);
}
await verifyPackage(projectRoot, descriptor);
}
for (const direct of [
PHP_ENGINE_LOCK.npm.web,
PHP_ENGINE_LOCK.npm.universal,
]) {
if (rootEntry?.devDependencies?.[direct.name] !== direct.version) {
throw new Error(`${direct.name} must be an exact root devDependency.`);
}
}
const web = path.join(projectRoot, "node_modules", "@php-wasm", "web-8-5");
const loader = path.join(web, "asyncify", "php_8_5.js");
const wasm = path.join(web, "asyncify", "8_5_8", "php_8_5.wasm");
const phpWasmLicense = path.join(web, "LICENSE");
const iniLicense = path.join(projectRoot, "node_modules", "ini", "LICENSE");
const featureDetectLicense = path.join(
projectRoot,
"node_modules",
"wasm-feature-detect",
"LICENSE",
);
const engineSourceDirectory = path.join(projectRoot, "engines", "php");
const nativeInventory = path.join(
engineSourceDirectory,
"native-components.json",
);
const legalFiles = Object.fromEntries(
Object.keys(PHP_ENGINE_LOCK.legalAssets).map((name) => [
name,
path.join(engineSourceDirectory, name),
]),
);
Object.assign(legalFiles, {
"LICENSE.php-wasm-GPL-2.0-or-later.txt": phpWasmLicense,
"LICENSE.ini-ISC.txt": iniLicense,
"LICENSE.wasm-feature-detect-Apache-2.0.txt": featureDetectLicense,
});
for (const [file, expected, label] of [
[loader, PHP_ENGINE_LOCK.sourceAssets.loader, "PHP Asyncify loader"],
[wasm, PHP_ENGINE_LOCK.sourceAssets.wasm, "PHP Asyncify WebAssembly"],
[
nativeInventory,
PHP_ENGINE_LOCK.sourceAssets.nativeInventory,
"PHP native-component inventory",
],
]) {
await assertRegularFile(file, label);
await assertHash(file, expected, label);
}
for (const [name, expected] of Object.entries(PHP_ENGINE_LOCK.legalAssets)) {
const file = legalFiles[name];
await assertRegularFile(file, `PHP legal asset ${name}`);
await assertHash(file, expected, `PHP legal asset ${name}`);
}
validateNativeInventory(JSON.parse(await readFile(nativeInventory, "utf8")));
return {
loader,
wasm,
nativeInventory,
legalFiles,
};
}
function patchLoader(source) {
if (!source.startsWith(`${LOADER_IMPORT}\n`)) {
throw new Error("PHP Asyncify loader import prelude drifted.");
}
const patched = source.replace(LOADER_IMPORT, PATCHED_LOADER);
if (
patched === source ||
patched.includes(LOADER_IMPORT) ||
!patched.includes("export function init(RuntimeName, PHPLoader)")
) {
throw new Error("PHP Asyncify loader patch was not exact.");
}
return patched;
}
async function fileRecord(directory, name) {
const value = await readFile(path.join(directory, name));
return { path: name, sha256: sha256(value), bytes: value.byteLength };
}
async function expectedMetadata(projectRoot, pack) {
const bridgePath = path.join(projectRoot, "engines", "php", "bridge.php");
const bridge = await readFile(bridgePath);
const inventory = validateNativeInventory(
JSON.parse(
await readFile(path.join(pack, "native-components.json"), "utf8"),
),
);
const inventoryRecord = await fileRecord(pack, "native-components.json");
const files = [];
for (const name of CHECKSUM_FILES) {
if (name !== "engine-metadata.json") {
files.push(await fileRecord(pack, name));
}
}
return {
schemaVersion: 1,
engine: "php",
engineVersion: `PHP ${PHP_ENGINE_LOCK.phpVersion} preg / PCRE2 ${PHP_ENGINE_LOCK.pcreVersion}`,
runtime: `@php-wasm/web-8-5 ${PHP_ENGINE_LOCK.packageVersion} Asyncify`,
platform: PHP_ENGINE_LOCK.platform,
status: "production-runtime",
bridge: {
abiVersion: PHP_ENGINE_LOCK.bridgeAbi,
sourceFile: {
path: "engines/php/bridge.php",
sha256: sha256(bridge),
bytes: bridge.byteLength,
},
transport: "fixed VFS script plus JSON request",
userValuesAreJsonData: true,
replacement:
"bounded PHP 8.5.8 preg_get_backref-compatible expansion over native preg_match records",
replacementParityOracle:
"preg_replace is called only by the fixed identity self-test",
replacementTransport:
"bounded UTF-8 bytes encoded as base64 in the JSON envelope",
nativeOffsetUnit: "UTF-8 bytes (PREG_OFFSET_CAPTURE)",
maximumPatternUtf16: PHP_ENGINE_LOCK.maximumPatternUtf16,
maximumSubjectBytes: PHP_ENGINE_LOCK.maximumSubjectBytes,
maximumReplacementUtf16: PHP_ENGINE_LOCK.maximumReplacementUtf16,
maximumOutputBytes: PHP_ENGINE_LOCK.maximumOutputBytes,
maximumMatches: PHP_ENGINE_LOCK.maximumMatches,
maximumCaptureRows: PHP_ENGINE_LOCK.maximumCaptureRows,
maximumCaptureGroups: PHP_ENGINE_LOCK.maximumCaptureGroups,
},
source: {
repository: "https://github.com/WordPress/wordpress-playground",
packages: Object.values(PHP_ENGINE_LOCK.npm).map(
({ name, version, integrity }) => ({ name, version, integrity }),
),
runtimeVariant: "PHP 8.5 Asyncify web build (without intl extension)",
loaderPatch: {
originalSha256: PHP_ENGINE_LOCK.sourceAssets.loader,
originalBytes: PHP_ENGINE_LOCK.loaderBytes,
change:
"replace bundler-only wasm import with same-directory php.wasm URL",
},
php: {
version: PHP_ENGINE_LOCK.phpVersion,
pcreVersion: PHP_ENGINE_LOCK.pcreVersion,
asyncMode: PHP_ENGINE_LOCK.asyncMode,
platform: PHP_ENGINE_LOCK.platform,
},
buildProvenance: {
wordpressPlaygroundRelease: "v3.1.46",
wordpressPlaygroundCommit: "581c7c172428159eb4e6c5309054a568cd39a97a",
phpTag: "php-8.5.8",
phpTagObject: "8a3b5a5124006c11a8fbfce838ec7dd53615cc77",
phpCommit: "26b97507444c4fbda072f57dda1820f7b7d5e467",
emscriptenVersion: "4.0.19",
emscriptenCommit: "08e2de1031913e4ba7963b1c56f35f036a7d4d56",
},
nativeComponents: {
...inventoryRecord,
linkedComponentCount: inventory.linkedComponents.length,
excludedDetectionCount: inventory.excludedDetections.length,
scope:
"complete native/source/legal inventory for the exact shipped php.wasm",
},
embeddedPathClassification:
"Strings such as /home/web_user in generated Emscripten artifacts are virtual/build filesystem paths, not host source provenance.",
phpWasmLicenseAppliesTo: [
"@php-wasm/web-8-5",
"@php-wasm/universal",
"@php-wasm/util",
"@php-wasm/logger",
"@php-wasm/stream-compression",
"@php-wasm/progress",
],
},
licensing: {
phpCodebase: {
selectedLicense: "PHP License version 4 (BSD-3-Clause)",
election:
"PHP 8.5.8 is used under PHP License version 4 via the later-version option in section 5 of PHP License 3.01.",
provenanceUrl: "https://www.php.net/license/",
licenseTextUrl: "https://www.php.net/license/4_0.txt",
licenseTextSha256: PHP_ENGINE_LOCK.legalAssets["LICENSE.PHP-4.0.txt"],
},
pcre2: {
version: PHP_ENGINE_LOCK.pcreVersion,
license: "PCRE2 BSD license",
provenanceUrl:
"https://github.com/PCRE2Project/pcre2/blob/pcre2-10.44/LICENCE",
licenseTextSha256:
PHP_ENGINE_LOCK.legalAssets["LICENSE.PCRE2-10.44.txt"],
},
phpWasmPackages: "GPL-2.0-or-later",
ini: "ISC",
wasmFeatureDetect: "Apache-2.0",
nativeInventory: {
path: inventoryRecord.path,
sha256: inventoryRecord.sha256,
independentlyPinnedLegalAssets: Object.keys(PHP_ENGINE_LOCK.legalAssets)
.length,
statement:
"Every linked component routes to an independently SHA-256-pinned licence, notice, or patent file; raw-string false positives and unshipped side modules are explicitly excluded.",
},
},
files,
};
}
async function runPhpRequest(php, request) {
php.writeFile("/regex-tools/request.json", JSON.stringify(request));
const response = await php.run({ scriptPath: "/regex-tools/bridge.php" });
if (
response.exitCode !== 0 ||
response.errors !== "" ||
response.text.length === 0
) {
throw new Error("PHP bridge process failed its pack smoke test.");
}
return JSON.parse(response.text);
}
async function auditPhpRuntime(php) {
const response = await php.run({
code: `<?php
$extensions = get_loaded_extensions();
sort($extensions, SORT_STRING);
$gd = gd_info();
ob_start();
phpinfo(INFO_MODULES);
$moduleInfo = ob_get_clean();
$audit = [
'extensions' => $extensions,
'versions' => [
'php' => PHP_VERSION,
'pcre2' => PCRE_VERSION,
'openssl' => OPENSSL_VERSION_TEXT,
'curl' => curl_version()['version'],
'zlib' => ZLIB_VERSION,
'sqlite' => SQLite3::version()['versionString'],
'libxml2' => LIBXML_DOTTED_VERSION,
'libgd' => GD_VERSION,
'libiconv' => ICONV_VERSION,
'oniguruma' => MB_ONIGURUMA_VERSION,
'libzip' => ZipArchive::LIBZIP_VERSION,
],
'moduleMarkers' => [
'timelib-2022.15' => str_contains($moduleInfo, '2022.15'),
'tzdata-2026.1' => str_contains($moduleInfo, '2026.1'),
'lexbor-2.7.0' => str_contains($moduleInfo, '2.7.0'),
'libmbfl-1.3.2' => str_contains($moduleInfo, '1.3.2'),
'uriparser-1.0.2' => str_contains($moduleInfo, '1.0.2'),
],
'gdFeatures' => [
'jpeg' => $gd['JPEG Support'] ?? false,
'png' => $gd['PNG Support'] ?? false,
'webp' => $gd['WebP Support'] ?? false,
'avif' => $gd['AVIF Support'] ?? false,
'syntheticFreeTypeReport' => $gd['FreeType Support'] ?? false,
],
'hashAlgorithms' => array_fill_keys(
['fnv1a64', 'murmur3a', 'murmur3c', 'murmur3f', 'sha3-256', 'xxh32', 'xxh64', 'xxh3', 'xxh128'],
false,
),
'phpZts' => (bool) PHP_ZTS,
'excluded' => [
'gmp' => !extension_loaded('gmp') && !function_exists('gmp_init'),
'libsodium' => !extension_loaded('sodium') && !function_exists('sodium_crypto_box'),
'tidy' => !extension_loaded('tidy') && !function_exists('tidy_parse_string') && !class_exists('tidy'),
'intl-and-icu' => !extension_loaded('intl') && !class_exists('IntlDateFormatter'),
'imagemagick-and-imagick' => !extension_loaded('imagick') && !class_exists('Imagick'),
],
];
foreach (array_keys($audit['hashAlgorithms']) as $algorithm) {
$audit['hashAlgorithms'][$algorithm] = in_array($algorithm, hash_algos(), true);
}
echo json_encode($audit, JSON_THROW_ON_ERROR);`,
});
if (
response.exitCode !== 0 ||
response.errors !== "" ||
response.text.length === 0
) {
throw new Error("PHP native-component audit process failed.");
}
return JSON.parse(response.text);
}
async function smokePhp(pack, projectRoot) {
const wasm = await readFile(path.join(pack, "php.wasm"));
if (
wasm.byteLength !== PHP_ENGINE_LOCK.wasmBytes ||
!WebAssembly.validate(wasm)
) {
throw new Error("PHP runtime is not the expected WebAssembly module.");
}
for (const marker of PHP_BINARY_MARKERS) {
if (!wasm.includes(Buffer.from(marker, "utf8"))) {
throw new Error(`PHP WebAssembly is missing native marker: ${marker}.`);
}
}
for (const marker of PHP_EXCLUDED_BINARY_MARKERS) {
if (wasm.includes(Buffer.from(marker, "utf8"))) {
throw new Error(`PHP WebAssembly contains excluded marker: ${marker}.`);
}
}
const loaderUrl = `${pathToFileURL(path.join(pack, "php-loader.mjs")).href}?verify=${Date.now()}-${Math.random()}`;
const loader = await import(loaderUrl);
if (
loader.dependenciesTotalSize !== PHP_ENGINE_LOCK.wasmBytes ||
typeof loader.init !== "function"
) {
throw new Error("PHP Asyncify loader identity is invalid.");
}
const hadRequire = Object.hasOwn(globalThis, "require");
const previousRequire = globalThis.require;
globalThis.require = createRequire(import.meta.url);
let php;
try {
const runtimeId = await loadPHPRuntime(loader, {
wasmBinary: wasm,
phpWasmAsyncMode: "asyncify",
debug: false,
print: () => undefined,
printErr: () => undefined,
});
php = new PHP(runtimeId);
php.mkdir("/regex-tools");
php.writeFile(
"/regex-tools/bridge.php",
await readFile(
path.join(projectRoot, "engines", "php", "bridge.php"),
"utf8",
),
);
const identity = await runPhpRequest(php, {
abi: PHP_ENGINE_LOCK.bridgeAbi,
operation: "identity",
});
if (
identity?.ok !== true ||
identity.identity?.phpVersion !== PHP_ENGINE_LOCK.phpVersion ||
identity.identity?.pcreVersion !== PHP_ENGINE_LOCK.pcreVersion ||
identity.identity?.phpSapi !== "wasm" ||
identity.identity?.integerBytes !== 8 ||
identity.identity?.selfTest !== true
) {
throw new Error("PHP runtime failed its identity smoke test.");
}
const common = {
abi: PHP_ENGINE_LOCK.bridgeAbi,
pattern: "(?<word>é+)",
flags: "gu",
options: {},
subject: "😀éé x",
scanAll: true,
maximumMatches: 10,
maximumCaptureRows: 10,
};
const execution = await runPhpRequest(php, {
...common,
operation: "execute",
});
if (
execution?.ok !== true ||
execution.groupCount !== 1 ||
JSON.stringify(execution.groupNames) !== JSON.stringify([[1, "word"]]) ||
JSON.stringify(execution.matches?.[0]?.span) !== JSON.stringify([4, 8])
) {
throw new Error("PHP runtime failed its native match smoke test.");
}
const replacement = await runPhpRequest(php, {
...common,
operation: "replace",
replacement: "[$1]",
maximumOutputBytes: 1_024,
});
if (
replacement?.ok !== true ||
Object.hasOwn(replacement, "output") ||
replacement.outputBase64 !==
Buffer.from("😀[éé] x", "utf8").toString("base64") ||
Buffer.from(replacement.outputBase64, "base64").toString("utf8") !==
"😀[éé] x" ||
replacement.outputTruncated !== false
) {
throw new Error(
"PHP runtime failed its bounded replacement transport smoke test.",
);
}
const audit = await auditPhpRuntime(php);
assertExactJson(
audit.extensions,
PHP_LOADED_EXTENSIONS,
"PHP loaded-extension list",
);
assertExactJson(
audit.versions,
{
php: "8.5.8",
pcre2: "10.44 2024-06-07",
openssl: "OpenSSL 1.1.1t 7 Feb 2023",
curl: "7.69.1",
zlib: "1.2.13",
sqlite: "3.51.0",
libxml2: "2.9.10",
libgd: "2.3.3",
libiconv: "1.17",
oniguruma: "6.9.10",
libzip: "1.9.2",
},
"PHP linked-library versions",
);
if (
Object.values(audit.moduleMarkers).some((present) => present !== true) ||
Object.values(audit.gdFeatures).some((present) => present !== true) ||
Object.values(audit.hashAlgorithms).some((present) => present !== true) ||
audit.phpZts !== false ||
Object.values(audit.excluded).some((excluded) => excluded !== true)
) {
throw new Error(
"PHP native-component positive/negative runtime audit failed.",
);
}
} finally {
try {
php?.exit(0);
} catch {
// The Emscripten runtime may signal its normal exit as an exception.
}
if (hadRequire) globalThis.require = previousRequire;
else delete globalThis.require;
}
}
export async function verifyPhpPack(directory, projectRoot = root) {
const pack = await assertRealDirectory(directory, "PHP engine pack");
const names = (await readdir(pack, { withFileTypes: true }))
.map((entry) => {
if (!entry.isFile() || entry.isSymbolicLink()) {
throw new Error(`PHP pack contains a non-regular entry: ${entry.name}`);
}
return entry.name;
})
.sort(compareText);
if (JSON.stringify(names) !== JSON.stringify(PACK_FILES)) {
throw new Error(`PHP pack must contain only: ${PACK_FILES.join(", ")}.`);
}
const checksumLines = (await readFile(path.join(pack, "SHA256SUMS"), "utf8"))
.trimEnd()
.split("\n");
const expectedLines = [];
for (const name of CHECKSUM_FILES) {
expectedLines.push(`${await sha256File(path.join(pack, name))} ${name}`);
}
if (JSON.stringify(checksumLines) !== JSON.stringify(expectedLines)) {
throw new Error("PHP pack checksum manifest is stale or malformed.");
}
await assertHash(
path.join(pack, "php.wasm"),
PHP_ENGINE_LOCK.sourceAssets.wasm,
"Packed PHP WebAssembly",
);
await assertHash(
path.join(pack, "native-components.json"),
PHP_ENGINE_LOCK.sourceAssets.nativeInventory,
"Packed PHP native-component inventory",
);
validateNativeInventory(
JSON.parse(
await readFile(path.join(pack, "native-components.json"), "utf8"),
),
);
for (const [name, expected] of Object.entries(PHP_ENGINE_LOCK.legalAssets)) {
await assertHash(
path.join(pack, name),
expected,
`Packed PHP legal asset ${name}`,
);
}
const loader = await readFile(path.join(pack, "php-loader.mjs"), "utf8");
if (
!loader.startsWith(`${PATCHED_LOADER}\n`) ||
loader.includes(LOADER_IMPORT)
) {
throw new Error("Packed PHP loader patch is invalid.");
}
const metadata = JSON.parse(
await readFile(path.join(pack, "engine-metadata.json"), "utf8"),
);
const expected = await expectedMetadata(projectRoot, pack);
if (JSON.stringify(metadata) !== JSON.stringify(expected)) {
throw new Error("PHP engine metadata does not match the pinned pack.");
}
await smokePhp(pack, projectRoot);
return metadata;
}
export async function buildPhpPack({
projectRoot = root,
output = path.join(root, ".engine-build", "php"),
force = false,
} = {}) {
const sources = await verifySources(projectRoot);
const target = path.resolve(output);
const existing = await lstat(target).catch(() => null);
if (existing && !force) {
throw new Error(`${target} exists; pass --force to replace it.`);
}
if (existing && (existing.isSymbolicLink() || !existing.isDirectory())) {
throw new Error("PHP pack output must be a real directory when replaced.");
}
await mkdir(path.dirname(target), { recursive: true });
const stage = await mkdtemp(path.join(path.dirname(target), ".php-pack-"));
try {
await copyFile(
sources.wasm,
path.join(stage, "php.wasm"),
fsConstants.COPYFILE_EXCL,
);
await copyFile(
sources.nativeInventory,
path.join(stage, "native-components.json"),
fsConstants.COPYFILE_EXCL,
);
for (const name of Object.keys(PHP_ENGINE_LOCK.legalAssets)) {
await copyFile(
sources.legalFiles[name],
path.join(stage, name),
fsConstants.COPYFILE_EXCL,
);
}
const loaderSource = await readFile(sources.loader, "utf8");
await writeFile(
path.join(stage, "php-loader.mjs"),
patchLoader(loaderSource),
{ flag: "wx", mode: 0o644 },
);
const metadata = await expectedMetadata(projectRoot, stage);
await writeFile(
path.join(stage, "engine-metadata.json"),
`${JSON.stringify(metadata, null, 2)}\n`,
{ flag: "wx", mode: 0o644 },
);
const checksumLines = [];
for (const name of CHECKSUM_FILES) {
checksumLines.push(
`${await sha256File(path.join(stage, name))} ${name}`,
);
}
await writeFile(
path.join(stage, "SHA256SUMS"),
`${checksumLines.join("\n")}\n`,
{ flag: "wx", mode: 0o644 },
);
await verifyPhpPack(stage, projectRoot);
if (existing) await rm(target, { recursive: true });
await rename(stage, target);
} catch (error) {
await rm(stage, { recursive: true, force: true });
throw error;
}
return {
output: target,
metadata: await verifyPhpPack(target, projectRoot),
};
}
export async function installPhpPack(source, projectRoot = root) {
const sourcePath = await assertRealDirectory(source, "PHP staged pack");
const target = path.join(projectRoot, "public", "engines", "php");
if (sourcePath === (await realpath(target).catch(() => ""))) {
throw new Error("PHP staged pack and installation target must differ.");
}
const metadata = await verifyPhpPack(sourcePath, projectRoot);
await mkdir(path.dirname(target), { recursive: true });
const stage = await mkdtemp(path.join(path.dirname(target), ".php-install-"));
let backup;
try {
for (const name of PACK_FILES) {
await copyFile(
path.join(sourcePath, name),
path.join(stage, name),
fsConstants.COPYFILE_EXCL,
);
}
await verifyPhpPack(stage, projectRoot);
const existing = await lstat(target).catch(() => null);
if (existing) {
if (existing.isSymbolicLink() || !existing.isDirectory()) {
throw new Error("Installed PHP pack target must be a real directory.");
}
backup = `${target}.backup-${process.pid}-${Date.now()}`;
await rename(target, backup);
}
await rename(stage, target);
if (backup) await rm(backup, { recursive: true });
} catch (error) {
await rm(stage, { recursive: true, force: true });
if (backup && !(await lstat(target).catch(() => null))) {
await rename(backup, target);
}
throw error;
}
return { output: target, metadata };
}
function parseArguments(arguments_) {
let output = path.join(root, ".engine-build", "php");
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;
} else if (argument === "--output") {
const value = arguments_[index + 1];
if (!value || value.startsWith("--")) {
throw new Error("--output requires a directory.");
}
output = path.resolve(root, value);
index += 1;
} else {
throw new Error(`Unknown PHP engine-pack argument: ${argument}`);
}
}
return { output, force };
}
async function main() {
const result = await buildPhpPack(parseArguments(process.argv.slice(2)));
console.log(
`Built verified ${result.metadata.engineVersion} pack at ${result.output}.`,
);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
await main();
}