Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 58 additions & 7 deletions packages/runtime-playground/src/mount-materialization.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { createHash } from "node:crypto"
import { cp, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"
import { cp, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"
import { materializationPhaseResult, namedFileTreeSkipPolicyNames, phpStringArrayLiteral, type MaterializationPhaseResult, type MountSpec } from "@automattic/wp-codebox-core"
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
import { materializationPhaseResult, namedFileTreeSkipPolicyNames, phpStringArrayLiteral, type MaterializationDiagnostic, type MaterializationPhaseResult, type MountSpec } from "@automattic/wp-codebox-core"
import type { PlaygroundCliServer } from "./preview-server.js"
import { SKIPPED_CAPTURE_DIRECTORIES } from "./artifacts.js"
import { assertPlaygroundResponseOk, errorMessage } from "./playground-command-errors.js"
Expand Down Expand Up @@ -36,6 +36,8 @@ export type StagedInputMaterializationResult = MountMaterializationResult

export interface ReadonlyMountStaging {
mounts: MountSpec[]
diagnostics: MaterializationDiagnostic[]
phaseResult: MaterializationPhaseResult
[Symbol.asyncDispose](): Promise<void>
}

Expand Down Expand Up @@ -78,6 +80,8 @@ export async function stageReadonlyPlaygroundMounts(mounts: MountSpec[]): Promis
if (readonlyMounts.length === 0) {
return {
mounts,
diagnostics: [],
phaseResult: readonlyMountStagingPhaseResult(0, []),
async [Symbol.asyncDispose]() {},
}
}
Expand All @@ -86,11 +90,47 @@ export async function stageReadonlyPlaygroundMounts(mounts: MountSpec[]): Promis
try {
const stagedMountResults = await Promise.allSettled(mounts.map(async (mount, index) => {
if (mount.mode !== "readonly") {
return mount
return { mount, diagnostics: [] }
}
const source = join(root, `${index}-${basename(mount.source) || "mount"}`)
await cp(mount.source, source, { recursive: mount.type !== "file", dereference: true })
return { ...mount, source }
const sourceRoot = await realpath(mount.source)
const diagnostics: MaterializationDiagnostic[] = []
await cp(mount.source, source, {
recursive: mount.type !== "file",
dereference: true,
async filter(entry) {
if (!(await lstat(entry)).isSymbolicLink()) {
return true
}
let reason: "dangling-target" | "source-escape"
try {
const target = await realpath(entry)
const relativeTarget = relative(sourceRoot, target)
if (relativeTarget !== ".." && !relativeTarget.startsWith(`..${sep}`) && !isAbsolute(relativeTarget)) {
return true
}
reason = "source-escape"
} catch {
reason = "dangling-target"
}
const path = relative(mount.source, entry).replace(/\\/g, "/") || "."
diagnostics.push({
code: "readonly-mount-symlink-skipped",
message: `Skipped ${reason} symlink in readonly mount: ${path}`,
severity: "warning",
phase: "playground-readonly-mount-staging",
metadata: {
mountIndex: index,
mountTarget: mount.target,
path,
reason,
},
})
return false
},
})
diagnostics.sort((left, right) => String(left.metadata?.path) < String(right.metadata?.path) ? -1 : String(left.metadata?.path) > String(right.metadata?.path) ? 1 : 0)
return { mount: { ...mount, source }, diagnostics }
}))
const failedMount = stagedMountResults.find((result) => result.status === "rejected")
if (failedMount?.status === "rejected") {
Expand All @@ -100,10 +140,13 @@ export async function stageReadonlyPlaygroundMounts(mounts: MountSpec[]): Promis
if (result.status !== "fulfilled") {
throw result.reason
}
return result.value
return result.value.mount
})
const diagnostics = stagedMountResults.flatMap((result) => result.status === "fulfilled" ? result.value.diagnostics : [])
return {
mounts: stagedMounts,
diagnostics,
phaseResult: readonlyMountStagingPhaseResult(readonlyMounts.length, diagnostics),
async [Symbol.asyncDispose]() {
await rm(root, { recursive: true, force: true })
},
Expand All @@ -114,6 +157,14 @@ export async function stageReadonlyPlaygroundMounts(mounts: MountSpec[]): Promis
}
}

function readonlyMountStagingPhaseResult(mounts: number, diagnostics: MaterializationDiagnostic[]): MaterializationPhaseResult {
return materializationPhaseResult({
phase: "playground-readonly-mount-staging",
status: mounts > 0 ? "completed" : "skipped",
metadata: { mounts, skipped: diagnostics.length, diagnostics },
})
}

function mountMaterializationResult(input: Omit<MountMaterializationResult, "phaseResult">): MountMaterializationResult {
return {
...input,
Expand Down
3 changes: 3 additions & 0 deletions packages/runtime-playground/src/playground-cli-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts:
}
usesArchiveCache = !wordpressDirectory && !spec.environment.assets?.wordpressZip
readonlyMountStaging = await stageReadonlyPlaygroundMounts(mounts)
emitProgress("preview:materializing-mounts", "complete", "Prepared mounted inputs", {
materialization: readonlyMountStaging.phaseResult,
})
const stagedMounts = readonlyMountStaging.mounts
const preinstallMounts = stagedMounts.filter((mount) => mount.target === "/wordpress/wp-config.php")
const postinstallMounts = stagedMounts.filter((mount) => mount.target !== "/wordpress/wp-config.php")
Expand Down
89 changes: 76 additions & 13 deletions tests/playground-readonly-mounts.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import assert from "node:assert/strict"
import { execFile } from "node:child_process"
import { createHash } from "node:crypto"
import { access, mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { promisify } from "node:util"

import { startPlaygroundCliServer, type PlaygroundCliModule } from "../packages/runtime-playground/src/playground-cli-runner.js"
import { stageReadonlyPlaygroundMounts } from "../packages/runtime-playground/src/mount-materialization.js"
import type { RuntimeCreateSpec } from "../packages/runtime-core/src/index.js"
import type { BrowserStartupProgressEvent, RuntimeCreateSpec } from "../packages/runtime-core/src/index.js"

const execFileAsync = promisify(execFile)

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

let mountedReadonlyPath = ""
const startupProgress: BrowserStartupProgressEvent[] = []
const cliModule: PlaygroundCliModule = {
async runCLI(options) {
const readonlyMount = options.mount.find((mount) => mount.vfsPath === "/readonly")
const readwriteMount = options.mount.find((mount) => mount.vfsPath === "/readwrite")
const pluginMount = options.mount.find((mount) => mount.vfsPath === "/wordpress/wp-content/plugins/tracked-symlink-plugin")
const wpConfigMount = options["mount-before-install"]?.find((mount) => mount.vfsPath === "/wordpress/wp-config.php")
assert.ok(readonlyMount)
assert.ok(readwriteMount)
assert.ok(pluginMount)
assert.ok(wpConfigMount)
assert.match(await readFile(join(pluginMount.hostPath, "plugin.php"), "utf8"), /Plugin Name: Tracked Symlink Fixture/)
assert.equal(await readFile(join(pluginMount.hostPath, "includes", "runtime.php"), "utf8"), "<?php return 'runtime';\n")
assert.equal(await readFile(join(pluginMount.hostPath, "runtime-link.php"), "utf8"), "<?php return 'runtime';\n")
assert.equal(await readFile(join(pluginMount.hostPath, "runtime-chain.php"), "utf8"), "<?php return 'runtime';\n")
await assert.rejects(access(join(pluginMount.hostPath, "build.sh")), /ENOENT/)
await assert.rejects(access(join(pluginMount.hostPath, "build-chain.sh")), /ENOENT/)
await assert.rejects(access(join(pluginMount.hostPath, "host-secret.php")), /ENOENT/)
await assert.rejects(access(join(pluginMount.hostPath, "host-secret-chain.php")), /ENOENT/)
await assert.rejects(access(join(pluginMount.hostPath, "prefix-secret.php")), /ENOENT/)
mountedReadonlyPath = readonlyMount.hostPath
// This is the host path Playground's writable Node mount handler receives.
await writeFile(readonlyMount.hostPath, Buffer.from("sandbox overwrite"))
Expand All @@ -46,29 +62,76 @@ const cliModule: PlaygroundCliModule = {
}

try {
const danglingSource = join(root, "dangling")
const largeSource = join(root, "large")
await mkdir(danglingSource)
await mkdir(largeSource)
await symlink("missing.php", join(danglingSource, "build.sh"))
await Promise.all(Array.from({ length: 500 }, (_, index) => writeFile(join(largeSource, `File${index}.php`), `<?php return ${index};\n`)))
const pluginSource = join(root, "tracked-symlink-plugin")
const prefixConfusionSource = join(root, "tracked-symlink-plugin-private")
const hostSecret = join(root, "host-secret.php")
await mkdir(join(pluginSource, "includes"), { recursive: true })
await mkdir(prefixConfusionSource)
await writeFile(join(pluginSource, "plugin.php"), "<?php /* Plugin Name: Tracked Symlink Fixture */\n")
await writeFile(join(pluginSource, "includes", "runtime.php"), "<?php return 'runtime';\n")
await writeFile(join(prefixConfusionSource, "secret.php"), "<?php return 'prefix secret';\n")
await writeFile(hostSecret, "<?php return 'host secret';\n")
await symlink("includes/runtime.php", join(pluginSource, "runtime-link.php"))
await symlink("runtime-link.php", join(pluginSource, "runtime-chain.php"))
await symlink("../../../.github/build.sh", join(pluginSource, "build.sh"))
await symlink("build.sh", join(pluginSource, "build-chain.sh"))
await symlink("../host-secret.php", join(pluginSource, "host-secret.php"))
await symlink("host-secret.php", join(pluginSource, "host-secret-chain.php"))
await symlink("../tracked-symlink-plugin-private/secret.php", join(pluginSource, "prefix-secret.php"))
await execFileAsync("git", ["init", "--quiet"], { cwd: pluginSource })
await execFileAsync("git", ["add", "."], { cwd: pluginSource })
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()
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")

const stagingDirectoriesBefore = await readonlyStagingDirectories()
await assert.rejects(stageReadonlyPlaygroundMounts([
{ source: danglingSource, target: "/dangling", mode: "readonly" },
{ source: largeSource, target: "/large", mode: "readonly" },
]), /ENOENT/, "the source failure must not be masked by cleanup racing another mount copy")
assert.deepEqual(await readonlyStagingDirectories(), stagingDirectoriesBefore, "failed concurrent staging must remove its temporary root")
const staging = await stageReadonlyPlaygroundMounts([
{ source: pluginSource, target: "/wordpress/wp-content/plugins/tracked-symlink-plugin", mode: "readonly" },
])
const stagedPlugin = staging.mounts[0].source
assert.match(await readFile(join(stagedPlugin, "plugin.php"), "utf8"), /Plugin Name: Tracked Symlink Fixture/, "the plugin entrypoint remains available")
assert.equal(await readFile(join(stagedPlugin, "includes", "runtime.php"), "utf8"), "<?php return 'runtime';\n", "nested runtime files remain available")
assert.equal(await readFile(join(stagedPlugin, "runtime-link.php"), "utf8"), "<?php return 'runtime';\n", "a contained symlink is dereferenced into the staged mount")
assert.equal(await readFile(join(stagedPlugin, "runtime-chain.php"), "utf8"), "<?php return 'runtime';\n", "a contained symlink chain is dereferenced into the staged mount")
await assert.rejects(access(join(stagedPlugin, "build.sh")), /ENOENT/, "a tracked dangling symlink is not materialized")
await assert.rejects(access(join(stagedPlugin, "build-chain.sh")), /ENOENT/, "a chained dangling symlink is not materialized")
await assert.rejects(access(join(stagedPlugin, "host-secret.php")), /ENOENT/, "a symlink escaping the source cannot expose a host file")
await assert.rejects(access(join(stagedPlugin, "host-secret-chain.php")), /ENOENT/, "a chained escaping symlink cannot expose a host file")
await assert.rejects(access(join(stagedPlugin, "prefix-secret.php")), /ENOENT/, "a sibling path sharing the source prefix remains outside containment")
assert.deepEqual(staging.diagnostics.map((diagnostic) => ({ code: diagnostic.code, metadata: diagnostic.metadata })), [
{ code: "readonly-mount-symlink-skipped", metadata: { mountIndex: 0, mountTarget: "/wordpress/wp-content/plugins/tracked-symlink-plugin", path: "build-chain.sh", reason: "dangling-target" } },
{ code: "readonly-mount-symlink-skipped", metadata: { mountIndex: 0, mountTarget: "/wordpress/wp-content/plugins/tracked-symlink-plugin", path: "build.sh", reason: "dangling-target" } },
{ code: "readonly-mount-symlink-skipped", metadata: { mountIndex: 0, mountTarget: "/wordpress/wp-content/plugins/tracked-symlink-plugin", path: "host-secret-chain.php", reason: "source-escape" } },
{ code: "readonly-mount-symlink-skipped", metadata: { mountIndex: 0, mountTarget: "/wordpress/wp-content/plugins/tracked-symlink-plugin", path: "host-secret.php", reason: "source-escape" } },
{ code: "readonly-mount-symlink-skipped", metadata: { mountIndex: 0, mountTarget: "/wordpress/wp-content/plugins/tracked-symlink-plugin", path: "prefix-secret.php", reason: "source-escape" } },
], "skipped symlinks produce deterministic structured diagnostics")
const serializedDiagnostics = JSON.stringify(staging.diagnostics)
assert.equal(serializedDiagnostics.includes(root), false, "diagnostics do not expose absolute host paths")
assert.equal(serializedDiagnostics.includes("../../../.github/build.sh"), false, "diagnostics do not expose dangling link targets")
assert.equal(serializedDiagnostics.includes("../tracked-symlink-plugin-private/secret.php"), false, "diagnostics do not expose escaping link targets")
assert.deepEqual(staging.phaseResult.metadata, { mounts: 1, skipped: 5, diagnostics: staging.diagnostics }, "staging evidence includes the skip diagnostics")
await staging[Symbol.asyncDispose]()
assert.deepEqual(await readonlyStagingDirectories(), stagingDirectoriesBefore, "successful staging cleanup removes its temporary root")

const beforeReadonlyHash = sha256(await readFile(readonlySource))
const server = await startPlaygroundCliServer(spec, [
{ type: "file", source: readonlySource, target: "/readonly", mode: "readonly" },
{ type: "file", source: readwriteSource, target: "/readwrite", mode: "readwrite" },
{ type: "file", source: wpConfigSource, target: "/wordpress/wp-config.php", mode: "readonly" },
], { cliModule })
{ type: "directory", source: pluginSource, target: "/wordpress/wp-content/plugins/tracked-symlink-plugin", mode: "readonly" },
], { cliModule, onProgress: (event) => startupProgress.push(event) })

assert.equal(sha256(await readFile(readonlySource)), beforeReadonlyHash, "readonly source bytes must survive a sandbox overwrite")
assert.deepEqual(await readFile(readwriteSource), Buffer.from("sandbox overwrite"), "readwrite mounts must retain host-write behavior")
assert.notEqual(mountedReadonlyPath, readonlySource, "readonly mounts must use a private staged path")
const mountMaterialization = startupProgress.find((event) => event.phase === "preview:materializing-mounts")?.detail?.materialization
assert.deepEqual((mountMaterialization as { metadata?: Record<string, unknown> })?.metadata, {
mounts: 3,
skipped: 5,
diagnostics: staging.diagnostics.map((diagnostic) => ({
...diagnostic,
metadata: { ...diagnostic.metadata, mountIndex: 3 },
})),
}, "startup progress retains structured symlink skip evidence")

await server[Symbol.asyncDispose]()
await assert.rejects(access(mountedReadonlyPath), /ENOENT/, "readonly mount staging must be removed with the runtime")
Expand Down
Loading