Skip to content

Commit 3a28ee5

Browse files
committed
fix: contain readonly mount symlinks
1 parent 8d01722 commit 3a28ee5

3 files changed

Lines changed: 137 additions & 20 deletions

File tree

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

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { createHash } from "node:crypto"
2-
import { cp, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"
2+
import { cp, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"
33
import { tmpdir } from "node:os"
4-
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"
5-
import { materializationPhaseResult, namedFileTreeSkipPolicyNames, phpStringArrayLiteral, type MaterializationPhaseResult, type MountSpec } from "@automattic/wp-codebox-core"
4+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
5+
import { materializationPhaseResult, namedFileTreeSkipPolicyNames, phpStringArrayLiteral, type MaterializationDiagnostic, type MaterializationPhaseResult, type MountSpec } from "@automattic/wp-codebox-core"
66
import type { PlaygroundCliServer } from "./preview-server.js"
77
import { SKIPPED_CAPTURE_DIRECTORIES } from "./artifacts.js"
88
import { assertPlaygroundResponseOk, errorMessage } from "./playground-command-errors.js"
@@ -36,6 +36,8 @@ export type StagedInputMaterializationResult = MountMaterializationResult
3636

3737
export interface ReadonlyMountStaging {
3838
mounts: MountSpec[]
39+
diagnostics: MaterializationDiagnostic[]
40+
phaseResult: MaterializationPhaseResult
3941
[Symbol.asyncDispose](): Promise<void>
4042
}
4143

@@ -78,6 +80,8 @@ export async function stageReadonlyPlaygroundMounts(mounts: MountSpec[]): Promis
7880
if (readonlyMounts.length === 0) {
7981
return {
8082
mounts,
83+
diagnostics: [],
84+
phaseResult: readonlyMountStagingPhaseResult(0, []),
8185
async [Symbol.asyncDispose]() {},
8286
}
8387
}
@@ -86,11 +90,47 @@ export async function stageReadonlyPlaygroundMounts(mounts: MountSpec[]): Promis
8690
try {
8791
const stagedMountResults = await Promise.allSettled(mounts.map(async (mount, index) => {
8892
if (mount.mode !== "readonly") {
89-
return mount
93+
return { mount, diagnostics: [] }
9094
}
9195
const source = join(root, `${index}-${basename(mount.source) || "mount"}`)
92-
await cp(mount.source, source, { recursive: mount.type !== "file", dereference: true })
93-
return { ...mount, source }
96+
const sourceRoot = await realpath(mount.source)
97+
const diagnostics: MaterializationDiagnostic[] = []
98+
await cp(mount.source, source, {
99+
recursive: mount.type !== "file",
100+
dereference: true,
101+
async filter(entry) {
102+
if (!(await lstat(entry)).isSymbolicLink()) {
103+
return true
104+
}
105+
let reason: "dangling-target" | "source-escape"
106+
try {
107+
const target = await realpath(entry)
108+
const relativeTarget = relative(sourceRoot, target)
109+
if (relativeTarget !== ".." && !relativeTarget.startsWith(`..${sep}`) && !isAbsolute(relativeTarget)) {
110+
return true
111+
}
112+
reason = "source-escape"
113+
} catch {
114+
reason = "dangling-target"
115+
}
116+
const path = relative(mount.source, entry).replace(/\\/g, "/") || "."
117+
diagnostics.push({
118+
code: "readonly-mount-symlink-skipped",
119+
message: `Skipped ${reason} symlink in readonly mount: ${path}`,
120+
severity: "warning",
121+
phase: "playground-readonly-mount-staging",
122+
metadata: {
123+
mountIndex: index,
124+
mountTarget: mount.target,
125+
path,
126+
reason,
127+
},
128+
})
129+
return false
130+
},
131+
})
132+
diagnostics.sort((left, right) => String(left.metadata?.path) < String(right.metadata?.path) ? -1 : String(left.metadata?.path) > String(right.metadata?.path) ? 1 : 0)
133+
return { mount: { ...mount, source }, diagnostics }
94134
}))
95135
const failedMount = stagedMountResults.find((result) => result.status === "rejected")
96136
if (failedMount?.status === "rejected") {
@@ -100,10 +140,13 @@ export async function stageReadonlyPlaygroundMounts(mounts: MountSpec[]): Promis
100140
if (result.status !== "fulfilled") {
101141
throw result.reason
102142
}
103-
return result.value
143+
return result.value.mount
104144
})
145+
const diagnostics = stagedMountResults.flatMap((result) => result.status === "fulfilled" ? result.value.diagnostics : [])
105146
return {
106147
mounts: stagedMounts,
148+
diagnostics,
149+
phaseResult: readonlyMountStagingPhaseResult(readonlyMounts.length, diagnostics),
107150
async [Symbol.asyncDispose]() {
108151
await rm(root, { recursive: true, force: true })
109152
},
@@ -114,6 +157,14 @@ export async function stageReadonlyPlaygroundMounts(mounts: MountSpec[]): Promis
114157
}
115158
}
116159

160+
function readonlyMountStagingPhaseResult(mounts: number, diagnostics: MaterializationDiagnostic[]): MaterializationPhaseResult {
161+
return materializationPhaseResult({
162+
phase: "playground-readonly-mount-staging",
163+
status: mounts > 0 ? "completed" : "skipped",
164+
metadata: { mounts, skipped: diagnostics.length, diagnostics },
165+
})
166+
}
167+
117168
function mountMaterializationResult(input: Omit<MountMaterializationResult, "phaseResult">): MountMaterializationResult {
118169
return {
119170
...input,

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
8787
}
8888
usesArchiveCache = !wordpressDirectory && !spec.environment.assets?.wordpressZip
8989
readonlyMountStaging = await stageReadonlyPlaygroundMounts(mounts)
90+
emitProgress("preview:materializing-mounts", "complete", "Prepared mounted inputs", {
91+
materialization: readonlyMountStaging.phaseResult,
92+
})
9093
const stagedMounts = readonlyMountStaging.mounts
9194
const preinstallMounts = stagedMounts.filter((mount) => mount.target === "/wordpress/wp-config.php")
9295
const postinstallMounts = stagedMounts.filter((mount) => mount.target !== "/wordpress/wp-config.php")

tests/playground-readonly-mounts.test.ts

Lines changed: 76 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import assert from "node:assert/strict"
2+
import { execFile } from "node:child_process"
23
import { createHash } from "node:crypto"
34
import { access, mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises"
45
import { tmpdir } from "node:os"
56
import { join } from "node:path"
7+
import { promisify } from "node:util"
68

79
import { startPlaygroundCliServer, type PlaygroundCliModule } from "../packages/runtime-playground/src/playground-cli-runner.js"
810
import { 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

1115
const root = await mkdtemp(join(tmpdir(), "wp-codebox-readonly-mounts-"))
1216
const readonlySource = join(root, "readonly.bin")
@@ -25,14 +29,26 @@ const spec: RuntimeCreateSpec = {
2529
}
2630

2731
let mountedReadonlyPath = ""
32+
const startupProgress: BrowserStartupProgressEvent[] = []
2833
const 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"), /Plugin Name: Tracked Symlink Fixture/)
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")), /ENOENT/)
48+
await assert.rejects(access(join(pluginMount.hostPath, "build-chain.sh")), /ENOENT/)
49+
await assert.rejects(access(join(pluginMount.hostPath, "host-secret.php")), /ENOENT/)
50+
await assert.rejects(access(join(pluginMount.hostPath, "host-secret-chain.php")), /ENOENT/)
51+
await assert.rejects(access(join(pluginMount.hostPath, "prefix-secret.php")), /ENOENT/)
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

4864
try {
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-
]), /ENOENT/, "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"), /Plugin Name: Tracked Symlink Fixture/, "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")), /ENOENT/, "a tracked dangling symlink is not materialized")
96+
await assert.rejects(access(join(stagedPlugin, "build-chain.sh")), /ENOENT/, "a chained dangling symlink is not materialized")
97+
await assert.rejects(access(join(stagedPlugin, "host-secret.php")), /ENOENT/, "a symlink escaping the source cannot expose a host file")
98+
await assert.rejects(access(join(stagedPlugin, "host-secret-chain.php")), /ENOENT/, "a chained escaping symlink cannot expose a host file")
99+
await assert.rejects(access(join(stagedPlugin, "prefix-secret.php")), /ENOENT/, "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), /ENOENT/, "readonly mount staging must be removed with the runtime")

0 commit comments

Comments
 (0)