Skip to content

Commit 4d82886

Browse files
authored
Fix nested mount overlays (#1853)
1 parent 65cc5fb commit 4d82886

4 files changed

Lines changed: 79 additions & 9 deletions

File tree

packages/cli/src/input-mount-paths.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,22 @@ export interface InputMountPathMapping {
99
}
1010

1111
export function recipeInputMountPathMap(recipe: WorkspaceRecipe): InputMountPathMapping[] {
12-
return (recipe.inputs?.mounts ?? []).map((mount, index) => ({
13-
originalTarget: normalizeSandboxPath(mount.target),
14-
canonicalTarget: canonicalInputMountTarget(mount.target, index),
15-
}))
12+
return (recipe.inputs?.mounts ?? []).reduce<InputMountPathMapping[]>((mappings, mount, index) => {
13+
const originalTarget = normalizeSandboxPath(mount.target)
14+
// Later declarations overlay the most-specific earlier target. Declaration
15+
// order therefore determines ownership for equal or otherwise ambiguous
16+
// overlaps, matching the order mounts are materialized into Playground.
17+
const parent = [...mappings]
18+
.filter((mapping) => pathHasMappedPrefix(originalTarget, mapping.originalTarget))
19+
.sort((a, b) => b.originalTarget.length - a.originalTarget.length)[0]
20+
mappings.push({
21+
originalTarget,
22+
canonicalTarget: parent
23+
? `${parent.canonicalTarget}${originalTarget.slice(parent.originalTarget.length)}`
24+
: canonicalInputMountTarget(originalTarget, index),
25+
})
26+
return mappings
27+
}, [])
1628
}
1729

1830
export function rewriteInputMountPathArgs(args: readonly string[] = [], mappings: readonly InputMountPathMapping[] = []): string[] {

packages/runtime-playground/src/mount-materialization.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface HostMountSnapshot {
1111
mountIndex: number
1212
target: string
1313
files: Record<string, string>
14+
excludedPaths?: string[]
1415
}
1516

1617
export interface VfsMountSnapshot {
@@ -122,6 +123,7 @@ export async function materializePlaygroundMountsFromVfs(server: PlaygroundCliSe
122123
mountIndex,
123124
target: mount.target,
124125
files: await hostFileHashes(mount.source),
126+
excludedPaths: nestedMountPaths(mounts, mountIndex, mount.target),
125127
})
126128
}
127129

@@ -130,6 +132,15 @@ export async function materializePlaygroundMountsFromVfs(server: PlaygroundCliSe
130132
return applyVfsMountSnapshots(mounts, parsed.mounts ?? [])
131133
}
132134

135+
function nestedMountPaths(mounts: MountSpec[], mountIndex: number, parentTarget: string): string[] {
136+
const normalizedParent = parentTarget.replace(/\/+$/, "")
137+
return mounts
138+
.slice(mountIndex + 1)
139+
.map((mount) => mount.target.replace(/\/+$/, ""))
140+
.filter((target) => target.startsWith(`${normalizedParent}/`))
141+
.map((target) => target.slice(normalizedParent.length + 1))
142+
}
143+
133144
function mountMaterializesVfsToHost(mount: MountSpec): boolean {
134145
return Boolean(mount.metadata && typeof mount.metadata === "object" && !Array.isArray(mount.metadata) && mount.metadata.materializeVfsToHost === true)
135146
}
@@ -536,9 +547,9 @@ export function vfsMountSnapshotPhp(hostSnapshots: HostMountSnapshot[]): string
536547
$payload = json_decode(${payload}, true);
537548
$skip = array_fill_keys(${skipList}, true);
538549
539-
function wp_codebox_vfs_mount_files(string $root, array $host_hashes, array $skip): array {
550+
function wp_codebox_vfs_mount_files(string $root, array $host_hashes, array $skip, array $excluded_paths): array {
540551
$files = array();
541-
$walk = function (string $directory, string $relative_directory) use (&$walk, &$files, $root, $host_hashes, $skip): void {
552+
$walk = function (string $directory, string $relative_directory) use (&$walk, &$files, $root, $host_hashes, $skip, $excluded_paths): void {
542553
if (!is_dir($directory)) {
543554
return;
544555
}
@@ -555,6 +566,16 @@ function wp_codebox_vfs_mount_files(string $root, array $host_hashes, array $ski
555566
}
556567
$relative_path = '' === $relative_directory ? $entry : $relative_directory . '/' . $entry;
557568
$path = $directory . '/' . $entry;
569+
$excluded = false;
570+
foreach ($excluded_paths as $excluded_path) {
571+
if ($relative_path === $excluded_path || str_starts_with($relative_path, $excluded_path . '/')) {
572+
$excluded = true;
573+
break;
574+
}
575+
}
576+
if ($excluded) {
577+
continue;
578+
}
558579
if (is_dir($path)) {
559580
$walk($path, $relative_path);
560581
continue;
@@ -594,7 +615,7 @@ foreach (($payload['mounts'] ?? array()) as $mount) {
594615
'mountIndex' => (int) ($mount['mountIndex'] ?? -1),
595616
'target' => $target,
596617
'authoritative' => true,
597-
'files' => wp_codebox_vfs_mount_files($target, is_array($mount['files'] ?? null) ? $mount['files'] : array(), $skip),
618+
'files' => wp_codebox_vfs_mount_files($target, is_array($mount['files'] ?? null) ? $mount['files'] : array(), $skip, is_array($mount['excludedPaths'] ?? null) ? $mount['excludedPaths'] : array()),
598619
);
599620
}
600621

tests/playground-readonly-mounts-integration.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,47 @@
11
import assert from "node:assert/strict"
22
import { execFile } from "node:child_process"
33
import { createHash } from "node:crypto"
4-
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"
4+
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"
55
import { tmpdir } from "node:os"
66
import { join } from "node:path"
77
import { promisify } from "node:util"
88

99
const execFileAsync = promisify(execFile)
1010
const root = await mkdtemp(join(tmpdir(), "wp-codebox-readonly-mounts-integration-"))
11+
const projectSource = join(root, "project")
12+
const projectConfigSource = join(projectSource, "config.php")
13+
const overlaySource = join(root, "config-overlay.php")
1114
const readonlySource = join(root, "readonly.bin")
1215
const readwriteSource = join(root, "readwrite.bin")
1316
const recipePath = join(root, "recipe.json")
1417
const artifactsPath = join(root, "artifacts")
18+
const originalConfig = "<?php return 'parent';\n"
19+
const overlayConfig = "<?php return 'overlay';\n"
1520
const readonlyBytes = Buffer.from([0, 255, 1, 2, 3, 127, 128])
1621
const overwrittenBytes = Buffer.from([128, 127, 3, 2, 1, 255, 0])
1722
const stagingDirectoriesBefore = await readonlyStagingDirectories()
1823

1924
try {
25+
await mkdir(projectSource)
26+
await writeFile(projectConfigSource, originalConfig)
27+
await writeFile(overlaySource, overlayConfig)
2028
await writeFile(readonlySource, readonlyBytes)
2129
await writeFile(readwriteSource, readonlyBytes)
2230
await writeFile(recipePath, `${JSON.stringify({
2331
schema: "wp-codebox/workspace-recipe/v1",
2432
runtime: { backend: "wordpress-playground", wp: "6.5", blueprint: { steps: [] } },
2533
inputs: {
2634
mounts: [
35+
{ source: projectSource, target: "/home/project", mode: "readwrite" },
36+
{ source: overlaySource, target: "/home/project/config.php", mode: "readonly" },
2737
{ source: readonlySource, target: "/wordpress/readonly.bin", mode: "readonly" },
2838
{ source: readwriteSource, target: "/wordpress/readwrite.bin", mode: "readwrite" },
2939
],
3040
},
3141
workflow: {
3242
steps: [{
3343
command: "wordpress.run-php",
34-
args: [`code=$contents = base64_decode('${overwrittenBytes.toString("base64")}'); file_put_contents('/wordpress/readonly.bin', $contents); file_put_contents('/wordpress/readwrite.bin', $contents);`],
44+
args: [`code=$config = file_get_contents('/home/project/config.php'); if ($config !== "<?php return 'overlay';\\n") { fwrite(STDERR, $config); exit(1); } $contents = base64_decode('${overwrittenBytes.toString("base64")}'); file_put_contents('/home/project/config.php', "overwritten"); file_put_contents('/home/project/mutated.txt', 'parent mutation'); file_put_contents('/wordpress/readonly.bin', $contents); file_put_contents('/wordpress/readwrite.bin', $contents);`],
3545
}],
3646
},
3747
})}\n`)
@@ -41,6 +51,9 @@ try {
4151
assert.equal(output.success, true, JSON.stringify(output))
4252
assert.equal(sha256(await readFile(readonlySource)), sha256(readonlyBytes), "readonly host bytes must survive an actual Playground PHP overwrite")
4353
assert.deepEqual(await readFile(readwriteSource), overwrittenBytes, "readwrite host bytes must reflect an actual Playground PHP overwrite")
54+
assert.equal(await readFile(overlaySource, "utf8"), overlayConfig, "readonly overlay source must survive an actual Playground PHP overwrite")
55+
assert.equal(await readFile(projectConfigSource, "utf8"), originalConfig, "the parent writeback must exclude the nested overlay path")
56+
assert.equal(await readFile(join(projectSource, "mutated.txt"), "utf8"), "parent mutation", "parent readwrite mutations must still materialize")
4457
assert.deepEqual(await readonlyStagingDirectories(), stagingDirectoriesBefore, "recipe-run cleanup must remove readonly mount staging")
4558
}
4659
} finally {

tests/recipe-runtime-setup-staged-materialization.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,30 @@ assert.deepEqual(rewriteInputMountPathArgs(["cwd=/home/example/public_html/bin/t
161161
{ originalTarget: "/home/example/public_html/bin/tests", canonicalTarget: "/tmp/wp-codebox-inputs/tests" },
162162
]), ["cwd=/tmp/wp-codebox-inputs/tests/foo"])
163163

164+
const nestedInputMountPathMap = recipeInputMountPathMap({
165+
schema: "wp-codebox/workspace-recipe/v1",
166+
inputs: {
167+
mounts: [
168+
{ source: "/workspace/project", target: "/home/project", mode: "readwrite" },
169+
{ source: "/workspace/config.php", target: "/home/project/config.php", mode: "readonly" },
170+
],
171+
},
172+
workflow: { steps: [] },
173+
})
174+
assert.equal(nestedInputMountPathMap[1].canonicalTarget, `${nestedInputMountPathMap[0].canonicalTarget}/config.php`, "a later nested mount overlays its earlier parent target")
175+
assert.deepEqual(rewriteInputMountPathArgs(["config=/home/project/config.php"], nestedInputMountPathMap), [`config=${nestedInputMountPathMap[1].canonicalTarget}`], "nested paths rewrite to their effective overlay target")
176+
const reverseNestedInputMountPathMap = recipeInputMountPathMap({
177+
schema: "wp-codebox/workspace-recipe/v1",
178+
inputs: {
179+
mounts: [
180+
{ source: "/workspace/config.php", target: "/home/project/config.php", mode: "readonly" },
181+
{ source: "/workspace/project", target: "/home/project", mode: "readwrite" },
182+
],
183+
},
184+
workflow: { steps: [] },
185+
})
186+
assert.match(reverseNestedInputMountPathMap[1].canonicalTarget, /^\/tmp\/wp-codebox-inputs\/1-project-[a-f0-9]{12}$/, "an earlier nested declaration does not alter a later parent target")
187+
164188
const wpcomPathMap = recipeInputMountPathMap({
165189
schema: "wp-codebox/workspace-recipe/v1",
166190
inputs: {

0 commit comments

Comments
 (0)