root = sys_get_temp_dir() . '/meshvault-hash-' . bin2hex(random_bytes(8)); mkdir($this->root); $this->path = $this->root . '/source'; file_put_contents($this->path, str_repeat('abc', 100000)); self::assertTrue(class_exists(StreamingHasher::class), 'StreamingHasher must exist'); } protected function tearDown(): void { foreach (scandir($this->root) as $name) { if ($name !== '.' && $name !== '..') { unlink($this->root . '/' . $name); } } rmdir($this->root); } private function hasher(?callable $observer = null, array $hooks = []): StreamingHasher { return new StreamingHasher(new PathSafety($this->root), $observer, $hooks); } public static function sizes(): iterable { foreach ([0, 1, 131072, 131073, 524305] as $size) { yield (string) $size => [$size]; } } #[DataProvider('sizes')] public function testDigestAndBoundedReads(int $size): void { file_put_contents($this->path, str_repeat('x', $size)); $observed = 0; $hasher = $this->hasher(function (int $bytes) use (&$observed): void { self::assertGreaterThan($observed, $bytes); self::assertLessThanOrEqual(131072, $bytes - $observed); $observed = $bytes; }, ['read' => static function ($stream, int $length): string|false { self::assertSame(131072, $length); return fread($stream, $length); }]); self::assertSame(hash_file('sha256', $this->path), $hasher->hash($this->path)); self::assertSame($size, $observed); } public static function failures(): iterable { yield 'open' => ['open', false, 'HASH_OPEN_FAILED']; yield 'read' => ['read', false, 'HASH_READ_FAILED']; yield 'stalled read' => ['read', '', 'HASH_READ_FAILED']; yield 'path stat' => ['stat', false, 'HASH_STAT_FAILED']; yield 'handle stat' => ['fstat', false, 'HASH_STAT_FAILED']; } #[DataProvider('failures')] public function testIoFailures(string $hook, mixed $result, string $reason): void { $this->rejects(fn () => $this->hasher(null, [$hook => fn () => $result])->hash($this->path), $reason); } public function testThrownIoFailurePreservesCauseAndClosesStream(): void { $stream = null; $cause = new RuntimeException('injected read failure'); $hasher = $this->hasher(null, [ 'open' => function (string $path) use (&$stream) { return $stream = fopen($path, 'rb'); }, 'read' => static fn () => throw $cause, ]); $error = $this->rejects(fn () => $hasher->hash($this->path), 'HASH_READ_FAILED'); self::assertSame($cause, $error->getPrevious()); self::assertFalse(is_resource($stream)); } public static function metadataChanges(): iterable { foreach (['stat', 'fstat'] as $hook) { foreach (['dev', 'ino', 'mode', 'size', 'mtime', 'ctime'] as $field) { yield "$hook $field" => [$hook, $field]; } } } #[DataProvider('metadataChanges')] public function testEndMetadataChanges(string $hook, string $field): void { $calls = 0; $hasher = $this->hasher(null, [$hook => static function ($target) use ($hook, $field, &$calls) { $stat = $hook($target); if (++$calls === 2) { ++$stat[$field]; } return $stat; }]); $this->rejects(fn () => $hasher->hash($this->path), 'HASH_SOURCE_CHANGED'); } public function testOpenHandleMustMatchPreOpenPath(): void { $hasher = $this->hasher(null, ['fstat' => static function ($stream) { $stat = fstat($stream); ++$stat['ino']; return $stat; }]); $this->rejects(fn () => $hasher->hash($this->path), 'HASH_SOURCE_CHANGED'); } public function testDirectoryRejected(): void { $this->rejects(fn () => $this->hasher()->hash($this->root), 'HASH_NOT_REGULAR_FILE'); } public static function mutations(): iterable { yield 'append' => ['append']; yield 'truncate' => ['truncate']; yield 'same size with changed timestamp' => ['rewrite']; yield 'replacement' => ['replace']; } #[DataProvider('mutations')] public function testRealMutation(string $mutation): void { $done = false; $hasher = $this->hasher(function () use ($mutation, &$done): void { if ($done) { return; } $done = true; if ($mutation === 'append') { file_put_contents($this->path, 'extra', FILE_APPEND); } elseif ($mutation === 'truncate') { file_put_contents($this->path, 'short'); } elseif ($mutation === 'rewrite') { file_put_contents($this->path, str_repeat('z', 300000)); touch($this->path, time() + 10); } else { if (!@rename($this->path, $this->root . '/old')) { self::markTestSkipped('Host cannot rename an open file.'); } file_put_contents($this->path, str_repeat('z', 300000)); touch($this->path, time() + 10); } }); $this->rejects(fn () => $hasher->hash($this->path), 'HASH_SOURCE_CHANGED'); } public function testUnsafeAndMissingSources(): void { foreach ( [ '' => 'INVALID_PATH', 'php://memory' => 'INVALID_PATH', $this->root . '/../source' => 'PATH_TRAVERSAL', $this->root . '/missing' => 'PATH_NOT_FOUND', ] as $path => $reason ) { $this->rejects(fn () => $this->hasher()->hash($path), $reason); } } public static function links(): iterable { yield 'file' => [false, false]; yield 'ancestor' => [true, false]; yield 'mid-read replacement' => [false, true]; } #[DataProvider('links')] public function testSymlinks(bool $ancestor, bool $duringRead): void { $link = $this->root . '/link'; if (!@symlink($ancestor ? $this->root : $this->path, $link)) { self::markTestSkipped('Host cannot create symlinks.'); } if (!$duringRead) { $this->rejects( fn () => $this->hasher()->hash($link . ($ancestor ? '/source' : '')), 'SYMLINK_ESCAPE' ); return; } unlink($link); file_put_contents($this->root . '/target', 'replacement'); $done = false; $hasher = $this->hasher(function () use (&$done): void { if (!$done) { $done = true; if (!@rename($this->path, $this->root . '/old')) { self::markTestSkipped('Host cannot rename an open file.'); } self::assertTrue(symlink($this->root . '/target', $this->path)); } }); $this->rejects(fn () => $hasher->hash($this->path), 'SYMLINK_ESCAPE'); } public function testEndStatFailure(): void { foreach (['stat', 'fstat'] as $operation) { $calls = 0; $hasher = $this->hasher(null, [$operation => static function ($target) use ($operation, &$calls) { return ++$calls === 2 ? false : $operation($target); }]); $this->rejects(fn () => $hasher->hash($this->path), 'HASH_STAT_FAILED'); } } public function testObserverFailureClosesStream(): void { $stream = null; $cause = new RuntimeException('observer'); $hasher = $this->hasher(static fn () => throw $cause, [ 'open' => function (string $path) use (&$stream) { return $stream = fopen($path, 'rb'); }, ]); try { $hasher->hash($this->path); self::fail('Expected observer exception'); } catch (RuntimeException $error) { self::assertSame($cause, $error); } self::assertFalse(is_resource($stream)); } private function rejects(callable $operation, string $reason): ImportException { try { $operation(); self::fail('Expected ' . $reason); } catch (ImportException $error) { self::assertSame($reason, $error->reason); self::assertNotSame('', $error->getMessage()); return $error; } } }