200 lines
8.1 KiB
PHP
200 lines
8.1 KiB
PHP
<?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);
|
|
}
|
|
}
|