1- import { decodeZip } from "@php-wasm/stream-compression"
21import { isWordPressRuntimeFile } from "./wordpress-runtime-corpus.js"
32
43export const WORDPRESS_RUNTIME_ARTIFACT_SCHEMA = "wp-codebox/wordpress-runtime-artifact/v1"
@@ -16,10 +15,18 @@ export interface WordPressRuntimeArtifactManifest {
1615}
1716
1817export 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+
2330export function wordpressRuntimeArtifactKey ( sha256 : string ) : string {
2431 if ( ! / ^ [ a - f 0 - 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
75129function isSafeRuntimePath ( path : string ) : boolean {
0 commit comments