Skip to content

Commit 0416e08

Browse files
committed
Fix readonly Playground mount isolation
1 parent 8af1572 commit 0416e08

4 files changed

Lines changed: 125 additions & 5 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
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",
9495
"test:browser-task-builder": "tsx tests/browser-task-builder.test.ts",
9596
"test:browser-runtime-generic-invoker": "tsx tests/browser-runtime-generic-invoker.test.ts",
9697
"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: 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)