Skip to content

Commit 532e3f7

Browse files
committed
Handle dangling readonly mount links
1 parent b3427cf commit 532e3f7

2 files changed

Lines changed: 28 additions & 4 deletions

File tree

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
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, rm, stat, writeFile } from "node:fs/promises"
33
import { tmpdir } from "node:os"
44
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"
55
import { materializationPhaseResult, namedFileTreeSkipPolicyNames, phpStringArrayLiteral, type MaterializationPhaseResult, type MountSpec } from "@automattic/wp-codebox-core"
@@ -83,7 +83,22 @@ export async function stageReadonlyPlaygroundMounts(mounts: MountSpec[]): Promis
8383
return mount
8484
}
8585
const source = join(root, `${index}-${basename(mount.source) || "mount"}`)
86-
await cp(mount.source, source, { recursive: mount.type !== "file", dereference: true })
86+
await stat(mount.source)
87+
await cp(mount.source, source, {
88+
recursive: mount.type !== "file",
89+
dereference: true,
90+
filter: async (candidate) => {
91+
const entry = await lstat(candidate)
92+
if (!entry.isSymbolicLink()) return true
93+
try {
94+
await stat(candidate)
95+
return true
96+
} catch (error) {
97+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false
98+
throw error
99+
}
100+
},
101+
})
87102
return { ...mount, source }
88103
}))
89104
const failedMount = stagedMountResults.find((result) => result.status === "rejected")

tests/playground-readonly-mounts.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,20 @@ try {
5353
await symlink("missing.php", join(danglingSource, "build.sh"))
5454
await Promise.all(Array.from({ length: 500 }, (_, index) => writeFile(join(largeSource, `File${index}.php`), `<?php return ${index};\n`)))
5555
const stagingDirectoriesBefore = await readonlyStagingDirectories()
56-
await assert.rejects(stageReadonlyPlaygroundMounts([
56+
await using danglingStaging = await stageReadonlyPlaygroundMounts([
5757
{ source: danglingSource, target: "/dangling", mode: "readonly" },
58+
])
59+
const stagedDanglingSource = danglingStaging.mounts[0]?.source
60+
assert.ok(stagedDanglingSource)
61+
await assert.rejects(access(join(stagedDanglingSource, "build.sh")), /ENOENT/, "unresolved links must be omitted from isolated readonly staging")
62+
const stagingDirectoriesWithDangling = await readonlyStagingDirectories()
63+
assert.equal(stagingDirectoriesWithDangling.length, stagingDirectoriesBefore.length + 1)
64+
65+
await assert.rejects(stageReadonlyPlaygroundMounts([
66+
{ source: join(root, "missing-source"), target: "/missing", mode: "readonly" },
5867
{ source: largeSource, target: "/large", mode: "readonly" },
5968
]), /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")
69+
assert.deepEqual(await readonlyStagingDirectories(), stagingDirectoriesWithDangling, "failed concurrent staging must remove its temporary root")
6170

6271
const beforeReadonlyHash = sha256(await readFile(readonlySource))
6372
const server = await startPlaygroundCliServer(spec, [

0 commit comments

Comments
 (0)