From a61034862a93bcfdca03d39e804fa7ca67871d79 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 2 Oct 2026 00:05:32 +0000 Subject: [PATCH 01/10] Enable PHPStan checked-exception analysis Turn on missingCheckedExceptionInThrows so that the PHPDoc of the pure PHP reader lists every checked exception that can reach a caller. Private helpers declare their exceptions, so the check follows them to the public methods. The Reader class PHPDoc notes that the C extension may differ. Configure Error and BadMethodCallException as unchecked, because they signal programmer errors, such as a lookup on a closed reader. Setting Error explicitly makes the result the same on all PHPStan 2.2 releases. 2.2.5 treats Error as checked by default, and 2.2.16 does not. LogicException stays checked. The reader throws InvalidArgumentException for an invalid IP address or a missing database file, and callers must handle those. The Reader constructor, get(), and getWithPrefixLen() now declare RuntimeException, which the decoder throws for an integer that needs gmp or bcmath when neither is installed, and for a data offset that is too large for the platform. The constructor also declares UnexpectedValueException. The lookup methods describe the IPv6-in-IPv4 InvalidArgumentException. close() declares BadMethodCallException in place of Exception, and metadata() no longer declares InvalidArgumentException, because both throw only BadMethodCallException and ArgumentCountError. Decoder::decode() and Util::read() declare their exceptions. The Decoder constructor declares InvalidDatabaseException, which isPlatformLittleEndian() can throw. Tests do not declare exceptions, because PHPUnit handles any exception a test throws. An ignoreErrors entry limits that to tests/, and all other rules still check the tests. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 +++++++ phpstan.neon | 12 +++++++ src/MaxMind/Db/Reader.php | 53 ++++++++++++++++++++++++++----- src/MaxMind/Db/Reader/Decoder.php | 48 ++++++++++++++++++++++++++++ src/MaxMind/Db/Reader/Util.php | 2 ++ 5 files changed, 118 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9982f6bb..f2eaf37a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ CHANGELOG with the bundled libmaxminddb or links a system library. The `libmaxminddb library version` row reads, for example, `1.14.0 (bundled)` or `1.9.1 (system)`. Pull request by Remi Collet. GitHub #289. +* The PHPDoc of the pure PHP reader now lists the exceptions that its methods + can throw: + * The `MaxMind\Db\Reader` constructor, `get()`, and `getWithPrefixLen()` + declare `RuntimeException`. The reader throws it for an integer that needs + the gmp or bcmath extension when neither is installed. + * The constructor declares `UnexpectedValueException`. + * `close()` declares `BadMethodCallException` in place of `Exception`. + `metadata()` no longer declares `InvalidArgumentException`, which it + cannot throw. + * `MaxMind\Db\Reader\Decoder::decode()` and `MaxMind\Db\Reader\Util::read()` + declare their exceptions. 1.14.0 (2026-09-10) ------------------- diff --git a/phpstan.neon b/phpstan.neon index 63aadbac..e43f8fb2 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,3 +3,15 @@ parameters: paths: - src - tests + exceptions: + # These classes signal programmer errors, so callers need not declare them. + uncheckedExceptionClasses: + - Error + - BadMethodCallException + check: + missingCheckedExceptionInThrows: true + ignoreErrors: + # PHPUnit handles any exception a test throws, so tests do not declare them. + - + identifier: missingType.checkedException + path: tests/* diff --git a/src/MaxMind/Db/Reader.php b/src/MaxMind/Db/Reader.php index 923dc02f..3fe4aa78 100644 --- a/src/MaxMind/Db/Reader.php +++ b/src/MaxMind/Db/Reader.php @@ -12,6 +12,9 @@ /** * Instances of this class provide a reader for the MaxMind DB format. IP * addresses can be looked up using the get method. + * + * The declared exceptions describe the pure PHP reader. The C extension may + * differ. */ class Reader { @@ -71,10 +74,18 @@ class Reader * * @param string $database the MaxMind DB file to use * - * @throws \InvalidArgumentException for invalid database path or unknown arguments + * @throws \InvalidArgumentException if the database file does not exist or + * is not readable * @throws InvalidDatabaseException * if the database is invalid or there is an error reading * from it + * @throws \UnexpectedValueException if the size of the database file + * cannot be determined + * @throws \RuntimeException if the metadata contains an integer + * that needs the gmp or bcmath extension + * and neither is installed, or a data + * offset that is too large for the + * platform */ public function __construct(string $database) { @@ -124,10 +135,17 @@ public function __construct(string $database) * @param string $ipAddress the IP address to look up * * @throws \BadMethodCallException if the database is closed or another lookup is in progress - * @throws \InvalidArgumentException if something other than a single IP address is passed to the method + * @throws \InvalidArgumentException if the IP address is not valid, or if + * it is an IPv6 address and the database + * is IPv4-only * @throws InvalidDatabaseException * if the database is invalid or there is an error reading * from it + * @throws \RuntimeException if the record contains an integer + * that needs the gmp or bcmath extension + * and neither is installed, or a data + * offset that is too large for the + * platform * * @return mixed the record for the IP address */ @@ -149,10 +167,17 @@ public function get(string $ipAddress) * @param string $ipAddress the IP address to look up * * @throws \BadMethodCallException if the database is closed or another lookup is in progress - * @throws \InvalidArgumentException if something other than a single IP address is passed to the method + * @throws \InvalidArgumentException if the IP address is not valid, or if + * it is an IPv6 address and the database + * is IPv4-only * @throws InvalidDatabaseException * if the database is invalid or there is an error reading * from it + * @throws \RuntimeException if the record contains an integer + * that needs the gmp or bcmath extension + * and neither is installed, or a data + * offset that is too large for the + * platform * * @return array{0:mixed, 1:int} an array where the first element is the record and the * second the network prefix length for the record @@ -193,6 +218,9 @@ public function getWithPrefixLen(string $ipAddress): array } /** + * @throws \InvalidArgumentException + * @throws InvalidDatabaseException + * * @return array{0:int, 1:int} */ private function findAddressInTree(string $ipAddress): array @@ -254,6 +282,9 @@ private function findAddressInTree(string $ipAddress): array ); } + /** + * @throws InvalidDatabaseException + */ private function ipV4StartNode(): int { // If we have an IPv4 database, the start node is the first node @@ -270,6 +301,9 @@ private function ipV4StartNode(): int return $node; } + /** + * @throws InvalidDatabaseException + */ private function readNode(int $nodeNumber, int $index): int { $baseOffset = $nodeNumber * $this->metadata->nodeByteSize; @@ -325,6 +359,9 @@ private function readNode(int $nodeNumber, int $index): int } /** + * @throws InvalidDatabaseException + * @throws \RuntimeException + * * @return mixed */ private function resolveDataPointer(int $pointer) @@ -342,10 +379,12 @@ private function resolveDataPointer(int $pointer) return $data; } - /* + /** * This is an extremely naive but reasonably readable implementation. There * are much faster algorithms (e.g., Boyer-Moore) for this if speed is ever * an issue, but I suspect it won't be. + * + * @throws InvalidDatabaseException */ private function findMetadataStart(string $filename): int { @@ -374,8 +413,7 @@ private function findMetadataStart(string $filename): int } /** - * @throws \InvalidArgumentException if arguments are passed to the method - * @throws \BadMethodCallException if the database has been closed + * @throws \BadMethodCallException if the database has been closed * * @return Metadata object for the database */ @@ -401,8 +439,7 @@ public function metadata(): Metadata /** * Closes the MaxMind DB and returns resources to the system. * - * @throws \Exception - * if an I/O error occurs + * @throws \BadMethodCallException if the database has already been closed */ public function close(): void { diff --git a/src/MaxMind/Db/Reader/Decoder.php b/src/MaxMind/Db/Reader/Decoder.php index 02d9a108..07c72534 100644 --- a/src/MaxMind/Db/Reader/Decoder.php +++ b/src/MaxMind/Db/Reader/Decoder.php @@ -96,6 +96,8 @@ class Decoder /** * @param resource $fileStream + * + * @throws InvalidDatabaseException */ public function __construct( $fileStream, @@ -111,6 +113,13 @@ public function __construct( } /** + * @throws InvalidDatabaseException if the data is invalid or there is an + * error reading it + * @throws \RuntimeException if the data contains an integer that + * needs the gmp or bcmath extension and + * neither is installed, or a data offset + * that is too large for the platform + * * @return array */ public function decode(int $offset): array @@ -129,6 +138,9 @@ public function decode(int $offset): array } /** + * @throws InvalidDatabaseException + * @throws \RuntimeException + * * @return array */ private function decodeWithBudget(int $offset, int $depth, bool $allowPointer = true): array @@ -194,6 +206,9 @@ private function decodeWithBudget(int $offset, int $depth, bool $allowPointer = /** * @param int<0, max> $size * + * @throws InvalidDatabaseException + * @throws \RuntimeException + * * @return array{0:mixed, 1:int} */ private function decodeByType(int $type, int $offset, int $size, int $depth): array @@ -275,6 +290,8 @@ private function decodeByType(int $type, int $offset, int $size, int $depth): ar * the previous one ended. * * @param int<0, max> $numberOfBytes + * + * @throws InvalidDatabaseException */ private function read(int $offset, int $numberOfBytes): string { @@ -300,6 +317,9 @@ private function read(int $offset, int $numberOfBytes): string return $value; } + /** + * @throws InvalidDatabaseException + */ private function verifySize(int $expected, int $actual): void { if ($expected !== $actual) { @@ -313,6 +333,8 @@ private function verifySize(int $expected, int $actual): void * Charges declared children before decoding them. An oversized container * fails before any child is read. Each visit to a shared container charges * its children again, which bounds pointer fan-out. + * + * @throws InvalidDatabaseException */ private function enterContainer( int $size, @@ -333,6 +355,9 @@ private function enterContainer( } /** + * @throws InvalidDatabaseException + * @throws \RuntimeException + * * @return array{0:array, 1:int} */ private function decodeArray(int $size, int $offset, int $depth): array @@ -354,6 +379,9 @@ private function decodeBoolean(int $size): bool return $size !== 0; } + /** + * @throws InvalidDatabaseException + */ private function decodeDouble(string $bytes): float { // This assumes IEEE 754 doubles, but most (all?) modern platforms @@ -369,6 +397,9 @@ private function decodeDouble(string $bytes): float return $double; } + /** + * @throws InvalidDatabaseException + */ private function decodeFloat(string $bytes): float { // This assumes IEEE 754 floats, but most (all?) modern platforms @@ -384,6 +415,9 @@ private function decodeFloat(string $bytes): float return $float; } + /** + * @throws InvalidDatabaseException + */ private function decodeInt32(string $bytes, int $size): int { switch ($size) { @@ -418,6 +452,9 @@ private function decodeInt32(string $bytes, int $size): int } /** + * @throws InvalidDatabaseException + * @throws \RuntimeException + * * @return array{0:array, 1:int} */ private function decodeMap(int $size, int $offset, int $depth): array @@ -437,6 +474,9 @@ private function decodeMap(int $size, int $offset, int $depth): array } /** + * @throws InvalidDatabaseException + * @throws \RuntimeException + * * @return array{0:int, 1:int} */ private function decodePointer(int $ctrlByte, int $offset): array @@ -515,6 +555,9 @@ private function decodePointer(int $ctrlByte, int $offset): array return [$pointer, $offset]; } + /** + * @throws \RuntimeException + */ // @phpstan-ignore-next-line private function decodeUint(string $bytes, int $byteLength) { @@ -558,6 +601,8 @@ private function decodeUint(string $bytes, int $byteLength) } /** + * @throws InvalidDatabaseException + * * @return array{0:int, 1:int} */ private function sizeFromCtrlByte(int $ctrlByte, int $offset): array @@ -601,6 +646,9 @@ private function maybeSwitchByteOrder(string $bytes): string return $this->switchByteOrder ? strrev($bytes) : $bytes; } + /** + * @throws InvalidDatabaseException + */ private function isPlatformLittleEndian(): bool { $testint = 0x00FF; diff --git a/src/MaxMind/Db/Reader/Util.php b/src/MaxMind/Db/Reader/Util.php index c5485ea7..a8f8167c 100644 --- a/src/MaxMind/Db/Reader/Util.php +++ b/src/MaxMind/Db/Reader/Util.php @@ -9,6 +9,8 @@ class Util /** * @param resource $stream * @param int<0, max> $numberOfBytes + * + * @throws InvalidDatabaseException if the bytes cannot be read */ public static function read($stream, int $offset, int $numberOfBytes): string { From 38190d70f69810cde21f327fa811ba2d5ab79917 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 2 Oct 2026 14:49:10 +0000 Subject: [PATCH 02/10] Set a minimum version for PHPStan composer.lock is not committed, so the require-dev constraints alone decide which tool versions CI and developers install. PHPStan used "*", which allows any version, including a future major release that changes behavior. Require PHPStan 2.2, or 1.12 on PHP 7.2 and 7.3. The test workflows install the dev dependencies on PHP 7.2, and PHPStan 2 needs PHP 7.4. php-cs-fixer and PHP_CodeSniffer already have a major-version bound. A higher php-cs-fixer floor would not resolve on PHP 7.2. Co-Authored-By: Claude Opus 5.5 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 4ce807d5..e235bca5 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "friendsofphp/php-cs-fixer": "3.*", "phpunit/phpunit": ">=8.0.0,<10.0.0", "squizlabs/php_codesniffer": "4.*", - "phpstan/phpstan": "*", + "phpstan/phpstan": "^1.12 || ^2.2", "symfony/polyfill-php80": "^1.33" }, "autoload": { From b6d93de892c6c617ba1b3706b3526c0044b97a2d Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 5 Oct 2026 19:01:06 -0700 Subject: [PATCH 03/10] Allow source-only analysis without unmatched PHPUnit ignores --- phpstan.neon | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/phpstan.neon b/phpstan.neon index e43f8fb2..7e1d22b9 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -11,7 +11,8 @@ parameters: check: missingCheckedExceptionInThrows: true ignoreErrors: - # PHPUnit handles any exception a test throws, so tests do not declare them. + # PHPUnit handles exceptions from test methods and their helpers. - identifier: missingType.checkedException path: tests/* + reportUnmatched: false From da47649589543edb1c26301fd87806e31988ce45 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 5 Oct 2026 19:01:09 -0700 Subject: [PATCH 04/10] Reject non-string database map keys --- CHANGELOG.md | 3 ++ src/MaxMind/Db/Reader/Decoder.php | 3 ++ tests/MaxMind/Db/Test/Reader/DecoderTest.php | 32 ++++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2eaf37a..0e1104f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ CHANGELOG 1.15.0 ------------------- +* The pure PHP reader now rejects non-string map keys with + `InvalidDatabaseException` instead of a `TypeError`, a warning, or an + implicit type conversion. * `phpinfo()` and `php --ri maxminddb` now show whether the extension was built with the bundled libmaxminddb or links a system library. The `libmaxminddb library version` row reads, for example, `1.14.0 (bundled)` or diff --git a/src/MaxMind/Db/Reader/Decoder.php b/src/MaxMind/Db/Reader/Decoder.php index 07c72534..6bd22169 100644 --- a/src/MaxMind/Db/Reader/Decoder.php +++ b/src/MaxMind/Db/Reader/Decoder.php @@ -466,6 +466,9 @@ private function decodeMap(int $size, int $offset, int $depth): array for ($i = 0; $i < $size; ++$i) { [$key, $offset] = $this->decodeWithBudget($offset, $depth + 1); + if (!\is_string($key)) { + throw new InvalidDatabaseException('A map key must be a string.'); + } [$value, $offset] = $this->decodeWithBudget($offset, $depth + 1); $map[$key] = $value; } diff --git a/tests/MaxMind/Db/Test/Reader/DecoderTest.php b/tests/MaxMind/Db/Test/Reader/DecoderTest.php index b8e465be..17db427a 100644 --- a/tests/MaxMind/Db/Test/Reader/DecoderTest.php +++ b/tests/MaxMind/Db/Test/Reader/DecoderTest.php @@ -365,6 +365,38 @@ public function testMaps(): void $this->validateTypeDecodingList('map', $this->maps); } + /** + * @dataProvider invalidMapKeys + */ + public function testInvalidMapKey(string $key): void + { + $handle = fopen('php://memory', 'rwb'); + fwrite($handle, "\xe1" . $key . "\xa0"); + $decoder = new Decoder($handle); + + try { + $this->expectException(InvalidDatabaseException::class); + $this->expectExceptionMessage('A map key must be a string.'); + $decoder->decode(0); + } finally { + fclose($handle); + } + } + + /** + * @return array + */ + public static function invalidMapKeys(): array + { + return [ + 'map' => ["\xe0"], + 'array' => ["\x00\x04"], + 'uint16' => ["\xa1\x01"], + 'double' => ["\x68" . pack('E', 1.5)], + 'boolean' => ["\x01\x07"], + ]; + } + public function testPointers(): void { $this->validateTypeDecodingList('pointers', $this->pointers()); From 0a57395cc0bcd718bc98ae105b41dda396f51be2 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 5 Oct 2026 19:01:11 -0700 Subject: [PATCH 05/10] Validate metadata before using its fields --- CHANGELOG.md | 4 + src/MaxMind/Db/Reader.php | 3 + src/MaxMind/Db/Reader/Metadata.php | 44 +++++++++- tests/MaxMind/Db/Test/Reader/MetadataTest.php | 80 +++++++++++++++++++ tests/MaxMind/Db/Test/ReaderTest.php | 29 +++++++ 5 files changed, 159 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e1104f3..88aaea99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ CHANGELOG * The pure PHP reader now rejects non-string map keys with `InvalidDatabaseException` instead of a `TypeError`, a warning, or an implicit type conversion. +* The pure PHP reader now rejects malformed metadata with + `InvalidDatabaseException` instead of a `TypeError`, a warning, or an + implicit type conversion. Missing optional `languages` and `description` + fields default to empty arrays. * `phpinfo()` and `php --ri maxminddb` now show whether the extension was built with the bundled libmaxminddb or links a system library. The `libmaxminddb library version` row reads, for example, `1.14.0 (bundled)` or diff --git a/src/MaxMind/Db/Reader.php b/src/MaxMind/Db/Reader.php index 3fe4aa78..795bf20b 100644 --- a/src/MaxMind/Db/Reader.php +++ b/src/MaxMind/Db/Reader.php @@ -121,6 +121,9 @@ public function __construct(string $database) $start = $this->findMetadataStart($database); $metadataDecoder = new Decoder($this->fileHandle, $start); [$metadataArray] = $metadataDecoder->decode($start); + if (!\is_array($metadataArray)) { + throw new InvalidDatabaseException('The database metadata must be a map.'); + } $this->metadata = new Metadata($metadataArray); $this->decoder = new Decoder( $this->fileHandle, diff --git a/src/MaxMind/Db/Reader/Metadata.php b/src/MaxMind/Db/Reader/Metadata.php index 6cb63329..f675e62b 100644 --- a/src/MaxMind/Db/Reader/Metadata.php +++ b/src/MaxMind/Db/Reader/Metadata.php @@ -29,7 +29,7 @@ class Metadata * This is an unsigned 64-bit integer that contains the database build * timestamp as a Unix epoch value. * - * @var int + * @var int|string */ public $buildEpoch; @@ -97,6 +97,8 @@ class Metadata /** * @param array $metadata + * + * @throws InvalidDatabaseException if a metadata field is missing or invalid */ public function __construct(array $metadata) { @@ -106,6 +108,46 @@ public function __construct(array $metadata) ); } + foreach ([ + 'binary_format_major_version', + 'binary_format_minor_version', + 'ip_version', + 'node_count', + 'record_size', + ] as $key) { + if (!isset($metadata[$key]) || !\is_int($metadata[$key]) || $metadata[$key] < 0) { + throw new InvalidDatabaseException("Metadata field $key must be an unsigned integer."); + } + } + $buildEpoch = $metadata['build_epoch'] ?? null; + if ((!\is_int($buildEpoch) || $buildEpoch < 0) + && (!\is_string($buildEpoch) || !preg_match('/\A[0-9]+\z/', $buildEpoch)) + ) { + throw new InvalidDatabaseException('Metadata build_epoch must be an unsigned integer.'); + } + if (!\in_array($metadata['record_size'], [24, 28, 32], true)) { + throw new InvalidDatabaseException('Metadata record_size must be 24, 28, or 32.'); + } + if (!\in_array($metadata['ip_version'], [4, 6], true)) { + throw new InvalidDatabaseException('Metadata ip_version must be 4 or 6.'); + } + if (!isset($metadata['database_type']) || !\is_string($metadata['database_type'])) { + throw new InvalidDatabaseException('Metadata database_type must be a string.'); + } + foreach (['languages', 'description'] as $key) { + if (!\array_key_exists($key, $metadata)) { + $metadata[$key] = []; + } + if (!\is_array($metadata[$key])) { + throw new InvalidDatabaseException("Metadata field $key must be an array of strings."); + } + foreach ($metadata[$key] as $value) { + if (!\is_string($value)) { + throw new InvalidDatabaseException("Metadata field $key must contain only strings."); + } + } + } + $this->binaryFormatMajorVersion = $metadata['binary_format_major_version']; $this->binaryFormatMinorVersion diff --git a/tests/MaxMind/Db/Test/Reader/MetadataTest.php b/tests/MaxMind/Db/Test/Reader/MetadataTest.php index bfddca01..2dcd7bef 100644 --- a/tests/MaxMind/Db/Test/Reader/MetadataTest.php +++ b/tests/MaxMind/Db/Test/Reader/MetadataTest.php @@ -4,6 +4,7 @@ namespace MaxMind\Db\Test\Reader; +use MaxMind\Db\Reader\InvalidDatabaseException; use MaxMind\Db\Reader\Metadata; use PHPUnit\Framework\TestCase; @@ -42,6 +43,85 @@ public function testConstructor(): void $this->assertSame($metadata->searchTreeSize, 6 * 665037); } + /** + * @dataProvider invalidMetadata + * + * @param mixed $value + */ + public function testInvalidMetadata(string $key, $value): void + { + if (\extension_loaded('maxminddb')) { + $this->markTestSkipped('This test covers the pure PHP metadata constructor.'); + } + + $metadata = [ + 'node_count' => 1, + 'record_size' => 24, + 'ip_version' => 6, + 'binary_format_major_version' => 2, + 'binary_format_minor_version' => 0, + 'build_epoch' => 1594066370, + 'database_type' => 'Test', + 'languages' => ['en'], + 'description' => ['en' => 'Test'], + ]; + $metadata[$key] = $value; + + $this->expectException(InvalidDatabaseException::class); + new Metadata($metadata); + } + + /** + * @return array + */ + public static function invalidMetadata(): array + { + return [ + 'string record size' => ['record_size', 'bad'], + 'map record size' => ['record_size', ['bad' => 1]], + 'null record size' => ['record_size', null], + 'unsupported record size' => ['record_size', 16], + 'string node count' => ['node_count', '1'], + 'negative node count' => ['node_count', -1], + 'unsupported IP version' => ['ip_version', 5], + 'invalid database type' => ['database_type', []], + 'invalid languages' => ['languages', 'en'], + 'invalid language' => ['languages', [1]], + 'invalid description' => ['description', ['en' => []]], + 'invalid build epoch' => ['build_epoch', 'bad'], + ]; + } + + public function testOptionalMetadata(): void + { + if (\extension_loaded('maxminddb')) { + $this->markTestSkipped('This test covers the pure PHP metadata constructor.'); + } + + $metadata = new Metadata([ + 'node_count' => 1, + 'record_size' => 24, + 'ip_version' => 6, + 'binary_format_major_version' => 2, + 'binary_format_minor_version' => 0, + 'build_epoch' => '2147483648', + 'database_type' => 'Test', + ]); + $this->assertSame([], $metadata->languages); + $this->assertSame([], $metadata->description); + $this->assertSame('2147483648', $metadata->buildEpoch); + } + + public function testMissingMetadata(): void + { + if (\extension_loaded('maxminddb')) { + $this->markTestSkipped('This test covers the pure PHP metadata constructor.'); + } + + $this->expectException(InvalidDatabaseException::class); + new Metadata([]); + } + public function testTooManyConstructorArgs(): void { $this->expectException(\ArgumentCountError::class); diff --git a/tests/MaxMind/Db/Test/ReaderTest.php b/tests/MaxMind/Db/Test/ReaderTest.php index 399fa0dd..1430d51c 100644 --- a/tests/MaxMind/Db/Test/ReaderTest.php +++ b/tests/MaxMind/Db/Test/ReaderTest.php @@ -129,6 +129,35 @@ public function testMax(): void $this->assertSame('340282366920938463463374607431768211455', $uint128); } + public function testInvalidMetadataType(): void + { + $path = tempnam(sys_get_temp_dir(), 'mmdb-metadata-'); + file_put_contents($path, "\xab\xcd\xefMaxMind.com\xa0"); + + try { + $this->expectException(InvalidDatabaseException::class); + new Reader($path); + } finally { + unlink($path); + } + } + + public function testInvalidMetadataRecordSize(): void + { + $database = file_get_contents('tests/data/test-data/MaxMind-DB-test-ipv4-24.mmdb'); + $database = str_replace("\x4brecord_size\xa1\x18", "\x4brecord_size\x41x", $database, $count); + $this->assertSame(1, $count); + $path = tempnam(sys_get_temp_dir(), 'mmdb-metadata-'); + file_put_contents($path, $database); + + try { + $this->expectException(InvalidDatabaseException::class); + new Reader($path); + } finally { + unlink($path); + } + } + public function testMetadataPointers(): void { $reader = new Reader( From ebc163bc617c88ec672f5ee39c9880a48d195a14 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 5 Oct 2026 19:01:14 -0700 Subject: [PATCH 06/10] Distinguish unsupported platforms from invalid databases --- CHANGELOG.md | 8 ++- src/MaxMind/Db/Reader.php | 69 ++++++++++--------- src/MaxMind/Db/Reader/Decoder.php | 28 ++++---- .../Reader/UnsupportedPlatformException.php | 11 +++ tests/MaxMind/Db/Test/Reader/DecoderTest.php | 19 +++++ 5 files changed, 85 insertions(+), 50 deletions(-) create mode 100644 src/MaxMind/Db/Reader/UnsupportedPlatformException.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 88aaea99..49e808c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ CHANGELOG `InvalidDatabaseException` instead of a `TypeError`, a warning, or an implicit type conversion. Missing optional `languages` and `description` fields default to empty arrays. +* Missing gmp/bcmath support and offsets that exceed the platform limit now + throw `MaxMind\Db\Reader\UnsupportedPlatformException`. It extends + `RuntimeException`, so existing catches continue to work. * `phpinfo()` and `php --ri maxminddb` now show whether the extension was built with the bundled libmaxminddb or links a system library. The `libmaxminddb library version` row reads, for example, `1.14.0 (bundled)` or @@ -18,8 +21,9 @@ CHANGELOG * The PHPDoc of the pure PHP reader now lists the exceptions that its methods can throw: * The `MaxMind\Db\Reader` constructor, `get()`, and `getWithPrefixLen()` - declare `RuntimeException`. The reader throws it for an integer that needs - the gmp or bcmath extension when neither is installed. + declare `UnsupportedPlatformException`. The reader throws it when an + integer needs gmp or bcmath and neither is installed, or a data offset + exceeds the platform limit. * The constructor declares `UnexpectedValueException`. * `close()` declares `BadMethodCallException` in place of `Exception`. `metadata()` no longer declares `InvalidArgumentException`, which it diff --git a/src/MaxMind/Db/Reader.php b/src/MaxMind/Db/Reader.php index 795bf20b..146bbe7d 100644 --- a/src/MaxMind/Db/Reader.php +++ b/src/MaxMind/Db/Reader.php @@ -7,6 +7,7 @@ use MaxMind\Db\Reader\Decoder; use MaxMind\Db\Reader\InvalidDatabaseException; use MaxMind\Db\Reader\Metadata; +use MaxMind\Db\Reader\UnsupportedPlatformException; use MaxMind\Db\Reader\Util; /** @@ -74,18 +75,18 @@ class Reader * * @param string $database the MaxMind DB file to use * - * @throws \InvalidArgumentException if the database file does not exist or - * is not readable + * @throws \InvalidArgumentException if the database file does not exist or + * is not readable * @throws InvalidDatabaseException - * if the database is invalid or there is an error reading - * from it - * @throws \UnexpectedValueException if the size of the database file - * cannot be determined - * @throws \RuntimeException if the metadata contains an integer - * that needs the gmp or bcmath extension - * and neither is installed, or a data - * offset that is too large for the - * platform + * if the database is invalid or there is an error reading + * from it + * @throws \UnexpectedValueException if the size of the database file + * cannot be determined + * @throws UnsupportedPlatformException if the metadata contains an integer + * that needs the gmp or bcmath extension + * and neither is installed, or a data + * offset that is too large for the + * platform */ public function __construct(string $database) { @@ -137,18 +138,18 @@ public function __construct(string $database) * * @param string $ipAddress the IP address to look up * - * @throws \BadMethodCallException if the database is closed or another lookup is in progress - * @throws \InvalidArgumentException if the IP address is not valid, or if - * it is an IPv6 address and the database - * is IPv4-only + * @throws \BadMethodCallException if the database is closed or another lookup is in progress + * @throws \InvalidArgumentException if the IP address is not valid, or if + * it is an IPv6 address and the database + * is IPv4-only * @throws InvalidDatabaseException - * if the database is invalid or there is an error reading - * from it - * @throws \RuntimeException if the record contains an integer - * that needs the gmp or bcmath extension - * and neither is installed, or a data - * offset that is too large for the - * platform + * if the database is invalid or there is an error reading + * from it + * @throws UnsupportedPlatformException if the record contains an integer + * that needs the gmp or bcmath extension + * and neither is installed, or a data + * offset that is too large for the + * platform * * @return mixed the record for the IP address */ @@ -169,18 +170,18 @@ public function get(string $ipAddress) * * @param string $ipAddress the IP address to look up * - * @throws \BadMethodCallException if the database is closed or another lookup is in progress - * @throws \InvalidArgumentException if the IP address is not valid, or if - * it is an IPv6 address and the database - * is IPv4-only + * @throws \BadMethodCallException if the database is closed or another lookup is in progress + * @throws \InvalidArgumentException if the IP address is not valid, or if + * it is an IPv6 address and the database + * is IPv4-only * @throws InvalidDatabaseException - * if the database is invalid or there is an error reading - * from it - * @throws \RuntimeException if the record contains an integer - * that needs the gmp or bcmath extension - * and neither is installed, or a data - * offset that is too large for the - * platform + * if the database is invalid or there is an error reading + * from it + * @throws UnsupportedPlatformException if the record contains an integer + * that needs the gmp or bcmath extension + * and neither is installed, or a data + * offset that is too large for the + * platform * * @return array{0:mixed, 1:int} an array where the first element is the record and the * second the network prefix length for the record @@ -363,7 +364,7 @@ private function readNode(int $nodeNumber, int $index): int /** * @throws InvalidDatabaseException - * @throws \RuntimeException + * @throws UnsupportedPlatformException * * @return mixed */ diff --git a/src/MaxMind/Db/Reader/Decoder.php b/src/MaxMind/Db/Reader/Decoder.php index 6bd22169..438018a1 100644 --- a/src/MaxMind/Db/Reader/Decoder.php +++ b/src/MaxMind/Db/Reader/Decoder.php @@ -113,12 +113,12 @@ public function __construct( } /** - * @throws InvalidDatabaseException if the data is invalid or there is an - * error reading it - * @throws \RuntimeException if the data contains an integer that - * needs the gmp or bcmath extension and - * neither is installed, or a data offset - * that is too large for the platform + * @throws InvalidDatabaseException if the data is invalid or there is an + * error reading it + * @throws UnsupportedPlatformException if the data contains an integer that + * needs the gmp or bcmath extension and + * neither is installed, or a data offset + * that is too large for the platform * * @return array */ @@ -139,7 +139,7 @@ public function decode(int $offset): array /** * @throws InvalidDatabaseException - * @throws \RuntimeException + * @throws UnsupportedPlatformException * * @return array */ @@ -207,7 +207,7 @@ private function decodeWithBudget(int $offset, int $depth, bool $allowPointer = * @param int<0, max> $size * * @throws InvalidDatabaseException - * @throws \RuntimeException + * @throws UnsupportedPlatformException * * @return array{0:mixed, 1:int} */ @@ -356,7 +356,7 @@ private function enterContainer( /** * @throws InvalidDatabaseException - * @throws \RuntimeException + * @throws UnsupportedPlatformException * * @return array{0:array, 1:int} */ @@ -453,7 +453,7 @@ private function decodeInt32(string $bytes, int $size): int /** * @throws InvalidDatabaseException - * @throws \RuntimeException + * @throws UnsupportedPlatformException * * @return array{0:array, 1:int} */ @@ -478,7 +478,7 @@ private function decodeMap(int $size, int $offset, int $depth): array /** * @throws InvalidDatabaseException - * @throws \RuntimeException + * @throws UnsupportedPlatformException * * @return array{0:int, 1:int} */ @@ -542,7 +542,7 @@ private function decodePointer(int $ctrlByte, int $offset): array if (\PHP_INT_MAX - $pointerBase >= $pointerOffset) { $pointer = $pointerOffset + $pointerBase; } else { - throw new \RuntimeException( + throw new UnsupportedPlatformException( 'The database offset is too large to be represented on your platform.' ); } @@ -559,7 +559,7 @@ private function decodePointer(int $ctrlByte, int $offset): array } /** - * @throws \RuntimeException + * @throws UnsupportedPlatformException */ // @phpstan-ignore-next-line private function decodeUint(string $bytes, int $byteLength) @@ -594,7 +594,7 @@ private function decodeUint(string $bytes, int $byteLength) } elseif (\extension_loaded('bcmath')) { $integerAsString = bcadd(bcmul($integerAsString, '256'), (string) $part); } else { - throw new \RuntimeException( + throw new UnsupportedPlatformException( 'The gmp or bcmath extension must be installed to read this database.' ); } diff --git a/src/MaxMind/Db/Reader/UnsupportedPlatformException.php b/src/MaxMind/Db/Reader/UnsupportedPlatformException.php new file mode 100644 index 00000000..eaa831cc --- /dev/null +++ b/src/MaxMind/Db/Reader/UnsupportedPlatformException.php @@ -0,0 +1,11 @@ +markTestSkipped('This test requires both gmp and bcmath to be disabled.'); + } + $handle = fopen('php://memory', 'rwb'); + // uint64 with the high bit set cannot fit in a signed PHP integer. + fwrite($handle, "\x08\x02\x80\x00\x00\x00\x00\x00\x00\x00"); + $decoder = new Decoder($handle); + + try { + $this->expectException(UnsupportedPlatformException::class); + $decoder->decode(0); + } finally { + fclose($handle); + } + } + public function testPointers(): void { $this->validateTypeDecodingList('pointers', $this->pointers()); From be567b18f1457d1e613cf5047090e72e69c4968a Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 5 Oct 2026 19:01:16 -0700 Subject: [PATCH 07/10] Declare decoded integer types instead of suppressing analysis --- src/MaxMind/Db/Reader/Decoder.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/MaxMind/Db/Reader/Decoder.php b/src/MaxMind/Db/Reader/Decoder.php index 438018a1..1764e6de 100644 --- a/src/MaxMind/Db/Reader/Decoder.php +++ b/src/MaxMind/Db/Reader/Decoder.php @@ -560,8 +560,9 @@ private function decodePointer(int $ctrlByte, int $offset): array /** * @throws UnsupportedPlatformException + * + * @return int|string */ - // @phpstan-ignore-next-line private function decodeUint(string $bytes, int $byteLength) { if ($byteLength === 0) { From a272d7a9ee371fcbbaf11eed37f885a7ae385f81 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 5 Oct 2026 19:01:18 -0700 Subject: [PATCH 08/10] Treat an impossible byte-order probe failure as a programmer error --- src/MaxMind/Db/Reader/Decoder.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/MaxMind/Db/Reader/Decoder.php b/src/MaxMind/Db/Reader/Decoder.php index 1764e6de..eeb3d1c6 100644 --- a/src/MaxMind/Db/Reader/Decoder.php +++ b/src/MaxMind/Db/Reader/Decoder.php @@ -96,8 +96,6 @@ class Decoder /** * @param resource $fileStream - * - * @throws InvalidDatabaseException */ public function __construct( $fileStream, @@ -650,16 +648,13 @@ private function maybeSwitchByteOrder(string $bytes): string return $this->switchByteOrder ? strrev($bytes) : $bytes; } - /** - * @throws InvalidDatabaseException - */ private function isPlatformLittleEndian(): bool { $testint = 0x00FF; $packed = pack('S', $testint); $rc = unpack('v', $packed); if ($rc === false) { - throw new InvalidDatabaseException( + throw new \Error( 'Could not unpack an unsigned short value from the given bytes.' ); } From b0f69f12669ad1bb8de159d2078b482952b3a541 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 5 Oct 2026 19:01:20 -0700 Subject: [PATCH 09/10] Document metadata decoding failures in the C extension --- src/MaxMind/Db/Reader.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/MaxMind/Db/Reader.php b/src/MaxMind/Db/Reader.php index 146bbe7d..04ed2c0f 100644 --- a/src/MaxMind/Db/Reader.php +++ b/src/MaxMind/Db/Reader.php @@ -417,6 +417,9 @@ private function findMetadataStart(string $filename): int } /** + * The C extension can also throw InvalidDatabaseException if it cannot + * decode the metadata. The pure PHP reader decodes it during construction. + * * @throws \BadMethodCallException if the database has been closed * * @return Metadata object for the database From e84048df30a12ccc9f27d0a5bb6dc324ea22a6d5 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Mon, 5 Oct 2026 19:50:19 -0700 Subject: [PATCH 10/10] Report metadata integer limits as unsupported platforms --- CHANGELOG.md | 4 ++ src/MaxMind/Db/Reader/Metadata.php | 21 +++++++++- tests/MaxMind/Db/Test/Reader/MetadataTest.php | 42 +++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49e808c6..6e47a6c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ CHANGELOG * Missing gmp/bcmath support and offsets that exceed the platform limit now throw `MaxMind\Db\Reader\UnsupportedPlatformException`. It extends `RuntimeException`, so existing catches continue to work. +* The pure PHP reader also throws `UnsupportedPlatformException` when the + metadata node count or the start of the data section exceeds the platform's + integer limit. These cases no longer produce `InvalidDatabaseException` or + overflow into a `TypeError`. * `phpinfo()` and `php --ri maxminddb` now show whether the extension was built with the bundled libmaxminddb or links a system library. The `libmaxminddb library version` row reads, for example, `1.14.0 (bundled)` or diff --git a/src/MaxMind/Db/Reader/Metadata.php b/src/MaxMind/Db/Reader/Metadata.php index f675e62b..62ecba9b 100644 --- a/src/MaxMind/Db/Reader/Metadata.php +++ b/src/MaxMind/Db/Reader/Metadata.php @@ -98,7 +98,8 @@ class Metadata /** * @param array $metadata * - * @throws InvalidDatabaseException if a metadata field is missing or invalid + * @throws InvalidDatabaseException if a metadata field is missing or invalid + * @throws UnsupportedPlatformException if the search tree exceeds the platform limit */ public function __construct(array $metadata) { @@ -108,6 +109,16 @@ public function __construct(array $metadata) ); } + $nodeCount = $metadata['node_count'] ?? null; + if (\is_string($nodeCount) && preg_match('/\A(?:0|[1-9][0-9]*)\z/', $nodeCount)) { + $maxInteger = (string) \PHP_INT_MAX; + if (\strlen($nodeCount) > \strlen($maxInteger) + || (\strlen($nodeCount) === \strlen($maxInteger) && strcmp($nodeCount, $maxInteger) > 0) + ) { + throw new UnsupportedPlatformException('The database node count exceeds the platform limit.'); + } + } + foreach ([ 'binary_format_major_version', 'binary_format_minor_version', @@ -148,6 +159,12 @@ public function __construct(array $metadata) } } + $nodeByteSize = intdiv($metadata['record_size'], 4); + // The data section starts after the search tree and its 16-byte separator. + if ($metadata['node_count'] > intdiv(\PHP_INT_MAX - 16, $nodeByteSize)) { + throw new UnsupportedPlatformException('The database search tree exceeds the platform limit.'); + } + $this->binaryFormatMajorVersion = $metadata['binary_format_major_version']; $this->binaryFormatMinorVersion @@ -159,7 +176,7 @@ public function __construct(array $metadata) $this->ipVersion = $metadata['ip_version']; $this->nodeCount = $metadata['node_count']; $this->recordSize = $metadata['record_size']; - $this->nodeByteSize = $this->recordSize / 4; + $this->nodeByteSize = $nodeByteSize; $this->searchTreeSize = $this->nodeCount * $this->nodeByteSize; } } diff --git a/tests/MaxMind/Db/Test/Reader/MetadataTest.php b/tests/MaxMind/Db/Test/Reader/MetadataTest.php index 2dcd7bef..c5391adf 100644 --- a/tests/MaxMind/Db/Test/Reader/MetadataTest.php +++ b/tests/MaxMind/Db/Test/Reader/MetadataTest.php @@ -6,6 +6,7 @@ use MaxMind\Db\Reader\InvalidDatabaseException; use MaxMind\Db\Reader\Metadata; +use MaxMind\Db\Reader\UnsupportedPlatformException; use PHPUnit\Framework\TestCase; /** @@ -122,6 +123,47 @@ public function testMissingMetadata(): void new Metadata([]); } + /** + * @dataProvider unsupportedNodeCounts + * + * @param int|string $nodeCount + */ + public function testUnsupportedNodeCount($nodeCount): void + { + if (\extension_loaded('maxminddb')) { + $this->markTestSkipped('This test covers the pure PHP metadata constructor.'); + } + + $this->expectException(UnsupportedPlatformException::class); + new Metadata([ + 'node_count' => $nodeCount, + 'record_size' => 32, + 'ip_version' => 6, + 'binary_format_major_version' => 2, + 'binary_format_minor_version' => 0, + 'build_epoch' => 1594066370, + 'database_type' => 'Test', + ]); + } + + /** + * @return array + */ + public static function unsupportedNodeCounts(): array + { + $firstUnsupportedInteger = '9223372036854775808'; + if (\PHP_INT_SIZE === 4) { + $firstUnsupportedInteger = '2147483648'; + } + + return [ + 'decoded integer just beyond platform limit' => [$firstUnsupportedInteger], + 'decoded integer beyond platform limit' => [(string) \PHP_INT_MAX . '0'], + 'search tree multiplication overflow' => [\PHP_INT_MAX], + 'data section separator overflow' => [intdiv(\PHP_INT_MAX, 8)], + ]; + } + public function testTooManyConstructorArgs(): void { $this->expectException(\ArgumentCountError::class);