validator = new ManufacturingFileValidator; } private function uploadedFile(string $path, string $clientName): UploadedFile { return new UploadedFile($path, $clientName, null, null, true); } public function test_accepts_valid_ascii_stl(): void { $path = tempnam(sys_get_temp_dir(), 'stl'); file_put_contents($path, "solid cube\nfacet normal 0 0 0\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid cube\n"); $result = $this->validator->validate($this->uploadedFile($path, 'cube.stl')); $this->assertSame('stl', $result['extension']); unlink($path); } public function test_accepts_valid_binary_stl(): void { $path = $this->makeBinaryStl(triangles: 2); $result = $this->validator->validate($this->uploadedFile($path, 'part.stl')); $this->assertSame('stl', $result['extension']); unlink($path); } public function test_rejects_binary_stl_with_triangle_count_mismatch(): void { $path = $this->makeBinaryStl(triangles: 2, declareExtraTriangle: true); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'part.stl')); } finally { unlink($path); } } public function test_rejects_ascii_stl_with_zero_facets(): void { // The exact review example: a single line with "solid" and "endsolid" // tokens but no actual facet/vertex data must not be accepted. $path = tempnam(sys_get_temp_dir(), 'stl'); file_put_contents($path, "solid garbage endsolid\n"); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'garbage.stl')); } finally { unlink($path); } } public function test_accepts_ascii_stl_with_multiple_facets(): void { $facet = "facet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\n"; $path = tempnam(sys_get_temp_dir(), 'stl'); file_put_contents($path, "solid cube\n".$facet.$facet."endsolid cube\n"); $result = $this->validator->validate($this->uploadedFile($path, 'cube.stl')); $this->assertSame('stl', $result['extension']); unlink($path); } public function test_rejects_ascii_stl_missing_outer_loop(): void { $path = tempnam(sys_get_temp_dir(), 'stl'); file_put_contents($path, "solid cube\nfacet normal 0 0 1\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid cube\n"); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'cube.stl')); } finally { unlink($path); } } public function test_rejects_ascii_stl_with_non_finite_vertex_value(): void { $path = tempnam(sys_get_temp_dir(), 'stl'); file_put_contents($path, "solid cube\nfacet normal 0 0 1\nouter loop\nvertex nan 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid cube\n"); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'cube.stl')); } finally { unlink($path); } } public function test_rejects_ascii_stl_missing_a_vertex(): void { $path = tempnam(sys_get_temp_dir(), 'stl'); file_put_contents($path, "solid cube\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nendloop\nendfacet\nendsolid cube\n"); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'cube.stl')); } finally { unlink($path); } } public function test_rejects_binary_stl_with_zero_triangles(): void { $path = tempnam(sys_get_temp_dir(), 'stl'); file_put_contents($path, str_repeat('A', 80).pack('V', 0)); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'empty.stl')); } finally { unlink($path); } } public function test_rejects_binary_stl_with_non_finite_values(): void { $path = tempnam(sys_get_temp_dir(), 'stl'); $handle = fopen($path, 'wb'); fwrite($handle, str_repeat('A', 80)); fwrite($handle, pack('V', 1)); fwrite($handle, str_repeat(pack('f', NAN), 12)); fwrite($handle, pack('v', 0)); fclose($handle); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'nan.stl')); } finally { unlink($path); } } public function test_rejects_binary_stl_with_absurd_triangle_count(): void { $path = tempnam(sys_get_temp_dir(), 'stl'); $header = str_repeat('A', 80); file_put_contents($path, $header.pack('V', 100_000_000)); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'huge.stl')); } finally { unlink($path); } } public function test_rejects_file_with_mismatched_extension_and_content(): void { $path = tempnam(sys_get_temp_dir(), 'fake'); file_put_contents($path, "G1 X10 Y10\nG1 X20 Y20\n"); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'fake.stl')); } finally { unlink($path); } } public function test_rejects_unsupported_extension(): void { $path = tempnam(sys_get_temp_dir(), 'exe'); file_put_contents($path, 'MZ'.str_repeat("\x00", 100)); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'payload.exe')); } finally { unlink($path); } } public function test_accepts_plain_text_gcode(): void { $path = tempnam(sys_get_temp_dir(), 'gcode'); file_put_contents($path, "; generated by slicer\nG28\nG1 X0 Y0 Z0.2 F3000\nM104 S200\n"); $result = $this->validator->validate($this->uploadedFile($path, 'part.gcode')); $this->assertSame('gcode', $result['extension']); unlink($path); } public function test_rejects_gcode_containing_binary_bytes(): void { $path = tempnam(sys_get_temp_dir(), 'gcode'); file_put_contents($path, "G1 X0\0\x01\x02 binary disguised as text"); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'part.gcode')); } finally { unlink($path); } } public function test_accepts_valid_3mf_archive(): void { $path = $this->make3mf([ '[Content_Types].xml' => '', '3D/3dmodel.model' => '', '_rels/.rels' => '', ]); $result = $this->validator->validate($this->uploadedFile($path, 'part.3mf')); $this->assertSame('3mf', $result['extension']); unlink($path); } public function test_rejects_3mf_model_missing_required_resources_or_build_elements(): void { $path = $this->make3mf([ '[Content_Types].xml' => '', '3D/3dmodel.model' => '', '_rels/.rels' => '', ]); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'incomplete.3mf')); } finally { unlink($path); } } public function test_rejects_3mf_model_with_malformed_not_well_formed_xml(): void { $path = $this->make3mf([ '[Content_Types].xml' => '', '3D/3dmodel.model' => '', '_rels/.rels' => '', ]); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'malformed.3mf')); } finally { unlink($path); } } public function test_rejects_3mf_relationship_with_single_quoted_external_target(): void { // The old validator only regex-matched double-quoted Target="..." attributes, // so a single-quoted external target slipped straight through unparsed. $path = $this->make3mf([ '[Content_Types].xml' => '', '3D/3dmodel.model' => '', '_rels/.rels' => "", ]); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'evil.3mf')); } finally { unlink($path); } } public function test_rejects_3mf_relationship_with_external_target_mode_regardless_of_target_value(): void { $path = $this->make3mf([ '[Content_Types].xml' => '', '3D/3dmodel.model' => '', '_rels/.rels' => '', ]); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'evil.3mf')); } finally { unlink($path); } } public function test_rejects_3mf_nested_rels_part_with_external_target(): void { // Per-part relationship files (not just the root _rels/.rels) must be // inspected too; a nested one pointing outside the package must still // be rejected. $path = $this->make3mf([ '[Content_Types].xml' => '', '3D/3dmodel.model' => '', '_rels/.rels' => '', '3D/_rels/3dmodel.model.rels' => '', ]); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'evil.3mf')); } finally { unlink($path); } } public function test_rejects_3mf_with_an_encrypted_entry(): void { $zip = new ZipArchive; $path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf'; $zip->open($path, ZipArchive::CREATE); $zip->addFromString('[Content_Types].xml', ''); $zip->addFromString('3D/3dmodel.model', ''); $zip->addFromString('secret.bin', 'payload'); $zip->setEncryptionName('secret.bin', ZipArchive::EM_AES_256, 'password'); $zip->close(); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'evil.3mf')); } finally { unlink($path); } } public function test_rejects_3mf_entry_exceeding_the_per_entry_xml_size_cap(): void { // Low-compressibility padding keeps this under the ratio-bomb threshold // while still being an individually oversized XML-ish part. $zip = new ZipArchive; $path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf'; $zip->open($path, ZipArchive::CREATE); $zip->addFromString('[Content_Types].xml', ''); $zip->addFromString('3D/3dmodel.model', ''); $zip->addFromString('_rels/.rels', ''); $zip->addFromString('huge.xml', ''.base64_encode(random_bytes(18 * 1024 * 1024)).''); $zip->setCompressionName('huge.xml', ZipArchive::CM_STORE); $zip->close(); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'huge.3mf')); } finally { unlink($path); } } public function test_rejects_3mf_with_a_duplicate_entry_path(): void { // ZipArchive's own API dedupes entries by name, so a genuine duplicate // (two local file headers + two central directory records sharing one // name — a known zip-parser confusion vector) has to be hand-built. $path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf'; file_put_contents($path, $this->rawZipWithDuplicateEntryName()); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'dup.3mf')); } finally { unlink($path); } } private function rawZipWithDuplicateEntryName(): string { // An otherwise entirely valid, acceptable 3MF archive, plus a duplicate // pair of an unrelated entry name — so this only fails if duplicate // detection itself exists, not for any other unrelated reason. $entries = [ ['[Content_Types].xml', ''], ['3D/3dmodel.model', ''], ['_rels/.rels', ''], ['same.txt', 'first'], ['same.txt', 'second'], ]; $local = ''; $central = ''; $offset = 0; foreach ($entries as [$name, $content]) { $crc = crc32($content); $len = strlen($content); $nameLen = strlen($name); $lfh = pack('VvvvvvVVVvv', 0x04034B50, 20, 0, 0, 0, 0, $crc, $len, $len, $nameLen, 0).$name.$content; $cdfh = pack('VvvvvvvVVVvvvvvVV', 0x02014B50, 20, 20, 0, 0, 0, 0, $crc, $len, $len, $nameLen, 0, 0, 0, 0, 0, $offset).$name; $local .= $lfh; $central .= $cdfh; $offset += strlen($lfh); } $eocd = pack('VvvvvVVv', 0x06054B50, 0, 0, count($entries), count($entries), strlen($central), $offset, 0); return $local.$central.$eocd; } public function test_rejects_3mf_missing_required_model_part(): void { $path = $this->make3mf([ '[Content_Types].xml' => '', ]); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'broken.3mf')); } finally { unlink($path); } } public function test_rejects_3mf_with_path_traversal_entry(): void { $path = $this->make3mf([ '[Content_Types].xml' => '', '3D/3dmodel.model' => '', '../../../etc/evil.txt' => 'pwned', ]); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'evil.3mf')); } finally { unlink($path); } } public function test_rejects_3mf_with_absolute_path_entry(): void { $zip = new ZipArchive; $path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf'; $zip->open($path, ZipArchive::CREATE); $zip->addFromString('[Content_Types].xml', ''); $zip->addFromString('3D/3dmodel.model', ''); // ZipArchive normalises leading slashes away, so write the entry via a raw local file header hack: // fall back to asserting the validator rejects entries containing backslashes / drive letters instead. $zip->addFromString('C:\\Windows\\evil.dll', 'pwned'); $zip->close(); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'evil.3mf')); } finally { unlink($path); } } public function test_rejects_3mf_model_part_with_doctype_declaration(): void { $path = $this->make3mf([ '[Content_Types].xml' => '', '3D/3dmodel.model' => ']>&xxe;', ]); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'xxe.3mf')); } finally { unlink($path); } } public function test_rejects_3mf_relationship_with_external_target(): void { $path = $this->make3mf([ '[Content_Types].xml' => '', '3D/3dmodel.model' => '', '_rels/.rels' => '', ]); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'evil.3mf')); } finally { unlink($path); } } public function test_rejects_3mf_with_symlink_entry(): void { $zip = new ZipArchive; $path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf'; $zip->open($path, ZipArchive::CREATE); $zip->addFromString('[Content_Types].xml', ''); $zip->addFromString('3D/3dmodel.model', ''); $zip->addFromString('evil-link', '/etc/passwd'); $zip->setExternalAttributesName('evil-link', ZipArchive::OPSYS_UNIX, 0120777 << 16); $zip->close(); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'evil.3mf')); } finally { unlink($path); } } public function test_rejects_3mf_with_too_many_entries(): void { $entries = ['[Content_Types].xml' => '', '3D/3dmodel.model' => '']; for ($i = 0; $i < 5000; $i++) { $entries["junk/{$i}.txt"] = 'x'; } $path = $this->make3mf($entries); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'bomb.3mf')); } finally { unlink($path); } } public function test_rejects_3mf_entry_with_extreme_compression_ratio(): void { $zip = new ZipArchive; $path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf'; $zip->open($path, ZipArchive::CREATE); $zip->addFromString('[Content_Types].xml', ''); $zip->addFromString('3D/3dmodel.model', ''); $zip->addFromString('bomb.bin', str_repeat("\0", 20 * 1024 * 1024)); $zip->setCompressionName('bomb.bin', ZipArchive::CM_DEFLATE, 9); $zip->close(); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'bomb.3mf')); } finally { unlink($path); } } public function test_rejects_file_exceeding_maximum_size(): void { $path = tempnam(sys_get_temp_dir(), 'stl'); $handle = fopen($path, 'wb'); fwrite($handle, str_repeat('A', 80)); fwrite($handle, pack('V', 1)); fseek($handle, (100 * 1024 * 1024) + 200); fwrite($handle, 'x'); fclose($handle); $this->expectException(RuntimeException::class); try { $this->validator->validate($this->uploadedFile($path, 'huge.stl')); } finally { unlink($path); } } private function makeBinaryStl(int $triangles, bool $declareExtraTriangle = false): string { $path = tempnam(sys_get_temp_dir(), 'stl'); $handle = fopen($path, 'wb'); fwrite($handle, str_repeat('A', 80)); fwrite($handle, pack('V', $declareExtraTriangle ? $triangles + 1 : $triangles)); for ($i = 0; $i < $triangles; $i++) { fwrite($handle, str_repeat(pack('f', 0.0), 12)); fwrite($handle, pack('v', 0)); } fclose($handle); return $path; } /** * @param array $entries */ private function make3mf(array $entries): string { $zip = new ZipArchive; $path = tempnam(sys_get_temp_dir(), 'tmf').'.3mf'; $zip->open($path, ZipArchive::CREATE); foreach ($entries as $name => $contents) { $zip->addFromString($name, $contents); } $zip->close(); return $path; } }