Files
MeshVault/tests/Unit/Indexer/FileEnumeratorTest.php
T

281 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Indexer\Contracts\FileEnumeratorInterface;
use MeshVault\Indexer\Contracts\FilesystemInterface;
use MeshVault\Indexer\FileEnumerator;
use MeshVault\Indexer\Filesystem\LocalFilesystem;
use MeshVault\Indexer\Filesystem\PathSafety;
use MeshVault\Indexer\ScanError;
use MeshVault\Indexer\ScannedFile;
use MeshVault\Indexer\ScanResult;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class FileEnumeratorTest extends TestCase
{
private string $root;
protected function setUp(): void
{
$this->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<string, array{string}> */
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<string, array{string}> */
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);
}
}
}
}