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