Skip to content

Commit 40b6a33

Browse files
authored
Merge pull request #1950 from Automattic/fix/1949-composer-static-autoload
fix: verify staged mount file writes
2 parents 32a7e8c + 50d2d22 commit 40b6a33

5 files changed

Lines changed: 132 additions & 3 deletions

File tree

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

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ interface HostMountFileMaterializationResponse {
6060
skipped?: number
6161
}
6262

63+
interface HostMountFileVerificationResponse {
64+
schema?: string
65+
repaired?: number
66+
skipped?: number
67+
}
68+
6369
const HOST_MOUNT_FILE_BATCH_SIZE = 100
6470
const HOST_MOUNT_DIRECTORY_BATCH_SIZE = 500
6571

@@ -200,6 +206,7 @@ async function materializeHostMountToVfs(server: PlaygroundCliServer, mount: Mou
200206
let skipped = 0
201207
const directoryBatch: string[] = []
202208
const fileBatch: HostMountFilePayload[] = []
209+
const verificationBatch: HostMountFilePayload[] = []
203210

204211
const flushDirectories = async () => {
205212
if (directoryBatch.length === 0) {
@@ -224,6 +231,13 @@ async function materializeHostMountToVfs(server: PlaygroundCliServer, mount: Mou
224231
created += result.created
225232
skipped += result.skipped
226233
}
234+
const flushVerificationBatch = async () => {
235+
if (verificationBatch.length === 0) {
236+
return
237+
}
238+
const result = await verifyHostMountFilesWithPhp(server, verificationBatch.splice(0, verificationBatch.length))
239+
skipped += result.skipped
240+
}
227241
const writeFilePayload = async (payload: HostMountFilePayload) => {
228242
if (!server.playground.writeFile) {
229243
fileBatch.push(payload)
@@ -249,6 +263,10 @@ async function materializeHostMountToVfs(server: PlaygroundCliServer, mount: Mou
249263
try {
250264
await server.playground.writeFile(target, text)
251265
materialized++
266+
verificationBatch.push(payload)
267+
if (verificationBatch.length >= HOST_MOUNT_FILE_BATCH_SIZE) {
268+
await flushVerificationBatch()
269+
}
252270
} catch {
253271
const fallback = await materializeHostMountFilesWithPhp(server, [payload], [])
254272
materialized += fallback.materialized
@@ -271,6 +289,7 @@ async function materializeHostMountToVfs(server: PlaygroundCliServer, mount: Mou
271289
}
272290
await flushDirectories()
273291
await flushFileBatch()
292+
await flushVerificationBatch()
274293
return { materialized, created, skipped }
275294
}
276295

@@ -366,6 +385,19 @@ async function materializeHostMountFilesWithPhp(server: PlaygroundCliServer, fil
366385
}
367386
}
368387

388+
async function verifyHostMountFilesWithPhp(server: PlaygroundCliServer, files: HostMountFilePayload[]): Promise<{ repaired: number; skipped: number }> {
389+
const response = await server.playground.run({ code: hostMountVerifyPhp(files) })
390+
assertPlaygroundResponseOk("playground-staged-input-verify", response)
391+
const parsed = parseMaterializationJson<HostMountFileVerificationResponse>(response.text, "wp-codebox/host-mount-verification/v1", "playground-staged-input-verify")
392+
if ((parsed.skipped ?? 0) > 0) {
393+
throw new Error(`playground-staged-input-verify could not preserve ${parsed.skipped} staged input file(s)`)
394+
}
395+
return {
396+
repaired: parsed.repaired ?? 0,
397+
skipped: parsed.skipped ?? 0,
398+
}
399+
}
400+
369401
function parseMaterializationJson<T extends { schema?: string }>(text: string, schema: string, command: string): T {
370402
let parsed: unknown
371403
try {
@@ -493,6 +525,7 @@ foreach (($payload['directories'] ?? array()) as $directory) {
493525
}
494526
$skipped++;
495527
}
528+
496529
foreach (($payload['files'] ?? array()) as $file) {
497530
$target = (string) ($file['target'] ?? '');
498531
$contents = (string) ($file['contentsBase64'] ?? '');
@@ -516,6 +549,39 @@ echo json_encode(array('schema' => 'wp-codebox/host-mount-materialization/v1', '
516549
`
517550
}
518551

552+
function hostMountVerifyPhp(files: HostMountFilePayload[]): string {
553+
const payload = JSON.stringify(JSON.stringify({ files }))
554+
return `<?php
555+
$payload = json_decode(${payload}, true);
556+
$repaired = 0;
557+
$skipped = 0;
558+
foreach (($payload['files'] ?? array()) as $file) {
559+
$target = (string) ($file['target'] ?? '');
560+
$contents = base64_decode((string) ($file['contentsBase64'] ?? ''), true);
561+
if ('' === $target || str_contains($target, "\0") || false === $contents) {
562+
$skipped++;
563+
continue;
564+
}
565+
$target_hash = is_file($target) ? hash_file('sha256', $target) : false;
566+
if (is_string($target_hash) && hash_equals(hash('sha256', $contents), $target_hash)) {
567+
continue;
568+
}
569+
$directory = dirname($target);
570+
if ((!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) || false === file_put_contents($target, $contents)) {
571+
$skipped++;
572+
continue;
573+
}
574+
$repaired_hash = hash_file('sha256', $target);
575+
if (!is_string($repaired_hash) || !hash_equals(hash('sha256', $contents), $repaired_hash)) {
576+
$skipped++;
577+
continue;
578+
}
579+
$repaired++;
580+
}
581+
echo json_encode(array('schema' => 'wp-codebox/host-mount-verification/v1', 'repaired' => $repaired, 'skipped' => $skipped), JSON_UNESCAPED_SLASHES);
582+
`
583+
}
584+
519585
function hostMountMkdirPhp(directories: string[]): string {
520586
const payload = JSON.stringify(JSON.stringify({ directories }))
521587
return `<?php

tests/fixtures/phpunit-playground-harness/composer.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
"yoast/phpunit-polyfills": "3.1.2"
1010
},
1111
"config": {
12+
"classmap-authoritative": true,
13+
"optimize-autoloader": true,
1214
"platform": {
1315
"php": "8.3.0"
1416
},

tests/mount-materialization.test.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ try {
1616

1717
const result = await materializePlaygroundStagedInputs({
1818
playground: {
19-
async run() { return { text: JSON.stringify({ schema: "wp-codebox/host-mount-directory-materialization/v1", created: 1, skipped: 0 }) } },
19+
async run({ code }: { code: string }) {
20+
return code.includes("wp-codebox/host-mount-verification/v1")
21+
? { text: JSON.stringify({ schema: "wp-codebox/host-mount-verification/v1", repaired: 0, skipped: 0 }) }
22+
: { text: JSON.stringify({ schema: "wp-codebox/host-mount-directory-materialization/v1", created: 1, skipped: 0 }) }
23+
},
2024
async writeFile(target: string, contents: string) { writes[target] = contents },
2125
},
2226
} as never, [{
@@ -45,6 +49,9 @@ try {
4549
playground: {
4650
async run({ code }: { code: string }) {
4751
const payload = materializationPayload(code)
52+
if (code.includes("wp-codebox/host-mount-verification/v1")) {
53+
return { text: JSON.stringify({ schema: "wp-codebox/host-mount-verification/v1", repaired: 0, skipped: 0 }) }
54+
}
4855
for (const directory of payload.directories ?? []) {
4956
readableDirectories.add(directory)
5057
}
@@ -108,6 +115,50 @@ try {
108115
await rm(fallbackSource, { recursive: true, force: true })
109116
}
110117

118+
const silentlyDroppedSource = await mkdtemp(join(tmpdir(), "wp-codebox-silently-dropped-materialization-"))
119+
const verifiedFiles: Record<string, string> = {}
120+
121+
try {
122+
await mkdir(join(silentlyDroppedSource, "composer"), { recursive: true })
123+
await writeFile(join(silentlyDroppedSource, "composer", "autoload_real.php"), "<?php require __DIR__ . '/autoload_static.php';")
124+
await writeFile(join(silentlyDroppedSource, "composer", "autoload_static.php"), "<?php class ComposerStaticInitFixture {}")
125+
126+
const result = await materializePlaygroundStagedInputs({
127+
playground: {
128+
async run({ code }: { code: string }) {
129+
const payload = materializationPayload(code)
130+
if (code.includes("wp-codebox/host-mount-verification/v1")) {
131+
let repaired = 0
132+
for (const file of payload.files ?? []) {
133+
const contents = Buffer.from(file.contentsBase64, "base64").toString("utf8")
134+
if (verifiedFiles[file.target] !== contents) {
135+
verifiedFiles[file.target] = contents
136+
repaired++
137+
}
138+
}
139+
return { text: JSON.stringify({ schema: "wp-codebox/host-mount-verification/v1", repaired, skipped: 0 }) }
140+
}
141+
return { text: JSON.stringify({ schema: "wp-codebox/host-mount-directory-materialization/v1", created: payload.directories?.length ?? 0, skipped: 0 }) }
142+
},
143+
async writeFile(target: string, contents: string) {
144+
if (!target.endsWith("autoload_static.php")) {
145+
verifiedFiles[target] = contents
146+
}
147+
},
148+
},
149+
} as never, [{
150+
type: "directory",
151+
source: silentlyDroppedSource,
152+
target: "/wp-codebox-vendor",
153+
mode: "readonly",
154+
}])
155+
156+
assert.equal(result.materialized, 2)
157+
assert.equal(verifiedFiles["/wp-codebox-vendor/composer/autoload_static.php"], "<?php class ComposerStaticInitFixture {}", "verification repairs a direct writer that silently omits a generated companion file")
158+
} finally {
159+
await rm(silentlyDroppedSource, { recursive: true, force: true })
160+
}
161+
111162
const unreadableTargetSource = await mkdtemp(join(tmpdir(), "wp-codebox-unreadable-target-"))
112163

113164
try {

tests/playground-phpunit-readonly-cache.integration.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ const sentinel = Buffer.from([0, 255, 1, 2, 3, 127, 128])
2020
try {
2121
await cp("tests/fixtures/phpunit-playground-harness", harness, { recursive: true })
2222
await execFileAsync("composer", ["install", "--no-interaction", "--prefer-dist"], { cwd: harness, timeout: 300_000, maxBuffer: 2 * 1024 * 1024 })
23+
const autoloadReal = await readFile(join(harness, "vendor", "composer", "autoload_real.php"), "utf8")
24+
const staticClass = autoloadReal.match(/ComposerStaticInit[\da-f]+/i)?.[0]
25+
assert.ok(staticClass, "fixture uses Composer's optimized static autoloader")
26+
assert.match(await readFile(join(harness, "vendor", "composer", "autoload_static.php"), "utf8"), new RegExp(`class ${staticClass}`), "generated Composer autoload files define the same static initializer")
2327
await writeFixture()
2428
const sourceDigest = await digestTree(plugin)
2529

@@ -53,6 +57,7 @@ try {
5357
await assert.rejects(readFile(join(plugin, ".phpunit.result.cache")), /ENOENT/, "PHPUnit must not create a host-source result cache")
5458
const runtime = JSON.parse(await readFile(join(artifactsPath, "latest-runtime.json"), "utf8")) as { paths?: { runtimeDirectory?: string } }
5559
const diagnostic = await readFile(join(artifactsPath, runtime.paths?.runtimeDirectory ?? "", "files/phpunit/.pg-test-result.txt"), "utf8")
60+
assert.match(diagnostic, /^DISCOVERY: .* found=1$/m, "actual PHPUnit runner must discover the fixture test file")
5661
assert.match(diagnostic, /^STAGE_BEGIN:run_tests/m, "actual PHPUnit runner must reach its test stage")
5762
} finally {
5863
await rm(root, { recursive: true, force: true })

tests/staged-input-materialization.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ await withTempDir("wp-codebox-staged-input-materialization-", async (root) => {
1414
const written = new Map<string, string>()
1515
const server = {
1616
playground: {
17-
async run() {
18-
return { text: JSON.stringify({ schema: "wp-codebox/host-mount-directory-materialization/v1", created: 2, skipped: 0 }) }
17+
async run({ code }: { code: string }) {
18+
return code.includes("wp-codebox/host-mount-verification/v1")
19+
? { text: JSON.stringify({ schema: "wp-codebox/host-mount-verification/v1", repaired: 0, skipped: 0 }) }
20+
: { text: JSON.stringify({ schema: "wp-codebox/host-mount-directory-materialization/v1", created: 2, skipped: 0 }) }
1921
},
2022
async writeFile(path: string, contents: string) {
2123
written.set(path, contents)
@@ -50,6 +52,9 @@ await withTempDir("wp-codebox-staged-input-materialization-fallback-", async (ro
5052
const server = {
5153
playground: {
5254
async run({ code }: { code: string }) {
55+
if (code.includes("wp-codebox/host-mount-verification/v1")) {
56+
return { text: JSON.stringify({ schema: "wp-codebox/host-mount-verification/v1", repaired: 0, skipped: 0 }) }
57+
}
5358
if (code.includes("wp-codebox/host-mount-materialization/v1")) {
5459
fallbackWrites++
5560
return { text: JSON.stringify({ schema: "wp-codebox/host-mount-materialization/v1", materialized: 1, skipped: 0 }) }

0 commit comments

Comments
 (0)