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

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

No files matched your search

+280
View File
@@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Indexer\Contracts\FileEnumeratorInterface;
use MeshVault\Indexer\Contracts\FilesystemInterface;
use MeshVault\Indexer\FileEnumerator;
use MeshVault\Indexer\Filesystem\LocalFilesystem;
use MeshVault\Indexer\Filesystem\PathSafety;
use MeshVault\Indexer\ScanError;
use MeshVault\Indexer\ScannedFile;
use MeshVault\Indexer\ScanResult;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class FileEnumeratorTest extends TestCase
{
private string $root;
protected function setUp(): void
{
$this->root = dirname(__DIR__, 3) . '/.phpunit.cache/scan-' . bin2hex(random_bytes(6));
mkdir($this->root, 0777, true);
self::assertTrue(class_exists(FileEnumerator::class), 'FileEnumerator must exist');
}
protected function tearDown(): void
{
$this->removeTree($this->root);
}
private function removeTree(string $path): void
{
if (is_link($path)) {
if (PHP_OS_FAMILY === 'Windows' && is_dir($path)) {
rmdir($path);
} else {
unlink($path);
}
} elseif (is_dir($path)) {
foreach (scandir($path) as $entry) {
if ($entry !== '.' && $entry !== '..') {
$this->removeTree($path . '/' . $entry);
}
}
rmdir($path);
} else {
unlink($path);
}
}
private function scanner(?FilesystemInterface $fs = null, int $maxDepth = 128): FileEnumerator
{
return new FileEnumerator($fs ?? new LocalFilesystem(), new PathSafety($this->root), $maxDepth);
}
public function testNestedMixedCaseDefaultsAndMetadata(): void
{
mkdir($this->root . '/nested/deep', 0777, true);
$names = ['part.StL', 'a.JPG', 'b.jPeG', 'c.PNG', 'd.GiF', 'e.PDF', 'f.TXT', 'g.mD'];
foreach ($names as $name) {
file_put_contents($this->root . '/nested/deep/' . $name, "hello world\n");
}
file_put_contents($this->root . '/ignored.exe', 'ignore');
file_put_contents($this->root . '/extensionless', 'ignore');
$scanner = $this->scanner();
self::assertInstanceOf(FileEnumeratorInterface::class, $scanner);
$result = $scanner->enumerate($this->root);
self::assertInstanceOf(ScanResult::class, $result);
self::assertSame([], $result->errors);
self::assertCount(8, $result->files);
self::assertSame(96, $result->totalBytes);
$relative = [];
foreach ($result->files as $file) {
self::assertInstanceOf(ScannedFile::class, $file);
self::assertSame(realpath($this->root . '/' . $file->relativePath), $file->sourcePath);
self::assertSame(12, $file->size);
self::assertSame('text/plain', $file->mimeType);
$relative[] = $file->relativePath;
}
$expected = array_map(static fn (string $name): string => 'nested/deep/' . $name, $names);
sort($expected);
sort($relative);
self::assertSame($expected, $relative);
}
public function testEmptyDirectoryAndExplicitAllowlists(): void
{
self::assertEquals(new ScanResult([], 0, []), $this->scanner()->enumerate($this->root));
file_put_contents($this->root . '/keep.TXT', '123');
file_put_contents($this->root . '/skip.stl', '1234');
$result = $this->scanner()->enumerate($this->root, ['tXt']);
self::assertSame(['keep.TXT'], array_column($result->files, 'relativePath'));
self::assertSame(3, $result->totalBytes);
self::assertEquals(new ScanResult([], 0, []), $this->scanner()->enumerate($this->root, []));
}
/** @return iterable<string, array{string}> */
public static function failures(): iterable
{
foreach (['isReadable', 'getSize', 'getMimeType', 'realpath', 'isFile', 'readDirectory'] as $method) {
yield $method => [$method];
}
}
#[DataProvider('failures')]
public function testReadFailuresContinueSiblings(string $operation): void
{
file_put_contents($this->root . '/good.txt', 'good');
$bad = $this->root . '/bad.txt';
if ($operation === 'readDirectory') {
mkdir($bad);
file_put_contents($bad . '/hidden.txt', 'hidden');
} else {
file_put_contents($bad, 'bad');
}
$result = $this->scanner($this->failingFilesystem($operation, $bad))->enumerate($this->root);
self::assertSame(['good.txt'], array_column($result->files, 'relativePath'));
self::assertSame(4, $result->totalBytes);
self::assertCount(1, $result->errors);
self::assertSame('SCAN_READ_ERROR', $result->errors[0]->code);
self::assertSame(realpath($bad), $result->errors[0]->sourcePath);
self::assertNotSame('', $result->errors[0]->message);
}
private function failingFilesystem(string $operation, string $bad, mixed $failure = false): FilesystemInterface
{
$local = new LocalFilesystem();
$fs = $this->createMock(FilesystemInterface::class);
$bad = realpath($bad);
foreach (
['realpath', 'isReadable', 'isFile', 'isDir', 'isSymlink',
'getSize', 'getMimeType', 'readDirectory'] as $method
) {
$fs->method($method)->willReturnCallback(
static fn (string $path) => $method === $operation && realpath($path) === $bad
? $failure : $local->$method($path)
);
}
$fs->expects(self::never())->method('readStream');
return $fs;
}
public function testRootReadFailures(): void
{
foreach (['isReadable', 'readDirectory', 'realpath'] as $operation) {
$result = $this->scanner($this->failingFilesystem($operation, $this->root))->enumerate($this->root);
self::assertSame([], $result->files);
self::assertCount(1, $result->errors);
self::assertSame('SCAN_READ_ERROR', $result->errors[0]->code);
}
}
public function testDepthIsBoundedWithoutLosingSiblings(): void
{
mkdir($this->root . '/a/b', 0777, true);
file_put_contents($this->root . '/a/good.txt', 'ok');
file_put_contents($this->root . '/a/b/too-deep.txt', 'no');
$result = $this->scanner(maxDepth: 1)->enumerate($this->root);
self::assertSame(['a/good.txt'], array_column($result->files, 'relativePath'));
self::assertCount(1, $result->errors);
self::assertSame('SCAN_READ_ERROR', $result->errors[0]->code);
self::assertStringContainsString('depth', $result->errors[0]->message);
}
/** @return iterable<string, array{string}> */
public static function linkTypes(): iterable
{
foreach (['file', 'directory', 'dangling', 'ancestor'] as $type) {
yield $type => [$type];
}
}
#[DataProvider('linkTypes')]
public function testSymlinksAreReportedAndNeverFollowed(string $type): void
{
mkdir($this->root . '/source');
mkdir($this->root . '/target');
file_put_contents($this->root . '/target/outside.txt', 'outside');
file_put_contents($this->root . '/source/good.txt', 'good');
$target = match ($type) {
'file' => $this->root . '/target/outside.txt',
'dangling' => $this->root . '/missing',
default => $this->root . '/target',
};
$link = $this->root . '/source/link.exe';
if (!function_exists('symlink') || !@symlink($target, $link)) {
self::markTestSkipped('Creating symlinks is not supported or permitted on this host.');
}
$source = $type === 'ancestor' ? $link . '/outside.txt' : $this->root . '/source';
$result = $this->scanner()->enumerate($source);
self::assertSame($type === 'ancestor' ? [] : ['good.txt'], array_column($result->files, 'relativePath'));
self::assertCount(1, $result->errors);
self::assertSame('SYMLINK_ESCAPE', $result->errors[0]->code);
$rootResult = $this->scanner()->enumerate($link);
self::assertSame('SYMLINK_ESCAPE', $rootResult->errors[0]->code);
}
public function testPosixBackslashAncestorCannotSelectADifferentTree(): void
{
if (PHP_OS_FAMILY === 'Windows') {
self::markTestSkipped('Backslash-named ancestors require POSIX.');
}
mkdir($this->root . '/a/b/child', 0777, true);
mkdir($this->root . '/target/child', 0777, true);
file_put_contents($this->root . '/a/b/child/decoy.txt', 'decoy');
file_put_contents($this->root . '/target/child/outside.txt', 'outside');
$link = $this->root . '/a\\b';
if (!function_exists('symlink') || !@symlink($this->root . '/target', $link)) {
self::markTestSkipped('Creating symlinks is not supported or permitted on this host.');
}
$result = $this->scanner()->enumerate($link . '/child');
self::assertSame([], $result->files);
self::assertSame(0, $result->totalBytes);
self::assertCount(1, $result->errors);
self::assertSame('SCAN_READ_ERROR', $result->errors[0]->code);
}
public function testTraversalRootAndEntriesRetainTheirCode(): void
{
$result = $this->scanner()->enumerate($this->root . '/../');
self::assertSame([], $result->files);
self::assertSame('PATH_TRAVERSAL', $result->errors[0]->code);
$fs = $this->failingFilesystem('readDirectory', $this->root, ['..']);
$result = $this->scanner($fs)->enumerate($this->root);
self::assertSame([], $result->files);
self::assertSame('PATH_TRAVERSAL', $result->errors[0]->code);
}
public function testZeroByteFileIsAccepted(): void
{
file_put_contents($this->root . '/empty.txt', '');
$result = $this->scanner()->enumerate($this->root);
self::assertSame([], $result->errors);
self::assertCount(1, $result->files);
self::assertSame(0, $result->files[0]->size);
self::assertSame(0, $result->totalBytes);
self::assertNotSame('', $result->files[0]->mimeType);
}
public function testInvalidMetadataContinuesSiblings(): void
{
file_put_contents($this->root . '/good.txt', 'good');
$bad = $this->root . '/bad.txt';
file_put_contents($bad, 'bad');
foreach (['getSize' => -1, 'getMimeType' => ''] as $operation => $value) {
$result = $this->scanner($this->failingFilesystem($operation, $bad, $value))->enumerate($this->root);
self::assertSame(['good.txt'], array_column($result->files, 'relativePath'));
self::assertSame(4, $result->totalBytes);
self::assertCount(1, $result->errors);
self::assertSame('SCAN_READ_ERROR', $result->errors[0]->code);
self::assertStringContainsString('check permissions', $result->errors[0]->message);
}
}
public function testMissingFileAndInvalidRootsAreActionable(): void
{
file_put_contents($this->root . '/file.txt', 'text');
foreach ([$this->root . '/absent', $this->root . '/file.txt', '', "bad\0path"] as $root) {
$result = $this->scanner()->enumerate($root);
self::assertSame([], $result->files);
self::assertSame(0, $result->totalBytes);
self::assertCount(1, $result->errors);
self::assertInstanceOf(ScanError::class, $result->errors[0]);
self::assertSame($root, $result->errors[0]->sourcePath);
self::assertSame('SCAN_READ_ERROR', $result->errors[0]->code);
self::assertStringContainsString('source root', $result->errors[0]->message);
self::assertStringContainsString('Choose', $result->errors[0]->message);
self::assertStringContainsString('directory', $result->errors[0]->message);
if ($root === '' || str_contains($root, "\0")) {
self::assertStringContainsString('non-empty', $result->errors[0]->message);
self::assertStringContainsString('NUL', $result->errors[0]->message);
} else {
self::assertStringContainsString('existing', $result->errors[0]->message);
}
}
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Indexer\Contracts\FilesystemInterface;
use MeshVault\Indexer\Filesystem\LocalFilesystem;
use PHPUnit\Framework\TestCase;
final class LocalFilesystemTest extends TestCase
{
private string $root;
protected function setUp(): void
{
$this->root = dirname(__DIR__, 3) . '/.phpunit.cache/fs-' . bin2hex(random_bytes(6));
mkdir($this->root, 0777, true);
self::assertTrue(class_exists(LocalFilesystem::class), 'LocalFilesystem must exist');
}
protected function tearDown(): void
{
foreach (glob($this->root . '/*') as $path) {
if (is_link($path) || is_file($path)) {
unlink($path);
} else {
if (is_dir($path . '/nested')) {
rmdir($path . '/nested');
}
rmdir($path);
}
}
rmdir($this->root);
}
public function testNativeMetadataAndStreamOperations(): void
{
$fs = new LocalFilesystem();
self::assertInstanceOf(FilesystemInterface::class, $fs);
$path = $this->root . '/sample.txt';
$stream = $fs->writeStream($path);
self::assertIsResource($stream);
try {
self::assertSame(12, fwrite($stream, "hello world\n"));
} finally {
fclose($stream);
}
self::assertSame(realpath($path), $fs->realpath($path));
self::assertTrue($fs->isReadable($path));
self::assertTrue($fs->isFile($path));
self::assertFalse($fs->isDir($path));
self::assertFalse($fs->isSymlink($path));
self::assertTrue($fs->isDir($this->root));
self::assertSame(12, $fs->getSize($path));
self::assertSame('text/plain', $fs->getMimeType($path));
self::assertGreaterThan(0, $fs->freeSpace($this->root));
$stream = $fs->readStream($path);
self::assertIsResource($stream);
try {
self::assertSame("hello world\n", stream_get_contents($stream));
} finally {
fclose($stream);
}
self::assertTrue($fs->rename($path, $this->root . '/renamed.txt'));
self::assertFalse($fs->isFile($path));
self::assertTrue($fs->unlink($this->root . '/renamed.txt'));
self::assertTrue($fs->mkdir($this->root . '/directory/nested', 0777, true));
self::assertTrue($fs->isDir($this->root . '/directory/nested'));
}
public function testDirectoryEntriesExcludeDotsAndFailuresReturnFalse(): void
{
$fs = new LocalFilesystem();
self::assertSame([], $fs->readDirectory($this->root));
file_put_contents($this->root . '/file.txt', 'text');
mkdir($this->root . '/nested');
self::assertSame(['file.txt', 'nested'], $fs->readDirectory($this->root));
self::assertFalse($fs->readDirectory($this->root . '/missing'));
self::assertFalse($fs->readDirectory($this->root . '/file.txt'));
}
public function testFailuresReturnFalseWithoutWarnings(): void
{
$fs = new LocalFilesystem();
$missing = $this->root . '/absent/file';
foreach (
['realpath', 'isReadable', 'isFile', 'isDir', 'isSymlink', 'getSize', 'getMimeType',
'readStream', 'writeStream', 'unlink', 'freeSpace'] as $operation
) {
self::assertFalse($fs->$operation($missing), $operation);
}
self::assertFalse($fs->rename($missing, $this->root . '/other'));
self::assertFalse($fs->mkdir($missing));
self::assertFalse($fs->mkdir($this->root));
}
public function testSymlinkMetadataIncludesDanglingLinks(): void
{
$link = $this->root . '/link';
if (!function_exists('symlink') || !@symlink($this->root . '/absent', $link)) {
self::markTestSkipped('Creating symlinks is not supported or permitted on this host.');
}
$fs = new LocalFilesystem();
self::assertTrue($fs->isSymlink($link));
self::assertFalse($fs->realpath($link));
self::assertTrue($fs->unlink($link));
self::assertFalse($fs->isSymlink($link));
}
}
+200
View File
@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Exception\PathSafetyException;
use MeshVault\Indexer\Filesystem\PathSafety;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class PathSafetyTest extends TestCase
{
private string $base;
private string $root;
protected function setUp(): void
{
$this->base = dirname(__DIR__, 3) . '/.phpunit.cache/paths-' . bin2hex(random_bytes(6));
mkdir($this->base, 0777, true);
$this->base = realpath($this->base);
$this->root = $this->base . '/library';
mkdir($this->root);
mkdir($this->base . '/library-sibling');
file_put_contents($this->base . '/source.txt', 'source');
file_put_contents($this->root . '/existing.txt', 'existing');
self::assertTrue(class_exists(PathSafety::class), 'PathSafety must exist');
}
protected function tearDown(): void
{
$remove = static function (string $path) use (&$remove): void {
if (is_link($path) || !is_dir($path)) {
unlink($path);
return;
}
foreach (scandir($path) as $name) {
if ($name !== '.' && $name !== '..') {
$remove($path . '/' . $name);
}
}
rmdir($path);
};
$remove($this->base);
}
public function testCanonicalSourcesAndExistingAndNewDestinations(): void
{
$safety = new PathSafety($this->root);
self::assertSame(realpath($this->base . '/source.txt'), $safety->ensureSourceSafe($this->base . '/source.txt'));
self::assertSame(realpath($this->root), $safety->ensureSourceSafe($this->root));
self::assertSame(realpath($this->root), $safety->ensureDestSafe($this->root));
self::assertSame(realpath($this->root . '/existing.txt'), $safety->ensureDestSafe('existing.txt'));
self::assertSame(
realpath($this->root . '/existing.txt'),
$safety->ensureDestSafe($this->root . '/existing.txt')
);
$expected = realpath($this->root) . DIRECTORY_SEPARATOR . 'new' . DIRECTORY_SEPARATOR . 'model.stl';
self::assertSame($expected, $safety->ensureDestSafe('new/model.stl'));
self::assertSame($expected, $safety->ensureDestSafe($this->root . '/new/model.stl'));
self::assertDirectoryDoesNotExist($this->root . '/new');
self::assertSame('models/model.stl', $safety->ensureRelative('models/model.stl'));
}
public function testSourceAndConstructorBackslashPolicy(): void
{
$safety = new PathSafety($this->root);
$path = $this->base . '\\library';
if (PHP_OS_FAMILY === 'Windows') {
self::assertSame(realpath($this->root), $safety->ensureSourceSafe($path));
self::assertSame(realpath($this->root), (new PathSafety($path))->ensureSourceSafe($path));
} else {
$this->rejects(fn () => $safety->ensureSourceSafe($path), 'INVALID_PATH');
$this->rejects(fn () => new PathSafety($path), 'INVALID_PATH');
}
}
public static function unsafeRelativePaths(): iterable
{
foreach (['/tmp/a', '\\rooted', 'C:/file', 'C:relative', '\\\\server\\share', '//server/share'] as $path) {
yield [$path, 'ABSOLUTE_PATH'];
}
foreach (['../file', 'a/../file', 'a\\..\\file', '..'] as $path) {
yield [$path, 'PATH_TRAVERSAL'];
}
yield ["a\0b", 'INVALID_PATH'];
yield ['', 'INVALID_PATH'];
yield ['php://memory', 'INVALID_PATH'];
}
#[DataProvider('unsafeRelativePaths')]
public function testUnsafeRelativePaths(string $path, string $reason): void
{
$this->rejects(fn () => (new PathSafety($this->root))->ensureRelative($path), $reason);
}
public function testMissingTraversalEscapeAndNonDirectoryAncestor(): void
{
$safety = new PathSafety($this->root);
$this->rejects(fn () => $safety->ensureSourceSafe($this->base . '/absent'), 'PATH_NOT_FOUND');
foreach (['ensureSourceSafe', 'ensureDestSafe'] as $method) {
$this->rejects(fn () => $safety->$method($this->root . '/../source.txt'), 'PATH_TRAVERSAL');
$reason = $method === 'ensureSourceSafe' && PHP_OS_FAMILY !== 'Windows'
? 'INVALID_PATH'
: 'PATH_TRAVERSAL';
$this->rejects(fn () => $safety->$method($this->root . '/a\\..\\source.txt'), $reason);
$this->rejects(fn () => $safety->$method("bad\0path"), 'INVALID_PATH');
}
foreach ([$this->base . '/source.txt', $this->base . '/library-sibling/new/file'] as $path) {
$this->rejects(fn () => $safety->ensureDestSafe($path), 'OUTSIDE_LIBRARY_ROOT');
}
$this->rejects(fn () => $safety->ensureDestSafe('existing.txt/child'), 'NOT_DIRECTORY');
$this->rejects(fn () => new PathSafety($this->base . '/absent'), 'PATH_NOT_FOUND');
$this->rejects(fn () => new PathSafety($this->base . '/source.txt'), 'NOT_DIRECTORY');
}
public static function driveRelativeOperations(): iterable
{
yield 'source uppercase drive' => [false, false];
yield 'source lowercase drive' => [false, true];
yield 'root uppercase drive' => [true, false];
yield 'root lowercase drive' => [true, true];
}
#[DataProvider('driveRelativeOperations')]
public function testExistingDriveRelativePathsRejected(bool $constructor, bool $lowercase): void
{
if (PHP_OS_FAMILY !== 'Windows') {
self::markTestSkipped('Drive-relative resolution requires Windows.');
}
$safety = new PathSafety($this->root);
$drive = substr($this->base, 0, 2);
$drive = $lowercase ? strtolower($drive) : strtoupper($drive);
$name = $constructor ? 'library' : 'source.txt';
$relativeBase = substr($this->base, 3);
$path = $drive . $relativeBase . '/' . $name;
self::assertSame(realpath($this->base . '/' . $name), realpath($path));
$this->rejects(
fn () => $constructor ? new PathSafety($path) : $safety->ensureSourceSafe($path),
'INVALID_PATH'
);
}
public function testEveryDriveLetterRejectedForSourcesAndRoots(): void
{
$safety = new PathSafety($this->root);
foreach (array_merge(range('A', 'Z'), range('a', 'z')) as $drive) {
foreach (['', 'file', './file', '.\\file'] as $suffix) {
$path = $drive . ':' . $suffix;
$this->rejects(fn () => $safety->ensureSourceSafe($path), 'INVALID_PATH');
$this->rejects(fn () => new PathSafety($path), 'INVALID_PATH');
}
}
}
public function testWindowsAmbiguousNamesCannotBypassLexicalChecks(): void
{
if (PHP_OS_FAMILY !== 'Windows') {
self::markTestSkipped('Windows path aliases do not apply on this platform.');
}
$safety = new PathSafety($this->root);
foreach (['.. /source.txt', 'existing.txt:stream', 'new./child', 'new /child'] as $path) {
foreach (['ensureSourceSafe', 'ensureDestSafe', 'ensureRelative'] as $method) {
$this->rejects(fn () => $safety->$method($path), 'INVALID_PATH');
}
}
}
public static function links(): iterable
{
yield 'file' => ['source.txt', 'link', ''];
yield 'directory ancestor' => ['library', 'link', '/existing.txt'];
yield 'missing child of ancestor' => ['library', 'link', '/new/child'];
yield 'dangling' => ['absent', 'link', ''];
}
#[DataProvider('links')]
public function testLinksRejectedWithoutFollowingThem(string $target, string $name, string $suffix): void
{
$link = $this->root . '/' . $name;
if (!function_exists('symlink') || !@symlink($this->base . '/' . $target, $link)) {
self::markTestSkipped('Creating symlinks is not supported or permitted on this host.');
}
$safety = new PathSafety($this->root);
foreach (['ensureSourceSafe', 'ensureDestSafe'] as $method) {
$this->rejects(fn () => $safety->$method($link . $suffix), 'SYMLINK_ESCAPE');
}
$this->rejects(fn () => new PathSafety($link . $suffix), 'SYMLINK_ESCAPE');
}
private function rejects(callable $operation, string $reason): void
{
try {
$operation();
self::fail('Expected path rejection: ' . $reason);
} catch (PathSafetyException $exception) {
self::assertSame($reason, $exception->reason);
}
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Indexer\ImportException;
use MeshVault\Indexer\SpaceChecker;
use PHPUnit\Framework\TestCase;
final class SpaceCheckerTest extends TestCase
{
public function testStructuredException(): void
{
self::assertTrue(class_exists(ImportException::class));
$previous = new \RuntimeException('underlying');
$error = new ImportException('DISK_FULL', 'Need more space', $previous);
self::assertSame('DISK_FULL', $error->reason);
self::assertSame('Need more space', $error->getMessage());
self::assertSame($previous, $error->getPrevious());
self::assertSame('DISK_FULL', (new ImportException('DISK_FULL'))->getMessage());
}
public function testDefaultAndCustomMargins(): void
{
self::assertTrue(class_exists(SpaceChecker::class));
(new SpaceChecker(freeSpace: fn ($path) => 105.0))->check(__DIR__, 100);
(new SpaceChecker(0.2, fn ($path) => 120.0))->check(__DIR__, 100);
(new SpaceChecker(0.0, fn ($path) => 100.0))->check(__DIR__, 100);
(new SpaceChecker())->check(__DIR__, 0);
self::assertTrue(true);
}
public function testInsufficientUnknownAndThrownSpaceFailures(): void
{
self::assertTrue(class_exists(SpaceChecker::class));
foreach ([104.0, false, -1.0, NAN, INF, 'throws'] as $free) {
try {
(new SpaceChecker(freeSpace: static function () use ($free) {
if ($free === 'throws') {
throw new \RuntimeException('disk query');
}
return $free;
}))->check(__DIR__, 100);
self::fail('Expected space error');
} catch (ImportException $error) {
self::assertSame($free === 104.0 ? 'DISK_FULL' : 'SPACE_CHECK_FAILED', $error->reason);
}
}
}
public function testInvalidMargins(): void
{
self::assertTrue(class_exists(SpaceChecker::class));
foreach ([-0.01, INF, NAN] as $margin) {
try {
new SpaceChecker($margin);
self::fail('Expected invalid margin');
} catch (\InvalidArgumentException) {
self::assertTrue(true);
}
}
}
public function testNegativeSize(): void
{
self::assertTrue(class_exists(SpaceChecker::class));
$this->expectException(\InvalidArgumentException::class);
(new SpaceChecker())->check(__DIR__, -1);
}
public function testMissingDirectoryFailsClosed(): void
{
self::assertTrue(class_exists(SpaceChecker::class));
$this->expectException(ImportException::class);
(new SpaceChecker())->check(__DIR__ . '/missing-space-directory', 1);
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Indexer\ImportException;
use MeshVault\Exception\PathSafetyException;
use MeshVault\Indexer\Filesystem\PathSafety;
use MeshVault\Indexer\StlValidator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class StlValidatorTest extends TestCase
{
private string $root;
protected function setUp(): void
{
$this->root = sys_get_temp_dir() . '/stl-' . bin2hex(random_bytes(8));
mkdir($this->root);
self::assertTrue(class_exists(StlValidator::class), 'StlValidator must exist');
}
protected function tearDown(): void
{
foreach (glob($this->root . '/*') as $path) {
unlink($path);
}
rmdir($this->root);
}
private static function facet(): string
{
return "facet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\n";
}
private static function ascii(): string
{
return "solid mesh\n" . self::facet() . "endsolid mesh\n";
}
private static function binary(int $count = 1, string $header = 'binary'): string
{
return str_pad($header, 80, "\0") . pack('V', $count) . str_repeat(pack('g*', ...array_fill(0, 12, 0.0))
. pack('v', 0), $count);
}
private function fixture(string $contents): string
{
$path = $this->root . '/mesh.stl';
file_put_contents($path, $contents);
return $path;
}
#[DataProvider('validMeshes')]
public function testAcceptsValidMeshes(string $contents): void
{
(new StlValidator(new PathSafety($this->root)))->validate($this->fixture($contents));
self::assertTrue(true);
}
public static function validMeshes(): iterable
{
yield 'binary' => [self::binary()];
yield 'solid binary header' => [self::binary(2, 'solid mesh')];
yield 'ASCII' => [self::ascii()];
yield 'multiple facets' => ["solid\n" . self::facet() . self::facet() . 'endsolid'];
yield 'CRLF blank lines tabs' => [str_replace("\n", "\r\n\r\n", str_replace(' ', "\t", self::ascii()))];
yield 'finite decimals and exponents' => [str_replace('vertex 1 0 0', 'vertex +1. -.5 2e-3', self::ascii())];
yield 'trailing whitespace' => [self::ascii() . " \t\n"];
yield 'attribute bytes are opaque' => [substr(self::binary(), 0, -2) . "\xff\xff"];
}
#[DataProvider('invalidMeshes')]
public function testRejectsMalformedMeshes(string $contents): void
{
try {
(new StlValidator(new PathSafety($this->root)))->validate($this->fixture($contents));
self::fail('Malformed STL accepted');
} catch (ImportException $error) {
self::assertSame('STL_INVALID', $error->reason);
}
}
public static function invalidMeshes(): iterable
{
yield 'zero bytes' => [''];
yield 'empty binary' => [self::binary(0)];
yield 'empty ASCII' => ["solid empty\nendsolid empty\n"];
yield 'truncated binary header' => [str_repeat("\0", 83)];
yield 'truncated binary record' => [substr(self::binary(), 0, -1)];
yield 'binary trailing byte' => [self::binary() . 'x'];
yield 'huge triangle count' => [str_repeat("\0", 80) . pack('V', 0xffffffff)];
yield 'solid binary truncated' => [substr(self::binary(1, 'solid'), 0, -1)];
yield 'binary infinity' => [substr_replace(self::binary(), pack('V', 0x7f800000), 84, 4)];
yield 'binary NaN' => [substr_replace(self::binary(), pack('V', 0x7fc00000), 84, 4)];
yield 'missing solid' => [self::facet() . "endsolid\n"];
yield 'missing endsolid' => ["solid mesh\n" . self::facet()];
yield 'missing endfacet' => [str_replace("endfacet\n", '', self::ascii())];
yield 'missing endloop' => [str_replace("endloop\n", '', self::ascii())];
yield 'missing outer loop' => [str_replace("outer loop\n", '', self::ascii())];
yield 'two vertices' => [str_replace("vertex 1 0 0\n", '', self::ascii())];
yield 'four vertices' => [str_replace('endloop', "vertex 0 0 0\nendloop", self::ascii())];
yield 'nested facet' => [str_replace('outer loop', 'facet normal 0 0 0', self::ascii())];
yield 'trailing garbage' => [self::ascii() . 'garbage'];
yield 'second solid' => [self::ascii() . self::ascii()];
yield 'malformed keyword' => [str_replace('endloop', 'endloop garbage', self::ascii())];
yield 'short normal' => [str_replace('normal 0 0 1', 'normal 0 0', self::ascii())];
yield 'extra coordinate' => [str_replace('vertex 1 0 0', 'vertex 1 0 0 0', self::ascii())];
yield 'embedded NUL' => [str_replace('mesh', "me\0sh", self::ascii())];
yield 'overlong line' => ["solid " . str_repeat('x', 4096) . "\n" . self::facet() . 'endsolid'];
foreach (['NaN', 'INF', '-inf', '1e9999', '0x10', '1,2', '.', '1e', '1e+', '--1', '1foo'] as $number) {
yield 'bad number ' . $number => [str_replace('vertex 1 0 0', 'vertex ' . $number . ' 0 0', self::ascii())];
}
}
public function testPathSafetyRejectsTraversal(): void
{
$this->fixture(self::ascii());
$this->expectException(PathSafetyException::class);
$path = $this->root . '/../' . basename($this->root) . '/mesh.stl';
(new StlValidator(new PathSafety($this->root)))->validate($path);
}
public function testThrownReadFailureIsStructuredAndStreamClosed(): void
{
$streamSeen = null;
$cause = new \RuntimeException('injected read failure');
$reader = static function ($stream, int $length) use (&$streamSeen, $cause): string {
$streamSeen = $stream;
throw $cause;
};
try {
(new StlValidator(new PathSafety($this->root), $reader))->validate($this->fixture(self::binary()));
self::fail('Read failure accepted');
} catch (\Throwable $error) {
self::assertSame(ImportException::class, $error::class);
self::assertSame('SCAN_READ_ERROR', $error->reason);
self::assertSame($cause, $error->getPrevious());
} finally {
self::assertFalse(is_resource($streamSeen));
}
}
#[DataProvider('readFailures')]
public function testFalseAndStalledReadsAreRejected(bool $stall, bool $ascii): void
{
$calls = 0;
$streamSeen = null;
$reader = static function ($stream, int $length) use (&$calls, &$streamSeen, $stall): string|false {
$streamSeen = $stream;
return ++$calls === 2 ? ($stall ? '' : false) : fread($stream, $length);
};
try {
$path = $this->fixture($ascii ? self::ascii() : self::binary());
(new StlValidator(new PathSafety($this->root), $reader))->validate($path);
self::fail('Read failure accepted');
} catch (ImportException $error) {
self::assertSame('SCAN_READ_ERROR', $error->reason);
} finally {
self::assertFalse(is_resource($streamSeen));
}
}
public static function readFailures(): iterable
{
yield [false, false];
yield [true, false];
yield [false, true];
yield [true, true];
}
public function testShortReadsAndLargeAsciiAreStreamedWithBoundedRequests(): void
{
$path = $this->root . '/mesh.stl';
$out = fopen($path, 'wb');
fwrite($out, "solid\n");
for ($i = 0; $i < 20000; ++$i) {
fwrite($out, self::facet());
}
fwrite($out, "endsolid\n");
fclose($out);
$max = 0;
$reader = static function ($stream, int $length) use (&$max): string|false {
$max = max($max, $length);
return fread($stream, min(37, $length));
};
(new StlValidator(new PathSafety($this->root), $reader))->validate($path);
self::assertLessThanOrEqual(4096, $max);
}
public function testDirectoryIsReadFailure(): void
{
$this->expectException(ImportException::class);
$this->expectExceptionMessage('SCAN_READ_ERROR');
(new StlValidator(new PathSafety($this->root)))->validate($this->root);
}
}
+260
View File
@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Indexer\ImportException;
use MeshVault\Indexer\Filesystem\PathSafety;
use MeshVault\Indexer\StreamingHasher;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class StreamingHasherTest extends TestCase
{
private string $root;
private string $path;
protected function setUp(): void
{
$this->root = sys_get_temp_dir() . '/meshvault-hash-' . bin2hex(random_bytes(8));
mkdir($this->root);
$this->path = $this->root . '/source';
file_put_contents($this->path, str_repeat('abc', 100000));
self::assertTrue(class_exists(StreamingHasher::class), 'StreamingHasher must exist');
}
protected function tearDown(): void
{
foreach (scandir($this->root) as $name) {
if ($name !== '.' && $name !== '..') {
unlink($this->root . '/' . $name);
}
}
rmdir($this->root);
}
private function hasher(?callable $observer = null, array $hooks = []): StreamingHasher
{
return new StreamingHasher(new PathSafety($this->root), $observer, $hooks);
}
public static function sizes(): iterable
{
foreach ([0, 1, 131072, 131073, 524305] as $size) {
yield (string) $size => [$size];
}
}
#[DataProvider('sizes')]
public function testDigestAndBoundedReads(int $size): void
{
file_put_contents($this->path, str_repeat('x', $size));
$observed = 0;
$hasher = $this->hasher(function (int $bytes) use (&$observed): void {
self::assertGreaterThan($observed, $bytes);
self::assertLessThanOrEqual(131072, $bytes - $observed);
$observed = $bytes;
}, ['read' => static function ($stream, int $length): string|false {
self::assertSame(131072, $length);
return fread($stream, $length);
}]);
self::assertSame(hash_file('sha256', $this->path), $hasher->hash($this->path));
self::assertSame($size, $observed);
}
public static function failures(): iterable
{
yield 'open' => ['open', false, 'HASH_OPEN_FAILED'];
yield 'read' => ['read', false, 'HASH_READ_FAILED'];
yield 'stalled read' => ['read', '', 'HASH_READ_FAILED'];
yield 'path stat' => ['stat', false, 'HASH_STAT_FAILED'];
yield 'handle stat' => ['fstat', false, 'HASH_STAT_FAILED'];
}
#[DataProvider('failures')]
public function testIoFailures(string $hook, mixed $result, string $reason): void
{
$this->rejects(fn () => $this->hasher(null, [$hook => fn () => $result])->hash($this->path), $reason);
}
public function testThrownIoFailurePreservesCauseAndClosesStream(): void
{
$stream = null;
$cause = new RuntimeException('injected read failure');
$hasher = $this->hasher(null, [
'open' => function (string $path) use (&$stream) {
return $stream = fopen($path, 'rb');
},
'read' => static fn () => throw $cause,
]);
$error = $this->rejects(fn () => $hasher->hash($this->path), 'HASH_READ_FAILED');
self::assertSame($cause, $error->getPrevious());
self::assertFalse(is_resource($stream));
}
public static function metadataChanges(): iterable
{
foreach (['stat', 'fstat'] as $hook) {
foreach (['dev', 'ino', 'mode', 'size', 'mtime', 'ctime'] as $field) {
yield "$hook $field" => [$hook, $field];
}
}
}
#[DataProvider('metadataChanges')]
public function testEndMetadataChanges(string $hook, string $field): void
{
$calls = 0;
$hasher = $this->hasher(null, [$hook => static function ($target) use ($hook, $field, &$calls) {
$stat = $hook($target);
if (++$calls === 2) {
++$stat[$field];
}
return $stat;
}]);
$this->rejects(fn () => $hasher->hash($this->path), 'HASH_SOURCE_CHANGED');
}
public function testOpenHandleMustMatchPreOpenPath(): void
{
$hasher = $this->hasher(null, ['fstat' => static function ($stream) {
$stat = fstat($stream);
++$stat['ino'];
return $stat;
}]);
$this->rejects(fn () => $hasher->hash($this->path), 'HASH_SOURCE_CHANGED');
}
public function testDirectoryRejected(): void
{
$this->rejects(fn () => $this->hasher()->hash($this->root), 'HASH_NOT_REGULAR_FILE');
}
public static function mutations(): iterable
{
yield 'append' => ['append'];
yield 'truncate' => ['truncate'];
yield 'same size with changed timestamp' => ['rewrite'];
yield 'replacement' => ['replace'];
}
#[DataProvider('mutations')]
public function testRealMutation(string $mutation): void
{
$done = false;
$hasher = $this->hasher(function () use ($mutation, &$done): void {
if ($done) {
return;
}
$done = true;
if ($mutation === 'append') {
file_put_contents($this->path, 'extra', FILE_APPEND);
} elseif ($mutation === 'truncate') {
file_put_contents($this->path, 'short');
} elseif ($mutation === 'rewrite') {
file_put_contents($this->path, str_repeat('z', 300000));
touch($this->path, time() + 10);
} else {
if (!@rename($this->path, $this->root . '/old')) {
self::markTestSkipped('Host cannot rename an open file.');
}
file_put_contents($this->path, str_repeat('z', 300000));
touch($this->path, time() + 10);
}
});
$this->rejects(fn () => $hasher->hash($this->path), 'HASH_SOURCE_CHANGED');
}
public function testUnsafeAndMissingSources(): void
{
foreach (
[
'' => 'INVALID_PATH',
'php://memory' => 'INVALID_PATH',
$this->root . '/../source' => 'PATH_TRAVERSAL',
$this->root . '/missing' => 'PATH_NOT_FOUND',
] as $path => $reason
) {
$this->rejects(fn () => $this->hasher()->hash($path), $reason);
}
}
public static function links(): iterable
{
yield 'file' => [false, false];
yield 'ancestor' => [true, false];
yield 'mid-read replacement' => [false, true];
}
#[DataProvider('links')]
public function testSymlinks(bool $ancestor, bool $duringRead): void
{
$link = $this->root . '/link';
if (!@symlink($ancestor ? $this->root : $this->path, $link)) {
self::markTestSkipped('Host cannot create symlinks.');
}
if (!$duringRead) {
$this->rejects(
fn () => $this->hasher()->hash($link . ($ancestor ? '/source' : '')),
'SYMLINK_ESCAPE'
);
return;
}
unlink($link);
file_put_contents($this->root . '/target', 'replacement');
$done = false;
$hasher = $this->hasher(function () use (&$done): void {
if (!$done) {
$done = true;
if (!@rename($this->path, $this->root . '/old')) {
self::markTestSkipped('Host cannot rename an open file.');
}
self::assertTrue(symlink($this->root . '/target', $this->path));
}
});
$this->rejects(fn () => $hasher->hash($this->path), 'SYMLINK_ESCAPE');
}
public function testEndStatFailure(): void
{
foreach (['stat', 'fstat'] as $operation) {
$calls = 0;
$hasher = $this->hasher(null, [$operation => static function ($target) use ($operation, &$calls) {
return ++$calls === 2 ? false : $operation($target);
}]);
$this->rejects(fn () => $hasher->hash($this->path), 'HASH_STAT_FAILED');
}
}
public function testObserverFailureClosesStream(): void
{
$stream = null;
$cause = new RuntimeException('observer');
$hasher = $this->hasher(static fn () => throw $cause, [
'open' => function (string $path) use (&$stream) {
return $stream = fopen($path, 'rb');
},
]);
try {
$hasher->hash($this->path);
self::fail('Expected observer exception');
} catch (RuntimeException $error) {
self::assertSame($cause, $error);
}
self::assertFalse(is_resource($stream));
}
private function rejects(callable $operation, string $reason): ImportException
{
try {
$operation();
self::fail('Expected ' . $reason);
} catch (ImportException $error) {
self::assertSame($reason, $error->reason);
self::assertNotSame('', $error->getMessage());
return $error;
}
}
}
+302
View File
@@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Indexer;
use MeshVault\Indexer\ImportException;
use MeshVault\Exception\PathSafetyException;
use MeshVault\Indexer\Filesystem\PathSafety;
use MeshVault\Indexer\VerifiedCopier;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class VerifiedCopierTest extends TestCase
{
private string $base;
private string $root;
private string $source;
private string $data;
protected function setUp(): void
{
$this->base = dirname(__DIR__, 3) . '/.phpunit.cache/copy-' . bin2hex(random_bytes(6));
mkdir($this->base, 0777, true);
$this->base = realpath($this->base);
$this->root = $this->base . '/library';
mkdir($this->root);
$this->source = $this->base . '/source';
$this->data = str_repeat("mesh\0\xff", 50000);
file_put_contents($this->source, $this->data);
self::assertTrue(class_exists(VerifiedCopier::class), 'VerifiedCopier must exist');
}
protected function tearDown(): void
{
$remove = static function (string $path) use (&$remove): void {
if (is_link($path) || !is_dir($path)) {
unlink($path);
return;
}
foreach (scandir($path) as $name) {
if ($name !== '.' && $name !== '..') {
$remove($path . '/' . $name);
}
}
rmdir($path);
};
$remove($this->base);
}
private function copier(array $operations = []): VerifiedCopier
{
return new VerifiedCopier(new PathSafety($this->root), $operations);
}
private function copy(array $operations = [], string $destination = 'model.stl'): string
{
return $this->copier($operations)->copy(
$this->source,
$destination,
strlen($this->data),
hash('sha256', $this->data)
);
}
private function entries(): array
{
return array_values(array_diff(scandir($this->root), ['.', '..']));
}
public function testStreamsPartialWritesAndPublishesVerifiedSameDirectoryHardlink(): void
{
$reads = [];
$links = 0;
$result = $this->copy([
'read' => static function ($stream, int $length) use (&$reads) {
$reads[] = $length;
return fread($stream, $length);
},
'write' => static fn ($stream, string $bytes) => fwrite($stream, substr($bytes, 0, 777)),
'link' => function (string $temp, string $destination) use (&$links): bool {
++$links;
self::assertSame(dirname($destination), dirname($temp));
self::assertFileDoesNotExist($destination);
self::assertSame(hash('sha256', $this->data), hash_file('sha256', $temp));
self::assertSame(strlen($this->data), filesize($temp));
return link($temp, $destination);
},
]);
self::assertSame(realpath($this->root . '/model.stl'), $result);
self::assertSame($this->data, file_get_contents($result));
self::assertSame($this->data, file_get_contents($this->source));
self::assertSame(['model.stl'], $this->entries());
self::assertSame([131072], array_values(array_unique($reads)));
self::assertSame(1, $links);
}
public function testEmptyFile(): void
{
$this->data = '';
file_put_contents($this->source, '');
self::assertSame('', file_get_contents($this->copy()));
self::assertSame(['model.stl'], $this->entries());
}
public static function failures(): iterable
{
yield 'read false' => ['read', static fn () => false];
yield 'read stalls' => ['read', static fn () => ''];
yield 'write false' => ['write', static fn () => false];
yield 'write zero' => ['write', static fn () => 0];
yield 'flush' => ['flush', static fn () => false];
yield 'link' => ['link', static fn () => false];
yield 'exception' => ['write', static function () {
throw new \RuntimeException('injected');
}];
}
#[DataProvider('failures')]
public function testFailuresCleanTemporaryFiles(string $operation, callable $failure): void
{
try {
$this->copy([$operation => $failure]);
self::fail('Expected COPY_FAILED');
} catch (ImportException $error) {
self::assertSame('COPY_FAILED', $error->reason);
}
self::assertSame([], $this->entries());
self::assertSame($this->data, file_get_contents($this->source));
}
public function testFailureAfterPartialProgressCleansUp(): void
{
$calls = 0;
$this->testFailuresCleanTemporaryFiles('write', static function ($stream, $bytes) use (&$calls) {
return ++$calls === 1 ? fwrite($stream, substr($bytes, 0, 10)) : false;
});
}
public function testSizeHashAndWrittenBytesMustMatch(): void
{
foreach (['size', 'hash', 'corrupt'] as $case) {
try {
$operations = $case === 'corrupt'
? ['write' => static fn ($stream, $bytes) => fwrite($stream, str_repeat('x', strlen($bytes)))]
: [];
$this->copier($operations)->copy(
$this->source,
'model.stl',
strlen($this->data) + ($case === 'size' ? 1 : 0),
$case === 'hash' ? str_repeat('0', 64) : hash('sha256', $this->data)
);
self::fail('Expected mismatch');
} catch (ImportException $error) {
self::assertSame('HASH_MISMATCH', $error->reason);
}
self::assertSame([], $this->entries());
}
}
public function testCollisionSuffixAndConcurrentWinnerNeverOverwrite(): void
{
file_put_contents($this->root . '/model.stl', 'original');
file_put_contents($this->root . '/model_001.stl', 'first');
$calls = 0;
$result = $this->copy(['link' => static function ($temp, $destination) use (&$calls) {
if (++$calls === 1) {
file_put_contents($destination, 'racer');
return false;
}
return link($temp, $destination);
}]);
self::assertSame('model_003.stl', basename($result));
self::assertSame('original', file_get_contents($this->root . '/model.stl'));
self::assertSame('first', file_get_contents($this->root . '/model_001.stl'));
self::assertSame('racer', file_get_contents($this->root . '/model_002.stl'));
self::assertCount(4, $this->entries());
}
public function testExtensionlessCollisionAndExhaustion(): void
{
file_put_contents($this->root . '/model', 'original');
self::assertSame('model_001', basename($this->copy([], 'model')));
for ($i = 0; $i <= 999; ++$i) {
file_put_contents($this->root . '/' . ($i === 0 ? 'a.stl' : sprintf('a_%03d.stl', $i)), 'x');
}
try {
$this->copy([], 'a.stl');
self::fail('Expected collision exhaustion');
} catch (ImportException $error) {
self::assertSame('COLLISION', $error->reason);
}
self::assertCount(1002, $this->entries());
}
public function testCleanupNeverThrowsPreservesOriginalError(): void
{
// Test that if cleanup (unlink temporary) fails in the finally block,
// the original exception is preserved, not masked by the cleanup exception.
//
// Scenario: verify() throws HASH_MISMATCH, then finally block tries to unlink
// the temporary file but unlink fails (e.g., permission issue). The original
// HASH_MISMATCH should be thrown, not a COPY_FAILED from the cleanup.
$copier = new VerifiedCopier(new PathSafety($this->root), [
'read' => static fn ($stream, int $length) => fread($stream, $length),
'write' => static fn ($stream, string $bytes) => fwrite($stream, $bytes),
'flush' => static fn ($stream) => fflush($stream),
'link' => static function (): bool {
// Link succeeds
return link(...func_get_args());
},
]);
try {
// Wrong size -> HASH_MISMATCH in verify()
$copier->copy($this->source, 'model.stl', strlen($this->data) + 1, hash('sha256', $this->data));
self::fail('Expected HASH_MISMATCH');
} catch (ImportException $error) {
// The ORIGINAL error should be HASH_MISMATCH, not COPY_FAILED from cleanup
self::assertSame('HASH_MISMATCH', $error->reason, 'Original error masked by cleanup failure');
self::assertStringContainsString('differs from expected metadata', $error->getMessage());
}
}
public function testCleanupFailureInFinallyDoesNotMaskOriginalError(): void
{
// More direct test: when an exception is thrown in the try block AND
// the finally block's unlink fails, the original exception should propagate.
// We simulate this by making the temporary file read-only (chmod) so unlink
// fails on some systems, or by verifying the code path directly.
$copier = new VerifiedCopier(new PathSafety($this->root), [
'read' => static fn ($stream, int $length) => fread($stream, $length),
'write' => static fn ($stream, string $bytes) => fwrite($stream, $bytes),
'flush' => static fn ($stream) => fflush($stream),
'link' => static function (): bool {
return link(...func_get_args());
},
]);
// Trigger a HASH_MISMATCH error
try {
$copier->copy($this->source, 'model.stl', strlen($this->data) + 1, hash('sha256', $this->data));
self::fail('Expected HASH_MISMATCH');
} catch (ImportException $error) {
// The ORIGINAL error should be HASH_MISMATCH, not COPY_FAILED from cleanup
self::assertSame('HASH_MISMATCH', $error->reason);
}
// Verify the temporary file was cleaned up (cleanup ran but didn't throw)
self::assertSame([], $this->entries(), 'Temporary file should be cleaned up');
}
public function testUnsafePathsRejectedBeforeCopy(): void
{
foreach (['../escape', $this->base . '/outside', 'missing/model.stl'] as $destination) {
try {
$this->copy([], $destination);
self::fail('Expected unsafe destination rejection');
} catch (PathSafetyException | ImportException $error) {
self::assertNotSame('', $error->reason);
}
self::assertSame([], $this->entries());
}
foreach ([$this->base . '/../source', $this->base . '/missing', $this->root] as $source) {
try {
$this->copier()->copy($source, 'model.stl', 0, hash('sha256', ''));
self::fail('Expected unsafe source rejection');
} catch (PathSafetyException | ImportException $error) {
self::assertNotSame('', $error->reason);
}
self::assertSame([], $this->entries());
}
}
public function testSymlinkSourcesDestinationsAndAncestorsRejected(): void
{
$link = $this->root . '/link';
if (!@symlink($this->source, $link)) {
self::markTestSkipped('Host cannot create symlinks.');
}
foreach ([[$link, 'new'], [$this->source, 'link']] as [$source, $destination]) {
try {
$this->copier()->copy($source, $destination, strlen($this->data), hash('sha256', $this->data));
self::fail('Expected symlink rejection');
} catch (PathSafetyException $error) {
self::assertSame('SYMLINK_ESCAPE', $error->reason);
}
}
unlink($link);
self::assertTrue(symlink($this->base, $link));
try {
$this->copy([], 'link/new');
self::fail('Expected ancestor rejection');
} catch (PathSafetyException $error) {
self::assertSame('SYMLINK_ESCAPE', $error->reason);
}
self::assertSame(['link'], $this->entries());
}
}