Skip to content

Commit 4e4a839

Browse files
committed
fix: harden cache retention race checks
1 parent 5dbba3c commit 4e4a839

5 files changed

Lines changed: 97 additions & 50 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,7 @@ kimaki tunnel -- sh -c 'npm run wp-codebox -- run \
519519

520520
When a caller exposes the local Playground through a tunnel or proxy, pass `--preview-public-url <url>` to report that public URL in `artifacts.preview.url`, `metadata.json`, and `files/review.json`. WP Codebox also passes the same URL to Playground as `site-url` and defines `WP_HOME` / `WP_SITEURL` in the sandbox config, so WordPress-generated links and canonical redirects align with the public preview URL. The local proxy URL remains recorded as `preview.localUrl`. If the fixed port is already occupied, WP Codebox fails clearly with `EADDRINUSE` and the requested `--preview-port` value.
521521

522-
Playground's content-addressed `custom-*.zip` WordPress archives are retained separately from stable release archives and cleaned after runtime use. The custom cache defaults to a seven-day age limit, 1 GiB, and 20 archives. Configure those bounds with `WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_MAX_AGE_MS`, `WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_MAX_BYTES`, and `WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_MAX_COUNT`. Active locks and runtimes use token-owned renewable leases; configure their duration with `WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_LEASE_MS` and crashed legacy lock recovery with `WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_STALE_LOCK_MS`. All maintenance uses `WP_CODEBOX_PLAYGROUND_WORDPRESS_CACHE_DIR` when set. The Playground runtime package exports `maintainPlaygroundCustomArchiveCache()` for deterministic `diagnose`, `dry-run`, and `apply` evidence, including filesystem protections, orphan sidecars, logical bytes removed, estimated allocated bytes removed, and the verified filesystem free-space delta.
522+
Playground's content-addressed `custom-*.zip` WordPress archives are retained separately from stable release archives and cleaned after runtime use. The custom cache defaults to a seven-day age limit, 1 GiB, and 20 archives. Configure those bounds with `WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_MAX_AGE_MS`, `WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_MAX_BYTES`, and `WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_MAX_COUNT`. Active locks and runtimes use token-owned renewable leases; configure their duration with `WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_LEASE_MS` and crashed legacy lock recovery with `WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_STALE_LOCK_MS`. All maintenance uses `WP_CODEBOX_PLAYGROUND_WORDPRESS_CACHE_DIR` when set. The Playground runtime package exports `maintainPlaygroundCustomArchiveCache()` for deterministic `diagnose`, `dry-run`, and `apply` evidence, including filesystem protections, orphan sidecars, logical bytes removed, estimated allocated inode bytes removed, and the concurrently observed filesystem free-space delta.
523523

