|
|
@@ -0,0 +1,121 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+declare(strict_types=1);
|
|
|
+
|
|
|
+namespace App\Tests\Unit\Enrichment;
|
|
|
+
|
|
|
+use App\Infrastructure\Enrichment\Downloaders\DownloaderException;
|
|
|
+use App\Infrastructure\Enrichment\Downloaders\MaxMindDownloader;
|
|
|
+use PharData;
|
|
|
+use PHPUnit\Framework\TestCase;
|
|
|
+
|
|
|
+/**
|
|
|
+ * SEC_REVIEW F48 — `MaxMindDownloader::assertSizeBudget` walks the
|
|
|
+ * tarball before extraction and rejects per-entry / total-size limit
|
|
|
+ * breaches so a small `.tar.gz` cannot decompress into multi-GB and
|
|
|
+ * exhaust disk before MmdbVerifier sees the file.
|
|
|
+ */
|
|
|
+final class MaxMindDownloaderTest extends TestCase
|
|
|
+{
|
|
|
+ private string $tmpDir = '';
|
|
|
+
|
|
|
+ protected function setUp(): void
|
|
|
+ {
|
|
|
+ $this->tmpDir = sys_get_temp_dir() . '/irdb-maxmind-' . bin2hex(random_bytes(6));
|
|
|
+ mkdir($this->tmpDir);
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function tearDown(): void
|
|
|
+ {
|
|
|
+ // Clean up the tar file and any generated companions.
|
|
|
+ if ($this->tmpDir !== '' && is_dir($this->tmpDir)) {
|
|
|
+ foreach (glob($this->tmpDir . '/*') ?: [] as $f) {
|
|
|
+ @unlink($f);
|
|
|
+ }
|
|
|
+ @rmdir($this->tmpDir);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testNormalArchivePasses(): void
|
|
|
+ {
|
|
|
+ $phar = $this->buildTar([
|
|
|
+ 'GeoLite2-Country.mmdb' => 'pretend-mmdb-bytes',
|
|
|
+ 'COPYRIGHT.txt' => 'CC BY-SA 4.0',
|
|
|
+ ]);
|
|
|
+
|
|
|
+ // Default caps (200 MiB / 400 MiB) — small fixtures pass cleanly.
|
|
|
+ MaxMindDownloader::assertSizeBudget($phar, 'country');
|
|
|
+ $this->expectNotToPerformAssertions();
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testEntryOverPerEntryCapIsRejected(): void
|
|
|
+ {
|
|
|
+ $phar = $this->buildTar([
|
|
|
+ 'big.bin' => str_repeat('A', 4096),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $this->expectException(DownloaderException::class);
|
|
|
+ $this->expectExceptionMessage('uncompressed size 4096 > cap 1024');
|
|
|
+ // Force the per-entry cap below the entry size.
|
|
|
+ MaxMindDownloader::assertSizeBudget($phar, 'country', maxEntryBytes: 1024);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testTotalOverArchiveCapIsRejected(): void
|
|
|
+ {
|
|
|
+ // Three 1 KiB entries individually pass per-entry; the total
|
|
|
+ // breaches the archive cap at the third entry.
|
|
|
+ $phar = $this->buildTar([
|
|
|
+ 'a.bin' => str_repeat('a', 1024),
|
|
|
+ 'b.bin' => str_repeat('b', 1024),
|
|
|
+ 'c.bin' => str_repeat('c', 1024),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $this->expectException(DownloaderException::class);
|
|
|
+ $this->expectExceptionMessage('archive uncompressed size > cap 2048');
|
|
|
+ MaxMindDownloader::assertSizeBudget(
|
|
|
+ $phar,
|
|
|
+ 'country',
|
|
|
+ maxEntryBytes: 4096,
|
|
|
+ maxTotalBytes: 2048,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ public function testNestedEntriesAreCounted(): void
|
|
|
+ {
|
|
|
+ // The real MaxMind tarball nests one directory like
|
|
|
+ // `GeoLite2-Country_20260427/GeoLite2-Country.mmdb`. Recursive
|
|
|
+ // iteration must walk into the directory and count nested
|
|
|
+ // entries against both caps.
|
|
|
+ $phar = $this->buildTar([
|
|
|
+ 'GeoLite2-Country_20260427/GeoLite2-Country.mmdb' => str_repeat('m', 2048),
|
|
|
+ 'GeoLite2-Country_20260427/COPYRIGHT.txt' => 'CC',
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $this->expectException(DownloaderException::class);
|
|
|
+ $this->expectExceptionMessage('GeoLite2-Country.mmdb uncompressed size 2048 > cap 1024');
|
|
|
+ MaxMindDownloader::assertSizeBudget(
|
|
|
+ $phar,
|
|
|
+ 'country',
|
|
|
+ maxEntryBytes: 1024,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * @param array<string, string> $entries map of entry path → contents
|
|
|
+ */
|
|
|
+ private function buildTar(array $entries): PharData
|
|
|
+ {
|
|
|
+ $tarPath = $this->tmpDir . '/' . bin2hex(random_bytes(4)) . '.tar';
|
|
|
+ $writer = new PharData($tarPath);
|
|
|
+ foreach ($entries as $name => $contents) {
|
|
|
+ $writer->addFromString($name, $contents);
|
|
|
+ }
|
|
|
+ // PharData iteration on the *write* handle returns nothing until
|
|
|
+ // the file is closed and re-opened. Production builds the
|
|
|
+ // PharData from an already-on-disk tarball, so this matches the
|
|
|
+ // production path.
|
|
|
+ unset($writer);
|
|
|
+
|
|
|
+ return new PharData($tarPath);
|
|
|
+ }
|
|
|
+}
|