281 lines
12 KiB
PHP
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);
|
|
}
|
|
}
|
|
}
|
|
}
|