Skip to content

Commit 3def924

Browse files
authored
Merge pull request #2021 from Automattic/fix/2015-readonly-mount-cycle
Fix readonly mount symlink cycles
2 parents e8de154 + 85139a3 commit 3def924

2 files changed

Lines changed: 86 additions & 36 deletions

File tree

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

Lines changed: 73 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -104,40 +104,11 @@ export async function stageReadonlyPlaygroundMounts(mounts: MountSpec[]): Promis
104104
const source = join(root, `${index}-${basename(mount.source) || "mount"}`)
105105
const sourceRoot = await realpath(mount.source)
106106
const diagnostics: MaterializationDiagnostic[] = []
107-
await cp(mount.source, source, {
108-
recursive: mount.type !== "file",
109-
dereference: true,
110-
async filter(entry) {
111-
if (!(await lstat(entry)).isSymbolicLink()) {
112-
return true
113-
}
114-
let reason: "dangling-target" | "source-escape"
115-
try {
116-
const target = await realpath(entry)
117-
const relativeTarget = relative(sourceRoot, target)
118-
if (relativeTarget !== ".." && !relativeTarget.startsWith(`..${sep}`) && !isAbsolute(relativeTarget)) {
119-
return true
120-
}
121-
reason = "source-escape"
122-
} catch {
123-
reason = "dangling-target"
124-
}
125-
const path = relative(mount.source, entry).replace(/\\/g, "/") || "."
126-
diagnostics.push({
127-
code: "readonly-mount-symlink-skipped",
128-
message: `Skipped ${reason} symlink in readonly mount: ${path}`,
129-
severity: "warning",
130-
phase: "playground-readonly-mount-staging",
131-
metadata: {
132-
mountIndex: index,
133-
mountTarget: mount.target,
134-
path,
135-
reason,
136-
},
137-
})
138-
return false
139-
},
140-
})
107+
if (mount.type === "file") {
108+
await cp(mount.source, source, { dereference: true })
109+
} else {
110+
await stageReadonlyDirectory(mount, index, sourceRoot, source, diagnostics)
111+
}
141112
diagnostics.sort((left, right) => String(left.metadata?.path) < String(right.metadata?.path) ? -1 : String(left.metadata?.path) > String(right.metadata?.path) ? 1 : 0)
142113
return { mount: { ...mount, source }, diagnostics }
143114
}))
@@ -166,6 +137,74 @@ export async function stageReadonlyPlaygroundMounts(mounts: MountSpec[]): Promis
166137
}
167138
}
168139

