11import { createHash } from "node:crypto"
2- import { cp , lstat , mkdir , mkdtemp , readdir , readFile , realpath , rm , stat , writeFile } from "node:fs/promises"
2+ import { cp , lstat , mkdir , mkdtemp , open , readdir , readFile , realpath , rm , stat , writeFile } from "node:fs/promises"
33import { tmpdir } from "node:os"
44import { basename , dirname , isAbsolute , join , relative , resolve , sep } from "node:path"
55import { materializationPhaseResult , namedFileTreeSkipPolicyNames , phpStringArrayLiteral , type MaterializationDiagnostic , type MaterializationPhaseResult , type MountSpec } from "@automattic/wp-codebox-core"
@@ -43,7 +43,14 @@ export interface ReadonlyMountStaging {
4343
4444interface HostMountFilePayload {
4545 target : string
46- contentsBase64 : string
46+ source : string
47+ size : number
48+ }
49+
50+ interface HostMountFileVerificationPayload {
51+ target : string
52+ contentsBase64 ?: string
53+ sha256 ?: string
4754}
4855
4956interface HostMountDirectoryMaterializationResponse {
@@ -70,6 +77,8 @@ interface HostMountFileVerificationResponse {
7077
7178const HOST_MOUNT_FILE_BATCH_SIZE = 100
7279const HOST_MOUNT_DIRECTORY_BATCH_SIZE = 500
80+ const HOST_MOUNT_CHUNKED_WRITE_THRESHOLD = 1024 * 1024
81+ const HOST_MOUNT_WRITE_CHUNK_SIZE = 256 * 1024
7382
7483/**
7584 * Playground's Node filesystem mount handler is writable. Snapshot readonly
@@ -256,8 +265,8 @@ async function materializeHostMountToVfs(server: PlaygroundCliServer, mount: Mou
256265 let created = 0
257266 let skipped = 0
258267 const directoryBatch : string [ ] = [ ]
259- const fileBatch : HostMountFilePayload [ ] = [ ]
260- const verificationBatch : HostMountFilePayload [ ] = [ ]
268+ const fileBatch : Array < HostMountFilePayload & { contentsBase64 : string } > = [ ]
269+ const verificationBatch : HostMountFileVerificationPayload [ ] = [ ]
261270
262271 const flushDirectories = async ( ) => {
263272 if ( directoryBatch . length === 0 ) {
@@ -290,22 +299,32 @@ async function materializeHostMountToVfs(server: PlaygroundCliServer, mount: Mou
290299 skipped += result . skipped
291300 }
292301 const writeFilePayload = async ( payload : HostMountFilePayload ) => {
302+ const target = payload . target . trim ( )
303+ if ( ! target || target . includes ( "\0" ) ) {
304+ skipped ++
305+ return
306+ }
307+ if ( payload . size > HOST_MOUNT_CHUNKED_WRITE_THRESHOLD ) {
308+ const verification = await materializeHostMountFileInChunks ( server , { ...payload , target } )
309+ materialized ++
310+ verificationBatch . push ( verification )
311+ if ( verificationBatch . length >= HOST_MOUNT_FILE_BATCH_SIZE ) {
312+ await flushVerificationBatch ( )
313+ }
314+ return
315+ }
316+ const contents = await readFile ( payload . source )
317+ const contentsBase64 = contents . toString ( "base64" )
293318 if ( ! server . playground . writeFile ) {
294- fileBatch . push ( payload )
319+ fileBatch . push ( { ... payload , contentsBase64 } )
295320 if ( fileBatch . length >= HOST_MOUNT_FILE_BATCH_SIZE ) {
296321 await flushFileBatch ( )
297322 }
298323 return
299324 }
300- const target = payload . target . trim ( )
301- if ( ! target || target . includes ( "\0" ) ) {
302- skipped ++
303- return
304- }
305- const contents = Buffer . from ( payload . contentsBase64 , "base64" )
306325 const text = contents . toString ( "utf8" )
307326 if ( ! Buffer . from ( text , "utf8" ) . equals ( contents ) ) {
308- fileBatch . push ( payload )
327+ fileBatch . push ( { ... payload , contentsBase64 } )
309328 if ( fileBatch . length >= HOST_MOUNT_FILE_BATCH_SIZE ) {
310329 await flushFileBatch ( )
311330 }
@@ -314,12 +333,12 @@ async function materializeHostMountToVfs(server: PlaygroundCliServer, mount: Mou
314333 try {
315334 await server . playground . writeFile ( target , text )
316335 materialized ++
317- verificationBatch . push ( payload )
336+ verificationBatch . push ( { target , contentsBase64 } )
318337 if ( verificationBatch . length >= HOST_MOUNT_FILE_BATCH_SIZE ) {
319338 await flushVerificationBatch ( )
320339 }
321340 } catch {
322- const fallback = await materializeHostMountFilesWithPhp ( server , [ payload ] , [ ] )
341+ const fallback = await materializeHostMountFilesWithPhp ( server , [ { ... payload , contentsBase64 } ] , [ ] )
323342 materialized += fallback . materialized
324343 created += fallback . created
325344 skipped += fallback . skipped
@@ -384,7 +403,7 @@ async function* hostMountEntriesForVfs(mount: MountSpec): AsyncGenerator<HostMou
384403 if ( parent && parent !== "." ) {
385404 yield { type : "directory" , target : parent }
386405 }
387- yield { type : "file" , file : { target : mount . target , contentsBase64 : ( await readFile ( mount . source ) ) . toString ( "base64" ) } }
406+ yield { type : "file" , file : { target : mount . target , source : mount . source , size : sourceStat . size } }
388407 return
389408 }
390409 if ( ! sourceStat . isDirectory ( ) ) {
@@ -420,12 +439,13 @@ async function* hostMountEntriesForVfs(mount: MountSpec): AsyncGenerator<HostMou
420439 if ( ! entry . isFile ( ) ) {
421440 continue
422441 }
423- yield { type : "file" , file : { target : `${ target } /${ relativePath } ` , contentsBase64 : ( await readFile ( absolutePath ) ) . toString ( "base64" ) } }
442+ const fileStat = await stat ( absolutePath )
443+ yield { type : "file" , file : { target : `${ target } /${ relativePath } ` , source : absolutePath , size : fileStat . size } }
424444 }
425445 }
426446}
427447
428- async function materializeHostMountFilesWithPhp ( server : PlaygroundCliServer , files : HostMountFilePayload [ ] , directories : string [ ] ) : Promise < { materialized : number ; created : number ; skipped : number } > {
448+ async function materializeHostMountFilesWithPhp ( server : PlaygroundCliServer , files : Array < HostMountFilePayload & { contentsBase64 : string } > , directories : string [ ] ) : Promise < { materialized : number ; created : number ; skipped : number } > {
429449 const response = await server . playground . run ( { code : hostMountWritePhp ( files , directories ) } )
430450 assertPlaygroundResponseOk ( "playground-staged-input-write" , response )
431451 const parsed = parseMaterializationJson < HostMountFileMaterializationResponse > ( response . text , "wp-codebox/host-mount-materialization/v1" , "playground-staged-input-write" )
@@ -436,7 +456,54 @@ async function materializeHostMountFilesWithPhp(server: PlaygroundCliServer, fil
436456 }
437457}
438458
439- async function verifyHostMountFilesWithPhp ( server : PlaygroundCliServer , files : HostMountFilePayload [ ] ) : Promise < { repaired : number ; skipped : number } > {
459+ async function materializeHostMountFileInChunks ( server : PlaygroundCliServer , file : HostMountFilePayload ) : Promise < HostMountFileVerificationPayload > {
460+ const hash = createHash ( "sha256" )
461+ const snapshotDirectory = await mkdtemp ( join ( tmpdir ( ) , "wp-codebox-staged-file-" ) )
462+ const snapshot = join ( snapshotDirectory , basename ( file . source ) || "staged-file" )
463+ try {
464+ await cp ( file . source , snapshot )
465+ const handle = await open ( snapshot , "r" )
466+ try {
467+ const truncateResponse = await server . playground . run ( { code : hostMountChunkWritePhp ( file . target ) } )
468+ assertPlaygroundResponseOk ( "playground-staged-input-chunked-write" , truncateResponse )
469+ assertChunkWriteResult ( truncateResponse . text , file . target , 0 )
470+
471+ const buffer = Buffer . allocUnsafe ( HOST_MOUNT_WRITE_CHUNK_SIZE )
472+ let position = 0
473+ let chunk = 0
474+ while ( position < file . size ) {
475+ const { bytesRead } = await handle . read ( buffer , 0 , Math . min ( buffer . length , file . size - position ) , position )
476+ if ( bytesRead === 0 ) {
477+ throw new Error ( `playground-staged-input-chunked-write could not read ${ boundedTarget ( file . target ) } after ${ position } byte(s)` )
478+ }
479+ const contents = buffer . subarray ( 0 , bytesRead )
480+ hash . update ( contents )
481+ const response = await server . playground . run ( { code : hostMountChunkWritePhp ( file . target , contents . toString ( "base64" ) ) } )
482+ assertPlaygroundResponseOk ( "playground-staged-input-chunked-write" , response )
483+ assertChunkWriteResult ( response . text , file . target , ++ chunk )
484+ position += bytesRead
485+ }
486+ } finally {
487+ await handle . close ( )
488+ }
489+ } finally {
490+ await rm ( snapshotDirectory , { recursive : true , force : true } )
491+ }
492+ return { target : file . target , sha256 : hash . digest ( "hex" ) }
493+ }
494+
495+ function assertChunkWriteResult ( text : string , target : string , chunk : number ) : void {
496+ const parsed = parseMaterializationJson < HostMountFileMaterializationResponse > ( text , "wp-codebox/host-mount-chunk-materialization/v1" , "playground-staged-input-chunked-write" )
497+ if ( ( parsed . skipped ?? 0 ) > 0 || ( chunk > 0 && ( parsed . materialized ?? 0 ) !== 1 ) ) {
498+ throw new Error ( `playground-staged-input-chunked-write could not write ${ boundedTarget ( target ) } at chunk ${ chunk } ` )
499+ }
500+ }
501+
502+ function boundedTarget ( target : string ) : string {
503+ return target . length <= 200 ? target : `${ target . slice ( 0 , 197 ) } ...`
504+ }
505+
506+ async function verifyHostMountFilesWithPhp ( server : PlaygroundCliServer , files : HostMountFileVerificationPayload [ ] ) : Promise < { repaired : number ; skipped : number } > {
440507 const response = await server . playground . run ( { code : hostMountVerifyPhp ( files ) } )
441508 assertPlaygroundResponseOk ( "playground-staged-input-verify" , response )
442509 const parsed = parseMaterializationJson < HostMountFileVerificationResponse > ( response . text , "wp-codebox/host-mount-verification/v1" , "playground-staged-input-verify" )
@@ -557,7 +624,7 @@ async function hostFileHashes(directory: string, relativeDirectory = ""): Promis
557624 return files
558625}
559626
560- function hostMountWritePhp ( files : HostMountFilePayload [ ] , directories : string [ ] ) : string {
627+ function hostMountWritePhp ( files : Array < HostMountFilePayload & { contentsBase64 : string } > , directories : string [ ] ) : string {
561628 const payload = JSON . stringify ( JSON . stringify ( { files, directories } ) )
562629 return `<?php
563630$payload = json_decode(${ payload } , true);
@@ -600,21 +667,26 @@ echo json_encode(array('schema' => 'wp-codebox/host-mount-materialization/v1', '
600667`
601668}
602669
603- function hostMountVerifyPhp ( files : HostMountFilePayload [ ] ) : string {
670+ function hostMountVerifyPhp ( files : HostMountFileVerificationPayload [ ] ) : string {
604671 const payload = JSON . stringify ( JSON . stringify ( { files } ) )
605672 return `<?php
606673$payload = json_decode(${ payload } , true);
607674$repaired = 0;
608675$skipped = 0;
609676foreach (($payload['files'] ?? array()) as $file) {
610677 $target = (string) ($file['target'] ?? '');
678+ $expected_hash = (string) ($file['sha256'] ?? '');
611679 $contents = base64_decode((string) ($file['contentsBase64'] ?? ''), true);
612- if ('' === $target || str_contains($target, "\0") || false === $contents) {
680+ if ('' === $target || str_contains($target, "\0") || ('' === $expected_hash && false === $contents) ) {
613681 $skipped++;
614682 continue;
615683 }
616684 $target_hash = is_file($target) ? hash_file('sha256', $target) : false;
617- if (is_string($target_hash) && hash_equals(hash('sha256', $contents), $target_hash)) {
685+ if (is_string($target_hash) && hash_equals('' === $expected_hash ? hash('sha256', $contents) : $expected_hash, $target_hash)) {
686+ continue;
687+ }
688+ if ('' !== $expected_hash) {
689+ $skipped++;
618690 continue;
619691 }
620692 $directory = dirname($target);
@@ -633,6 +705,35 @@ echo json_encode(array('schema' => 'wp-codebox/host-mount-verification/v1', 'rep
633705`
634706}
635707
708+ function hostMountChunkWritePhp ( target : string , contentsBase64 ?: string ) : string {
709+ const payload = JSON . stringify ( JSON . stringify ( { target, contentsBase64 } ) )
710+ return `<?php
711+ $payload = json_decode(${ payload } , true);
712+ $target = (string) ($payload['target'] ?? '');
713+ $contents_base64 = $payload['contentsBase64'] ?? null;
714+ $materialized = 0;
715+ $skipped = 0;
716+ if ('' === $target || str_contains($target, "\0")) {
717+ $skipped++;
718+ } elseif (null === $contents_base64) {
719+ $handle = fopen($target, 'wb');
720+ if (false === $handle) {
721+ $skipped++;
722+ } else {
723+ fclose($handle);
724+ }
725+ } else {
726+ $contents = base64_decode((string) $contents_base64, true);
727+ if (false === $contents || strlen($contents) !== file_put_contents($target, $contents, FILE_APPEND)) {
728+ $skipped++;
729+ } else {
730+ $materialized++;
731+ }
732+ }
733+ echo json_encode(array('schema' => 'wp-codebox/host-mount-chunk-materialization/v1', 'materialized' => $materialized, 'skipped' => $skipped), JSON_UNESCAPED_SLASHES);
734+ `
735+ }
736+
636737function hostMountMkdirPhp ( directories : string [ ] ) : string {
637738 const payload = JSON . stringify ( JSON . stringify ( { directories } ) )
638739 return `<?php
0 commit comments