Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.0.x will end 8 May 2023 (12 months).
  • Bug fixes for security issues in 4.0.x will end 13 November 2023 (18 months).
  • PHP version: minimum PHP 7.3.0 Note: the minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is also supported.

Differences Between: [Versions 310 and 400] [Versions 311 and 400] [Versions 39 and 400]

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