Skip to content

Commit 962acac

Browse files
committed
Buffer the bounded R2 runtime artifact
1 parent c00988e commit 962acac

2 files changed

Lines changed: 24 additions & 28 deletions

File tree

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

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { isWordPressRuntimeFile } from "./wordpress-runtime-corpus.js"
33

44
export const WORDPRESS_RUNTIME_ARTIFACT_SCHEMA = "wp-codebox/wordpress-runtime-artifact/v1"
55
export const WORDPRESS_RUNTIME_MAX_FILES = 2_000
6-
export const WORDPRESS_RUNTIME_MAX_UNCOMPRESSED_BYTES = 32 * 1024 * 1024
7-
export const WORDPRESS_RUNTIME_MAX_ARCHIVE_BYTES = 32 * 1024 * 1024
6+
export const WORDPRESS_RUNTIME_MAX_UNCOMPRESSED_BYTES = 24 * 1024 * 1024
7+
export const WORDPRESS_RUNTIME_MAX_ARCHIVE_BYTES = 8 * 1024 * 1024
88
export const WORDPRESS_RUNTIME_MAX_FILE_BYTES = 8 * 1024 * 1024
99

1010
export interface WordPressRuntimeArtifactManifest {
@@ -46,14 +46,16 @@ export async function materializeWordPressRuntimeArtifact(php: RuntimeMemfs, buc
4646
validateWordPressRuntimeArtifactManifest(manifest)
4747
const object = await bucket.get(manifest.key)
4848
if (!object) throw new Error(`WordPress runtime artifact is unavailable: ${manifest.key}`)
49+
if (object.size > WORDPRESS_RUNTIME_MAX_ARCHIVE_BYTES) throw new Error("WordPress runtime artifact archive exceeds its size budget.")
4950
if (object.size !== manifest.archive.size) throw new Error("WordPress runtime artifact size does not match its manifest.")
5051

51-
const [hashBody, zipBody] = object.body.tee()
52-
const archiveHash = sha256ReadableStream(hashBody)
52+
// The archive and expanded corpus caps leave most of the 128 MiB isolate for PHP-WASM and runtime overhead.
53+
const archiveBytes = new Uint8Array(await object.arrayBuffer())
54+
if (await sha256Hex(archiveBytes) !== manifest.archive.sha256) throw new Error("WordPress runtime artifact archive hash does not match its manifest.")
5355
const expected = new Map(manifest.files.map((file) => [file.path, file]))
5456
let materializedFiles = 0
5557
let materializedBytes = 0
56-
for await (const entry of decodeZip(zipBody)) {
58+
for await (const entry of decodeZip(new Blob([archiveBytes]).stream())) {
5759
const file = expected.get(entry.name)
5860
if (!file) throw new Error(`WordPress runtime artifact contains an unexpected file: ${entry.name}`)
5961
const bytes = new Uint8Array(await entry.arrayBuffer())
@@ -66,7 +68,6 @@ export async function materializeWordPressRuntimeArtifact(php: RuntimeMemfs, buc
6668
php.writeFile(destination, bytes)
6769
expected.delete(entry.name)
6870
}
69-
if (await archiveHash !== manifest.archive.sha256) throw new Error("WordPress runtime artifact archive hash does not match its manifest.")
7071
if (expected.size) throw new Error("WordPress runtime artifact is missing manifest files.")
7172
return { materializedFiles, materializedBytes }
7273
}
@@ -75,23 +76,6 @@ function isSafeRuntimePath(path: string): boolean {
7576
return path.startsWith("wordpress/") && !path.includes("\\") && !path.split("/").some((segment) => !segment || segment === "." || segment === "..")
7677
}
7778

78-
async function sha256ReadableStream(stream: ReadableStream): Promise<string> {
79-
const chunks: Uint8Array[] = []
80-
let size = 0
81-
for await (const chunk of stream as AsyncIterable<Uint8Array>) {
82-
size += chunk.byteLength
83-
if (size > WORDPRESS_RUNTIME_MAX_ARCHIVE_BYTES) throw new Error("WordPress runtime artifact archive exceeds its size budget.")
84-
chunks.push(chunk)
85-
}
86-
const bytes = new Uint8Array(size)
87-
let offset = 0
88-
for (const chunk of chunks) {
89-
bytes.set(chunk, offset)
90-
offset += chunk.byteLength
91-
}
92-
return sha256Hex(bytes)
93-
}
94-
9579
async function sha256Hex(bytes: Uint8Array): Promise<string> {
9680
const digest = await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer)
9781
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")

tests/cloudflare-runtime.test.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -221,14 +221,26 @@ test("WordPress runtime artifacts are content-addressed and reject unavailable o
221221
}
222222
const writes = new Map<string, Uint8Array>()
223223
const php = { mkdir: () => {}, writeFile: (path: string, bytes: Uint8Array) => writes.set(path, bytes) }
224-
const bucket = (bytes: Uint8Array | null) => ({ get: async () => bytes === null ? null : { size: bytes.byteLength, body: new Blob([bytes]).stream() } })
224+
let arrayBufferReads = 0
225+
const bucket = (bytes: Uint8Array | null) => ({ get: async () => bytes === null ? null : {
226+
size: bytes.byteLength,
227+
// R2 supplies both helpers. Reading body would fail this production-shaped mock.
228+
body: new ReadableStream({ pull: (controller) => controller.error(new Error("R2 body stream should not be read directly.")) }),
229+
arrayBuffer: async () => {
230+
arrayBufferReads++
231+
return new Blob([bytes]).arrayBuffer()
232+
},
233+
} })
225234

226235
await assert.rejects(materializeWordPressRuntimeArtifact(php, bucket(null) as never, manifest), /unavailable/)
227236
await assert.rejects(materializeWordPressRuntimeArtifact(php, bucket(new Uint8Array([...archive, 0])) as never, manifest), /size does not match/)
228-
const corrupted = archive.slice()
229-
corrupted[100] ^= 1
230-
await assert.rejects(materializeWordPressRuntimeArtifact(php, bucket(corrupted) as never, manifest), /validation failed|archive hash/)
237+
const oversized = { get: async () => ({ size: 33 * 1024 * 1024, body: new ReadableStream(), arrayBuffer: async () => { throw new Error("oversized archive must not be read") } }) }
238+
await assert.rejects(materializeWordPressRuntimeArtifact(php, oversized as never, manifest), /exceeds its size budget/)
239+
const hashCorruptManifest = { ...manifest, key: wordpressRuntimeArtifactKey("a".repeat(64)), archive: { ...manifest.archive, sha256: "a".repeat(64) } }
240+
await assert.rejects(materializeWordPressRuntimeArtifact(php, bucket(archive) as never, hashCorruptManifest), /archive hash does not match/)
241+
assert.equal(writes.size, 0, "hash-corrupt archives fail before extraction")
231242
const evidence = await materializeWordPressRuntimeArtifact(php, bucket(archive) as never, manifest)
243+
assert.equal(arrayBufferReads, 2, "valid and hash-corrupt archives use R2 arrayBuffer()")
232244
assert.deepEqual(evidence, { materializedFiles: 1, materializedBytes: source.byteLength })
233245
assert.deepEqual(writes.get("/wordpress/wp-includes/version.php"), source)
234246
})
@@ -253,7 +265,7 @@ test("WordPress runtime corpus generator keeps the ZIP outside the Worker bundle
253265
assert.match(generator, /encodeZip\(selected\)/)
254266
assert.match(generator, /lastModified: 0/)
255267
assert.match(generator, /artifacts\/cloudflare-wordpress-runtime-corpus\.zip/)
256-
assert.match(artifact, /decodeZip\(zipBody\)/)
268+
assert.match(artifact, /decodeZip\(new Blob\(\[archiveBytes\]\)\.stream\(\)\)/)
257269
assert.equal(manifest.key, wordpressRuntimeArtifactKey(manifest.archive.sha256))
258270
assert.ok(manifest.files.length > 0)
259271
})

0 commit comments

Comments
 (0)