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,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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user