root = dirname(__DIR__, 3) . '/.phpunit.cache/scan-' . bin2hex(random_bytes(6)); mkdir($this->root, 0777, true); self::assertTrue(class_exists(FileEnumerator::class), 'FileEnumerator must exist'); } protected function tearDown(): void { $this->removeTree($this->root); } private function removeTree(string $path): void { if (is_link($path)) { if (PHP_OS_FAMILY === 'Windows' && is_dir($path)) { rmdir($path); } else { unlink($path); } } elseif (is_dir($path)) { foreach (scandir($path) as $entry) { if ($entry !== '.' && $entry !== '..') { $this->removeTree($path . '/' . $entry); } } rmdir($path); } else { unlink($path); } } private function scanner(?FilesystemInterface $fs = null, int $maxDepth = 128): FileEnumerator { return new FileEnumerator($fs ?? new LocalFilesystem(), new PathSafety($this->root), $maxDepth); } public function testNestedMixedCaseDefaultsAndMetadata(): void { mkdir($this->root . '/nested/deep', 0777, true); $names = ['part.StL', 'a.JPG', 'b.jPeG', 'c.PNG', 'd.GiF', 'e.PDF', 'f.TXT', 'g.mD']; foreach ($names as $name) { file_put_contents($this->root . '/nested/deep/' . $name, "hello world\n"); } file_put_contents($this->root . '/ignored.exe', 'ignore'); file_put_contents($this->root . '/extensionless', 'ignore'); $scanner = $this->scanner(); self::assertInstanceOf(FileEnumeratorInterface::class, $scanner); $result = $scanner->enumerate($this->root); self::assertInstanceOf(ScanResult::class, $result); self::assertSame([], $result->errors); self::assertCount(8, $result->files); self::assertSame(96, $result->totalBytes); $relative = []; foreach ($result->files as $file) { self::assertInstanceOf(ScannedFile::class, $file); self::assertSame(realpath($this->root . '/' . $file->relativePath), $file->sourcePath); self::assertSame(12, $file->size); self::assertSame('text/plain', $file->mimeType); $relative[] = $file->relativePath; } $expected = array_map(static fn (string $name): string => 'nested/deep/' . $name, $names); sort($expected); sort($relative); self::assertSame($expected, $relative); } public function testEmptyDirectoryAndExplicitAllowlists(): void { self::assertEquals(new ScanResult([], 0, []), $this->scanner()->enumerate($this->root)); file_put_contents($this->root . '/keep.TXT', '123'); file_put_contents($this->root . '/skip.stl', '1234'); $result = $this->scanner()->enumerate($this->root, ['tXt']); self::assertSame(['keep.TXT'], array_column($result->files, 'relativePath')); self::assertSame(3, $result->totalBytes); self::assertEquals(new ScanResult([], 0, []), $this->scanner()->enumerate($this->root, [])); } /** @return iterable */ public static function failures(): iterable { foreach (['isReadable', 'getSize', 'getMimeType', 'realpath', 'isFile', 'readDirectory'] as $method) { yield $method => [$method]; } } #[DataProvider('failures')] public function testReadFailuresContinueSiblings(string $operation): void { file_put_contents($this->root . '/good.txt', 'good'); $bad = $this->root . '/bad.txt'; if ($operation === 'readDirectory') { mkdir($bad); file_put_contents($bad . '/hidden.txt', 'hidden'); } else { file_put_contents($bad, 'bad'); } $result = $this->scanner($this->failingFilesystem($operation, $bad))->enumerate($this->root); self::assertSame(['good.txt'], array_column($result->files, 'relativePath')); self::assertSame(4, $result->totalBytes); self::assertCount(1, $result->errors); self::assertSame('SCAN_READ_ERROR', $result->errors[0]->code); self::assertSame(realpath($bad), $result->errors[0]->sourcePath); self::assertNotSame('', $result->errors[0]->message); } private function failingFilesystem(string $operation, string $bad, mixed $failure = false): FilesystemInterface { $local = new LocalFilesystem(); $fs = $this->createMock(FilesystemInterface::class); $bad = realpath($bad); foreach ( ['realpath', 'isReadable', 'isFile', 'isDir', 'isSymlink', 'getSize', 'getMimeType', 'readDirectory'] as $method ) { $fs->method($method)->willReturnCallback( static fn (string $path) => $method === $operation && realpath($path) === $bad ? $failure : $local->$method($path) ); } $fs->expects(self::never())->method('readStream'); return $fs; } public function testRootReadFailures(): void { foreach (['isReadable', 'readDirectory', 'realpath'] as $operation) { $result = $this->scanner($this->failingFilesystem($operation, $this->root))->enumerate($this->root); self::assertSame([], $result->files); self::assertCount(1, $result->errors); self::assertSame('SCAN_READ_ERROR', $result->errors[0]->code); } } public function testDepthIsBoundedWithoutLosingSiblings(): void { mkdir($this->root . '/a/b', 0777, true); file_put_contents($this->root . '/a/good.txt', 'ok'); file_put_contents($this->root . '/a/b/too-deep.txt', 'no'); $result = $this->scanner(maxDepth: 1)->enumerate($this->root); self::assertSame(['a/good.txt'], array_column($result->files, 'relativePath')); self::assertCount(1, $result->errors); self::assertSame('SCAN_READ_ERROR', $result->errors[0]->code); self::assertStringContainsString('depth', $result->errors[0]->message); } /** @return iterable */ public static function linkTypes(): iterable { foreach (['file', 'directory', 'dangling', 'ancestor'] as $type) { yield $type => [$type]; } } #[DataProvider('linkTypes')] public function testSymlinksAreReportedAndNeverFollowed(string $type): void { mkdir($this->root . '/source'); mkdir($this->root . '/target'); file_put_contents($this->root . '/target/outside.txt', 'outside'); file_put_contents($this->root . '/source/good.txt', 'good'); $target = match ($type) { 'file' => $this->root . '/target/outside.txt', 'dangling' => $this->root . '/missing', default => $this->root . '/target', }; $link = $this->root . '/source/link.exe'; if (!function_exists('symlink') || !@symlink($target, $link)) { self::markTestSkipped('Creating symlinks is not supported or permitted on this host.'); } $source = $type === 'ancestor' ? $link . '/outside.txt' : $this->root . '/source'; $result = $this->scanner()->enumerate($source); self::assertSame($type === 'ancestor' ? [] : ['good.txt'], array_column($result->files, 'relativePath')); self::assertCount(1, $result->errors); self::assertSame('SYMLINK_ESCAPE', $result->errors[0]->code); $rootResult = $this->scanner()->enumerate($link); self::assertSame('SYMLINK_ESCAPE', $rootResult->errors[0]->code); } public function testPosixBackslashAncestorCannotSelectADifferentTree(): void { if (PHP_OS_FAMILY === 'Windows') { self::markTestSkipped('Backslash-named ancestors require POSIX.'); } mkdir($this->root . '/a/b/child', 0777, true); mkdir($this->root . '/target/child', 0777, true); file_put_contents($this->root . '/a/b/child/decoy.txt', 'decoy'); file_put_contents($this->root . '/target/child/outside.txt', 'outside'); $link = $this->root . '/a\\b'; if (!function_exists('symlink') || !@symlink($this->root . '/target', $link)) { self::markTestSkipped('Creating symlinks is not supported or permitted on this host.'); } $result = $this->scanner()->enumerate($link . '/child'); self::assertSame([], $result->files); self::assertSame(0, $result->totalBytes); self::assertCount(1, $result->errors); self::assertSame('SCAN_READ_ERROR', $result->errors[0]->code); } public function testTraversalRootAndEntriesRetainTheirCode(): void { $result = $this->scanner()->enumerate($this->root . '/../'); self::assertSame([], $result->files); self::assertSame('PATH_TRAVERSAL', $result->errors[0]->code); $fs = $this->failingFilesystem('readDirectory', $this->root, ['..']); $result = $this->scanner($fs)->enumerate($this->root); self::assertSame([], $result->files); self::assertSame('PATH_TRAVERSAL', $result->errors[0]->code); } public function testZeroByteFileIsAccepted(): void { file_put_contents($this->root . '/empty.txt', ''); $result = $this->scanner()->enumerate($this->root); self::assertSame([], $result->errors); self::assertCount(1, $result->files); self::assertSame(0, $result->files[0]->size); self::assertSame(0, $result->totalBytes); self::assertNotSame('', $result->files[0]->mimeType); } public function testInvalidMetadataContinuesSiblings(): void { file_put_contents($this->root . '/good.txt', 'good'); $bad = $this->root . '/bad.txt'; file_put_contents($bad, 'bad'); foreach (['getSize' => -1, 'getMimeType' => ''] as $operation => $value) { $result = $this->scanner($this->failingFilesystem($operation, $bad, $value))->enumerate($this->root); self::assertSame(['good.txt'], array_column($result->files, 'relativePath')); self::assertSame(4, $result->totalBytes); self::assertCount(1, $result->errors); self::assertSame('SCAN_READ_ERROR', $result->errors[0]->code); self::assertStringContainsString('check permissions', $result->errors[0]->message); } } public function testMissingFileAndInvalidRootsAreActionable(): void { file_put_contents($this->root . '/file.txt', 'text'); foreach ([$this->root . '/absent', $this->root . '/file.txt', '', "bad\0path"] as $root) { $result = $this->scanner()->enumerate($root); self::assertSame([], $result->files); self::assertSame(0, $result->totalBytes); self::assertCount(1, $result->errors); self::assertInstanceOf(ScanError::class, $result->errors[0]); self::assertSame($root, $result->errors[0]->sourcePath); self::assertSame('SCAN_READ_ERROR', $result->errors[0]->code); self::assertStringContainsString('source root', $result->errors[0]->message); self::assertStringContainsString('Choose', $result->errors[0]->message); self::assertStringContainsString('directory', $result->errors[0]->message); if ($root === '' || str_contains($root, "\0")) { self::assertStringContainsString('non-empty', $result->errors[0]->message); self::assertStringContainsString('NUL', $result->errors[0]->message); } else { self::assertStringContainsString('existing', $result->errors[0]->message); } } } }