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