From 3a28ee5f904a1adbf86bfa37b0a0b4e37bc9e1e6 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 22 Jul 2026 19:51:39 +0000 Subject: [PATCH] fix: contain readonly mount symlinks --- .../src/mount-materialization.ts | 65 ++++++++++++-- .../src/playground-cli-runner.ts | 3 + tests/playground-readonly-mounts.test.ts | 89 ++++++++++++++++--- 3 files changed, 137 insertions(+), 20 deletions(-) diff --git a/packages/runtime-playground/src/mount-materialization.ts b/packages/runtime-playground/src/mount-materialization.ts index 5c87c752d..7d12fd4aa 100644 --- a/packages/runtime-playground/src/mount-materialization.ts +++ b/packages/runtime-playground/src/mount-materialization.ts @@ -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" @@ -36,6 +36,8 @@ export type StagedInputMaterializationResult = MountMaterializationResult export interface ReadonlyMountStaging { mounts: MountSpec[] + diagnostics: MaterializationDiagnostic[] + phaseResult: MaterializationPhaseResult [Symbol.asyncDispose](): Promise } @@ -78,6 +80,8 @@ export async function stageReadonlyPlaygroundMounts(mounts: MountSpec[]): Promis if (readonlyMounts.length === 0) { return { mounts, + diagnostics: [], + phaseResult: readonlyMountStagingPhaseResult(0, []), async [Symbol.asyncDispose]() {}, } } @@ -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") { @@ -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 }) }, @@ -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 { return { ...input, diff --git a/packages/runtime-playground/src/playground-cli-runner.ts b/packages/runtime-playground/src/playground-cli-runner.ts index 2f3266e30..735c7e55a 100644 --- a/packages/runtime-playground/src/playground-cli-runner.ts +++ b/packages/runtime-playground/src/playground-cli-runner.ts @@ -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") diff --git a/tests/playground-readonly-mounts.test.ts b/tests/playground-readonly-mounts.test.ts index 8d6243d1d..30466197d 100644 --- a/tests/playground-readonly-mounts.test.ts +++ b/tests/playground-readonly-mounts.test.ts @@ -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") @@ -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"), " writeFile(join(largeSource, `File${index}.php`), ` 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"), " ({ 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 })?.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")