feat: M3 import & indexing engine delivery layer with durable collision handling, space checking and console
This commit is contained in:
1 parent
93d991703c
commit
9d277d9e72
48 files changed
+5147
-4
No files matched your search
@@ -0,0 +1,280 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user