diff --git a/src/Cache/AnalysisResultCache.php b/src/Cache/AnalysisResultCache.php index d0894506..17b7bfa9 100644 --- a/src/Cache/AnalysisResultCache.php +++ b/src/Cache/AnalysisResultCache.php @@ -65,7 +65,7 @@ final class AnalysisResultCache * their shape or naming changes: it is recorded in the metadata marker, * so a cache written by an older format is cleared on its next use. */ - public const FORMAT_VERSION = 8; + public const FORMAT_VERSION = 9; private readonly string $cacheDirectory; @@ -325,7 +325,7 @@ private function analysisNodePayload(string $file, string $namespace): ?array { $payload = $this->read($this->analysisNodesKey($file, $namespace)); - if ($payload === null || ($payload['metadata'] ?? null) !== $this->fileMetadata($file, $namespace)) { + if ($payload === null || ($payload['metadata'] ?? null) !== $this->fileMetadataHash($file, $namespace)) { return null; } @@ -437,7 +437,7 @@ public function storeAnalysisNodes( $this->ensureCacheInitialised(); $payload = [ - 'metadata' => $this->fileMetadata($file, $namespace), + 'metadata' => $this->fileMetadataHash($file, $namespace), 'nodes' => array_map($this->classNodeToArray(...), $classNodes), ]; @@ -1493,15 +1493,9 @@ private function analysisNodesKey(string $file, string $namespace): string return 'analysis-nodes-' . hash('xxh128', $namespace . "\0" . $file); } - /** - * @return array - */ - private function fileMetadata(string $file, string $namespace): array + /** The metadata is only compared for equality; store one hash instead of repeating the file and namespace. */ + private function fileMetadataHash(string $file, string $namespace): string { - return [ - 'namespace' => $namespace, - 'file' => $file, - 'hash' => $this->fileHashProvider->hash($file), - ]; + return hash('xxh128', $namespace . "\0" . $file . "\0" . $this->fileHashProvider->hash($file)); } } diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index 74a9e4c6..249ee7a3 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -1357,12 +1357,13 @@ public function testClassNodesLoadOldCachePayloadWithoutInterfaceExtends(): void file_put_contents($sourceFile, 'storeAnalysisNodes($sourceFile, 'config', []); + $cacheFile = $this->firstJsonFile($cacheDirectory); + $payload = json_decode((string) file_get_contents($cacheFile), true, 512, JSON_THROW_ON_ERROR); + $this->assertIsArray($payload); + $this->writeCachePayload($cacheDirectory, [ - 'metadata' => [ - 'namespace' => 'config', - 'file' => $sourceFile, - 'hash' => hash('xxh128', (string) file_get_contents($sourceFile)), - ], + 'metadata' => $payload['metadata'], 'nodes' => [ [ 'className' => Foo::class, @@ -1388,7 +1389,7 @@ public function testClassNodesLoadOldCachePayloadWithoutInterfaceExtends(): void 'layers' => [], ], ], - ], 'analysis-nodes-' . hash('xxh128', "config\0" . $sourceFile) . '.json'); + ], $cacheFile); $loaded = $analysisResultCache->loadAnalysisNodes($sourceFile, 'config')['classNodes'] ?? null; @@ -1877,6 +1878,104 @@ public function testClassNodesMissWhenFileMetadataChanges(): void } } + /** @return iterable */ + public static function differentAnalysisIdentityProvider(): iterable + { + yield 'same contents at another path' => ['Bar.php', 'config']; + yield 'same file in another namespace' => ['Foo.php', 'other-config']; + } + + #[DataProvider('differentAnalysisIdentityProvider')] + public function testAnalysisNodesRejectPayloadFromDifferentIdentity(string $filename, string $namespace): void + { + $cacheDirectory = $this->createTempDirectory(); + $sourceFile = $cacheDirectory . '/Foo.php'; + $otherFile = $cacheDirectory . '/' . $filename; + $analysisResultCache = new AnalysisResultCache(__DIR__, new FileHashProvider(), $cacheDirectory); + + file_put_contents($sourceFile, 'storeAnalysisNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); + $originalPayload = (string) file_get_contents($this->firstJsonFile($cacheDirectory)); + + $analysisResultCache->storeAnalysisNodes($otherFile, $namespace, [$this->makeClassNode($otherFile)]); + $this->assertNotNull($analysisResultCache->loadAnalysisNodes($otherFile, $namespace)); + + $otherCacheFile = $cacheDirectory . '/analysis-nodes-' + . hash('xxh128', $namespace . "\0" . $otherFile) . '.json'; + file_put_contents($otherCacheFile, $originalPayload); + + $this->assertNull($analysisResultCache->loadAnalysisNodes($otherFile, $namespace)); + $this->assertNotNull($analysisResultCache->loadAnalysisNodes($sourceFile, 'config')); + } finally { + unlink($sourceFile); + + if ($otherFile !== $sourceFile) { + unlink($otherFile); + } + + $this->removeTempDirectory($cacheDirectory); + } + } + + public function testAnalysisNodesStoreCompactMetadataAndRejectMalformedHashes(): void + { + $cacheDirectory = $this->createTempDirectory(); + $sourceFile = $cacheDirectory . '/Foo.php'; + $analysisResultCache = new AnalysisResultCache(__DIR__, new FileHashProvider(), $cacheDirectory); + + file_put_contents($sourceFile, 'storeAnalysisNodes( + $sourceFile, + 'config', + [], + new FileAnalysis($sourceFile, false, true, null, true, true, false, 1), + ); + $cacheFile = $this->firstJsonFile($cacheDirectory); + $payload = json_decode((string) file_get_contents($cacheFile), true, 512, JSON_THROW_ON_ERROR); + $this->assertIsArray($payload); + $this->assertIsString($payload['metadata']); + $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $payload['metadata']); + $this->assertNotNull($analysisResultCache->loadAnalysisNodesWithFileAnalysis($sourceFile, 'config')); + + $invalidMetadata = [ + 'null' => null, + 'integer' => 1, + 'boolean' => false, + 'list' => [], + 'string' => 'invalid', + 'wrong hash' => hash('xxh128', 'other identity'), + 'old format' => [ + 'namespace' => 'config', + 'file' => $sourceFile, + 'hash' => hash_file('xxh128', $sourceFile), + ], + ]; + + foreach ($invalidMetadata as $description => $metadata) { + $payload['metadata'] = $metadata; + $this->writeCachePayload($cacheDirectory, $payload, $cacheFile); + + $this->assertNull($analysisResultCache->loadAnalysisNodes($sourceFile, 'config'), $description); + $this->assertNull( + $analysisResultCache->loadAnalysisNodesWithFileAnalysis($sourceFile, 'config'), + $description, + ); + } + + unset($payload['metadata']); + $this->writeCachePayload($cacheDirectory, $payload, $cacheFile); + $this->assertNull($analysisResultCache->loadAnalysisNodes($sourceFile, 'config')); + } finally { + unlink($sourceFile); + $this->removeTempDirectory($cacheDirectory); + } + } + public function testClearResetsSharedFileHashes(): void { $cacheDirectory = $this->createTempDirectory(); @@ -2660,13 +2759,11 @@ public function testClassNodesMissWhenPayloadIsMalformed(array $payloadOverride) try { $analysisResultCache->storeAnalysisNodes($sourceFile, 'config', [$this->makeClassNode($sourceFile)]); $cacheFile = $this->firstJsonFile($cacheDirectory); + $payload = json_decode((string) file_get_contents($cacheFile), true, 512, JSON_THROW_ON_ERROR); + $this->assertIsArray($payload); $this->writeCachePayload($cacheDirectory, [ - 'metadata' => [ - 'namespace' => 'config', - 'file' => $sourceFile, - 'hash' => hash('xxh128', (string) file_get_contents($sourceFile)), - ], + ...$payload, ...$payloadOverride, ], $cacheFile);