[ Index ] |
PHP Cross Reference of YOURLS |
[Summary view] [Print] [Text view]
1 <?php 2 3 declare(strict_types=1); 4 5 namespace MaxMind\Db; 6 7 use MaxMind\Db\Reader\Decoder; 8 use MaxMind\Db\Reader\InvalidDatabaseException; 9 use MaxMind\Db\Reader\Metadata; 10 use MaxMind\Db\Reader\Util; 11 12 /** 13 * Instances of this class provide a reader for the MaxMind DB format. IP 14 * addresses can be looked up using the get method. 15 */ 16 class Reader 17 { 18 /** 19 * @var int 20 */ 21 private static $DATA_SECTION_SEPARATOR_SIZE = 16; 22 23 /** 24 * @var string 25 */ 26 private static $METADATA_START_MARKER = "\xAB\xCD\xEFMaxMind.com"; 27 28 /** 29 * @var int<0, max> 30 */ 31 private static $METADATA_START_MARKER_LENGTH = 14; 32 33 /** 34 * @var int 35 */ 36 private static $METADATA_MAX_SIZE = 131072; // 128 * 1024 = 128KiB 37 38 /** 39 * @var Decoder 40 */ 41 private $decoder; 42 43 /** 44 * @var resource 45 */ 46 private $fileHandle; 47 48 /** 49 * @var int 50 */ 51 private $fileSize; 52 53 /** 54 * @var int 55 */ 56 private $ipV4Start; 57 58 /** 59 * @var Metadata 60 */ 61 private $metadata; 62 63 /** 64 * Constructs a Reader for the MaxMind DB format. The file passed to it must 65 * be a valid MaxMind DB file such as a GeoIp2 database file. 66 * 67 * @param string $database the MaxMind DB file to use 68 * 69 * @throws \InvalidArgumentException for invalid database path or unknown arguments 70 * @throws InvalidDatabaseException 71 * if the database is invalid or there is an error reading 72 * from it 73 */ 74 public function __construct(string $database) 75 { 76 if (\func_num_args() !== 1) { 77 throw new \ArgumentCountError( 78 \sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args()) 79 ); 80 } 81 82 if (is_dir($database)) { 83 // This matches the error that the C extension throws. 84 throw new InvalidDatabaseException( 85 "Error opening database file ($database). Is this a valid MaxMind DB file?" 86 ); 87 } 88 89 $fileHandle = @fopen($database, 'rb'); 90 if ($fileHandle === false) { 91 throw new \InvalidArgumentException( 92 "The file \"$database\" does not exist or is not readable." 93 ); 94 } 95 $this->fileHandle = $fileHandle; 96 97 $fileSize = @filesize($database); 98 if ($fileSize === false) { 99 throw new \UnexpectedValueException( 100 "Error determining the size of \"$database\"." 101 ); 102 } 103 $this->fileSize = $fileSize; 104 105 $start = $this->findMetadataStart($database); 106 $metadataDecoder = new Decoder($this->fileHandle, $start); 107 [$metadataArray] = $metadataDecoder->decode($start); 108 $this->metadata = new Metadata($metadataArray); 109 $this->decoder = new Decoder( 110 $this->fileHandle, 111 $this->metadata->searchTreeSize + self::$DATA_SECTION_SEPARATOR_SIZE 112 ); 113 $this->ipV4Start = $this->ipV4StartNode(); 114 } 115 116 /** 117 * Retrieves the record for the IP address. 118 * 119 * @param string $ipAddress the IP address to look up 120 * 121 * @throws \BadMethodCallException if this method is called on a closed database 122 * @throws \InvalidArgumentException if something other than a single IP address is passed to the method 123 * @throws InvalidDatabaseException 124 * if the database is invalid or there is an error reading 125 * from it 126 * 127 * @return mixed the record for the IP address 128 */ 129 public function get(string $ipAddress) 130 { 131 if (\func_num_args() !== 1) { 132 throw new \ArgumentCountError( 133 \sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args()) 134 ); 135 } 136 [$record] = $this->getWithPrefixLen($ipAddress); 137 138 return $record; 139 } 140 141 /** 142 * Retrieves the record for the IP address and its associated network prefix length. 143 * 144 * @param string $ipAddress the IP address to look up 145 * 146 * @throws \BadMethodCallException if this method is called on a closed database 147 * @throws \InvalidArgumentException if something other than a single IP address is passed to the method 148 * @throws InvalidDatabaseException 149 * if the database is invalid or there is an error reading 150 * from it 151 * 152 * @return array{0:mixed, 1:int} an array where the first element is the record and the 153 * second the network prefix length for the record 154 */ 155 public function getWithPrefixLen(string $ipAddress): array 156 { 157 if (\func_num_args() !== 1) { 158 throw new \ArgumentCountError( 159 \sprintf('%s() expects exactly 1 parameter, %d given', __METHOD__, \func_num_args()) 160 ); 161 } 162 163 if (!\is_resource($this->fileHandle)) { 164 throw new \BadMethodCallException( 165 'Attempt to read from a closed MaxMind DB.' 166 ); 167 } 168 169 [$pointer, $prefixLen] = $this->findAddressInTree($ipAddress); 170 if ($pointer === 0) { 171 return [null, $prefixLen]; 172 } 173 174 return [$this->resolveDataPointer($pointer), $prefixLen]; 175 } 176 177 /** 178 * @return array{0:int, 1:int} 179 */ 180 private function findAddressInTree(string $ipAddress): array 181 { 182 $packedAddr = @inet_pton($ipAddress); 183 if ($packedAddr === false) { 184 throw new \InvalidArgumentException( 185 "The value \"$ipAddress\" is not a valid IP address." 186 ); 187 } 188 189 $rawAddress = unpack('C*', $packedAddr); 190 if ($rawAddress === false) { 191 throw new InvalidDatabaseException( 192 'Could not unpack the unsigned char of the packed in_addr representation.' 193 ); 194 } 195 196 $bitCount = \count($rawAddress) * 8; 197 198 // The first node of the tree is always node 0, at the beginning of the 199 // value 200 $node = 0; 201 202 $metadata = $this->metadata; 203 204 // Check if we are looking up an IPv4 address in an IPv6 tree. If this 205 // is the case, we can skip over the first 96 nodes. 206 if ($metadata->ipVersion === 6) { 207 if ($bitCount === 32) { 208 $node = $this->ipV4Start; 209 } 210 } elseif ($metadata->ipVersion === 4 && $bitCount === 128) { 211 throw new \InvalidArgumentException( 212 "Error looking up $ipAddress. You attempted to look up an" 213 . ' IPv6 address in an IPv4-only database.' 214 ); 215 } 216 217 $nodeCount = $metadata->nodeCount; 218 219 for ($i = 0; $i < $bitCount && $node < $nodeCount; ++$i) { 220 $tempBit = 0xFF & $rawAddress[($i >> 3) + 1]; 221 $bit = 1 & ($tempBit >> 7 - ($i % 8)); 222 223 $node = $this->readNode($node, $bit); 224 } 225 if ($node === $nodeCount) { 226 // Record is empty 227 return [0, $i]; 228 } 229 if ($node > $nodeCount) { 230 // Record is a data pointer 231 return [$node, $i]; 232 } 233 234 throw new InvalidDatabaseException( 235 'Invalid or corrupt database. Maximum search depth reached without finding a leaf node' 236 ); 237 } 238 239 private function ipV4StartNode(): int 240 { 241 // If we have an IPv4 database, the start node is the first node 242 if ($this->metadata->ipVersion === 4) { 243 return 0; 244 } 245 246 $node = 0; 247 248 for ($i = 0; $i < 96 && $node < $this->metadata->nodeCount; ++$i) { 249 $node = $this->readNode($node, 0); 250 } 251 252 return $node; 253 } 254 255 private function readNode(int $nodeNumber, int $index): int 256 { 257 $baseOffset = $nodeNumber * $this->metadata->nodeByteSize; 258 259 switch ($this->metadata->recordSize) { 260 case 24: 261 $bytes = Util::read($this->fileHandle, $baseOffset + $index * 3, 3); 262 $rc = unpack('N', "\x00" . $bytes); 263 if ($rc === false) { 264 throw new InvalidDatabaseException( 265 'Could not unpack the unsigned long of the node.' 266 ); 267 } 268 [, $node] = $rc; 269 270 return $node; 271 272 case 28: 273 $bytes = Util::read($this->fileHandle, $baseOffset + 3 * $index, 4); 274 if ($index === 0) { 275 $middle = (0xF0 & \ord($bytes[3])) >> 4; 276 } else { 277 $middle = 0x0F & \ord($bytes[0]); 278 } 279 $rc = unpack('N', \chr($middle) . substr($bytes, $index, 3)); 280 if ($rc === false) { 281 throw new InvalidDatabaseException( 282 'Could not unpack the unsigned long of the node.' 283 ); 284 } 285 [, $node] = $rc; 286 287 return $node; 288 289 case 32: 290 $bytes = Util::read($this->fileHandle, $baseOffset + $index * 4, 4); 291 $rc = unpack('N', $bytes); 292 if ($rc === false) { 293 throw new InvalidDatabaseException( 294 'Could not unpack the unsigned long of the node.' 295 ); 296 } 297 [, $node] = $rc; 298 299 return $node; 300 301 default: 302 throw new InvalidDatabaseException( 303 'Unknown record size: ' 304 . $this->metadata->recordSize 305 ); 306 } 307 } 308 309 /** 310 * @return mixed 311 */ 312 private function resolveDataPointer(int $pointer) 313 { 314 $resolved = $pointer - $this->metadata->nodeCount 315 + $this->metadata->searchTreeSize; 316 if ($resolved >= $this->fileSize) { 317 throw new InvalidDatabaseException( 318 "The MaxMind DB file's search tree is corrupt" 319 ); 320 } 321 322 [$data] = $this->decoder->decode($resolved); 323 324 return $data; 325 } 326 327 /* 328 * This is an extremely naive but reasonably readable implementation. There 329 * are much faster algorithms (e.g., Boyer-Moore) for this if speed is ever 330 * an issue, but I suspect it won't be. 331 */ 332 private function findMetadataStart(string $filename): int 333 { 334 $handle = $this->fileHandle; 335 $fstat = fstat($handle); 336 if ($fstat === false) { 337 throw new InvalidDatabaseException( 338 "Error getting file information ($filename)." 339 ); 340 } 341 $fileSize = $fstat['size']; 342 $marker = self::$METADATA_START_MARKER; 343 $markerLength = self::$METADATA_START_MARKER_LENGTH; 344 345 $minStart = $fileSize - min(self::$METADATA_MAX_SIZE, $fileSize); 346 347 for ($offset = $fileSize - $markerLength; $offset >= $minStart; --$offset) { 348 if (fseek($handle, $offset) !== 0) { 349 break; 350 } 351 352 $value = fread($handle, $markerLength); 353 if ($value === $marker) { 354 return $offset + $markerLength; 355 } 356 } 357 358 throw new InvalidDatabaseException( 359 "Error opening database file ($filename). " . 360 'Is this a valid MaxMind DB file?' 361 ); 362 } 363 364 /** 365 * @throws \InvalidArgumentException if arguments are passed to the method 366 * @throws \BadMethodCallException if the database has been closed 367 * 368 * @return Metadata object for the database 369 */ 370 public function metadata(): Metadata 371 { 372 if (\func_num_args()) { 373 throw new \ArgumentCountError( 374 \sprintf('%s() expects exactly 0 parameters, %d given', __METHOD__, \func_num_args()) 375 ); 376 } 377 378 // Not technically required, but this makes it consistent with 379 // C extension and it allows us to change our implementation later. 380 if (!\is_resource($this->fileHandle)) { 381 throw new \BadMethodCallException( 382 'Attempt to read from a closed MaxMind DB.' 383 ); 384 } 385 386 return clone $this->metadata; 387 } 388 389 /** 390 * Closes the MaxMind DB and returns resources to the system. 391 * 392 * @throws \Exception 393 * if an I/O error occurs 394 */ 395 public function close(): void 396 { 397 if (\func_num_args()) { 398 throw new \ArgumentCountError( 399 \sprintf('%s() expects exactly 0 parameters, %d given', __METHOD__, \func_num_args()) 400 ); 401 } 402 403 if (!\is_resource($this->fileHandle)) { 404 throw new \BadMethodCallException( 405 'Attempt to close a closed MaxMind DB.' 406 ); 407 } 408 fclose($this->fileHandle); 409 } 410 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated: Mon Mar 31 05:10:02 2025 | Cross-referenced by PHPXref 0.7.1 |