feat: M3 import & indexing engine delivery layer with durable collision handling, space checking and console

This commit is contained in:
Cline committed 2026-10-12 11:29:17 -05:00
1 parent 93d991703c
commit 9d277d9e72
48 files changed
+5147 -4

No files matched your search

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Console;
use MeshVault\Console\ConsoleServices;
use MeshVault\Console\LazyConsoleServices;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class ConsoleServicesWiringTest extends TestCase
{
public function testLazyServicesDoNotBuildTheEngineUntilUsed(): void
{
$built = 0;
$services = new LazyConsoleServices(function () use (&$built): ConsoleServices {
$built++;
return new FakeConsoleServices(importResult: 9);
});
self::assertSame(0, $built, 'Construction alone must not touch configuration.');
self::assertSame(9, $services->import('/srv/in', ['stl']));
self::assertSame(1, $built);
}
public function testLazyServicesBuildTheEngineOnlyOnce(): void
{
$built = 0;
$delegate = new FakeConsoleServices(jobs: [1, 2]);
$services = new LazyConsoleServices(function () use (&$built, $delegate): ConsoleServices {
$built++;
return $delegate;
});
$services->runWorkerOnce(static fn (): bool => false);
$services->runWorkerOnce(static fn (): bool => false);
self::assertSame(1, $built);
self::assertSame(2, $delegate->runs);
}
public function testLazyServicesPropagateConstructionFailures(): void
{
$services = new LazyConsoleServices(static function (): ConsoleServices {
throw new RuntimeException('bad configuration');
});
$this->expectException(RuntimeException::class);
$services->runWorkerOnce(static fn (): bool => false);
}
}
+225
View File
@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Console;
use MeshVault\Console\Console;
use MeshVault\Console\ConsoleServices;
use MeshVault\Indexer\ImportException;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class ConsoleTest extends TestCase
{
public function testHelpIsSuccessfulAndListsEverySubcommand(): void
{
[$code, $out, $err] = $this->execute(['help']);
self::assertSame(0, $code);
self::assertSame('', $err);
foreach (['import', 'worker', 'worker-once', '--ext'] as $fragment) {
self::assertStringContainsString($fragment, $out);
}
}
public function testMissingCommandIsAUsageError(): void
{
[$code, $out, $err] = $this->execute([]);
self::assertSame(1, $code);
self::assertSame('', $out);
self::assertStringContainsString('Usage: meshvault', $err);
}
public function testUnknownCommandIsAUsageError(): void
{
[$code, , $err] = $this->execute(['nope']);
self::assertSame(1, $code);
self::assertStringContainsString('Unknown command', $err);
}
public function testImportWithoutSourceIsAUsageError(): void
{
[$code, , $err] = $this->execute(['import']);
self::assertSame(1, $code);
self::assertStringContainsString('import <source-path>', $err);
}
public function testImportRejectsExtraOperandsAndUnknownOptions(): void
{
[$code, , $err] = $this->execute(['import', 'a', 'b']);
self::assertSame(1, $code);
self::assertStringContainsString('one source path', $err);
[$code, , $err] = $this->execute(['import', 'a', '--deep']);
self::assertSame(1, $code);
self::assertStringContainsString('Unknown option', $err);
self::assertStringContainsString('--deep', $err);
}
public function testImportRejectsEmptyExtensionList(): void
{
foreach (['--ext=', '--ext= , '] as $option) {
[$code, , $err] = $this->execute(['import', 'a', $option]);
self::assertSame(1, $code, $option);
self::assertStringContainsString('--ext', $err);
}
}
public function testImportQueuesAJobWithDefaultExtensions(): void
{
$services = new FakeConsoleServices(importResult: 42);
[$code, $out] = $this->execute(['import', '/srv/incoming'], $services);
self::assertSame(0, $code);
self::assertSame(
[['/srv/incoming', ['stl', 'jpg', 'jpeg', 'png', 'gif', 'pdf', 'txt', 'md']]],
$services->imports
);
self::assertStringContainsString('42', $out);
}
public function testImportNormalizesTheExtensionOption(): void
{
$services = new FakeConsoleServices(importResult: 7);
[$code] = $this->execute(['import', '/srv/in', '--ext= STL , .3mf ,stl'], $services);
self::assertSame(0, $code);
self::assertSame([['/srv/in', ['stl', '3mf']]], $services->imports);
}
public function testImportFailureReportsTheReasonCodeAndExitsTwo(): void
{
$services = new FakeConsoleServices(importError: new ImportException('SCAN_EMPTY', 'Nothing found.'));
[$code, $out, $err] = $this->execute(['import', '/srv/in'], $services);
self::assertSame(2, $code);
self::assertSame('', $out);
self::assertStringContainsString('SCAN_EMPTY', $err);
self::assertStringContainsString('Nothing found.', $err);
}
public function testUnexpectedFailureIsReportedWithoutLeakingDetails(): void
{
$services = new FakeConsoleServices(
importError: new RuntimeException('SQLSTATE[08006] password=hunter2')
);
[$code, , $err] = $this->execute(['import', '/srv/in'], $services);
self::assertSame(2, $code);
self::assertStringNotContainsString('hunter2', $err);
self::assertStringNotContainsString('#0 ', $err);
self::assertStringContainsString('RuntimeException', $err);
}
public function testWorkerOnceReportsTheProcessedJob(): void
{
$services = new FakeConsoleServices(jobs: [11]);
[$code, $out] = $this->execute(['worker-once'], $services);
self::assertSame(0, $code);
self::assertSame(1, $services->runs);
self::assertStringContainsString('11', $out);
}
public function testWorkerOnceReportsAnIdleQueue(): void
{
$services = new FakeConsoleServices(jobs: [null]);
[$code, $out] = $this->execute(['worker-once'], $services);
self::assertSame(0, $code);
self::assertStringContainsString('No pending import jobs', $out);
}
public function testWorkerOnceRejectsOperands(): void
{
[$code, , $err] = $this->execute(['worker-once', 'x']);
self::assertSame(1, $code);
self::assertStringContainsString('takes no arguments', $err);
}
public function testWorkerLoopsUntilTheIterationBoundAndSleepsWhenIdle(): void
{
$services = new FakeConsoleServices(jobs: [5, null, null]);
$slept = [];
[$code, $out] = $this->execute(
['worker', '--max-iterations=3', '--sleep=2'],
$services,
static function (int $seconds) use (&$slept): void {
$slept[] = $seconds;
}
);
self::assertSame(0, $code);
self::assertSame(3, $services->runs);
// Only idle passes sleep; a processed job is followed immediately by the next claim.
self::assertSame([2, 2], $slept);
self::assertStringContainsString('stopped', $out);
}
public function testWorkerStopsWhenAStopIsRequested(): void
{
$services = new FakeConsoleServices(jobs: [1, 2, 3]);
$calls = 0;
[$code, $out] = $this->execute(
['worker', '--max-iterations=99'],
$services,
null,
static function () use (&$calls): bool {
$calls++;
return $calls > 2;
}
);
self::assertSame(0, $code);
self::assertSame(2, $services->runs);
self::assertStringContainsString('stopped', $out);
}
public function testWorkerRejectsInvalidOptions(): void
{
foreach (['--max-iterations=0', '--max-iterations=x', '--sleep=-1', '--bogus', 'operand'] as $argument) {
[$code, , $err] = $this->execute(['worker', $argument]);
self::assertSame(1, $code, $argument);
self::assertNotSame('', $err);
}
}
public function testWorkerFailureExitsTwo(): void
{
$services = new FakeConsoleServices(
workerError: new ImportException('LOCK_UNAVAILABLE', 'Another worker holds the library lock.')
);
[$code, , $err] = $this->execute(['worker', '--max-iterations=1'], $services);
self::assertSame(2, $code);
self::assertStringContainsString('LOCK_UNAVAILABLE', $err);
}
/** @return array{0: int, 1: string, 2: string} */
private function execute(
array $argv,
?ConsoleServices $services = null,
?callable $sleep = null,
?callable $stopRequested = null
): array {
$out = fopen('php://memory', 'w+');
$err = fopen('php://memory', 'w+');
$console = new Console(
$services ?? new FakeConsoleServices(),
$out,
$err,
$sleep ?? static function (int $seconds): void {
},
$stopRequested ?? static fn (): bool => false
);
$code = $console->run($argv);
rewind($out);
rewind($err);
return [$code, (string) stream_get_contents($out), (string) stream_get_contents($err)];
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Console;
use MeshVault\Config\AppConfig;
use MeshVault\Repository\CatalogRepositoryInterface;
use MeshVault\Repository\ImportJobRepositoryInterface;
use MeshVault\Repository\PdoCatalogRepository;
use MeshVault\Repository\PdoImportJobRepository;
use PDO;
use Phinx\Config\Config;
use Phinx\Migration\Manager;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
final class ContainerWiringTest extends TestCase
{
private PDO $pdo;
protected function setUp(): void
{
$this->pdo = new PDO('sqlite::memory:', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$config = new Config([
'paths' => ['migrations' => __DIR__ . '/../../../db/migrations'],
'environments' => [
'default_migration_table' => 'phinxlog',
'default_database' => 'test',
'test' => ['adapter' => 'sqlite', 'connection' => $this->pdo],
],
]);
(new Manager($config, new ArrayInput([]), new NullOutput()))->migrate('test');
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
public function testRepositoriesShareSamePdoInstance(): void
{
// L5: assert that the container would wire both repositories to the SAME PDO
// instance so the job repository's atomic() is a real transaction, not a no-op.
$catalog = new PdoCatalogRepository($this->pdo);
$jobs = new PdoImportJobRepository($this->pdo);
$reflectionCatalog = new \ReflectionObject($catalog);
$reflectionJobs = new \ReflectionObject($jobs);
$pdoCatalog = $reflectionCatalog->getProperty('pdo');
$pdoCatalog->setAccessible(true);
$pdoJobs = $reflectionJobs->getProperty('pdo');
$pdoJobs->setAccessible(true);
self::assertSame(
$pdoCatalog->getValue($catalog),
$pdoJobs->getValue($jobs),
'CatalogRepository and ImportJobRepository must share the same PDO instance for atomic() to work'
);
}
public function testAtomicCallbackUsesSharedConnection(): void
{
$catalog = new PdoCatalogRepository($this->pdo);
$jobs = new PdoImportJobRepository($this->pdo);
// Test that atomic() on jobs uses the same connection as catalog
$called = false;
$jobs->atomic(function () use ($jobs, $catalog, &$called): void {
$called = true;
// Inside the transaction, both repositories should see each other's changes
$jobId = $jobs->create('/tmp/source', [
['source_path' => '/tmp/source/file.stl', 'size' => 100, 'model_id' => null],
]);
$items = $jobs->items($jobId);
self::assertCount(1, $items);
});
self::assertTrue($called);
// After commit, the job should be visible
$allJobs = $this->pdo->query('SELECT * FROM import_jobs')->fetchAll(PDO::FETCH_ASSOC);
self::assertCount(1, $allJobs);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Console;
use MeshVault\Console\ConsoleServices;
use Throwable;
final class FakeConsoleServices implements ConsoleServices
{
/** @var list<array{0: string, 1: list<string>}> */
public array $imports = [];
public int $runs = 0;
/** @param list<int|null> $jobs */
public function __construct(
private readonly int $importResult = 1,
private readonly ?Throwable $importError = null,
private array $jobs = [],
private readonly ?Throwable $workerError = null,
) {
}
public function import(string $sourcePath, array $extensions): int
{
$this->imports[] = [$sourcePath, $extensions];
if ($this->importError !== null) {
throw $this->importError;
}
return $this->importResult;
}
public function runWorkerOnce(callable $shouldStop): ?int
{
$this->runs++;
if ($this->workerError !== null) {
throw $this->workerError;
}
// The real worker consults the callback between items; mirror that contract.
$shouldStop();
return array_shift($this->jobs);
}
}
+280
View File
@@ -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);
}
}
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Indexer\Contracts\FilesystemInterface;
use MeshVault\Indexer\Filesystem\LocalFilesystem;
use PHPUnit\Framework\TestCase;
final class LocalFilesystemTest extends TestCase
{
private string $root;
protected function setUp(): void
{
$this->root = dirname(__DIR__, 3) . '/.phpunit.cache/fs-' . bin2hex(random_bytes(6));
mkdir($this->root, 0777, true);
self::assertTrue(class_exists(LocalFilesystem::class), 'LocalFilesystem must exist');
}
protected function tearDown(): void
{
foreach (glob($this->root . '/*') as $path) {
if (is_link($path) || is_file($path)) {
unlink($path);
} else {
if (is_dir($path . '/nested')) {
rmdir($path . '/nested');
}
rmdir($path);
}
}
rmdir($this->root);
}
public function testNativeMetadataAndStreamOperations(): void
{
$fs = new LocalFilesystem();
self::assertInstanceOf(FilesystemInterface::class, $fs);
$path = $this->root . '/sample.txt';
$stream = $fs->writeStream($path);
self::assertIsResource($stream);
try {
self::assertSame(12, fwrite($stream, "hello world\n"));
} finally {
fclose($stream);
}
self::assertSame(realpath($path), $fs->realpath($path));
self::assertTrue($fs->isReadable($path));
self::assertTrue($fs->isFile($path));
self::assertFalse($fs->isDir($path));
self::assertFalse($fs->isSymlink($path));
self::assertTrue($fs->isDir($this->root));
self::assertSame(12, $fs->getSize($path));
self::assertSame('text/plain', $fs->getMimeType($path));
self::assertGreaterThan(0, $fs->freeSpace($this->root));
$stream = $fs->readStream($path);
self::assertIsResource($stream);
try {
self::assertSame("hello world\n", stream_get_contents($stream));
} finally {
fclose($stream);
}
self::assertTrue($fs->rename($path, $this->root . '/renamed.txt'));
self::assertFalse($fs->isFile($path));
self::assertTrue($fs->unlink($this->root . '/renamed.txt'));
self::assertTrue($fs->mkdir($this->root . '/directory/nested', 0777, true));
self::assertTrue($fs->isDir($this->root . '/directory/nested'));
}
public function testDirectoryEntriesExcludeDotsAndFailuresReturnFalse(): void
{
$fs = new LocalFilesystem();
self::assertSame([], $fs->readDirectory($this->root));
file_put_contents($this->root . '/file.txt', 'text');
mkdir($this->root . '/nested');
self::assertSame(['file.txt', 'nested'], $fs->readDirectory($this->root));
self::assertFalse($fs->readDirectory($this->root . '/missing'));
self::assertFalse($fs->readDirectory($this->root . '/file.txt'));
}
public function testFailuresReturnFalseWithoutWarnings(): void
{
$fs = new LocalFilesystem();
$missing = $this->root . '/absent/file';
foreach (
['realpath', 'isReadable', 'isFile', 'isDir', 'isSymlink', 'getSize', 'getMimeType',
'readStream', 'writeStream', 'unlink', 'freeSpace'] as $operation
) {
self::assertFalse($fs->$operation($missing), $operation);
}
self::assertFalse($fs->rename($missing, $this->root . '/other'));
self::assertFalse($fs->mkdir($missing));
self::assertFalse($fs->mkdir($this->root));
}
public function testSymlinkMetadataIncludesDanglingLinks(): void
{
$link = $this->root . '/link';
if (!function_exists('symlink') || !@symlink($this->root . '/absent', $link)) {
self::markTestSkipped('Creating symlinks is not supported or permitted on this host.');
}
$fs = new LocalFilesystem();
self::assertTrue($fs->isSymlink($link));
self::assertFalse($fs->realpath($link));
self::assertTrue($fs->unlink($link));
self::assertFalse($fs->isSymlink($link));
}
}
+200
View File
@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Exception\PathSafetyException;
use MeshVault\Indexer\Filesystem\PathSafety;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class PathSafetyTest extends TestCase
{
private string $base;
private string $root;
protected function setUp(): void
{
$this->base = dirname(__DIR__, 3) . '/.phpunit.cache/paths-' . bin2hex(random_bytes(6));
mkdir($this->base, 0777, true);
$this->base = realpath($this->base);
$this->root = $this->base . '/library';
mkdir($this->root);
mkdir($this->base . '/library-sibling');
file_put_contents($this->base . '/source.txt', 'source');
file_put_contents($this->root . '/existing.txt', 'existing');
self::assertTrue(class_exists(PathSafety::class), 'PathSafety must exist');
}
protected function tearDown(): void
{
$remove = static function (string $path) use (&$remove): void {
if (is_link($path) || !is_dir($path)) {
unlink($path);
return;
}
foreach (scandir($path) as $name) {
if ($name !== '.' && $name !== '..') {
$remove($path . '/' . $name);
}
}
rmdir($path);
};
$remove($this->base);
}
public function testCanonicalSourcesAndExistingAndNewDestinations(): void
{
$safety = new PathSafety($this->root);
self::assertSame(realpath($this->base . '/source.txt'), $safety->ensureSourceSafe($this->base . '/source.txt'));
self::assertSame(realpath($this->root), $safety->ensureSourceSafe($this->root));
self::assertSame(realpath($this->root), $safety->ensureDestSafe($this->root));
self::assertSame(realpath($this->root . '/existing.txt'), $safety->ensureDestSafe('existing.txt'));
self::assertSame(
realpath($this->root . '/existing.txt'),
$safety->ensureDestSafe($this->root . '/existing.txt')
);
$expected = realpath($this->root) . DIRECTORY_SEPARATOR . 'new' . DIRECTORY_SEPARATOR . 'model.stl';
self::assertSame($expected, $safety->ensureDestSafe('new/model.stl'));
self::assertSame($expected, $safety->ensureDestSafe($this->root . '/new/model.stl'));
self::assertDirectoryDoesNotExist($this->root . '/new');
self::assertSame('models/model.stl', $safety->ensureRelative('models/model.stl'));
}
public function testSourceAndConstructorBackslashPolicy(): void
{
$safety = new PathSafety($this->root);
$path = $this->base . '\\library';
if (PHP_OS_FAMILY === 'Windows') {
self::assertSame(realpath($this->root), $safety->ensureSourceSafe($path));
self::assertSame(realpath($this->root), (new PathSafety($path))->ensureSourceSafe($path));
} else {
$this->rejects(fn () => $safety->ensureSourceSafe($path), 'INVALID_PATH');
$this->rejects(fn () => new PathSafety($path), 'INVALID_PATH');
}
}
public static function unsafeRelativePaths(): iterable
{
foreach (['/tmp/a', '\\rooted', 'C:/file', 'C:relative', '\\\\server\\share', '//server/share'] as $path) {
yield [$path, 'ABSOLUTE_PATH'];
}
foreach (['../file', 'a/../file', 'a\\..\\file', '..'] as $path) {
yield [$path, 'PATH_TRAVERSAL'];
}
yield ["a\0b", 'INVALID_PATH'];
yield ['', 'INVALID_PATH'];
yield ['php://memory', 'INVALID_PATH'];
}
#[DataProvider('unsafeRelativePaths')]
public function testUnsafeRelativePaths(string $path, string $reason): void
{
$this->rejects(fn () => (new PathSafety($this->root))->ensureRelative($path), $reason);
}
public function testMissingTraversalEscapeAndNonDirectoryAncestor(): void
{
$safety = new PathSafety($this->root);
$this->rejects(fn () => $safety->ensureSourceSafe($this->base . '/absent'), 'PATH_NOT_FOUND');
foreach (['ensureSourceSafe', 'ensureDestSafe'] as $method) {
$this->rejects(fn () => $safety->$method($this->root . '/../source.txt'), 'PATH_TRAVERSAL');
$reason = $method === 'ensureSourceSafe' && PHP_OS_FAMILY !== 'Windows'
? 'INVALID_PATH'
: 'PATH_TRAVERSAL';
$this->rejects(fn () => $safety->$method($this->root . '/a\\..\\source.txt'), $reason);
$this->rejects(fn () => $safety->$method("bad\0path"), 'INVALID_PATH');
}
foreach ([$this->base . '/source.txt', $this->base . '/library-sibling/new/file'] as $path) {
$this->rejects(fn () => $safety->ensureDestSafe($path), 'OUTSIDE_LIBRARY_ROOT');
}
$this->rejects(fn () => $safety->ensureDestSafe('existing.txt/child'), 'NOT_DIRECTORY');
$this->rejects(fn () => new PathSafety($this->base . '/absent'), 'PATH_NOT_FOUND');
$this->rejects(fn () => new PathSafety($this->base . '/source.txt'), 'NOT_DIRECTORY');
}
public static function driveRelativeOperations(): iterable
{
yield 'source uppercase drive' => [false, false];
yield 'source lowercase drive' => [false, true];
yield 'root uppercase drive' => [true, false];
yield 'root lowercase drive' => [true, true];
}
#[DataProvider('driveRelativeOperations')]
public function testExistingDriveRelativePathsRejected(bool $constructor, bool $lowercase): void
{
if (PHP_OS_FAMILY !== 'Windows') {
self::markTestSkipped('Drive-relative resolution requires Windows.');
}
$safety = new PathSafety($this->root);
$drive = substr($this->base, 0, 2);
$drive = $lowercase ? strtolower($drive) : strtoupper($drive);
$name = $constructor ? 'library' : 'source.txt';
$relativeBase = substr($this->base, 3);
$path = $drive . $relativeBase . '/' . $name;
self::assertSame(realpath($this->base . '/' . $name), realpath($path));
$this->rejects(
fn () => $constructor ? new PathSafety($path) : $safety->ensureSourceSafe($path),
'INVALID_PATH'
);
}
public function testEveryDriveLetterRejectedForSourcesAndRoots(): void
{
$safety = new PathSafety($this->root);
foreach (array_merge(range('A', 'Z'), range('a', 'z')) as $drive) {
foreach (['', 'file', './file', '.\\file'] as $suffix) {
$path = $drive . ':' . $suffix;
$this->rejects(fn () => $safety->ensureSourceSafe($path), 'INVALID_PATH');
$this->rejects(fn () => new PathSafety($path), 'INVALID_PATH');
}
}
}
public function testWindowsAmbiguousNamesCannotBypassLexicalChecks(): void
{
if (PHP_OS_FAMILY !== 'Windows') {
self::markTestSkipped('Windows path aliases do not apply on this platform.');
}
$safety = new PathSafety($this->root);
foreach (['.. /source.txt', 'existing.txt:stream', 'new./child', 'new /child'] as $path) {
foreach (['ensureSourceSafe', 'ensureDestSafe', 'ensureRelative'] as $method) {
$this->rejects(fn () => $safety->$method($path), 'INVALID_PATH');
}
}
}
public static function links(): iterable
{
yield 'file' => ['source.txt', 'link', ''];
yield 'directory ancestor' => ['library', 'link', '/existing.txt'];
yield 'missing child of ancestor' => ['library', 'link', '/new/child'];
yield 'dangling' => ['absent', 'link', ''];
}
#[DataProvider('links')]
public function testLinksRejectedWithoutFollowingThem(string $target, string $name, string $suffix): void
{
$link = $this->root . '/' . $name;
if (!function_exists('symlink') || !@symlink($this->base . '/' . $target, $link)) {
self::markTestSkipped('Creating symlinks is not supported or permitted on this host.');
}
$safety = new PathSafety($this->root);
foreach (['ensureSourceSafe', 'ensureDestSafe'] as $method) {
$this->rejects(fn () => $safety->$method($link . $suffix), 'SYMLINK_ESCAPE');
}
$this->rejects(fn () => new PathSafety($link . $suffix), 'SYMLINK_ESCAPE');
}
private function rejects(callable $operation, string $reason): void
{
try {
$operation();
self::fail('Expected path rejection: ' . $reason);
} catch (PathSafetyException $exception) {
self::assertSame($reason, $exception->reason);
}
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Indexer\ImportException;
use MeshVault\Indexer\SpaceChecker;
use PHPUnit\Framework\TestCase;
final class SpaceCheckerTest extends TestCase
{
public function testStructuredException(): void
{
self::assertTrue(class_exists(ImportException::class));
$previous = new \RuntimeException('underlying');
$error = new ImportException('DISK_FULL', 'Need more space', $previous);
self::assertSame('DISK_FULL', $error->reason);
self::assertSame('Need more space', $error->getMessage());
self::assertSame($previous, $error->getPrevious());
self::assertSame('DISK_FULL', (new ImportException('DISK_FULL'))->getMessage());
}
public function testDefaultAndCustomMargins(): void
{
self::assertTrue(class_exists(SpaceChecker::class));
(new SpaceChecker(freeSpace: fn ($path) => 105.0))->check(__DIR__, 100);
(new SpaceChecker(0.2, fn ($path) => 120.0))->check(__DIR__, 100);
(new SpaceChecker(0.0, fn ($path) => 100.0))->check(__DIR__, 100);
(new SpaceChecker())->check(__DIR__, 0);
self::assertTrue(true);
}
public function testInsufficientUnknownAndThrownSpaceFailures(): void
{
self::assertTrue(class_exists(SpaceChecker::class));
foreach ([104.0, false, -1.0, NAN, INF, 'throws'] as $free) {
try {
(new SpaceChecker(freeSpace: static function () use ($free) {
if ($free === 'throws') {
throw new \RuntimeException('disk query');
}
return $free;
}))->check(__DIR__, 100);
self::fail('Expected space error');
} catch (ImportException $error) {
self::assertSame($free === 104.0 ? 'DISK_FULL' : 'SPACE_CHECK_FAILED', $error->reason);
}
}
}
public function testInvalidMargins(): void
{
self::assertTrue(class_exists(SpaceChecker::class));
foreach ([-0.01, INF, NAN] as $margin) {
try {
new SpaceChecker($margin);
self::fail('Expected invalid margin');
} catch (\InvalidArgumentException) {
self::assertTrue(true);
}
}
}
public function testNegativeSize(): void
{
self::assertTrue(class_exists(SpaceChecker::class));
$this->expectException(\InvalidArgumentException::class);
(new SpaceChecker())->check(__DIR__, -1);
}
public function testMissingDirectoryFailsClosed(): void
{
self::assertTrue(class_exists(SpaceChecker::class));
$this->expectException(ImportException::class);
(new SpaceChecker())->check(__DIR__ . '/missing-space-directory', 1);
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Indexer\ImportException;
use MeshVault\Exception\PathSafetyException;
use MeshVault\Indexer\Filesystem\PathSafety;
use MeshVault\Indexer\StlValidator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class StlValidatorTest extends TestCase
{
private string $root;
protected function setUp(): void
{
$this->root = sys_get_temp_dir() . '/stl-' . bin2hex(random_bytes(8));
mkdir($this->root);
self::assertTrue(class_exists(StlValidator::class), 'StlValidator must exist');
}
protected function tearDown(): void
{
foreach (glob($this->root . '/*') as $path) {
unlink($path);
}
rmdir($this->root);
}
private static function facet(): string
{
return "facet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\n";
}
private static function ascii(): string
{
return "solid mesh\n" . self::facet() . "endsolid mesh\n";
}
private static function binary(int $count = 1, string $header = 'binary'): string
{
return str_pad($header, 80, "\0") . pack('V', $count) . str_repeat(pack('g*', ...array_fill(0, 12, 0.0))
. pack('v', 0), $count);
}
private function fixture(string $contents): string
{
$path = $this->root . '/mesh.stl';
file_put_contents($path, $contents);
return $path;
}
#[DataProvider('validMeshes')]
public function testAcceptsValidMeshes(string $contents): void
{
(new StlValidator(new PathSafety($this->root)))->validate($this->fixture($contents));
self::assertTrue(true);
}
public static function validMeshes(): iterable
{
yield 'binary' => [self::binary()];
yield 'solid binary header' => [self::binary(2, 'solid mesh')];
yield 'ASCII' => [self::ascii()];
yield 'multiple facets' => ["solid\n" . self::facet() . self::facet() . 'endsolid'];
yield 'CRLF blank lines tabs' => [str_replace("\n", "\r\n\r\n", str_replace(' ', "\t", self::ascii()))];
yield 'finite decimals and exponents' => [str_replace('vertex 1 0 0', 'vertex +1. -.5 2e-3', self::ascii())];
yield 'trailing whitespace' => [self::ascii() . " \t\n"];
yield 'attribute bytes are opaque' => [substr(self::binary(), 0, -2) . "\xff\xff"];
}
#[DataProvider('invalidMeshes')]
public function testRejectsMalformedMeshes(string $contents): void
{
try {
(new StlValidator(new PathSafety($this->root)))->validate($this->fixture($contents));
self::fail('Malformed STL accepted');
} catch (ImportException $error) {
self::assertSame('STL_INVALID', $error->reason);
}
}
public static function invalidMeshes(): iterable
{
yield 'zero bytes' => [''];
yield 'empty binary' => [self::binary(0)];
yield 'empty ASCII' => ["solid empty\nendsolid empty\n"];
yield 'truncated binary header' => [str_repeat("\0", 83)];
yield 'truncated binary record' => [substr(self::binary(), 0, -1)];
yield 'binary trailing byte' => [self::binary() . 'x'];
yield 'huge triangle count' => [str_repeat("\0", 80) . pack('V', 0xffffffff)];
yield 'solid binary truncated' => [substr(self::binary(1, 'solid'), 0, -1)];
yield 'binary infinity' => [substr_replace(self::binary(), pack('V', 0x7f800000), 84, 4)];
yield 'binary NaN' => [substr_replace(self::binary(), pack('V', 0x7fc00000), 84, 4)];
yield 'missing solid' => [self::facet() . "endsolid\n"];
yield 'missing endsolid' => ["solid mesh\n" . self::facet()];
yield 'missing endfacet' => [str_replace("endfacet\n", '', self::ascii())];
yield 'missing endloop' => [str_replace("endloop\n", '', self::ascii())];
yield 'missing outer loop' => [str_replace("outer loop\n", '', self::ascii())];
yield 'two vertices' => [str_replace("vertex 1 0 0\n", '', self::ascii())];
yield 'four vertices' => [str_replace('endloop', "vertex 0 0 0\nendloop", self::ascii())];
yield 'nested facet' => [str_replace('outer loop', 'facet normal 0 0 0', self::ascii())];
yield 'trailing garbage' => [self::ascii() . 'garbage'];
yield 'second solid' => [self::ascii() . self::ascii()];
yield 'malformed keyword' => [str_replace('endloop', 'endloop garbage', self::ascii())];
yield 'short normal' => [str_replace('normal 0 0 1', 'normal 0 0', self::ascii())];
yield 'extra coordinate' => [str_replace('vertex 1 0 0', 'vertex 1 0 0 0', self::ascii())];
yield 'embedded NUL' => [str_replace('mesh', "me\0sh", self::ascii())];
yield 'overlong line' => ["solid " . str_repeat('x', 4096) . "\n" . self::facet() . 'endsolid'];
foreach (['NaN', 'INF', '-inf', '1e9999', '0x10', '1,2', '.', '1e', '1e+', '--1', '1foo'] as $number) {
yield 'bad number ' . $number => [str_replace('vertex 1 0 0', 'vertex ' . $number . ' 0 0', self::ascii())];
}
}
public function testPathSafetyRejectsTraversal(): void
{
$this->fixture(self::ascii());
$this->expectException(PathSafetyException::class);
$path = $this->root . '/../' . basename($this->root) . '/mesh.stl';
(new StlValidator(new PathSafety($this->root)))->validate($path);
}
public function testThrownReadFailureIsStructuredAndStreamClosed(): void
{
$streamSeen = null;
$cause = new \RuntimeException('injected read failure');
$reader = static function ($stream, int $length) use (&$streamSeen, $cause): string {
$streamSeen = $stream;
throw $cause;
};
try {
(new StlValidator(new PathSafety($this->root), $reader))->validate($this->fixture(self::binary()));
self::fail('Read failure accepted');
} catch (\Throwable $error) {
self::assertSame(ImportException::class, $error::class);
self::assertSame('SCAN_READ_ERROR', $error->reason);
self::assertSame($cause, $error->getPrevious());
} finally {
self::assertFalse(is_resource($streamSeen));
}
}
#[DataProvider('readFailures')]
public function testFalseAndStalledReadsAreRejected(bool $stall, bool $ascii): void
{
$calls = 0;
$streamSeen = null;
$reader = static function ($stream, int $length) use (&$calls, &$streamSeen, $stall): string|false {
$streamSeen = $stream;
return ++$calls === 2 ? ($stall ? '' : false) : fread($stream, $length);
};
try {
$path = $this->fixture($ascii ? self::ascii() : self::binary());
(new StlValidator(new PathSafety($this->root), $reader))->validate($path);
self::fail('Read failure accepted');
} catch (ImportException $error) {
self::assertSame('SCAN_READ_ERROR', $error->reason);
} finally {
self::assertFalse(is_resource($streamSeen));
}
}
public static function readFailures(): iterable
{
yield [false, false];
yield [true, false];
yield [false, true];
yield [true, true];
}
public function testShortReadsAndLargeAsciiAreStreamedWithBoundedRequests(): void
{
$path = $this->root . '/mesh.stl';
$out = fopen($path, 'wb');
fwrite($out, "solid\n");
for ($i = 0; $i < 20000; ++$i) {
fwrite($out, self::facet());
}
fwrite($out, "endsolid\n");
fclose($out);
$max = 0;
$reader = static function ($stream, int $length) use (&$max): string|false {
$max = max($max, $length);
return fread($stream, min(37, $length));
};
(new StlValidator(new PathSafety($this->root), $reader))->validate($path);
self::assertLessThanOrEqual(4096, $max);
}
public function testDirectoryIsReadFailure(): void
{
$this->expectException(ImportException::class);
$this->expectExceptionMessage('SCAN_READ_ERROR');
(new StlValidator(new PathSafety($this->root)))->validate($this->root);
}
}
+260
View File
@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Indexer\ImportException;
use MeshVault\Indexer\Filesystem\PathSafety;
use MeshVault\Indexer\StreamingHasher;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class StreamingHasherTest extends TestCase
{
private string $root;
private string $path;
protected function setUp(): void
{
$this->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;
}
}
}
+302
View File
@@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Indexer\ImportException;
use MeshVault\Exception\PathSafetyException;
use MeshVault\Indexer\Filesystem\PathSafety;
use MeshVault\Indexer\VerifiedCopier;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class VerifiedCopierTest extends TestCase
{
private string $base;
private string $root;
private string $source;
private string $data;
protected function setUp(): void
{
$this->base = dirname(__DIR__, 3) . '/.phpunit.cache/copy-' . bin2hex(random_bytes(6));
mkdir($this->base, 0777, true);
$this->base = realpath($this->base);
$this->root = $this->base . '/library';
mkdir($this->root);
$this->source = $this->base . '/source';
$this->data = str_repeat("mesh\0\xff", 50000);
file_put_contents($this->source, $this->data);
self::assertTrue(class_exists(VerifiedCopier::class), 'VerifiedCopier must exist');
}
protected function tearDown(): void
{
$remove = static function (string $path) use (&$remove): void {
if (is_link($path) || !is_dir($path)) {
unlink($path);
return;
}
foreach (scandir($path) as $name) {
if ($name !== '.' && $name !== '..') {
$remove($path . '/' . $name);
}
}
rmdir($path);
};
$remove($this->base);
}
private function copier(array $operations = []): VerifiedCopier
{
return new VerifiedCopier(new PathSafety($this->root), $operations);
}
private function copy(array $operations = [], string $destination = 'model.stl'): string
{
return $this->copier($operations)->copy(
$this->source,
$destination,
strlen($this->data),
hash('sha256', $this->data)
);
}
private function entries(): array
{
return array_values(array_diff(scandir($this->root), ['.', '..']));
}
public function testStreamsPartialWritesAndPublishesVerifiedSameDirectoryHardlink(): void
{
$reads = [];
$links = 0;
$result = $this->copy([
'read' => static function ($stream, int $length) use (&$reads) {
$reads[] = $length;
return fread($stream, $length);
},
'write' => static fn ($stream, string $bytes) => fwrite($stream, substr($bytes, 0, 777)),
'link' => function (string $temp, string $destination) use (&$links): bool {
++$links;
self::assertSame(dirname($destination), dirname($temp));
self::assertFileDoesNotExist($destination);
self::assertSame(hash('sha256', $this->data), hash_file('sha256', $temp));
self::assertSame(strlen($this->data), filesize($temp));
return link($temp, $destination);
},
]);
self::assertSame(realpath($this->root . '/model.stl'), $result);
self::assertSame($this->data, file_get_contents($result));
self::assertSame($this->data, file_get_contents($this->source));
self::assertSame(['model.stl'], $this->entries());
self::assertSame([131072], array_values(array_unique($reads)));
self::assertSame(1, $links);
}
public function testEmptyFile(): void
{
$this->data = '';
file_put_contents($this->source, '');
self::assertSame('', file_get_contents($this->copy()));
self::assertSame(['model.stl'], $this->entries());
}
public static function failures(): iterable
{
yield 'read false' => ['read', static fn () => false];
yield 'read stalls' => ['read', static fn () => ''];
yield 'write false' => ['write', static fn () => false];
yield 'write zero' => ['write', static fn () => 0];
yield 'flush' => ['flush', static fn () => false];
yield 'link' => ['link', static fn () => false];
yield 'exception' => ['write', static function () {
throw new \RuntimeException('injected');
}];
}
#[DataProvider('failures')]
public function testFailuresCleanTemporaryFiles(string $operation, callable $failure): void
{
try {
$this->copy([$operation => $failure]);
self::fail('Expected COPY_FAILED');
} catch (ImportException $error) {
self::assertSame('COPY_FAILED', $error->reason);
}
self::assertSame([], $this->entries());
self::assertSame($this->data, file_get_contents($this->source));
}
public function testFailureAfterPartialProgressCleansUp(): void
{
$calls = 0;
$this->testFailuresCleanTemporaryFiles('write', static function ($stream, $bytes) use (&$calls) {
return ++$calls === 1 ? fwrite($stream, substr($bytes, 0, 10)) : false;
});
}
public function testSizeHashAndWrittenBytesMustMatch(): void
{
foreach (['size', 'hash', 'corrupt'] as $case) {
try {
$operations = $case === 'corrupt'
? ['write' => static fn ($stream, $bytes) => fwrite($stream, str_repeat('x', strlen($bytes)))]
: [];
$this->copier($operations)->copy(
$this->source,
'model.stl',
strlen($this->data) + ($case === 'size' ? 1 : 0),
$case === 'hash' ? str_repeat('0', 64) : hash('sha256', $this->data)
);
self::fail('Expected mismatch');
} catch (ImportException $error) {
self::assertSame('HASH_MISMATCH', $error->reason);
}
self::assertSame([], $this->entries());
}
}
public function testCollisionSuffixAndConcurrentWinnerNeverOverwrite(): void
{
file_put_contents($this->root . '/model.stl', 'original');
file_put_contents($this->root . '/model_001.stl', 'first');
$calls = 0;
$result = $this->copy(['link' => static function ($temp, $destination) use (&$calls) {
if (++$calls === 1) {
file_put_contents($destination, 'racer');
return false;
}
return link($temp, $destination);
}]);
self::assertSame('model_003.stl', basename($result));
self::assertSame('original', file_get_contents($this->root . '/model.stl'));
self::assertSame('first', file_get_contents($this->root . '/model_001.stl'));
self::assertSame('racer', file_get_contents($this->root . '/model_002.stl'));
self::assertCount(4, $this->entries());
}
public function testExtensionlessCollisionAndExhaustion(): void
{
file_put_contents($this->root . '/model', 'original');
self::assertSame('model_001', basename($this->copy([], 'model')));
for ($i = 0; $i <= 999; ++$i) {
file_put_contents($this->root . '/' . ($i === 0 ? 'a.stl' : sprintf('a_%03d.stl', $i)), 'x');
}
try {
$this->copy([], 'a.stl');
self::fail('Expected collision exhaustion');
} catch (ImportException $error) {
self::assertSame('COLLISION', $error->reason);
}
self::assertCount(1002, $this->entries());
}
public function testCleanupNeverThrowsPreservesOriginalError(): void
{
// Test that if cleanup (unlink temporary) fails in the finally block,
// the original exception is preserved, not masked by the cleanup exception.
//
// Scenario: verify() throws HASH_MISMATCH, then finally block tries to unlink
// the temporary file but unlink fails (e.g., permission issue). The original
// HASH_MISMATCH should be thrown, not a COPY_FAILED from the cleanup.
$copier = new VerifiedCopier(new PathSafety($this->root), [
'read' => static fn ($stream, int $length) => fread($stream, $length),
'write' => static fn ($stream, string $bytes) => fwrite($stream, $bytes),
'flush' => static fn ($stream) => fflush($stream),
'link' => static function (): bool {
// Link succeeds
return link(...func_get_args());
},
]);
try {
// Wrong size -> HASH_MISMATCH in verify()
$copier->copy($this->source, 'model.stl', strlen($this->data) + 1, hash('sha256', $this->data));
self::fail('Expected HASH_MISMATCH');
} catch (ImportException $error) {
// The ORIGINAL error should be HASH_MISMATCH, not COPY_FAILED from cleanup
self::assertSame('HASH_MISMATCH', $error->reason, 'Original error masked by cleanup failure');
self::assertStringContainsString('differs from expected metadata', $error->getMessage());
}
}
public function testCleanupFailureInFinallyDoesNotMaskOriginalError(): void
{
// More direct test: when an exception is thrown in the try block AND
// the finally block's unlink fails, the original exception should propagate.
// We simulate this by making the temporary file read-only (chmod) so unlink
// fails on some systems, or by verifying the code path directly.
$copier = new VerifiedCopier(new PathSafety($this->root), [
'read' => static fn ($stream, int $length) => fread($stream, $length),
'write' => static fn ($stream, string $bytes) => fwrite($stream, $bytes),
'flush' => static fn ($stream) => fflush($stream),
'link' => static function (): bool {
return link(...func_get_args());
},
]);
// Trigger a HASH_MISMATCH error
try {
$copier->copy($this->source, 'model.stl', strlen($this->data) + 1, hash('sha256', $this->data));
self::fail('Expected HASH_MISMATCH');
} catch (ImportException $error) {
// The ORIGINAL error should be HASH_MISMATCH, not COPY_FAILED from cleanup
self::assertSame('HASH_MISMATCH', $error->reason);
}
// Verify the temporary file was cleaned up (cleanup ran but didn't throw)
self::assertSame([], $this->entries(), 'Temporary file should be cleaned up');
}
public function testUnsafePathsRejectedBeforeCopy(): void
{
foreach (['../escape', $this->base . '/outside', 'missing/model.stl'] as $destination) {
try {
$this->copy([], $destination);
self::fail('Expected unsafe destination rejection');
} catch (PathSafetyException | ImportException $error) {
self::assertNotSame('', $error->reason);
}
self::assertSame([], $this->entries());
}
foreach ([$this->base . '/../source', $this->base . '/missing', $this->root] as $source) {
try {
$this->copier()->copy($source, 'model.stl', 0, hash('sha256', ''));
self::fail('Expected unsafe source rejection');
} catch (PathSafetyException | ImportException $error) {
self::assertNotSame('', $error->reason);
}
self::assertSame([], $this->entries());
}
}
public function testSymlinkSourcesDestinationsAndAncestorsRejected(): void
{
$link = $this->root . '/link';
if (!@symlink($this->source, $link)) {
self::markTestSkipped('Host cannot create symlinks.');
}
foreach ([[$link, 'new'], [$this->source, 'link']] as [$source, $destination]) {
try {
$this->copier()->copy($source, $destination, strlen($this->data), hash('sha256', $this->data));
self::fail('Expected symlink rejection');
} catch (PathSafetyException $error) {
self::assertSame('SYMLINK_ESCAPE', $error->reason);
}
}
unlink($link);
self::assertTrue(symlink($this->base, $link));
try {
$this->copy([], 'link/new');
self::fail('Expected ancestor rejection');
} catch (PathSafetyException $error) {
self::assertSame('SYMLINK_ESCAPE', $error->reason);
}
self::assertSame(['link'], $this->entries());
}
}