diff --git a/phpunit/src/Build/AtomicFileTest.php b/phpunit/src/Build/AtomicFileTest.php new file mode 100644 index 00000000..dcadf708 --- /dev/null +++ b/phpunit/src/Build/AtomicFileTest.php @@ -0,0 +1,67 @@ +directory = sys_get_temp_dir() . '/typephp-atomic-file-' . bin2hex(random_bytes(6)); + } + + protected function tearDown(): void + { + if (!is_dir($this->directory)) { + return; + } + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($this->directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($iterator as $entry) { + $entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); + } + rmdir($this->directory); + } + + public function testWriteCreatesDirectoriesAndReplacesExistingContents(): void + { + $path = $this->directory . '/nested/state.json'; + + AtomicFile::write($path, 'first', '.state-'); + AtomicFile::write($path, 'second', '.state-'); + + self::assertSame('second', file_get_contents($path)); + self::assertSame([$path], glob($this->directory . '/nested/*')); + } + + public function testMissingReplacementDoesNotRemoveExistingTarget(): void + { + $target = $this->directory . '/state.json'; + AtomicFile::write($target, 'stable'); + + try { + AtomicFile::replace($this->directory . '/missing.tmp', $target); + self::fail('Missing replacement must be rejected'); + } catch (\RuntimeException $error) { + self::assertStringContainsString('does not exist', $error->getMessage()); + } + + self::assertSame('stable', file_get_contents($target)); + } +} diff --git a/src/Build/AstCache.php b/src/Build/AstCache.php index 637d9b0d..81fe3f8d 100644 --- a/src/Build/AstCache.php +++ b/src/Build/AstCache.php @@ -100,13 +100,6 @@ final class AstCache private function store(string $cacheFile, string $source, string $sourceHash, array $ast): void { try { - if (!is_dir($this->cacheDirectory) - && !mkdir($this->cacheDirectory, 0777, true) - && !is_dir($this->cacheDirectory) - ) { - return; - } - $contents = serialize([ 'schema' => self::SCHEMA_VERSION, 'phpVersion' => $this->phpVersion, @@ -115,26 +108,7 @@ final class AstCache 'sourceHash' => $sourceHash, 'ast' => $ast, ]); - $temporary = tempnam($this->cacheDirectory, '.ast-'); - if ($temporary === false) { - return; - } - try { - if (file_put_contents($temporary, $contents, LOCK_EX) === false) { - return; - } - if (!@rename($temporary, $cacheFile)) { - // Windows cannot atomically replace an existing file. A - // concurrent reader may safely treat the short gap as a - // cache miss and rebuild the disposable snapshot. - @unlink($cacheFile); - @rename($temporary, $cacheFile); - } - } finally { - if (is_file($temporary)) { - @unlink($temporary); - } - } + AtomicFile::write($cacheFile, $contents, '.ast-'); } catch (\Throwable) { // AST caching is an optimization. Serialization or filesystem // failures must never prevent an otherwise valid compilation. diff --git a/src/Build/AtomicFile.php b/src/Build/AtomicFile.php new file mode 100644 index 00000000..61ede1de --- /dev/null +++ b/src/Build/AtomicFile.php @@ -0,0 +1,64 @@ + $generatorFingerprint, 'files' => $files, ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL; - $directory = dirname($this->file); - if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { - throw new \RuntimeException('Cannot create incremental build state directory: ' . $directory); - } - $temporary = tempnam($directory, '.graph-'); - if ($temporary === false) { - throw new \RuntimeException('Cannot create incremental build state temporary file'); - } - try { - if (file_put_contents($temporary, $contents, LOCK_EX) === false) { - throw new \RuntimeException('Cannot write incremental build state: ' . $this->file); - } - if (!@rename($temporary, $this->file)) { - @unlink($this->file); - if (!@rename($temporary, $this->file)) { - throw new \RuntimeException('Cannot write incremental build state: ' . $this->file); - } - } - } finally { - if (is_file($temporary)) { - @unlink($temporary); - } - } + AtomicFile::write($this->file, $contents, '.graph-'); $this->generatorFingerprint = $generatorFingerprint; $this->files = $files; } diff --git a/src/Build/PrecompiledHeaderManager.php b/src/Build/PrecompiledHeaderManager.php index d415ac51..a1344f69 100644 --- a/src/Build/PrecompiledHeaderManager.php +++ b/src/Build/PrecompiledHeaderManager.php @@ -183,15 +183,14 @@ final readonly class PrecompiledHeaderManager } if ($digests !== $saved && is_dir($cacheDirectory)) { - $temporary = tempnam($cacheDirectory, '.pch-digests-'); - if ($temporary !== false) { - try { - if (file_put_contents($temporary, json_encode($digests, JSON_THROW_ON_ERROR)) !== false) { - @rename($temporary, $digestFile); - } - } finally { - @unlink($temporary); - } + try { + AtomicFile::write( + $digestFile, + json_encode($digests, JSON_THROW_ON_ERROR), + '.pch-digests-', + ); + } catch (\Throwable) { + // Digest reuse is optional; hashing every dependency remains correct. } } diff --git a/src/Build/PreparedProjectCacheTrait.php b/src/Build/PreparedProjectCacheTrait.php index 0f31de65..ade0d097 100644 --- a/src/Build/PreparedProjectCacheTrait.php +++ b/src/Build/PreparedProjectCacheTrait.php @@ -98,7 +98,6 @@ trait PreparedProjectCacheTrait if ($this->externalImportStubFiles !== []) { return; } - $temporary = ''; $bodies = []; try { // Conversion always loads a fresh pristine AST. The prepared tree @@ -133,24 +132,13 @@ trait PreparedProjectCacheTrait $state[$field] = $this->{$field}; } $file = $this->preparedProjectCacheFile(); - if (!is_dir(dirname($file)) && !mkdir(dirname($file), 0777, true) && !is_dir(dirname($file))) { - return; - } - $candidate = tempnam(dirname($file), '.prepared-'); - if (is_string($candidate)) { - $temporary = $candidate; - $this->writeFile($temporary, $key . "\n" . serialize($state)); - @rename($temporary, $file); - } + AtomicFile::write($file, $key . "\n" . serialize($state), '.prepared-'); } catch (\Throwable) { // Optional cache failures must not fail a valid compilation. } finally { foreach ($bodies as [$node, $statements]) { $node->stmts = $statements; } - if (is_string($temporary) && is_file($temporary)) { - @unlink($temporary); - } } } } diff --git a/src/Build/StableIdRegistry.php b/src/Build/StableIdRegistry.php index d5f7ff2a..25893d9c 100644 --- a/src/Build/StableIdRegistry.php +++ b/src/Build/StableIdRegistry.php @@ -65,34 +65,12 @@ final class StableIdRegistry if (!$this->dirty || $this->file === '') { return; } - $directory = dirname($this->file); - if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { - throw new \RuntimeException('Cannot create stable ID cache directory: ' . $directory); - } $contents = json_encode([ 'schema' => self::SCHEMA_VERSION, 'domains' => $this->domains, 'nextIds' => $this->nextIds, ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL; - $temporary = tempnam($directory, '.ids-'); - if ($temporary === false) { - throw new \RuntimeException('Cannot create stable ID cache temporary file'); - } - try { - if (file_put_contents($temporary, $contents, LOCK_EX) === false) { - throw new \RuntimeException('Cannot write stable ID cache: ' . $this->file); - } - if (!@rename($temporary, $this->file)) { - @unlink($this->file); - if (!@rename($temporary, $this->file)) { - throw new \RuntimeException('Cannot replace stable ID cache: ' . $this->file); - } - } - } finally { - if (is_file($temporary)) { - @unlink($temporary); - } - } + AtomicFile::write($this->file, $contents, '.ids-'); $this->dirty = false; }