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,418 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Tests\Integration;
|
||||
|
||||
use MeshVault\Indexer\FileEnumerator;
|
||||
use MeshVault\Indexer\Filesystem\LocalFilesystem;
|
||||
use MeshVault\Indexer\Filesystem\PathSafety;
|
||||
use MeshVault\Indexer\ImportException;
|
||||
use MeshVault\Indexer\ImportService;
|
||||
use MeshVault\Indexer\ImportWorker;
|
||||
use MeshVault\Indexer\SpaceChecker;
|
||||
use MeshVault\Indexer\StlValidator;
|
||||
use MeshVault\Indexer\StreamingHasher;
|
||||
use MeshVault\Indexer\VerifiedCopier;
|
||||
use MeshVault\Repository\PdoCatalogRepository;
|
||||
use MeshVault\Repository\PdoImportJobRepository;
|
||||
use PDO;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
final class ImportPipelineTest extends TestCase
|
||||
{
|
||||
private PDO $pdo;
|
||||
private string $database;
|
||||
private string $library;
|
||||
private string $source;
|
||||
private PdoImportJobRepository $jobs;
|
||||
private PdoCatalogRepository $catalog;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->database = tempnam(sys_get_temp_dir(), 'meshvault-pipeline-');
|
||||
$this->pdo = RepositoryDatabase::migrate($this->database);
|
||||
$this->jobs = new PdoImportJobRepository($this->pdo);
|
||||
$this->catalog = new PdoCatalogRepository($this->pdo);
|
||||
$this->library = RepositoryDatabase::directory('library');
|
||||
$this->source = RepositoryDatabase::directory('source');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
unset($this->pdo, $this->jobs, $this->catalog);
|
||||
@unlink($this->database);
|
||||
RepositoryDatabase::remove($this->library);
|
||||
RepositoryDatabase::remove($this->source);
|
||||
}
|
||||
|
||||
private function service(?SpaceChecker $space = null): ImportService
|
||||
{
|
||||
$safety = new PathSafety($this->source);
|
||||
return new ImportService(
|
||||
new FileEnumerator(new LocalFilesystem(), $safety),
|
||||
$this->jobs,
|
||||
$this->catalog,
|
||||
$space ?? new SpaceChecker(),
|
||||
$this->library,
|
||||
);
|
||||
}
|
||||
|
||||
private function worker(?callable $shouldStop = null): ImportWorker
|
||||
{
|
||||
$safety = new PathSafety($this->library);
|
||||
$sourceSafety = new PathSafety($this->source);
|
||||
return new ImportWorker(
|
||||
$this->jobs,
|
||||
$this->catalog,
|
||||
new StreamingHasher($sourceSafety),
|
||||
new VerifiedCopier($safety),
|
||||
new StlValidator($sourceSafety),
|
||||
$safety,
|
||||
new SpaceChecker(),
|
||||
$this->library,
|
||||
new NullLogger(),
|
||||
$shouldStop,
|
||||
);
|
||||
}
|
||||
|
||||
private function writeStl(string $relative, string $variant = 'default'): string
|
||||
{
|
||||
$path = $this->source . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative);
|
||||
if (!is_dir(dirname($path))) {
|
||||
mkdir(dirname($path), 0777, true);
|
||||
}
|
||||
$facet = "facet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\n";
|
||||
$content = "solid test_$variant\n" . $facet . "endsolid test_$variant\n";
|
||||
file_put_contents($path, $content);
|
||||
return $path;
|
||||
}
|
||||
|
||||
public function testImportCreatesJobWithExplicitSubfolderGrouping(): void
|
||||
{
|
||||
$this->writeStl('planes/wing.stl');
|
||||
$this->writeStl('planes/body.stl');
|
||||
$this->writeStl('loose.stl');
|
||||
$job = $this->service()->import($this->source);
|
||||
$items = $this->jobs->items($job);
|
||||
self::assertCount(3, $items);
|
||||
self::assertSame('Pending', $this->jobs->get($job)['status']);
|
||||
$models = [];
|
||||
foreach ($items as $item) {
|
||||
self::assertNotNull($item['model_id']);
|
||||
$aggregate = $this->catalog->aggregate((int) $item['model_id']);
|
||||
$models[$aggregate['model']['name']] = $aggregate['folder']['path'];
|
||||
}
|
||||
// Explicit policy: one model per immediate source subfolder; root files join
|
||||
// a model named after the source directory itself.
|
||||
self::assertSame('/planes', $models['planes']);
|
||||
self::assertSame('/' . basename($this->source), $models[basename($this->source)]);
|
||||
}
|
||||
|
||||
public function testImportRejectsEmptySourceAndInsufficientSpace(): void
|
||||
{
|
||||
try {
|
||||
$this->service()->import($this->source);
|
||||
self::fail('Empty source accepted');
|
||||
} catch (ImportException $error) {
|
||||
self::assertSame('SCAN_EMPTY', $error->reason);
|
||||
}
|
||||
$this->writeStl('planes/wing.stl');
|
||||
$full = new SpaceChecker(0.05, static fn (): float => 1.0);
|
||||
try {
|
||||
$this->service($full)->import($this->source);
|
||||
self::fail('Full disk accepted');
|
||||
} catch (ImportException $error) {
|
||||
self::assertSame('DISK_FULL', $error->reason);
|
||||
}
|
||||
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM import_jobs')->fetchColumn());
|
||||
}
|
||||
|
||||
public function testWorkerCopiesPlansAndCompletesCatalog(): void
|
||||
{
|
||||
$this->writeStl('planes/wing.stl');
|
||||
$job = $this->service()->import($this->source);
|
||||
self::assertSame($job, $this->worker()->runOnce());
|
||||
$item = $this->jobs->items($job)[0];
|
||||
self::assertSame('Completed', $item['status']);
|
||||
self::assertSame('Completed', $this->jobs->get($job)['status']);
|
||||
self::assertNotNull($item['managed_path']);
|
||||
$published = $this->library . DIRECTORY_SEPARATOR
|
||||
. str_replace('/', DIRECTORY_SEPARATOR, $item['managed_path']);
|
||||
self::assertFileExists($published);
|
||||
self::assertSame(hash_file('sha256', $published), $item['hash']);
|
||||
$files = $this->catalog->aggregate((int) $item['model_id'])['files'];
|
||||
self::assertCount(1, $files);
|
||||
self::assertSame('Present', $files[0]['status']);
|
||||
self::assertSame($item['managed_path'], $files[0]['managed_path']);
|
||||
self::assertNull($this->worker()->runOnce());
|
||||
}
|
||||
|
||||
public function testFailedItemsDoNotStopRemainingWork(): void
|
||||
{
|
||||
$this->writeStl('planes/wing.stl');
|
||||
$broken = $this->writeStl('planes/broken.stl');
|
||||
file_put_contents($broken, 'not an stl at all');
|
||||
$job = $this->service()->import($this->source);
|
||||
$this->worker()->runOnce();
|
||||
$statuses = array_column($this->jobs->items($job), 'status');
|
||||
self::assertContains('Failed', $statuses);
|
||||
self::assertContains('Completed', $statuses);
|
||||
self::assertSame('Failed', $this->jobs->get($job)['status']);
|
||||
self::assertSame(2, (int) $this->jobs->get($job)['processed_files']);
|
||||
$failure = array_values(array_filter(
|
||||
$this->jobs->items($job),
|
||||
static fn (array $item): bool => $item['status'] === 'Failed'
|
||||
))[0];
|
||||
self::assertStringContainsString('STL_INVALID', (string) $failure['failure_reason']);
|
||||
}
|
||||
|
||||
public function testCrashRecoveryCompletesPlannedItemWithoutDuplicateCopy(): void
|
||||
{
|
||||
$this->writeStl('planes/wing.stl');
|
||||
$job = $this->service()->import($this->source);
|
||||
$this->worker()->runOnce();
|
||||
$item = $this->jobs->items($job)[0];
|
||||
// Simulate a crash after the copy but before the catalog/progress commit.
|
||||
$this->pdo->exec(
|
||||
"UPDATE import_items SET status = 'Copying', completed_at = NULL WHERE id = " . (int) $item['id']
|
||||
);
|
||||
$this->pdo->exec("UPDATE import_jobs SET status = 'Copying', processed_files = 0 WHERE id = $job");
|
||||
$this->pdo->exec('DELETE FROM files');
|
||||
$this->jobs->resumeInterrupted();
|
||||
self::assertSame($job, $this->worker()->runOnce());
|
||||
self::assertSame('Completed', $this->jobs->items($job)[0]['status']);
|
||||
self::assertSame($item['managed_path'], $this->jobs->items($job)[0]['managed_path']);
|
||||
$directory = dirname($this->library . DIRECTORY_SEPARATOR
|
||||
. str_replace('/', DIRECTORY_SEPARATOR, (string) $item['managed_path']));
|
||||
self::assertCount(1, array_diff(scandir($directory), ['.', '..']));
|
||||
self::assertCount(1, $this->catalog->aggregate((int) $item['model_id'])['files']);
|
||||
}
|
||||
|
||||
public function testGracefulStopLeavesJobResumable(): void
|
||||
{
|
||||
$this->writeStl('planes/wing.stl');
|
||||
$this->writeStl('planes/body.stl');
|
||||
$job = $this->service()->import($this->source);
|
||||
$calls = 0;
|
||||
$this->worker(static function () use (&$calls): bool {
|
||||
// Stop only after the first item has been processed.
|
||||
return $calls++ > 0;
|
||||
})->runOnce();
|
||||
self::assertSame('Pending', $this->jobs->get($job)['status']);
|
||||
$statuses = array_column($this->jobs->items($job), 'status');
|
||||
self::assertContains('Pending', $statuses);
|
||||
self::assertContains('Completed', $statuses);
|
||||
self::assertSame($job, $this->worker()->runOnce());
|
||||
self::assertSame('Completed', $this->jobs->get($job)['status']);
|
||||
self::assertSame(2, (int) $this->jobs->get($job)['processed_files']);
|
||||
}
|
||||
|
||||
public function testConcurrentWorkerCannotHoldTheLibraryLock(): void
|
||||
{
|
||||
$this->writeStl('planes/wing.stl');
|
||||
$job = $this->service()->import($this->source);
|
||||
$lock = fopen($this->library . DIRECTORY_SEPARATOR . '.meshvault-import.lock', 'c');
|
||||
self::assertNotFalse($lock);
|
||||
self::assertTrue(flock($lock, LOCK_EX | LOCK_NB));
|
||||
try {
|
||||
$this->worker()->runOnce();
|
||||
self::fail('Worker ignored the global library lock');
|
||||
} catch (ImportException $error) {
|
||||
self::assertSame('LOCK_UNAVAILABLE', $error->reason);
|
||||
} finally {
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
}
|
||||
self::assertSame('Pending', $this->jobs->get($job)['status']);
|
||||
}
|
||||
|
||||
public function testTwoSameBasenameSourcesPublishWithCollisionSuffixAndBothRowsPointToRealFiles(): void
|
||||
{
|
||||
// Two sources with same basename but DIFFERENT content in different subdirectories
|
||||
$this->writeStl('planes/wing.stl', 'wing1');
|
||||
$this->writeStl('planes/sub/wing.stl', 'wing2');
|
||||
$job = $this->service()->import($this->source);
|
||||
$this->worker()->runOnce();
|
||||
|
||||
$items = $this->jobs->items($job);
|
||||
self::assertCount(2, $items);
|
||||
|
||||
// Both should complete successfully
|
||||
$statuses = array_column($items, 'status');
|
||||
self::assertSame(['Completed', 'Completed'], $statuses);
|
||||
|
||||
// Both managed_path entries should point to actual published files (with suffixes)
|
||||
foreach ($items as $item) {
|
||||
$published = $this->library . DIRECTORY_SEPARATOR
|
||||
. str_replace('/', DIRECTORY_SEPARATOR, $item['managed_path']);
|
||||
self::assertFileExists($published, "Published file missing for managed_path: " . $item['managed_path']);
|
||||
self::assertSame(hash_file('sha256', $published), $item['hash']);
|
||||
}
|
||||
|
||||
// Filenames should differ (one gets _001 suffix)
|
||||
$basenames = array_map('basename', array_column($items, 'managed_path'));
|
||||
self::assertNotSame($basenames[0], $basenames[1], 'Collision suffix not applied - both have same basename');
|
||||
self::assertMatchesRegularExpression('/^wing(_001)?\\.stl$/', $basenames[0]);
|
||||
self::assertMatchesRegularExpression('/^wing(_001)?\\.stl$/', $basenames[1]);
|
||||
}
|
||||
|
||||
public function testCrashAfterPublishBeforeCompletionResumesWithoutRecopyOrCollision(): void
|
||||
{
|
||||
$this->writeStl('planes/wing.stl');
|
||||
$job = $this->service()->import($this->source);
|
||||
$this->worker()->runOnce();
|
||||
$item = $this->jobs->items($job)[0];
|
||||
|
||||
// Simulate crash AFTER copy but BEFORE catalog/progress commit
|
||||
// Set item to Copying (plan already committed, file already copied)
|
||||
$this->pdo->exec(
|
||||
"UPDATE import_items SET status = 'Copying', completed_at = NULL WHERE id = " . (int) $item['id']
|
||||
);
|
||||
$this->pdo->exec("UPDATE import_jobs SET status = 'Copying', processed_files = 0 WHERE id = $job");
|
||||
$this->pdo->exec('DELETE FROM files'); // Remove catalog entry but keep file on disk
|
||||
|
||||
// Resume and run again
|
||||
$this->jobs->resumeInterrupted();
|
||||
$resumedJob = $this->worker()->runOnce();
|
||||
self::assertSame($job, $resumedJob);
|
||||
|
||||
// Item should complete without re-copying
|
||||
$resumedItem = $this->jobs->items($job)[0];
|
||||
self::assertSame('Completed', $resumedItem['status']);
|
||||
self::assertSame($item['managed_path'], $resumedItem['managed_path'], 'managed_path changed on resume');
|
||||
|
||||
// Only ONE file should exist in the destination directory (no duplicate copy)
|
||||
$directory = dirname($this->library . DIRECTORY_SEPARATOR
|
||||
. str_replace('/', DIRECTORY_SEPARATOR, (string) $item['managed_path']));
|
||||
self::assertCount(1, array_diff(scandir($directory), ['.', '..']), 'Duplicate file created on resume');
|
||||
|
||||
// Catalog should have exactly one entry
|
||||
self::assertCount(1, $this->catalog->aggregate((int) $item['model_id'])['files']);
|
||||
}
|
||||
|
||||
public function testAdoptExistingRevalidatesWithPathSafetyAndRejectsLinks(): void
|
||||
{
|
||||
// Create a legitimate file first
|
||||
$this->writeStl('planes/wing.stl');
|
||||
$job = $this->service()->import($this->source);
|
||||
$this->worker()->runOnce();
|
||||
$item = $this->jobs->items($job)[0];
|
||||
$managedPath = $item['managed_path'];
|
||||
$publishedPath = $this->library . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $managedPath);
|
||||
|
||||
// Verify it exists and is a regular file
|
||||
self::assertFileExists($publishedPath);
|
||||
self::assertFalse(is_link($publishedPath));
|
||||
|
||||
// Now simulate a crash and replace the file with a symlink
|
||||
$this->pdo->exec(
|
||||
"UPDATE import_items SET status = 'Copying', completed_at = NULL WHERE id = " . (int) $item['id']
|
||||
);
|
||||
$this->pdo->exec("UPDATE import_jobs SET status = 'Copying', processed_files = 0 WHERE id = $job");
|
||||
$this->pdo->exec('DELETE FROM files');
|
||||
|
||||
// Replace the file with a symlink pointing outside the library
|
||||
$outside = $this->source . DIRECTORY_SEPARATOR . 'outside.stl';
|
||||
file_put_contents($outside, 'malicious');
|
||||
unlink($publishedPath);
|
||||
if (!@symlink($outside, $publishedPath)) {
|
||||
self::markTestSkipped('Host cannot create symlinks.');
|
||||
}
|
||||
|
||||
// Resume - should reject the symlink and fail the item
|
||||
$this->jobs->resumeInterrupted();
|
||||
$this->worker()->runOnce();
|
||||
|
||||
$resumedItem = $this->jobs->items($job)[0];
|
||||
self::assertSame('Failed', $resumedItem['status']);
|
||||
self::assertStringContainsString('SYMLINK_ESCAPE', $resumedItem['failure_reason']);
|
||||
}
|
||||
|
||||
public function testSpaceCheckerPreflightBeforeEachCopy(): void
|
||||
{
|
||||
$this->writeStl('planes/wing.stl');
|
||||
$this->writeStl('planes/body.stl');
|
||||
$job = $this->service()->import($this->source);
|
||||
|
||||
// Simulate disk full on second item
|
||||
$spaceChecks = 0;
|
||||
$spaceChecker = new SpaceChecker(0.05, static function (string $path) use (&$spaceChecks): float {
|
||||
$spaceChecks++;
|
||||
// First check passes, second fails
|
||||
return $spaceChecks === 1 ? 1000000.0 : 100.0;
|
||||
});
|
||||
|
||||
// Create worker with custom SpaceChecker
|
||||
$safety = new PathSafety($this->library);
|
||||
$sourceSafety = new PathSafety($this->source);
|
||||
$worker = new ImportWorker(
|
||||
$this->jobs,
|
||||
$this->catalog,
|
||||
new StreamingHasher($sourceSafety),
|
||||
new VerifiedCopier($safety),
|
||||
new StlValidator($sourceSafety),
|
||||
$safety,
|
||||
$spaceChecker,
|
||||
$this->library,
|
||||
new NullLogger(),
|
||||
);
|
||||
$worker->runOnce();
|
||||
|
||||
$items = $this->jobs->items($job);
|
||||
$statuses = array_column($items, 'status');
|
||||
|
||||
// First item should complete, second should fail with DISK_FULL
|
||||
self::assertSame('Completed', $statuses[0]);
|
||||
self::assertSame('Failed', $statuses[1]);
|
||||
self::assertStringContainsString('DISK_FULL', $items[1]['failure_reason']);
|
||||
|
||||
// Job should be Failed overall but first item completed
|
||||
self::assertSame('Failed', $this->jobs->get($job)['status']);
|
||||
}
|
||||
|
||||
public function testWorkerUsesCanonicalLibraryRootForRelativePaths(): void
|
||||
{
|
||||
// Test that the worker uses canonical (realpath) library root for
|
||||
// computing relative paths, not the raw string which might have
|
||||
// symlinks or case differences.
|
||||
//
|
||||
// Create library with a symlink in the path
|
||||
$realLibrary = $this->library;
|
||||
$linkLibrary = $realLibrary . '-link';
|
||||
if (!@symlink($realLibrary, $linkLibrary)) {
|
||||
self::markTestSkipped('Host cannot create symlinks.');
|
||||
}
|
||||
|
||||
$this->writeStl('planes/wing.stl');
|
||||
$job = $this->service()->import($this->source);
|
||||
|
||||
// Create worker with symlinked library root
|
||||
$safety = new PathSafety($linkLibrary);
|
||||
$sourceSafety = new PathSafety($this->source);
|
||||
$worker = new ImportWorker(
|
||||
$this->jobs,
|
||||
$this->catalog,
|
||||
new StreamingHasher($sourceSafety),
|
||||
new VerifiedCopier($safety),
|
||||
new StlValidator($sourceSafety),
|
||||
$safety,
|
||||
new SpaceChecker(),
|
||||
$linkLibrary, // Symlinked path
|
||||
new NullLogger(),
|
||||
);
|
||||
|
||||
$worker->runOnce();
|
||||
$item = $this->jobs->items($job)[0];
|
||||
|
||||
// The managed_path should be relative to the CANONICAL root, not the symlink
|
||||
self::assertStringNotContainsString('link', $item['managed_path']);
|
||||
$published = $this->library . DIRECTORY_SEPARATOR
|
||||
. str_replace('/', DIRECTORY_SEPARATOR, $item['managed_path']);
|
||||
self::assertFileExists($published);
|
||||
|
||||
// Cleanup
|
||||
unlink($linkLibrary);
|
||||
}
|
||||
}
|
||||
@@ -79,4 +79,4 @@ final class MigrationTest extends TestCase
|
||||
$this->assertContains('completed', $tags);
|
||||
$this->assertContains('print-ready', $tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Tests\Integration;
|
||||
|
||||
use PDO;
|
||||
use Phinx\Config\Config;
|
||||
use Phinx\Migration\Manager;
|
||||
use Symfony\Component\Console\Input\ArrayInput;
|
||||
use Symfony\Component\Console\Output\NullOutput;
|
||||
|
||||
/** Runs the production migrations against SQLite and manages scratch directories. */
|
||||
final class RepositoryDatabase
|
||||
{
|
||||
/** Never a hand-maintained test schema: drift must fail the suite. */
|
||||
public static function migrate(string $path): PDO
|
||||
{
|
||||
$pdo = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||
$config = new Config([
|
||||
'paths' => ['migrations' => dirname(__DIR__, 2) . '/db/migrations'],
|
||||
'environments' => [
|
||||
'default_migration_table' => 'phinxlog',
|
||||
'default_database' => 'test',
|
||||
'test' => ['adapter' => 'sqlite', 'connection' => $pdo],
|
||||
],
|
||||
]);
|
||||
(new Manager($config, new ArrayInput([]), new NullOutput()))->migrate('test');
|
||||
$pdo->exec('PRAGMA foreign_keys = ON');
|
||||
$pdo->exec('PRAGMA busy_timeout = 5000');
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
public static function directory(string $prefix): string
|
||||
{
|
||||
$path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'meshvault-' . $prefix . '-' . bin2hex(random_bytes(6));
|
||||
mkdir($path, 0777, true);
|
||||
return (string) realpath($path);
|
||||
}
|
||||
|
||||
public static function remove(string $path): void
|
||||
{
|
||||
foreach (array_diff(@scandir($path) ?: [], ['.', '..']) as $entry) {
|
||||
$child = $path . DIRECTORY_SEPARATOR . $entry;
|
||||
is_dir($child) ? self::remove($child) : @unlink($child);
|
||||
}
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Tests\Integration;
|
||||
|
||||
use DomainException;
|
||||
use MeshVault\Repository\CatalogRepositoryInterface;
|
||||
use MeshVault\Repository\ImportJobRepositoryInterface;
|
||||
use MeshVault\Repository\PdoCatalogRepository;
|
||||
use MeshVault\Repository\PdoImportJobRepository;
|
||||
use PDO;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
final class RepositoryTest extends TestCase
|
||||
{
|
||||
private PDO $pdo;
|
||||
private string $database;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->database = tempnam(sys_get_temp_dir(), 'meshvault-repository-');
|
||||
$this->pdo = RepositoryDatabase::migrate($this->database);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
unset($this->pdo);
|
||||
unlink($this->database);
|
||||
}
|
||||
|
||||
public function testCatalogGroupingAndFileLifecycle(): void
|
||||
{
|
||||
self::assertTrue(class_exists(PdoCatalogRepository::class), 'Catalog repository must exist');
|
||||
$catalog = new PdoCatalogRepository($this->pdo);
|
||||
self::assertInstanceOf(CatalogRepositoryInterface::class, $catalog);
|
||||
$model = $catalog->ensureModel('/vehicles/aircraft', 'Plane');
|
||||
self::assertSame($model, $catalog->ensureModel('/vehicles/aircraft', 'Plane'));
|
||||
self::assertNotSame($model, $catalog->ensureModel('/vehicles/aircraft', 'Other'));
|
||||
$root = $catalog->ensureModel('/', 'Root');
|
||||
self::assertNull($catalog->aggregate($root)['folder']['parent_id']);
|
||||
self::assertSame(3, (int) $this->pdo->query('SELECT COUNT(*) FROM folders')->fetchColumn());
|
||||
$file = $catalog->recordFile($model, 'vehicles/aircraft/wing.stl', str_repeat('a', 64), 12, 'model/stl');
|
||||
self::assertSame($file, $catalog->recordFile($model, 'vehicles/aircraft/wing.stl', str_repeat('a', 64), 12));
|
||||
foreach (['Missing', 'Updated', 'Duplicate', 'Present'] as $status) {
|
||||
$catalog->setFileStatus($file, $status);
|
||||
self::assertSame($status, $catalog->aggregate($model)['files'][0]['status']);
|
||||
}
|
||||
$aggregate = $catalog->aggregate($model);
|
||||
self::assertSame('Plane', $aggregate['model']['name']);
|
||||
self::assertSame('/vehicles/aircraft', $aggregate['folder']['path']);
|
||||
self::assertSame('model/stl', $aggregate['files'][0]['mime_type']);
|
||||
self::assertNull($catalog->aggregate(99999));
|
||||
$this->expectException(DomainException::class);
|
||||
$catalog->recordFile($model, 'vehicles/aircraft/wing.stl', str_repeat('b', 64), 12);
|
||||
}
|
||||
|
||||
public function testRecoveryReleaseCancellationFailureAndEmptyJobs(): void
|
||||
{
|
||||
self::assertTrue(class_exists(PdoImportJobRepository::class));
|
||||
$jobs = new PdoImportJobRepository($this->pdo);
|
||||
$manifest = [['source_path' => '/a', 'size' => 5, 'model_id' => null]];
|
||||
$job = $jobs->create('/', $manifest);
|
||||
$jobs->claimNext();
|
||||
$item = (int) $jobs->items($job)[0]['id'];
|
||||
$jobs->planItem($item, 'a', 'hash', 5);
|
||||
$jobs->release($job);
|
||||
self::assertSame('Pending', $jobs->get($job)['status']);
|
||||
$jobs->claimNext();
|
||||
$jobs->resumeInterrupted();
|
||||
self::assertSame('Pending', $jobs->items($job)[0]['status']);
|
||||
self::assertSame('a', $jobs->items($job)[0]['managed_path']);
|
||||
$this->pdo->exec("UPDATE import_jobs SET status = 'Scanning' WHERE id = $job");
|
||||
$jobs->resumeInterrupted();
|
||||
self::assertSame('Pending', $jobs->get($job)['status']);
|
||||
$jobs->cancel($job);
|
||||
$jobs->cancel($job);
|
||||
$jobs->resumeInterrupted();
|
||||
self::assertSame('Cancelled', $jobs->get($job)['status']);
|
||||
self::assertSame('Skipped', $jobs->items($job)[0]['status']);
|
||||
$failed = $jobs->create('/', $manifest);
|
||||
$jobs->fail($failed, 'disk full');
|
||||
$jobs->fail($failed, 'disk full');
|
||||
self::assertSame('Failed', $jobs->get($failed)['status']);
|
||||
self::assertSame('disk full', $jobs->items($failed)[0]['failure_reason']);
|
||||
$empty = $jobs->create('/', []);
|
||||
$jobs->completeEmpty($empty);
|
||||
$jobs->completeEmpty($empty);
|
||||
self::assertSame('Completed', $jobs->get($empty)['status']);
|
||||
$scan = $jobs->create('/', [
|
||||
['source_path' => '/bad', 'size' => 0, 'model_id' => null,
|
||||
'status' => 'Failed', 'failure_reason' => 'scan failed'],
|
||||
]);
|
||||
self::assertSame('Failed', $jobs->get($scan)['status']);
|
||||
self::assertSame(1, (int) $jobs->get($scan)['processed_files']);
|
||||
self::assertNull($jobs->claimNext());
|
||||
}
|
||||
|
||||
public function testImmutablePlansAndSuccessfulCompletion(): void
|
||||
{
|
||||
self::assertTrue(class_exists(PdoImportJobRepository::class));
|
||||
$jobs = new PdoImportJobRepository($this->pdo);
|
||||
$job = $jobs->create('/', [['source_path' => '/a', 'size' => 5, 'model_id' => null]]);
|
||||
$jobs->claimNext();
|
||||
$item = (int) $jobs->items($job)[0]['id'];
|
||||
$jobs->planItem($item, 'a', 'hash', 5);
|
||||
foreach ([['b', 'hash', 5], ['a', 'other', 5], ['a', 'hash', 6]] as $plan) {
|
||||
try {
|
||||
$jobs->planItem($item, ...$plan);
|
||||
self::fail('Plan mutation accepted');
|
||||
} catch (DomainException) {
|
||||
self::assertSame('a', $jobs->items($job)[0]['managed_path']);
|
||||
}
|
||||
}
|
||||
$jobs->finishItem($item, 'Completed');
|
||||
self::assertSame('Completed', $jobs->get($job)['status']);
|
||||
$this->expectException(DomainException::class);
|
||||
$jobs->finishItem($item, 'Failed', 'late failure');
|
||||
}
|
||||
|
||||
public function testAtomicRollbackAndIndependentClaims(): void
|
||||
{
|
||||
self::assertTrue(class_exists(PdoImportJobRepository::class));
|
||||
$jobs = new PdoImportJobRepository($this->pdo);
|
||||
$catalog = new PdoCatalogRepository($this->pdo);
|
||||
$job = $jobs->create('/', [['source_path' => '/a', 'size' => 5, 'model_id' => null]]);
|
||||
$second = $jobs->create('/', []);
|
||||
$other = new PdoImportJobRepository(new PDO('sqlite:' . $this->database));
|
||||
self::assertSame($job, (int) $jobs->claimNext()['id']);
|
||||
self::assertSame($second, (int) $other->claimNext()['id']);
|
||||
self::assertNull($other->claimNext());
|
||||
try {
|
||||
$jobs->atomic(function () use ($jobs, $catalog, $job): void {
|
||||
$model = $catalog->ensureModel('/rollback', 'Rollback');
|
||||
$catalog->recordFile($model, 'rollback/a', 'hash', 5);
|
||||
$jobs->finishItem((int) $jobs->items($job)[0]['id'], 'Completed');
|
||||
throw new RuntimeException('rollback');
|
||||
});
|
||||
self::fail('Expected rollback');
|
||||
} catch (RuntimeException $error) {
|
||||
self::assertSame('rollback', $error->getMessage());
|
||||
}
|
||||
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM models')->fetchColumn());
|
||||
self::assertSame('Pending', $jobs->items($job)[0]['status']);
|
||||
self::assertSame(42, $jobs->atomic(static fn (): int => 42));
|
||||
$before = (int) $this->pdo->query('SELECT COUNT(*) FROM import_jobs')->fetchColumn();
|
||||
try {
|
||||
$jobs->create('/', [
|
||||
['source_path' => '/valid', 'size' => 1, 'model_id' => null],
|
||||
['source_path' => '/invalid', 'size' => 1, 'model_id' => 99999],
|
||||
]);
|
||||
self::fail('Expected foreign key rejection');
|
||||
} catch (\PDOException) {
|
||||
self::assertSame($before, (int) $this->pdo->query('SELECT COUNT(*) FROM import_jobs')->fetchColumn());
|
||||
}
|
||||
}
|
||||
|
||||
public function testManifestClaimPlanProgressAndIdempotency(): void
|
||||
{
|
||||
self::assertTrue(class_exists(PdoImportJobRepository::class), 'Job repository must exist');
|
||||
$jobs = new PdoImportJobRepository($this->pdo);
|
||||
self::assertInstanceOf(ImportJobRepositoryInterface::class, $jobs);
|
||||
$job = $jobs->create('/source', [
|
||||
['source_path' => '/source/a', 'size' => 10, 'model_id' => null],
|
||||
['source_path' => '/source/b', 'size' => 20, 'model_id' => null],
|
||||
['source_path' => '/source/c', 'size' => 30, 'model_id' => null],
|
||||
]);
|
||||
self::assertSame('Pending', $jobs->get($job)['status']);
|
||||
self::assertSame(60, (int) $jobs->get($job)['total_bytes']);
|
||||
self::assertSame($job, (int) $jobs->claimNext()['id']);
|
||||
self::assertNotNull($jobs->get($job)['started_at']);
|
||||
self::assertNull($jobs->claimNext());
|
||||
$items = $jobs->items($job);
|
||||
$jobs->planItem((int) $items[0]['id'], 'a.stl', str_repeat('a', 64), 10);
|
||||
$jobs->planItem((int) $items[0]['id'], 'a.stl', str_repeat('a', 64), 10);
|
||||
self::assertSame('Copying', $jobs->items($job)[0]['status']);
|
||||
self::assertSame('a.stl', $jobs->items($job)[0]['managed_path']);
|
||||
$jobs->finishItem((int) $items[0]['id'], 'Completed');
|
||||
$jobs->finishItem((int) $items[0]['id'], 'Completed');
|
||||
self::assertSame(1, (int) $jobs->get($job)['processed_files']);
|
||||
self::assertSame(10, (int) $jobs->get($job)['copied_bytes']);
|
||||
$jobs->finishItem((int) $items[1]['id'], 'Skipped');
|
||||
$jobs->finishItem((int) $items[2]['id'], 'Failed', 'unreadable');
|
||||
self::assertSame('Failed', $jobs->get($job)['status']);
|
||||
self::assertSame(3, (int) $jobs->get($job)['processed_files']);
|
||||
self::assertSame('unreadable', $jobs->items($job)[2]['failure_reason']);
|
||||
self::assertNotNull($jobs->get($job)['completed_at']);
|
||||
self::assertNull($jobs->get(99999));
|
||||
self::assertSame([], $jobs->items(99999));
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,14 @@ final class TaskRunnerTest extends TestCase
|
||||
self::assertStringContainsString('compose', $output);
|
||||
self::assertStringContainsString(str_replace('\\', '/', $root), str_replace('\\', '/', $output));
|
||||
}
|
||||
// Import engine entrypoints must reach bin/meshvault with their arguments intact.
|
||||
foreach (['import', 'worker', 'worker-once'] as $command) {
|
||||
[$code, $output] = $this->invoke([...$runner, $command, '--probe'], $temporary, $environment);
|
||||
self::assertSame(37, $code, $command . ': ' . $output);
|
||||
self::assertStringContainsString('bin/meshvault', $output);
|
||||
self::assertStringContainsString($command, $output);
|
||||
self::assertStringContainsString('--probe', $output);
|
||||
}
|
||||
foreach (['unknown'] as $command) {
|
||||
[$code, $output] = $this->invoke([...$runner, $command], $temporary, $environment);
|
||||
self::assertNotSame(0, $code);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user