Skip to content

Commit fac127a

Browse files
committed
Extract the runtime corpus inside PHP
1 parent 962acac commit fac127a

2 files changed

Lines changed: 103 additions & 24 deletions

File tree

packages/runtime-cloudflare/src/wordpress-runtime-artifact.ts

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { decodeZip } from "@php-wasm/stream-compression"
21
import { isWordPressRuntimeFile } from "./wordpress-runtime-corpus.js"
32

43
export const WORDPRESS_RUNTIME_ARTIFACT_SCHEMA = "wp-codebox/wordpress-runtime-artifact/v1"
@@ -16,10 +15,18 @@ export interface WordPressRuntimeArtifactManifest {
1615
}
1716

1817
export interface RuntimeMemfs {
19-
mkdir(path: string): void
2018
writeFile(path: string, bytes: Uint8Array): void
19+
run(request: { code: string }): Promise<{ text: string }>
2120
}
2221

22+
const WORDPRESS_RUNTIME_ARCHIVE_TEMP_PATH = "/tmp/wp-codebox-wordpress-runtime.zip"
23+
const REQUIRED_WORDPRESS_RUNTIME_FILES = [
24+
"wordpress/index.php",
25+
"wordpress/wp-load.php",
26+
"wordpress/wp-includes/version.php",
27+
"wordpress/wp-settings.php",
28+
]
29+
2330
export function wordpressRuntimeArtifactKey(sha256: string): string {
2431
if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error("WordPress runtime artifact hash must be a SHA-256 digest.")
2532
return `runtime/wordpress/${sha256}.zip`
@@ -52,24 +59,71 @@ export async function materializeWordPressRuntimeArtifact(php: RuntimeMemfs, buc
5259
// The archive and expanded corpus caps leave most of the 128 MiB isolate for PHP-WASM and runtime overhead.
5360
const archiveBytes = new Uint8Array(await object.arrayBuffer())
5461
if (await sha256Hex(archiveBytes) !== manifest.archive.sha256) throw new Error("WordPress runtime artifact archive hash does not match its manifest.")
55-
const expected = new Map(manifest.files.map((file) => [file.path, file]))
56-
let materializedFiles = 0
57-
let materializedBytes = 0
58-
for await (const entry of decodeZip(new Blob([archiveBytes]).stream())) {
59-
const file = expected.get(entry.name)
60-
if (!file) throw new Error(`WordPress runtime artifact contains an unexpected file: ${entry.name}`)
61-
const bytes = new Uint8Array(await entry.arrayBuffer())
62-
if (bytes.byteLength !== file.size || await sha256Hex(bytes) !== file.sha256) throw new Error(`WordPress runtime artifact file validation failed: ${entry.name}`)
63-
materializedBytes += bytes.byteLength
64-
materializedFiles++
65-
if (materializedFiles > WORDPRESS_RUNTIME_MAX_FILES || materializedBytes > WORDPRESS_RUNTIME_MAX_UNCOMPRESSED_BYTES) throw new Error("WordPress runtime artifact exceeds its materialization budget.")
66-
const destination = `/${entry.name}`
67-
php.mkdir(destination.slice(0, destination.lastIndexOf("/")))
68-
php.writeFile(destination, bytes)
69-
expected.delete(entry.name)
62+
63+
// The archive digest binds these bytes to the already path- and budget-checked manifest.
64+
// Crossing into PHP once avoids per-file JS/WASM calls during cold boot.
65+
php.writeFile(WORDPRESS_RUNTIME_ARCHIVE_TEMP_PATH, archiveBytes)
66+
const output = (await php.run({ code: zipMaterializationCode(manifest) })).text.trim()
67+
let evidence: unknown
68+
try {
69+
evidence = JSON.parse(output)
70+
} catch {
71+
throw new Error(`WordPress runtime artifact extraction did not return valid evidence: ${output}`)
7072
}
71-
if (expected.size) throw new Error("WordPress runtime artifact is missing manifest files.")
72-
return { materializedFiles, materializedBytes }
73+
if (!isMaterializationEvidence(evidence, manifest)) throw new Error("WordPress runtime artifact extraction returned invalid evidence.")
74+
return evidence
75+
}
76+
77+
function zipMaterializationCode(manifest: WordPressRuntimeArtifactManifest): string {
78+
const expected = Object.fromEntries(manifest.files.map((file) => [file.path, file.size]))
79+
const expectedJson = JSON.stringify(expected).replace(/</g, "\\u003c")
80+
const requiredJson = JSON.stringify(REQUIRED_WORDPRESS_RUNTIME_FILES)
81+
return `<?php
82+
$archive_path = ${JSON.stringify(WORDPRESS_RUNTIME_ARCHIVE_TEMP_PATH)};
83+
$expected = json_decode(${JSON.stringify(expectedJson)}, true, 512, JSON_THROW_ON_ERROR);
84+
$required = json_decode(${JSON.stringify(requiredJson)}, true, 512, JSON_THROW_ON_ERROR);
85+
try {
86+
if (!extension_loaded('zip') || !class_exists('ZipArchive')) {
87+
throw new RuntimeException('WordPress runtime artifact extraction requires the PHP ZipArchive extension.');
88+
}
89+
$zip = new ZipArchive();
90+
$opened = $zip->open($archive_path);
91+
if (true !== $opened) {
92+
throw new RuntimeException('WordPress runtime artifact ZIP could not be opened (ZipArchive status ' . $opened . ').');
93+
}
94+
try {
95+
if ($zip->numFiles !== count($expected)) {
96+
throw new RuntimeException('WordPress runtime artifact ZIP file count does not match its manifest.');
97+
}
98+
for ($index = 0; $index < $zip->numFiles; $index++) {
99+
$name = $zip->getNameIndex($index);
100+
$stat = $zip->statIndex($index);
101+
if (!is_string($name) || !array_key_exists($name, $expected) || !is_array($stat) || !isset($stat['size']) || (int) $stat['size'] !== $expected[$name]) {
102+
throw new RuntimeException('WordPress runtime artifact ZIP entries do not match its manifest.');
103+
}
104+
}
105+
if (!$zip->extractTo('/', array_keys($expected))) {
106+
throw new RuntimeException('WordPress runtime artifact ZIP extraction failed.');
107+
}
108+
foreach ($required as $path) {
109+
if (!is_file('/' . $path)) {
110+
throw new RuntimeException('WordPress runtime artifact is missing required file after extraction: ' . $path);
111+
}
112+
}
113+
echo json_encode(array('materializedFiles' => count($expected), 'materializedBytes' => array_sum($expected)), JSON_THROW_ON_ERROR);
114+
} finally {
115+
$zip->close();
116+
}
117+
} finally {
118+
@unlink($archive_path);
119+
}`
120+
}
121+
122+
function isMaterializationEvidence(value: unknown, manifest: WordPressRuntimeArtifactManifest): value is { materializedFiles: number; materializedBytes: number } {
123+
if (!value || typeof value !== "object") return false
124+
const evidence = value as Record<string, unknown>
125+
return evidence.materializedFiles === manifest.files.length
126+
&& evidence.materializedBytes === manifest.files.reduce((total, file) => total + file.size, 0)
73127
}
74128

75129
function isSafeRuntimePath(path: string): boolean {

tests/cloudflare-runtime.test.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,14 @@ test("WordPress runtime artifacts are content-addressed and reject unavailable o
220220
files: [{ path: "wordpress/wp-includes/version.php", size: source.byteLength, sha256: sha256(source) }],
221221
}
222222
const writes = new Map<string, Uint8Array>()
223-
const php = { mkdir: () => {}, writeFile: (path: string, bytes: Uint8Array) => writes.set(path, bytes) }
223+
const phpRuns: string[] = []
224+
const php = {
225+
writeFile: (path: string, bytes: Uint8Array) => writes.set(path, bytes),
226+
run: async ({ code }: { code: string }) => {
227+
phpRuns.push(code)
228+
return { text: JSON.stringify({ materializedFiles: 1, materializedBytes: source.byteLength }) }
229+
},
230+
}
224231
let arrayBufferReads = 0
225232
const bucket = (bytes: Uint8Array | null) => ({ get: async () => bytes === null ? null : {
226233
size: bytes.byteLength,
@@ -239,10 +246,25 @@ test("WordPress runtime artifacts are content-addressed and reject unavailable o
239246
const hashCorruptManifest = { ...manifest, key: wordpressRuntimeArtifactKey("a".repeat(64)), archive: { ...manifest.archive, sha256: "a".repeat(64) } }
240247
await assert.rejects(materializeWordPressRuntimeArtifact(php, bucket(archive) as never, hashCorruptManifest), /archive hash does not match/)
241248
assert.equal(writes.size, 0, "hash-corrupt archives fail before extraction")
249+
const malformed = new Uint8Array([0, 1, 2, 3])
250+
const malformedManifest = { ...manifest, key: wordpressRuntimeArtifactKey(sha256(malformed)), archive: { sha256: sha256(malformed), size: malformed.byteLength } }
251+
const malformedWrites: Uint8Array[] = []
252+
await assert.rejects(materializeWordPressRuntimeArtifact({
253+
writeFile: (_path, bytes) => malformedWrites.push(bytes),
254+
run: async () => { throw new Error("WordPress runtime artifact ZIP could not be opened (ZipArchive status 19).") },
255+
}, bucket(malformed) as never, malformedManifest), /ZIP could not be opened/)
256+
assert.equal(malformedWrites.length, 1, "a hash-valid malformed archive is rejected by PHP after its single archive write")
242257
const evidence = await materializeWordPressRuntimeArtifact(php, bucket(archive) as never, manifest)
243-
assert.equal(arrayBufferReads, 2, "valid and hash-corrupt archives use R2 arrayBuffer()")
258+
assert.equal(arrayBufferReads, 3, "valid, hash-corrupt, and malformed archives use R2 arrayBuffer()")
244259
assert.deepEqual(evidence, { materializedFiles: 1, materializedBytes: source.byteLength })
245-
assert.deepEqual(writes.get("/wordpress/wp-includes/version.php"), source)
260+
assert.equal(writes.size, 1, "the verified archive crosses the JS/WASM boundary once")
261+
assert.deepEqual(writes.get("/tmp/wp-codebox-wordpress-runtime.zip"), archive)
262+
assert.equal(phpRuns.length, 1)
263+
assert.match(phpRuns[0], /extension_loaded\('zip'\)/)
264+
assert.match(phpRuns[0], /\$zip->numFiles !== count\(\$expected\)/)
265+
assert.match(phpRuns[0], /\$zip->extractTo\('\/', array_keys\(\$expected\)\)/)
266+
assert.match(phpRuns[0], /missing required file after extraction/)
267+
assert.match(phpRuns[0], /finally \{\n @unlink\(\$archive_path\)/)
246268
})
247269

248270
test("WordPress runtime artifact validation rejects path traversal and invalid budgets", async () => {
@@ -253,8 +275,10 @@ test("WordPress runtime artifact validation rejects path traversal and invalid b
253275
source: { url: "https://downloads.wordpress.org/release/wordpress-6.8.1.zip" },
254276
files: [{ path: "wordpress/../wp-includes/version.php", size: 9 * 1024 * 1024, sha256: "b".repeat(64) }],
255277
}
256-
const php = { mkdir: () => {}, writeFile: () => {} }
278+
let writes = 0
279+
const php = { writeFile: () => { writes++ }, run: async () => ({ text: "" }) }
257280
await assert.rejects(materializeWordPressRuntimeArtifact(php, { get: async () => null } as never, manifest), /invalid file path/)
281+
assert.equal(writes, 0, "unsafe manifests are rejected before any archive write")
258282
})
259283

260284
test("WordPress runtime corpus generator keeps the ZIP outside the Worker bundle", async () => {
@@ -265,7 +289,8 @@ test("WordPress runtime corpus generator keeps the ZIP outside the Worker bundle
265289
assert.match(generator, /encodeZip\(selected\)/)
266290
assert.match(generator, /lastModified: 0/)
267291
assert.match(generator, /artifacts\/cloudflare-wordpress-runtime-corpus\.zip/)
268-
assert.match(artifact, /decodeZip\(new Blob\(\[archiveBytes\]\)\.stream\(\)\)/)
292+
assert.doesNotMatch(artifact, /decodeZip/)
293+
assert.match(artifact, /php\.writeFile\(WORDPRESS_RUNTIME_ARCHIVE_TEMP_PATH, archiveBytes\)/)
269294
assert.equal(manifest.key, wordpressRuntimeArtifactKey(manifest.archive.sha256))
270295
assert.ok(manifest.files.length > 0)
271296
})

0 commit comments

Comments
 (0)