140+
async function stageReadonlyDirectory(mount: MountSpec, mountIndex: number, sourceRoot: string, destination: string, diagnostics: MaterializationDiagnostic[]): Promise<void> {
141+
const visit = async (directory: string, stagedDirectory: string, relativeDirectory: string, ancestors: ReadonlySet<string>): Promise<void> => {
142+
await mkdir(stagedDirectory, { recursive: true })
143+
const entries = await readdir(directory, { withFileTypes: true })
144+
entries.sort((left, right) => left.name.localeCompare(right.name))
145+
146+
for (const entry of entries) {
147+
const path = join(directory, entry.name)
148+
const stagedPath = join(stagedDirectory, entry.name)
149+
const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name
150+
const entryStat = await lstat(path)
151+
if (!entryStat.isSymbolicLink()) {
152+
if (entryStat.isDirectory()) {
153+
const target = await realpath(path)
154+
await visit(target, stagedPath, relativePath, new Set([...ancestors, target]))
155+
} else {
156+
await cp(path, stagedPath)
157+
}
158+
continue
159+
}
160+
161+
let target: string
162+
try {
163+
target = await realpath(path)
164+
} catch {
165+
addReadonlySymlinkDiagnostic(diagnostics, mount, mountIndex, relativePath, "dangling-target")
166+
continue
167+
}
168+
if (!pathIsWithinRoot(sourceRoot, target)) {
169+
addReadonlySymlinkDiagnostic(diagnostics, mount, mountIndex, relativePath, "source-escape")
170+
continue
171+
}
172+
const targetStat = await stat(target)
173+
if (!targetStat.isDirectory()) {
174+
await cp(target, stagedPath)
175+
continue
176+
}
177+
if (ancestors.has(target)) {
178+
addReadonlySymlinkDiagnostic(diagnostics, mount, mountIndex, relativePath, "directory-cycle")
179+
continue
180+
}
181+
await visit(target, stagedPath, relativePath, new Set([...ancestors, target]))
182+
}
183+
}
184+
185+
await visit(sourceRoot, destination, "", new Set([sourceRoot]))
186+
}
187+
188+
function pathIsWithinRoot(root: string, path: string): boolean {
189+
const relativePath = relative(root, path)
190+
return relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath)
191+
}
192+
193+
function addReadonlySymlinkDiagnostic(diagnostics: MaterializationDiagnostic[], mount: MountSpec, mountIndex: number, path: string, reason: "dangling-target" | "source-escape" | "directory-cycle"): void {
194+
diagnostics.push({
195+
code: "readonly-mount-symlink-skipped",
196+
message: `Skipped ${reason} symlink in readonly mount: ${path}`,
197+
severity: "warning",
198+
phase: "playground-readonly-mount-staging",
199+
metadata: {
200+
mountIndex,
201+
mountTarget: mount.target,
202+
path,
203+
reason,
204+
},
205+
})
206+
}
207+
169208
function readonlyMountStagingPhaseResult(mounts: number, diagnostics: MaterializationDiagnostic[]): MaterializationPhaseResult {
170209
return materializationPhaseResult({
171210
phase: "playground-readonly-mount-staging",

tests/playground-readonly-mounts.test.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ try {
7878
await symlink("../host-secret.php", join(pluginSource, "host-secret.php"))
7979
await symlink("host-secret.php", join(pluginSource, "host-secret-chain.php"))
8080
await symlink("../tracked-symlink-plugin-private/secret.php", join(pluginSource, "prefix-secret.php"))
81+
await mkdir(join(pluginSource, "package-a", "vendor"), { recursive: true })
82+
await mkdir(join(pluginSource, "package-b", "vendor"), { recursive: true })
83+
await writeFile(join(pluginSource, "package-a", "package.php"), "<?php return 'package-a';\n")
84+
await writeFile(join(pluginSource, "package-b", "package.php"), "<?php return 'package-b';\n")
85+
await symlink("../../package-b", join(pluginSource, "package-a", "vendor", "package-b"))
86+
await symlink("../../package-a", join(pluginSource, "package-b", "vendor", "package-a"))
8187
await execFileAsync("git", ["init", "--quiet"], { cwd: pluginSource })
8288
await execFileAsync("git", ["add", "."], { cwd: pluginSource })
8389
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()
@@ -92,6 +98,9 @@ try {
9298
assert.equal(await readFile(join(stagedPlugin, "includes", "runtime.php"), "utf8"), "<?php return 'runtime';\n", "nested runtime files remain available")
9399
assert.equal(await readFile(join(stagedPlugin, "runtime-link.php"), "utf8"), "<?php return 'runtime';\n", "a contained symlink is dereferenced into the staged mount")
94100
assert.equal(await readFile(join(stagedPlugin, "runtime-chain.php"), "utf8"), "<?php return 'runtime';\n", "a contained symlink chain is dereferenced into the staged mount")
101+
assert.equal(await readFile(join(stagedPlugin, "package-a", "vendor", "package-b", "package.php"), "utf8"), "<?php return 'package-b';\n", "a contained directory symlink is dereferenced into the staged mount")
102+
await assert.rejects(access(join(stagedPlugin, "package-a", "vendor", "package-b", "vendor", "package-a")), /ENOENT/, "a directory cycle stops at the repeated real directory")
103+
await assert.rejects(access(join(stagedPlugin, "package-b", "vendor", "package-a", "vendor", "package-b")), /ENOENT/, "both cycle directions remain bounded")
95104
await assert.rejects(access(join(stagedPlugin, "build.sh")), /ENOENT/, "a tracked dangling symlink is not materialized")
96105
await assert.rejects(access(join(stagedPlugin, "build-chain.sh")), /ENOENT/, "a chained dangling symlink is not materialized")
97106
await assert.rejects(access(join(stagedPlugin, "host-secret.php")), /ENOENT/, "a symlink escaping the source cannot expose a host file")
@@ -102,13 +111,15 @@ try {
102111
{ code: "readonly-mount-symlink-skipped", metadata: { mountIndex: 0, mountTarget: "/wordpress/wp-content/plugins/tracked-symlink-plugin", path: "build.sh", reason: "dangling-target" } },
103112
{ code: "readonly-mount-symlink-skipped", metadata: { mountIndex: 0, mountTarget: "/wordpress/wp-content/plugins/tracked-symlink-plugin", path: "host-secret-chain.php", reason: "source-escape" } },
104113
{ code: "readonly-mount-symlink-skipped", metadata: { mountIndex: 0, mountTarget: "/wordpress/wp-content/plugins/tracked-symlink-plugin", path: "host-secret.php", reason: "source-escape" } },
114+
{ code: "readonly-mount-symlink-skipped", metadata: { mountIndex: 0, mountTarget: "/wordpress/wp-content/plugins/tracked-symlink-plugin", path: "package-a/vendor/package-b/vendor/package-a", reason: "directory-cycle" } },
115+
{ code: "readonly-mount-symlink-skipped", metadata: { mountIndex: 0, mountTarget: "/wordpress/wp-content/plugins/tracked-symlink-plugin", path: "package-b/vendor/package-a/vendor/package-b", reason: "directory-cycle" } },
105116
{ code: "readonly-mount-symlink-skipped", metadata: { mountIndex: 0, mountTarget: "/wordpress/wp-content/plugins/tracked-symlink-plugin", path: "prefix-secret.php", reason: "source-escape" } },
106117
], "skipped symlinks produce deterministic structured diagnostics")
107118
const serializedDiagnostics = JSON.stringify(staging.diagnostics)
108119
assert.equal(serializedDiagnostics.includes(root), false, "diagnostics do not expose absolute host paths")
109120
assert.equal(serializedDiagnostics.includes("../../../.github/build.sh"), false, "diagnostics do not expose dangling link targets")
110121
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")
122+
assert.deepEqual(staging.phaseResult.metadata, { mounts: 1, skipped: 7, diagnostics: staging.diagnostics }, "staging evidence includes the skip diagnostics")
112123
await staging[Symbol.asyncDispose]()
113124
assert.deepEqual(await readonlyStagingDirectories(), stagingDirectoriesBefore, "successful staging cleanup removes its temporary root")
114125

@@ -126,7 +137,7 @@ try {
126137
const mountMaterialization = startupProgress.find((event) => event.phase === "preview:materializing-mounts")?.detail?.materialization
127138
assert.deepEqual((mountMaterialization as { metadata?: Record<string, unknown> })?.metadata, {
128139
mounts: 3,
129-
skipped: 5,
140+
skipped: 7,
130141
diagnostics: staging.diagnostics.map((diagnostic) => ({
131142
...diagnostic,
132143
metadata: { ...diagnostic.metadata, mountIndex: 3 },

0 commit comments

Comments
 (0)