11import assert from "node:assert/strict"
2+ import { execFile } from "node:child_process"
23import { createHash } from "node:crypto"
34import { access , mkdir , mkdtemp , readdir , readFile , rm , symlink , writeFile } from "node:fs/promises"
45import { tmpdir } from "node:os"
56import { join } from "node:path"
7+ import { promisify } from "node:util"
68
79import { startPlaygroundCliServer , type PlaygroundCliModule } from "../packages/runtime-playground/src/playground-cli-runner.js"
810import { stageReadonlyPlaygroundMounts } from "../packages/runtime-playground/src/mount-materialization.js"
9- import type { RuntimeCreateSpec } from "../packages/runtime-core/src/index.js"
11+ import type { BrowserStartupProgressEvent , RuntimeCreateSpec } from "../packages/runtime-core/src/index.js"
12+
13+ const execFileAsync = promisify ( execFile )
1014
1115const root = await mkdtemp ( join ( tmpdir ( ) , "wp-codebox-readonly-mounts-" ) )
1216const readonlySource = join ( root , "readonly.bin" )
@@ -25,14 +29,26 @@ const spec: RuntimeCreateSpec = {
2529}
2630
2731let mountedReadonlyPath = ""
32+ const startupProgress : BrowserStartupProgressEvent [ ] = [ ]
2833const cliModule : PlaygroundCliModule = {
2934 async runCLI ( options ) {
3035 const readonlyMount = options . mount . find ( ( mount ) => mount . vfsPath === "/readonly" )
3136 const readwriteMount = options . mount . find ( ( mount ) => mount . vfsPath === "/readwrite" )
37+ const pluginMount = options . mount . find ( ( mount ) => mount . vfsPath === "/wordpress/wp-content/plugins/tracked-symlink-plugin" )
3238 const wpConfigMount = options [ "mount-before-install" ] ?. find ( ( mount ) => mount . vfsPath === "/wordpress/wp-config.php" )
3339 assert . ok ( readonlyMount )
3440 assert . ok ( readwriteMount )
41+ assert . ok ( pluginMount )
3542 assert . ok ( wpConfigMount )
43+ assert . match ( await readFile ( join ( pluginMount . hostPath , "plugin.php" ) , "utf8" ) , / P l u g i n N a m e : T r a c k e d S y m l i n k F i x t u r e / )
44+ assert . equal ( await readFile ( join ( pluginMount . hostPath , "includes" , "runtime.php" ) , "utf8" ) , "<?php return 'runtime';\n" )
45+ assert . equal ( await readFile ( join ( pluginMount . hostPath , "runtime-link.php" ) , "utf8" ) , "<?php return 'runtime';\n" )
46+ assert . equal ( await readFile ( join ( pluginMount . hostPath , "runtime-chain.php" ) , "utf8" ) , "<?php return 'runtime';\n" )
47+ await assert . rejects ( access ( join ( pluginMount . hostPath , "build.sh" ) ) , / E N O E N T / )
48+ await assert . rejects ( access ( join ( pluginMount . hostPath , "build-chain.sh" ) ) , / E N O E N T / )
49+ await assert . rejects ( access ( join ( pluginMount . hostPath , "host-secret.php" ) ) , / E N O E N T / )
50+ await assert . rejects ( access ( join ( pluginMount . hostPath , "host-secret-chain.php" ) ) , / E N O E N T / )
51+ await assert . rejects ( access ( join ( pluginMount . hostPath , "prefix-secret.php" ) ) , / E N O E N T / )
3652 mountedReadonlyPath = readonlyMount . hostPath
3753 // This is the host path Playground's writable Node mount handler receives.
3854 await writeFile ( readonlyMount . hostPath , Buffer . from ( "sandbox overwrite" ) )
@@ -46,29 +62,76 @@ const cliModule: PlaygroundCliModule = {
4662}
4763
4864try {
49- const danglingSource = join ( root , "dangling" )
50- const largeSource = join ( root , "large" )
51- await mkdir ( danglingSource )
52- await mkdir ( largeSource )
53- await symlink ( "missing.php" , join ( danglingSource , "build.sh" ) )
54- await Promise . all ( Array . from ( { length : 500 } , ( _ , index ) => writeFile ( join ( largeSource , `File${ index } .php` ) , `<?php return ${ index } ;\n` ) ) )
65+ const pluginSource = join ( root , "tracked-symlink-plugin" )
66+ const prefixConfusionSource = join ( root , "tracked-symlink-plugin-private" )
67+ const hostSecret = join ( root , "host-secret.php" )
68+ await mkdir ( join ( pluginSource , "includes" ) , { recursive : true } )
69+ await mkdir ( prefixConfusionSource )
70+ await writeFile ( join ( pluginSource , "plugin.php" ) , "<?php /* Plugin Name: Tracked Symlink Fixture */\n" )
71+ await writeFile ( join ( pluginSource , "includes" , "runtime.php" ) , "<?php return 'runtime';\n" )
72+ await writeFile ( join ( prefixConfusionSource , "secret.php" ) , "<?php return 'prefix secret';\n" )
73+ await writeFile ( hostSecret , "<?php return 'host secret';\n" )
74+ await symlink ( "includes/runtime.php" , join ( pluginSource , "runtime-link.php" ) )
75+ await symlink ( "runtime-link.php" , join ( pluginSource , "runtime-chain.php" ) )
76+ await symlink ( "../../../.github/build.sh" , join ( pluginSource , "build.sh" ) )
77+ await symlink ( "build.sh" , join ( pluginSource , "build-chain.sh" ) )
78+ await symlink ( "../host-secret.php" , join ( pluginSource , "host-secret.php" ) )
79+ await symlink ( "host-secret.php" , join ( pluginSource , "host-secret-chain.php" ) )
80+ await symlink ( "../tracked-symlink-plugin-private/secret.php" , join ( pluginSource , "prefix-secret.php" ) )
81+ await execFileAsync ( "git" , [ "init" , "--quiet" ] , { cwd : pluginSource } )
82+ await execFileAsync ( "git" , [ "add" , "." ] , { cwd : pluginSource } )
83+ const trackedSymlinks = ( await execFileAsync ( "git" , [ "ls-files" , "-s" , "--" , "*.php" , "*.sh" ] , { cwd : pluginSource } ) ) . stdout . trim ( ) . split ( "\n" ) . filter ( ( entry ) => entry . startsWith ( "120000 " ) ) . map ( ( entry ) => entry . split ( "\t" ) [ 1 ] ) . sort ( )
84+ assert . deepEqual ( trackedSymlinks , [ "build-chain.sh" , "build.sh" , "host-secret-chain.php" , "host-secret.php" , "prefix-secret.php" , "runtime-chain.php" , "runtime-link.php" ] , "all fixture symlinks are tracked by Git" )
85+
5586 const stagingDirectoriesBefore = await readonlyStagingDirectories ( )
56- await assert . rejects ( stageReadonlyPlaygroundMounts ( [
57- { source : danglingSource , target : "/dangling" , mode : "readonly" } ,
58- { source : largeSource , target : "/large" , mode : "readonly" } ,
59- ] ) , / E N O E N T / , "the source failure must not be masked by cleanup racing another mount copy" )
60- assert . deepEqual ( await readonlyStagingDirectories ( ) , stagingDirectoriesBefore , "failed concurrent staging must remove its temporary root" )
87+ const staging = await stageReadonlyPlaygroundMounts ( [
88+ { source : pluginSource , target : "/wordpress/wp-content/plugins/tracked-symlink-plugin" , mode : "readonly" } ,
89+ ] )
90+ const stagedPlugin = staging . mounts [ 0 ] . source
91+ assert . match ( await readFile ( join ( stagedPlugin , "plugin.php" ) , "utf8" ) , / P l u g i n N a m e : T r a c k e d S y m l i n k F i x t u r e / , "the plugin entrypoint remains available" )
92+ assert . equal ( await readFile ( join ( stagedPlugin , "includes" , "runtime.php" ) , "utf8" ) , "<?php return 'runtime';\n" , "nested runtime files remain available" )
93+ assert . equal ( await readFile ( join ( stagedPlugin , "runtime-link.php" ) , "utf8" ) , "<?php return 'runtime';\n" , "a contained symlink is dereferenced into the staged mount" )
94+ assert . equal ( await readFile ( join ( stagedPlugin , "runtime-chain.php" ) , "utf8" ) , "<?php return 'runtime';\n" , "a contained symlink chain is dereferenced into the staged mount" )
95+ await assert . rejects ( access ( join ( stagedPlugin , "build.sh" ) ) , / E N O E N T / , "a tracked dangling symlink is not materialized" )
96+ await assert . rejects ( access ( join ( stagedPlugin , "build-chain.sh" ) ) , / E N O E N T / , "a chained dangling symlink is not materialized" )
97+ await assert . rejects ( access ( join ( stagedPlugin , "host-secret.php" ) ) , / E N O E N T / , "a symlink escaping the source cannot expose a host file" )
98+ await assert . rejects ( access ( join ( stagedPlugin , "host-secret-chain.php" ) ) , / E N O E N T / , "a chained escaping symlink cannot expose a host file" )
99+ await assert . rejects ( access ( join ( stagedPlugin , "prefix-secret.php" ) ) , / E N O E N T / , "a sibling path sharing the source prefix remains outside containment" )
100+ assert . deepEqual ( staging . diagnostics . map ( ( diagnostic ) => ( { code : diagnostic . code , metadata : diagnostic . metadata } ) ) , [
101+ { code : "readonly-mount-symlink-skipped" , metadata : { mountIndex : 0 , mountTarget : "/wordpress/wp-content/plugins/tracked-symlink-plugin" , path : "build-chain.sh" , reason : "dangling-target" } } ,
102+ { code : "readonly-mount-symlink-skipped" , metadata : { mountIndex : 0 , mountTarget : "/wordpress/wp-content/plugins/tracked-symlink-plugin" , path : "build.sh" , reason : "dangling-target" } } ,
103+ { code : "readonly-mount-symlink-skipped" , metadata : { mountIndex : 0 , mountTarget : "/wordpress/wp-content/plugins/tracked-symlink-plugin" , path : "host-secret-chain.php" , reason : "source-escape" } } ,
104+ { code : "readonly-mount-symlink-skipped" , metadata : { mountIndex : 0 , mountTarget : "/wordpress/wp-content/plugins/tracked-symlink-plugin" , path : "host-secret.php" , reason : "source-escape" } } ,
105+ { code : "readonly-mount-symlink-skipped" , metadata : { mountIndex : 0 , mountTarget : "/wordpress/wp-content/plugins/tracked-symlink-plugin" , path : "prefix-secret.php" , reason : "source-escape" } } ,
106+ ] , "skipped symlinks produce deterministic structured diagnostics" )
107+ const serializedDiagnostics = JSON . stringify ( staging . diagnostics )
108+ assert . equal ( serializedDiagnostics . includes ( root ) , false , "diagnostics do not expose absolute host paths" )
109+ assert . equal ( serializedDiagnostics . includes ( "../../../.github/build.sh" ) , false , "diagnostics do not expose dangling link targets" )
110+ assert . equal ( serializedDiagnostics . includes ( "../tracked-symlink-plugin-private/secret.php" ) , false , "diagnostics do not expose escaping link targets" )
111+ assert . deepEqual ( staging . phaseResult . metadata , { mounts : 1 , skipped : 5 , diagnostics : staging . diagnostics } , "staging evidence includes the skip diagnostics" )
112+ await staging [ Symbol . asyncDispose ] ( )
113+ assert . deepEqual ( await readonlyStagingDirectories ( ) , stagingDirectoriesBefore , "successful staging cleanup removes its temporary root" )
61114
62115 const beforeReadonlyHash = sha256 ( await readFile ( readonlySource ) )
63116 const server = await startPlaygroundCliServer ( spec , [
64117 { type : "file" , source : readonlySource , target : "/readonly" , mode : "readonly" } ,
65118 { type : "file" , source : readwriteSource , target : "/readwrite" , mode : "readwrite" } ,
66119 { type : "file" , source : wpConfigSource , target : "/wordpress/wp-config.php" , mode : "readonly" } ,
67- ] , { cliModule } )
120+ { type : "directory" , source : pluginSource , target : "/wordpress/wp-content/plugins/tracked-symlink-plugin" , mode : "readonly" } ,
121+ ] , { cliModule, onProgress : ( event ) => startupProgress . push ( event ) } )
68122
69123 assert . equal ( sha256 ( await readFile ( readonlySource ) ) , beforeReadonlyHash , "readonly source bytes must survive a sandbox overwrite" )
70124 assert . deepEqual ( await readFile ( readwriteSource ) , Buffer . from ( "sandbox overwrite" ) , "readwrite mounts must retain host-write behavior" )
71125 assert . notEqual ( mountedReadonlyPath , readonlySource , "readonly mounts must use a private staged path" )
126+ const mountMaterialization = startupProgress . find ( ( event ) => event . phase === "preview:materializing-mounts" ) ?. detail ?. materialization
127+ assert . deepEqual ( ( mountMaterialization as { metadata ?: Record < string , unknown > } ) ?. metadata , {
128+ mounts : 3 ,
129+ skipped : 5 ,
130+ diagnostics : staging . diagnostics . map ( ( diagnostic ) => ( {
131+ ...diagnostic ,
132+ metadata : { ...diagnostic . metadata , mountIndex : 3 } ,
133+ } ) ) ,
134+ } , "startup progress retains structured symlink skip evidence" )
72135
73136 await server [ Symbol . asyncDispose ] ( )
74137 await assert . rejects ( access ( mountedReadonlyPath ) , / E N O E N T / , "readonly mount staging must be removed with the runtime" )
0 commit comments