524524
Remote-host previews can opt into `--preview-bind <host>` with `--preview-port`. The flag changes the WP Codebox preview proxy bind address only; the upstream runtime server remains loopback-bound until backend bind-host control is available. The default stays `127.0.0.1`. Use `--preview-bind 0.0.0.0` only behind trusted firewall, tunnel, or reverse-proxy controls because the sandbox preview is reachable for the hold duration.
525525

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -631,7 +631,13 @@ function errorDetail(error: unknown): Record<string, unknown> {
631631

632632
async function automaticPlaygroundCustomArchiveCacheMaintenance(warnOnFailure = false): Promise<{ result?: PlaygroundCustomArchiveCacheMaintenance; diagnostics: PlaygroundCustomArchiveCacheDiagnostic[] }> {
633633
try {
634-
return { result: await maintainPlaygroundCustomArchiveCache(), diagnostics: [] }
634+
const result = await maintainPlaygroundCustomArchiveCache()
635+
if (warnOnFailure) {
636+
for (const diagnostic of result.diagnostics) {
637+
console.warn(`[wp-codebox] ${diagnostic.code}: ${diagnostic.message}`)
638+
}
639+
}
640+
return { result, diagnostics: result.diagnostics }
635641
} catch (error) {
636642
const diagnostic = {
637643
code: "playground-custom-archive-cache-maintenance-failed",

packages/runtime-playground/src/playground-wordpress-archive-cache.ts

Lines changed: 55 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export interface PlaygroundCustomArchiveCachePolicy {
7070
export interface PlaygroundCustomArchiveCacheMaintenanceOptions extends Partial<PlaygroundCustomArchiveCachePolicy> {
7171
mode?: "diagnose" | "dry-run" | "apply"
7272
now?: number
73+
candidateInterlock?: (archivePath: string) => void | Promise<void>
7374
}
7475

7576
export interface PlaygroundCustomArchiveCacheDiagnostic {
@@ -105,7 +106,7 @@ export interface PlaygroundCustomArchiveCacheMaintenance {
105106
removedCount: number
106107
removedBytes: number
107108
estimatedAllocatedBytesRemoved: number
108-
verifiedReclaimedBytes: number
109+
observedFilesystemFreeBytesDelta: number
109110
retainedCount: number
110111
retainedBytes: number
111112
diagnostics: PlaygroundCustomArchiveCacheDiagnostic[]
@@ -115,6 +116,8 @@ interface CustomArchiveEntry {
115116
path: string
116117
size: number
117118
mtimeMs: number
119+
dev: number
120+
ino: number
118121
lockPath: string
119122
activeLock: boolean
120123
activeReferences: number
@@ -211,6 +214,8 @@ export async function maintainPlaygroundCustomArchiveCache(cacheDirectory = play
211214
path: archivePath,
212215
size: regular ? archiveStat.size : 0,
213216
mtimeMs: archiveStat.mtimeMs,
217+
dev: archiveStat.dev,
218+
ino: archiveStat.ino,
214219
lockPath,
215220
activeLock,
216221
activeReferences,
@@ -246,6 +251,7 @@ export async function maintainPlaygroundCustomArchiveCache(cacheDirectory = play
246251
const filesystemBefore = mode === "apply" ? await filesystemAvailableBytes(cacheDirectory) : undefined
247252
if (mode === "apply") {
248253
for (const candidate of candidates.sort(oldestFirst)) {
254+
await options.candidateInterlock?.(candidate.path)
249255
const lease = await tryAcquireCacheLock(candidate.lockPath, policy.leaseMs)
250256
if (!lease) {
251257
continue
@@ -267,6 +273,10 @@ export async function maintainPlaygroundCustomArchiveCache(cacheDirectory = play
267273
diagnostics.push({ code: "custom-archive-changed-before-removal", message: "Custom archive changed to an unsafe filesystem entry before removal and was protected.", severity: "warning", path: candidate.path })
268274
continue
269275
}
276+
if (current.dev !== candidate.dev || current.ino !== candidate.ino || current.mtimeMs !== candidate.mtimeMs || current.size !== candidate.size) {
277+
diagnostics.push({ code: "custom-archive-generation-changed", message: "Custom archive was replaced or modified after candidate selection and was protected from removal.", severity: "warning", path: candidate.path })
278+
continue
279+
}
270280
const currentAllocatedBytes = allocatedBytes(current.blocks)
271281
await unlink(candidate.path)
272282
try {
@@ -289,7 +299,7 @@ export async function maintainPlaygroundCustomArchiveCache(cacheDirectory = play
289299

290300
const sidecars = await maintainOrphanSidecars(cacheDirectory, directoryEntries.map((entry) => entry.name), now, policy, mode, diagnostics)
291301
const filesystemAfter = mode === "apply" ? await filesystemAvailableBytes(cacheDirectory) : undefined
292-
const verifiedReclaimedBytes = filesystemBefore === undefined || filesystemAfter === undefined ? 0 : Math.max(0, filesystemAfter - filesystemBefore)
302+
const observedFilesystemFreeBytesDelta = filesystemBefore === undefined || filesystemAfter === undefined ? 0 : filesystemAfter - filesystemBefore
293303
const totalBytes = entries.reduce((total, entry) => total + entry.size, 0)
294304
return {
295305
schema: "wp-codebox/playground-custom-archive-cache-maintenance/v1",
@@ -312,7 +322,7 @@ export async function maintainPlaygroundCustomArchiveCache(cacheDirectory = play
312322
removedCount,
313323
removedBytes,
314324
estimatedAllocatedBytesRemoved,
315-
verifiedReclaimedBytes,
325+
observedFilesystemFreeBytesDelta,
316326
retainedCount: entries.length - removedCount,
317327
retainedBytes: totalBytes - removedBytes,
318328
diagnostics,
@@ -440,7 +450,7 @@ async function cacheLockIsActive(lockPath: string, now: number, policy: Pick<Pla
440450
}
441451
continue
442452
}
443-
const state = await leaseState(path, path, now, policy.staleLockMs)
453+
const state = await inspectLeaseFile(path, now, policy.staleLockMs, removeStale)
444454
if (state.active) {
445455
active = true
446456
} else if (removeStale) {
@@ -482,13 +492,9 @@ async function activeArchiveReferenceCount(archivePath: string, now: number, pol
482492
try {
483493
for (const name of names.sort()) {
484494
const path = join(anchoredDirectory, name)
485-
const state = await leaseState(path, path, now, policy.staleLockMs)
495+
const state = await inspectLeaseFile(path, now, policy.staleLockMs, removeStale, diagnostics)
486496
if (state.active) {
487497
active += 1
488-
} else if (removeStale) {
489-
await unlink(path).catch((error) => {
490-
if (!errorHasCode(error, "ENOENT")) throw error
491-
})
492498
}
493499
}
494500
} finally {
@@ -497,20 +503,47 @@ async function activeArchiveReferenceCount(archivePath: string, now: number, pol
497503
return active
498504
}
499505

500-
async function leaseState(ownerPath: string, sidecarPath: string, now: number, legacyStaleMs: number): Promise<LeaseState> {
501-
const owner = await readOwnerRecord(ownerPath)
502-
if (owner) {
503-
const expiresAt = Date.parse(owner.expiresAt)
504-
return { active: Number.isFinite(expiresAt) && expiresAt > now, token: owner.token }
506+
async function inspectLeaseFile(path: string, now: number, legacyStaleMs: number, removeUnsafe: boolean, diagnostics: PlaygroundCustomArchiveCacheDiagnostic[] = []): Promise<LeaseState> {
507+
let pathStat
508+
try {
509+
pathStat = await lstat(path)
510+
} catch (error) {
511+
if (errorHasCode(error, "ENOENT")) return { active: false }
512+
throw error
505513
}
514+
if (!pathStat.isFile() || pathStat.isSymbolicLink() || pathStat.nlink !== 1) {
515+
diagnostics.push({ code: "custom-archive-ref-entry-unsafe", message: "Archive lease entry is not a singly-linked regular file and was not followed.", severity: "warning", path })
516+
if (removeUnsafe && !pathStat.isDirectory()) await unlink(path)
517+
return { active: !removeUnsafe || pathStat.isDirectory() }
518+
}
519+
520+
let handle: FileHandle | undefined
506521
try {
507-
const sidecarStat = await lstat(sidecarPath)
508-
return { active: now - sidecarStat.mtimeMs <= legacyStaleMs }
522+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW)
523+
const openedStat = await handle.stat()
524+
if (openedStat.dev !== pathStat.dev || openedStat.ino !== pathStat.ino) {
525+
diagnostics.push({ code: "custom-archive-ref-entry-changed", message: "Archive lease entry changed while opening and was conservatively protected.", severity: "warning", path })
526+
return { active: true }
527+
}
528+
const owner = await readOwnerFromHandle(handle)
529+
if (owner) {
530+
const expiresAt = Date.parse(owner.expiresAt)
531+
if (Number.isFinite(expiresAt) && expiresAt > now) return { active: true, token: owner.token }
532+
if (removeUnsafe) await unlink(path)
533+
return { active: false, token: owner.token }
534+
}
535+
const active = now - pathStat.mtimeMs <= legacyStaleMs
536+
if (!active && removeUnsafe) await unlink(path)
537+
return { active }
509538
} catch (error) {
510-
if (errorHasCode(error, "ENOENT")) {
511-
return { active: false }
539+
if (errorHasCode(error, "ENOENT")) return { active: false }
540+
if (errorHasCode(error, "ELOOP")) {
541+
diagnostics.push({ code: "custom-archive-ref-entry-unsafe", message: "Archive lease entry became a symlink and was conservatively protected.", severity: "warning", path })
542+
return { active: true }
512543
}
513544
throw error
545+
} finally {
546+
await handle?.close()
514547
}
515548
}
516549

@@ -534,7 +567,7 @@ async function maintainOrphanSidecars(cacheDirectory: string, names: string[], n
534567
}
535568
continue
536569
}
537-
const active = await activeReferenceCountInDirectory(path, now, policy, mode === "apply")
570+
const active = await activeReferenceCountInDirectory(path, now, policy, mode === "apply", diagnostics)
538571
if (active > 0) {
539572
activeCount += 1
540573
} else if (mode === "apply" && !await pathExists(path)) {
@@ -546,8 +579,8 @@ async function maintainOrphanSidecars(cacheDirectory: string, names: string[], n
546579
return { orphanCount: paths.length, activeCount, removedCount, paths }
547580
}
548581

549-
async function activeReferenceCountInDirectory(path: string, now: number, policy: Pick<PlaygroundCustomArchiveCachePolicy, "staleLockMs">, removeStale: boolean): Promise<number> {
550-
return activeArchiveReferenceCount(path.slice(0, -".refs".length), now, policy, removeStale)
582+
async function activeReferenceCountInDirectory(path: string, now: number, policy: Pick<PlaygroundCustomArchiveCachePolicy, "staleLockMs">, removeStale: boolean, diagnostics: PlaygroundCustomArchiveCacheDiagnostic[] = []): Promise<number> {
583+
return activeArchiveReferenceCount(path.slice(0, -".refs".length), now, policy, removeStale, diagnostics)
551584
}
552585

553586
async function removeOrphanReferencesDirectory(path: string, now: number, policy: Pick<PlaygroundCustomArchiveCachePolicy, "staleLockMs">, diagnostics: PlaygroundCustomArchiveCacheDiagnostic[]): Promise<boolean> {
@@ -563,7 +596,7 @@ async function removeOrphanReferencesDirectory(path: string, now: number, policy
563596
await unlink(path)
564597
return true
565598
}
566-
if (await activeReferenceCountInDirectory(path, now, policy, true) > 0) {
599+
if (await activeReferenceCountInDirectory(path, now, policy, true, diagnostics) > 0) {
567600
return false
568601
}
569602
try {
@@ -578,28 +611,6 @@ async function removeOrphanReferencesDirectory(path: string, now: number, policy
578611
}
579612
}
580613

581-
async function readOwnerRecord(path: string): Promise<CacheOwnerRecord | undefined> {
582-
try {
583-
const value = JSON.parse(await readFile(path, "utf8")) as Partial<CacheOwnerRecord>
584-
return value.schema === "wp-codebox/playground-cache-lease/v1"
585-
&& typeof value.token === "string"
586-
&& typeof value.hostname === "string"
587-
&& typeof value.bootId === "string"
588-
&& Number.isInteger(value.pid)
589-
&& typeof value.processStart === "string"
590-
&& typeof value.createdAt === "string"
591-
&& typeof value.heartbeatAt === "string"
592-
&& typeof value.expiresAt === "string"
593-
? value as CacheOwnerRecord
594-
: undefined
595-
} catch (error) {
596-
if (errorHasCode(error, "ENOENT") || errorHasCode(error, "EISDIR") || error instanceof SyntaxError) {
597-
return undefined
598-
}
599-
throw error
600-
}
601-
}
602-
603614
async function openSafeLeaseDirectory(path: string, create: boolean): Promise<FileHandle> {
604615
if (create) {
605616
await mkdir(path).catch((error) => {

tests/playground-custom-archive-cache.integration.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import assert from "node:assert/strict"
22
import { existsSync } from "node:fs"
33
import { createServer } from "node:http"
4-
import { mkdtemp, readdir, rm, utimes, writeFile } from "node:fs/promises"
4+
import { mkdtemp, readdir, rm, symlink, utimes, writeFile } from "node:fs/promises"
55
import { tmpdir } from "node:os"
66
import { join } from "node:path"
77

@@ -61,11 +61,13 @@ try {
6161
assert.equal((cacheValidation?.retention as Record<string, unknown> | undefined)?.schema, "wp-codebox/playground-custom-archive-cache-maintenance/v1")
6262
assert.equal((cacheValidation?.retention as Record<string, unknown>).removedCount, 1)
6363
assert.equal(((cacheValidation?.retention as Record<string, unknown>).activeProtection as Record<string, unknown>).referenceCount, 0)
64+
console.warn = (message?: unknown) => { warnings.push(String(message)) }
65+
await symlink(root, join(root, "custom-teardown-warning.zip.refs"))
6466
await server[Symbol.asyncDispose]()
67+
assert.ok(warnings.some((warning) => warning.includes("custom-archive-refs-not-directory")), "successful teardown maintenance diagnostics must emit bounded warning evidence")
6568
assert.deepEqual((await readdir(root)).filter((name) => /^custom-.*\.zip(?:\.(?:refs|lock))?$/.test(name)), [], "teardown must release and retain no zero-bound custom cache entries or sidecars")
6669

6770
process.env.WP_CODEBOX_PLAYGROUND_CUSTOM_ARCHIVE_MAX_COUNT = "invalid"
68-
console.warn = (message?: unknown) => { warnings.push(String(message)) }
6971
const invalidProgress: Array<Record<string, unknown>> = []
7072
const invalidServer = await startPlaygroundCliServer(spec, [], { cliModule, onProgress(event) { invalidProgress.push(event as unknown as Record<string, unknown>) } })
7173
await new Promise((resolve) => setTimeout(resolve, 0))

tests/playground-custom-archive-cache.test.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ try {
5252
assert.equal(applied.removedCount, 2)
5353
assert.equal(applied.removedBytes, 24)
5454
assert.equal(applied.estimatedAllocatedBytesRemoved, staleAllocated)
55-
assert.ok(applied.verifiedReclaimedBytes >= 0)
55+
assert.ok(Number.isFinite(applied.observedFilesystemFreeBytesDelta))
5656
assert.deepEqual(await customArchiveNames(), ["custom-locked.zip", "custom-referenced.zip"])
5757
assert.ok((await readdir(root)).includes("6.9.1.zip"), "stable release archive must remain intact")
5858

@@ -104,6 +104,18 @@ try {
104104
assert.equal(await readFile(attackMarker, "utf8"), "outside cache")
105105
await rm(attackTarget, { recursive: true, force: true })
106106

107+
const childAttackTarget = await mkdtemp(join(tmpdir(), "wp-codebox-playground-ref-child-attack-"))
108+
const externalLease = join(childAttackTarget, "external-lease.json")
109+
await writeLease(externalLease, { token: "external-symlink-lease", expiresAt: now + 60_000, ownerHostname: "outside-cache" })
110+
const childAttackArchive = await archive("custom-ref-child-attack.zip", 42, now - 100_000)
111+
await mkdir(`${childAttackArchive}.refs`)
112+
await symlink(externalLease, join(`${childAttackArchive}.refs`, "malicious.json"))
113+
const childAttackCleanup = await maintainPlaygroundCustomArchiveCache(root, { mode: "apply", now, maxAgeMs: 1 })
114+
assert.ok(childAttackCleanup.diagnostics.some((item) => item.code === "custom-archive-ref-entry-unsafe"))
115+
assert.ok(!await exists(childAttackArchive), "unsafe child lease entries must not protect stale archives")
116+
assert.ok(await exists(externalLease), "lease child inspection must never follow or remove an external symlink target")
117+
await rm(childAttackTarget, { recursive: true, force: true })
118+
107119
const generationArchive = await archive("custom-generation-race.zip", 43, now - 100_000)
108120
const oldGeneration = await acquirePlaygroundArchiveReference(generationArchive, { leaseMs: 100, heartbeat: false })
109121
await new Promise((resolve) => setTimeout(resolve, 125))
@@ -117,6 +129,22 @@ try {
117129
assert.ok(await exists(replacementGeneration.path), "old generation release must not remove replacement ownership")
118130
await replacementGeneration.release()
119131

132+
const replacedCandidate = await archive("custom-replaced-candidate.zip", 47, now - 100_000)
133+
let replaced = false
134+
const replacementResult = await maintainPlaygroundCustomArchiveCache(root, {
135+
mode: "apply",
136+
now,
137+
maxAgeMs: 1,
138+
async candidateInterlock(path) {
139+
if (path !== replacedCandidate || replaced) return
140+
replaced = true
141+
await rm(path)
142+
await writeFile(path, "fresh replacement generation")
143+
},
144+
})
145+
assert.equal(await readFile(replacedCandidate, "utf8"), "fresh replacement generation")
146+
assert.ok(replacementResult.diagnostics.some((item) => item.code === "custom-archive-generation-changed"), "replacement generation must be revalidated after lock acquisition")
147+
120148
const allocationRoot = await mkdtemp(join(tmpdir(), "wp-codebox-playground-allocation-"))
121149
const openArchive = join(allocationRoot, "custom-open-file.zip")
122150
await writeFile(openArchive, Buffer.alloc(1024 * 1024, "a"))
@@ -125,7 +153,7 @@ try {
125153
const openRemoval = await maintainPlaygroundCustomArchiveCache(allocationRoot, { mode: "apply", maxAgeMs: 1 })
126154
assert.equal(openRemoval.removedBytes, 1024 * 1024)
127155
assert.ok(openRemoval.estimatedAllocatedBytesRemoved >= 1024 * 1024)
128-
assert.equal(openRemoval.verifiedReclaimedBytes, 0, "open unlinked allocation is not verified as reclaimed until the final handle closes")
156+
assert.equal(openRemoval.observedFilesystemFreeBytesDelta, 0, "open unlinked allocation should not increase observed free space until the final handle closes")
129157
await openHandle.close()
130158
await rm(allocationRoot, { recursive: true, force: true })
131159

0 commit comments

Comments
 (0)