*/ private array $tempFiles = []; protected function tearDown(): void { foreach ($this->tempFiles as $file) { if (is_file($file)) { @unlink($file); } } parent::tearDown(); } public function test_rejects_zip_slip_path(): void { $archive = $this->makeZip([ '../evil.txt' => 'x', ]); $result = app(ArchiveInspectorService::class)->inspect($archive); $this->assertFalse($result->valid); $this->assertStringContainsString('path traversal', (string) $result->reason); } public function test_rejects_symlink_entries(): void { $archive = $this->makeZipWithCallback([ 'safe/file.txt' => 'ok', 'safe/link' => 'target', ], function (ZipArchive $zip, string $entryName): void { if ($entryName === 'safe/link') { $zip->setExternalAttributesName($entryName, ZipArchive::OPSYS_UNIX, 0120777 << 16); } }); $result = app(ArchiveInspectorService::class)->inspect($archive); $this->assertFalse($result->valid); $this->assertStringContainsString('symlink', strtolower((string) $result->reason)); } public function test_rejects_deep_nesting(): void { $archive = $this->makeZip([ 'a/b/c/d/e/f/file.txt' => 'too deep', ]); $result = app(ArchiveInspectorService::class)->inspect($archive); $this->assertFalse($result->valid); $this->assertStringContainsString('depth', strtolower((string) $result->reason)); } public function test_rejects_too_many_files(): void { $entries = []; for ($index = 0; $index < 5001; $index++) { $entries['f' . $index . '.txt'] = 'x'; } $archive = $this->makeZip($entries); $result = app(ArchiveInspectorService::class)->inspect($archive); $this->assertFalse($result->valid); $this->assertStringContainsString('5000', (string) $result->reason); } public function test_rejects_executable_extensions(): void { $archive = $this->makeZip([ 'skins/readme.txt' => 'ok', 'skins/run.exe' => 'MZ', ]); $result = app(ArchiveInspectorService::class)->inspect($archive); $this->assertFalse($result->valid); $this->assertStringContainsString('blocked', strtolower((string) $result->reason)); } public function test_rejects_zip_bomb_ratio(): void { $archive = $this->makeZip([ 'payload.txt' => str_repeat('A', 6 * 1024 * 1024), ]); $result = app(ArchiveInspectorService::class)->inspect($archive); $this->assertFalse($result->valid); $this->assertStringContainsString('ratio', strtolower((string) $result->reason)); } public function test_valid_archive_passes(): void { $archive = $this->makeZip([ 'skins/theme/readme.txt' => 'safe', 'skins/theme/colors.ini' => 'accent=blue', ]); $result = app(ArchiveInspectorService::class)->inspect($archive); $this->assertInstanceOf(InspectionResult::class, $result); $this->assertTrue($result->valid); $this->assertNull($result->reason); $this->assertIsArray($result->stats); $this->assertArrayHasKey('files', $result->stats); $this->assertArrayHasKey('depth', $result->stats); $this->assertArrayHasKey('size', $result->stats); $this->assertArrayHasKey('ratio', $result->stats); } /** * @param array $entries */ private function makeZip(array $entries): string { return $this->makeZipWithCallback($entries, null); } /** * @param array $entries * @param (callable(ZipArchive,string):void)|null $entryCallback */ private function makeZipWithCallback(array $entries, ?callable $entryCallback): string { if (! class_exists(ZipArchive::class)) { $this->markTestSkipped('ZipArchive extension is required.'); } $path = tempnam(sys_get_temp_dir(), 'sb_zip_'); if ($path === false) { throw new \RuntimeException('Unable to create temporary zip path.'); } $this->tempFiles[] = $path; $zip = new ZipArchive(); if ($zip->open($path, ZipArchive::OVERWRITE | ZipArchive::CREATE) !== true) { throw new \RuntimeException('Unable to open temporary zip for writing.'); } foreach ($entries as $name => $content) { $zip->addFromString($name, $content); if ($entryCallback !== null) { $entryCallback($zip, $name); } } $zip->close(); return $path; } }