Skip to content

Commit f2dd340

Browse files
authored
Fix readonly Playground mount isolation (#1799)
* Fix readonly Playground mount isolation * Add readonly Playground mount integration test * Narrow readonly mount integration skip
1 parent 8af1572 commit f2dd340

5 files changed

Lines changed: 249 additions & 5 deletions

File tree

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@
9191
"test:external-native-package-materialization": "tsx tests/external-native-package-materialization.test.ts",
9292
"test:runtime-sources-materialization": "tsx tests/runtime-sources-materialization.test.ts",
9393
"test:runtime-sources-playground-integration": "tsx tests/runtime-sources-playground-integration.test.ts",
94+
"test:playground-readonly-mounts": "tsx tests/playground-readonly-mounts.test.ts",
95+
"test:playground-readonly-mounts-integration": "tsx tests/playground-readonly-mounts-integration.test.ts",
9496
"test:browser-task-builder": "tsx tests/browser-task-builder.test.ts",
9597
"test:browser-runtime-generic-invoker": "tsx tests/browser-runtime-generic-invoker.test.ts",
9698
"test:browser-runtime-file-ops": "tsx tests/browser-runtime-file-ops.test.ts",

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

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createHash } from "node:crypto"
2-
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"
3-
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
2+
import { cp, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"
3+
import { tmpdir } from "node:os"
4+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"
45
import { materializationPhaseResult, namedFileTreeSkipPolicyNames, phpStringArrayLiteral, type MaterializationPhaseResult, type MountSpec } from "@automattic/wp-codebox-core"
56
import type { PlaygroundCliServer } from "./preview-server.js"
67
import { SKIPPED_CAPTURE_DIRECTORIES } from "./artifacts.js"
@@ -32,6 +33,11 @@ export interface MountMaterializationResult {
3233

3334
export type StagedInputMaterializationResult = MountMaterializationResult
3435

36+
export interface ReadonlyMountStaging {
37+
mounts: MountSpec[]
38+
[Symbol.asyncDispose](): Promise<void>
39+
}
40+
3541
interface HostMountFilePayload {
3642
target: string
3743
contentsBase64: string
@@ -56,6 +62,41 @@ interface HostMountFileMaterializationResponse {
5662
const HOST_MOUNT_FILE_BATCH_SIZE = 100
5763
const HOST_MOUNT_DIRECTORY_BATCH_SIZE = 500
5864

65+
/**
66+
* Playground's Node filesystem mount handler is writable. Snapshot readonly
67+
* inputs into a runtime-owned directory before handing them to that handler.
68+
*/
69+
export async function stageReadonlyPlaygroundMounts(mounts: MountSpec[]): Promise<ReadonlyMountStaging> {
70+
const readonlyMounts = mounts.filter((mount) => mount.mode === "readonly")
71+
if (readonlyMounts.length === 0) {
72+
return {
73+
mounts,
74+
async [Symbol.asyncDispose]() {},
75+
}
76+
}
77+
78+
const root = await mkdtemp(join(tmpdir(), "wp-codebox-readonly-mounts-"))
79+
try {
80+
const stagedMounts = await Promise.all(mounts.map(async (mount, index) => {
81+
if (mount.mode !== "readonly") {
82+
return mount
83+
}
84+
const source = join(root, `${index}-${basename(mount.source) || "mount"}`)
85+
await cp(mount.source, source, { recursive: mount.type !== "file", dereference: true })
86+
return { ...mount, source }
87+
}))
88+
return {
89+
mounts: stagedMounts,
90+
async [Symbol.asyncDispose]() {
91+
await rm(root, { recursive: true, force: true })
92+
},
93+
}
94+
} catch (error) {
95+
await rm(root, { recursive: true, force: true })
96+
throw error
97+
}
98+
}
99+
59100
function mountMaterializationResult(input: Omit<MountMaterializationResult, "phaseResult">): MountMaterializationResult {
60101
return {
61102
...input,

packages/runtime-playground/src/playground-cli-runner.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { createServer as createNetServer } from "node:net"
1313
import * as PlaygroundStorage from "@wp-playground/storage"
1414
import { resolveWordPressRelease } from "@wp-playground/wordpress"
1515
import { phpEnvAssignments, phpWpConfigDefineAssignments } from "./php-snippets.js"
16+
import { stageReadonlyPlaygroundMounts, type ReadonlyMountStaging } from "./mount-materialization.js"
1617

1718
const DEFAULT_RUNTIME_PHP_INI_ENTRIES = { memory_limit: "512M" }
1819

@@ -62,6 +63,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
6263
mounts: mounts.length,
6364
})
6465
emitProgress("preview:loading-client", "running", "Loading preview")
66+
let readonlyMountStaging: ReadonlyMountStaging | undefined
6567
try {
6668
if (spec.preview?.port) {
6769
await assertPreviewPortAvailable(spec.preview.port)
@@ -74,6 +76,8 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
7476
const wordpressInstallMode = spec.environment.wordpressInstallMode ?? "install-from-existing-files"
7577
const bootstrapIniEntries = runtimeBootstrapPhpIniEntries(spec)
7678
const useProgrammaticRunner = shouldUseProgrammaticPlaygroundRunner(spec, options)
79+
readonlyMountStaging = await stageReadonlyPlaygroundMounts(mounts)
80+
const stagedMounts = readonlyMountStaging.mounts
7781
const wordpressStartupAsset = wordpressDirectory ? undefined : await resolvePlaygroundWordPressStartupAsset(spec.environment.version, spec.environment.assets?.wordpressZip)
7882
const cacheValidation = wordpressStartupAsset?.cacheValidation ?? {
7983
version: spec.environment.version ?? "mounted-wordpress-source",
@@ -99,7 +103,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
99103
...spec.preview,
100104
port,
101105
},
102-
}, mounts, {
106+
}, stagedMounts, {
103107
bootstrapIniEntries: bootstrapIniEntries!,
104108
phpIniEntries: pluginRuntimePhpIniEntries(spec),
105109
wordpressDirectory: wordpressDirectory!,
@@ -119,7 +123,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
119123
skipBrowser: true,
120124
workers: 6,
121125
mount: [
122-
...mounts.map((mount) => ({
126+
...stagedMounts.map((mount) => ({
123127
hostPath: mount.source,
124128
vfsPath: mount.target,
125129
})),
@@ -154,12 +158,22 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
154158
upstreamUrl: server.serverUrl,
155159
lease: proxiedServer.previewLease ? previewLeaseDetail(proxiedServer.previewLease) : undefined,
156160
})
157-
return proxiedServer
161+
return {
162+
...proxiedServer,
163+
async [Symbol.asyncDispose]() {
164+
try {
165+
await proxiedServer[Symbol.asyncDispose]()
166+
} finally {
167+
await readonlyMountStaging?.[Symbol.asyncDispose]()
168+
}
169+
},
170+
}
158171
} catch (error) {
159172
emitProgress("preview:error", "failed", "Preview failed to start", {
160173
error: errorDetail(error),
161174
})
162175

176+
await readonlyMountStaging?.[Symbol.asyncDispose]()
163177
if (spec.preview?.port && errorHasCode(error, "EADDRINUSE")) {
164178
throw new PlaygroundPreviewPortUnavailableError(spec.preview.port, error)
165179
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import assert from "node:assert/strict"
2+
import { execFile } from "node:child_process"
3+
import { createHash } from "node:crypto"
4+
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"
5+
import { tmpdir } from "node:os"
6+
import { join } from "node:path"
7+
import { promisify } from "node:util"
8+
9+
const execFileAsync = promisify(execFile)
10+
const root = await mkdtemp(join(tmpdir(), "wp-codebox-readonly-mounts-integration-"))
11+
const readonlySource = join(root, "readonly.bin")
12+
const readwriteSource = join(root, "readwrite.bin")
13+
const recipePath = join(root, "recipe.json")
14+
const artifactsPath = join(root, "artifacts")
15+
const readonlyBytes = Buffer.from([0, 255, 1, 2, 3, 127, 128])
16+
const overwrittenBytes = Buffer.from([128, 127, 3, 2, 1, 255, 0])
17+
const stagingDirectoriesBefore = await readonlyStagingDirectories()
18+
19+
try {
20+
await writeFile(readonlySource, readonlyBytes)
21+
await writeFile(readwriteSource, readonlyBytes)
22+
await writeFile(recipePath, `${JSON.stringify({
23+
schema: "wp-codebox/workspace-recipe/v1",
24+
runtime: { backend: "wordpress-playground", wp: "6.5", blueprint: { steps: [] } },
25+
inputs: {
26+
mounts: [
27+
{ source: readonlySource, target: "/wordpress/readonly.bin", mode: "readonly" },
28+
{ source: readwriteSource, target: "/wordpress/readwrite.bin", mode: "readwrite" },
29+
],
30+
},
31+
workflow: {
32+
steps: [{
33+
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);`],
35+
}],
36+
},
37+
})}\n`)
38+
39+
const output = await runRecipe()
40+
if (output) {
41+
assert.equal(output.success, true, JSON.stringify(output))
42+
assert.equal(sha256(await readFile(readonlySource)), sha256(readonlyBytes), "readonly host bytes must survive an actual Playground PHP overwrite")
43+
assert.deepEqual(await readFile(readwriteSource), overwrittenBytes, "readwrite host bytes must reflect an actual Playground PHP overwrite")
44+
assert.deepEqual(await readonlyStagingDirectories(), stagingDirectoriesBefore, "recipe-run cleanup must remove readonly mount staging")
45+
}
46+
} finally {
47+
await rm(root, { recursive: true, force: true })
48+
}
49+
50+
assert.equal(isUnavailableWordPressRuntimeSource({
51+
stdout: JSON.stringify({
52+
schema: "wp-codebox/recipe-run/v1",
53+
success: false,
54+
phaseEvidence: [{ name: "runtime_startup", status: "failed", error: { message: "Unable to resolve Playground startup asset wordpress-archive-cache for WordPress 6.5: fetch failed" } }],
55+
}),
56+
}), true, "only a structured pre-runtime WordPress archive acquisition failure may skip")
57+
58+
assert.equal(isUnavailableWordPressRuntimeSource({
59+
stdout: JSON.stringify({
60+
schema: "wp-codebox/recipe-run/v1",
61+
success: false,
62+
phaseEvidence: [{ name: "runtime_startup", status: "completed" }, { name: "run_workloads", status: "failed", error: { message: "fetch failed" } }],
63+
}),
64+
}), false, "a post-start failure containing fetch failed must fail the integration test")
65+
66+
function sha256(bytes: Buffer): string {
67+
return createHash("sha256").update(bytes).digest("hex")
68+
}
69+
70+
async function readonlyStagingDirectories(): Promise<string[]> {
71+
return (await readdir(tmpdir())).filter((entry) => entry.startsWith("wp-codebox-readonly-mounts-")).sort()
72+
}
73+
74+
async function runRecipe(): Promise<RecipeRunOutput | undefined> {
75+
try {
76+
const result = await execFileAsync(process.execPath, ["packages/cli/dist/index.js", "recipe-run", "--recipe", recipePath, "--artifacts", artifactsPath, "--json"], {
77+
cwd: process.cwd(),
78+
timeout: 300_000,
79+
maxBuffer: 2 * 1024 * 1024,
80+
})
81+
return recipeRunOutput(result.stdout)
82+
} catch (error) {
83+
if (isUnavailableWordPressRuntimeSource(error)) {
84+
console.log("playground readonly mount integration skipped: WordPress runtime source was unavailable before runtime startup")
85+
return undefined
86+
}
87+
throw error
88+
}
89+
}
90+
91+
interface RecipeRunOutput {
92+
schema?: string
93+
success?: boolean
94+
phaseEvidence?: Array<{
95+
name?: string
96+
status?: string
97+
error?: { message?: string }
98+
}>
99+
}
100+
101+
function isUnavailableWordPressRuntimeSource(error: unknown): boolean {
102+
const output = recipeRunOutput(error && typeof error === "object" && "stdout" in error ? error.stdout : undefined)
103+
const startup = output?.phaseEvidence?.find((phase) => phase.name === "runtime_startup")
104+
const message = startup?.error?.message ?? ""
105+
return output?.schema === "wp-codebox/recipe-run/v1"
106+
&& startup?.status === "failed"
107+
&& /Unable to resolve Playground startup asset (wordpress-archive-cache|wordpress-release-metadata)/.test(message)
108+
&& /fetch failed|Could not resolve host|Connection timed out|network is unreachable/i.test(message)
109+
}
110+
111+
function recipeRunOutput(value: unknown): RecipeRunOutput | undefined {
112+
if (typeof value !== "string") {
113+
return undefined
114+
}
115+
try {
116+
const output = JSON.parse(value) as RecipeRunOutput
117+
return output && typeof output === "object" ? output : undefined
118+
} catch {
119+
return undefined
120+
}
121+
}
122+
123+
console.log("playground readonly mount integration ok")
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import assert from "node:assert/strict"
2+
import { createHash } from "node:crypto"
3+
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
4+
import { tmpdir } from "node:os"
5+
import { join } from "node:path"
6+
7+
import { startPlaygroundCliServer, type PlaygroundCliModule } from "../packages/runtime-playground/src/playground-cli-runner.js"
8+
import type { RuntimeCreateSpec } from "../packages/runtime-core/src/index.js"
9+
10+
const root = await mkdtemp(join(tmpdir(), "wp-codebox-readonly-mounts-"))
11+
const readonlySource = join(root, "readonly.bin")
12+
const readwriteSource = join(root, "readwrite.bin")
13+
const readonlyBytes = Buffer.from([0, 255, 1, 2, 3, 127, 128])
14+
const readwriteBytes = Buffer.from([10, 20, 30])
15+
await writeFile(readonlySource, readonlyBytes)
16+
await writeFile(readwriteSource, readwriteBytes)
17+
18+
const spec: RuntimeCreateSpec = {
19+
backend: "wordpress-playground",
20+
environment: { version: "6.8", phpVersion: "8.4", blueprint: {} },
21+
policy: { network: "deny", filesystem: "readwrite-mounts", commands: ["wordpress.run-php"], secrets: "none", approvals: "never" },
22+
}
23+
24+
let mountedReadonlyPath = ""
25+
const cliModule: PlaygroundCliModule = {
26+
async runCLI(options) {
27+
const readonlyMount = options.mount.find((mount) => mount.vfsPath === "/readonly")
28+
const readwriteMount = options.mount.find((mount) => mount.vfsPath === "/readwrite")
29+
assert.ok(readonlyMount)
30+
assert.ok(readwriteMount)
31+
mountedReadonlyPath = readonlyMount.hostPath
32+
// This is the host path Playground's writable Node mount handler receives.
33+
await writeFile(readonlyMount.hostPath, Buffer.from("sandbox overwrite"))
34+
await writeFile(readwriteMount.hostPath, Buffer.from("sandbox overwrite"))
35+
return {
36+
serverUrl: "http://127.0.0.1:65535",
37+
playground: { async run() { return { text: "" } } },
38+
async [Symbol.asyncDispose]() {},
39+
}
40+
},
41+
}
42+
43+
try {
44+
const beforeReadonlyHash = sha256(await readFile(readonlySource))
45+
const server = await startPlaygroundCliServer(spec, [
46+
{ type: "file", source: readonlySource, target: "/readonly", mode: "readonly" },
47+
{ type: "file", source: readwriteSource, target: "/readwrite", mode: "readwrite" },
48+
], { cliModule })
49+
50+
assert.equal(sha256(await readFile(readonlySource)), beforeReadonlyHash, "readonly source bytes must survive a sandbox overwrite")
51+
assert.deepEqual(await readFile(readwriteSource), Buffer.from("sandbox overwrite"), "readwrite mounts must retain host-write behavior")
52+
assert.notEqual(mountedReadonlyPath, readonlySource, "readonly mounts must use a private staged path")
53+
54+
await server[Symbol.asyncDispose]()
55+
await assert.rejects(access(mountedReadonlyPath), /ENOENT/, "readonly mount staging must be removed with the runtime")
56+
} finally {
57+
await rm(root, { recursive: true, force: true })
58+
}
59+
60+
function sha256(bytes: Buffer): string {
61+
return createHash("sha256").update(bytes).digest("hex")
62+
}
63+
64+
console.log("playground readonly mount isolation ok")

0 commit comments

Comments
 (0)