From 3e1c60db8a3e7b631881b4ee7eb356ed16edb803 Mon Sep 17 00:00:00 2001 From: Alexander Makarov Date: Sat, 13 Jun 2026 11:41:14 +0300 Subject: [PATCH] Harden build manifest persistence --- src/Build/BuildManifest.php | 45 +++++++++++++++++++------- tests/Unit/Build/BuildManifestTest.php | 31 ++++++++++++++++++ 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/Build/BuildManifest.php b/src/Build/BuildManifest.php index 3622a67..c35cbe6 100644 --- a/src/Build/BuildManifest.php +++ b/src/Build/BuildManifest.php @@ -4,6 +4,7 @@ namespace YiiPress\Build; +use JsonException; use RuntimeException; use function dirname; @@ -33,13 +34,19 @@ public function load(): void $json = file_get_contents($this->manifestPath); if ($json === false) { - $this->entries = []; + $this->reset(); + return; + } + + try { + $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + $this->reset(); return; } - $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR); if (!is_array($data)) { - $this->entries = []; + $this->reset(); return; } @@ -62,14 +69,30 @@ public function save(): void throw new RuntimeException(sprintf('Directory "%s" was not created', $dir)); } - file_put_contents( - $this->manifestPath, - json_encode([ - 'entries' => $this->entries, - 'configFiles' => $this->configFiles, - 'trackedDirectories' => $this->trackedDirectories, - ], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), - ); + $json = json_encode([ + 'entries' => $this->entries, + 'configFiles' => $this->configFiles, + 'trackedDirectories' => $this->trackedDirectories, + ], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + + $temporaryPath = $this->manifestPath . '.tmp'; + if (file_put_contents($temporaryPath, $json) === false) { + throw new RuntimeException(sprintf('Unable to write build manifest temporary file "%s".', $temporaryPath)); + } + + if (!rename($temporaryPath, $this->manifestPath)) { + if (is_file($temporaryPath)) { + unlink($temporaryPath); + } + throw new RuntimeException(sprintf('Unable to replace build manifest "%s".', $this->manifestPath)); + } + } + + private function reset(): void + { + $this->entries = []; + $this->configFiles = []; + $this->trackedDirectories = []; } public function isChanged(string $sourceFile): bool diff --git a/tests/Unit/Build/BuildManifestTest.php b/tests/Unit/Build/BuildManifestTest.php index 6cfa719..5c933db 100644 --- a/tests/Unit/Build/BuildManifestTest.php +++ b/tests/Unit/Build/BuildManifestTest.php @@ -132,6 +132,37 @@ public function testEmptyManifestLoadsCleanly(): void assertSame([], $manifest->removedOutputs([$sourceFile])); } + public function testCorruptManifestLoadsAsEmptyCache(): void + { + $manifestPath = $this->tempDir . '/manifest.json'; + file_put_contents($manifestPath, '{"entries":'); + + $manifest = new BuildManifest($manifestPath); + $manifest->load(); + + $sourceFile = $this->tempDir . '/entry.md'; + file_put_contents($sourceFile, '# Hello'); + + assertTrue($manifest->isChanged($sourceFile)); + assertSame([], $manifest->sourceFiles()); + assertSame([], $manifest->configFiles()); + assertFalse($manifest->hasTrackedDirectories()); + } + + public function testSaveDoesNotLeaveTemporaryManifestFile(): void + { + $sourceFile = $this->tempDir . '/entry.md'; + file_put_contents($sourceFile, '# Hello'); + $manifestPath = $this->tempDir . '/manifest.json'; + + $manifest = new BuildManifest($manifestPath); + $manifest->record($sourceFile, ['/out/entry/index.html']); + $manifest->save(); + + assertTrue(is_file($manifestPath)); + assertFalse(is_file($manifestPath . '.tmp')); + } + public function testReplaceReturnsOutputsThatAreNoLongerReferenced(): void { $sourceFile = $this->tempDir . '/asset.css';