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

+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);
}
}