pathSafety->ensureSourceSafe($path); if (!is_file($path) || ($stream = @fopen($path, 'rb')) === false) { throw new ImportException('SCAN_READ_ERROR', 'SCAN_READ_ERROR: Cannot open STL file.'); } try { $stat = @fstat($stream); if ($stat === false || !is_int($stat['size']) || $stat['size'] < 0) { throw new ImportException('SCAN_READ_ERROR', 'Cannot determine STL size.'); } $size = $stat['size']; $header = $this->readUpTo($stream, 84); $count = strlen($header) === 84 ? unpack('Vcount', substr($header, 80, 4))['count'] : 0; // Division avoids overflowing 84 + 50 * an untrusted uint32 count. $binary = $size >= 84 && ($size - 84) % 50 === 0 && intdiv($size - 84, 50) === $count; if ($binary) { if ($count === 0) { $this->invalid('Empty mesh.'); } for ($index = 0; $index < $count; ++$index) { $record = $this->readUpTo($stream, 50); if (strlen($record) !== 50) { $this->invalid('Truncated binary facet.'); } foreach (unpack('g12', substr($record, 0, 48)) as $number) { if (!is_finite($number)) { $this->invalid('Non-finite binary coordinate or normal.'); } } } if ($this->readUpTo($stream, 1) !== '') { $this->invalid('Trailing binary data.'); } } else { if (@fseek($stream, 0) !== 0) { throw new ImportException('SCAN_READ_ERROR', 'Cannot rewind STL file.'); } $this->validateAscii($stream); } $after = @fstat($stream); if ($after === false || $after['size'] !== $size) { throw new ImportException('SCAN_READ_ERROR', 'STL size changed during validation.'); } } finally { fclose($stream); } } /** @param resource $stream */ private function readUpTo($stream, int $length): string { $result = ''; while (strlen($result) < $length) { $remaining = $length - strlen($result); try { $chunk = $this->reader === null ? @fread($stream, $remaining) : ($this->reader)($stream, $remaining); } catch (\Throwable $error) { throw new ImportException('SCAN_READ_ERROR', 'Failed reading STL stream.', $error); } if ($chunk === false || ($chunk === '' && !feof($stream)) || strlen($chunk) > $remaining) { throw new ImportException('SCAN_READ_ERROR', 'Failed reading STL stream.'); } if ($chunk === '') { break; } $result .= $chunk; } return $result; } /** @param resource $stream @return \Generator */ private function lines($stream): \Generator { $pending = ''; while (($chunk = $this->readUpTo($stream, 4096)) !== '') { $pending .= $chunk; while (($newline = strpos($pending, "\n")) !== false) { if ($newline > 4096) { $this->invalid('ASCII line exceeds 4096 bytes.'); } yield substr($pending, 0, $newline); $pending = substr($pending, $newline + 1); } if (strlen($pending) > 4096) { $this->invalid('ASCII line exceeds 4096 bytes.'); } } if ($pending !== '') { yield $pending; } } /** @param resource $stream */ private function validateAscii($stream): void { $state = 'solid'; $hasFacet = false; foreach ($this->lines($stream) as $line) { $line = str_ends_with($line, "\r") ? substr($line, 0, -1) : $line; if (preg_match('/[^\x20-\x7e\t]/', $line)) { $this->invalid('Non-ASCII or control byte.'); } $line = trim($line, " \t"); if ($line === '') { continue; } switch ($state) { case 'solid': if (!preg_match('/^solid(?:[ \t].*)?$/D', $line)) { $this->invalid('Expected solid.'); } $state = 'facet'; break; case 'facet': if (preg_match('/^endsolid(?:[ \t].*)?$/D', $line)) { if (!$hasFacet) { $this->invalid('Empty mesh.'); } $state = 'done'; break; } $this->vector($line, 'facet normal'); $state = 'outer'; break; case 'outer': if (!preg_match('/^outer[ \t]+loop$/D', $line)) { $this->invalid('Expected outer loop.'); } $state = 'vertex1'; break; case 'vertex1': case 'vertex2': case 'vertex3': $this->vector($line, 'vertex'); $state = ['vertex1' => 'vertex2', 'vertex2' => 'vertex3', 'vertex3' => 'endloop'][$state]; break; case 'endloop': case 'endfacet': if ($line !== $state) { $this->invalid('Expected ' . $state . '.'); } if ($state === 'endfacet') { $hasFacet = true; $state = 'facet'; } else { $state = 'endfacet'; } break; default: $this->invalid('Trailing ASCII data.'); } } if ($state !== 'done') { $this->invalid('Incomplete ASCII STL.'); } } private function vector(string $line, string $prefix): void { $number = '([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?)'; $prefix = str_replace(' ', '[ \t]+', $prefix); $pattern = '/^' . $prefix . '[ \t]+' . $number . '[ \t]+' . $number . '[ \t]+' . $number . '$/D'; if (!preg_match($pattern, $line, $m)) { $this->invalid('Expected a normal or vertex with three decimal coordinates.'); } foreach (array_slice($m, 1) as $value) { if (!is_finite((float) $value)) { $this->invalid('Non-finite ASCII coordinate or normal.'); } } } private function invalid(string $message): never { throw new ImportException('STL_INVALID', $message); } }