Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

Differences Between: [Versions 310 and 311] [Versions 311 and 400] [Versions 311 and 401] [Versions 311 and 402] [Versions 311 and 403] [Versions 39 and 311]

   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          } elseif ($node > $nodeCount) {
 215              // Record is a data pointer
 216              return [$node, $i];
 217          }
 218          throw new InvalidDatabaseException(
 219              'Invalid or corrupt database. Maximum search depth reached without finding a leaf node'
 220          );
 221      }
 222  
 223      private function ipV4StartNode(): int
 224      {
 225          // If we have an IPv4 database, the start node is the first node
 226          if ($this->metadata->ipVersion === 4) {
 227              return 0;
 228          }
 229  
 230          $node = 0;
 231  
 232          for ($i = 0; $i < 96 && $node < $this->metadata->nodeCount; ++$i) {
 233              $node = $this->readNode($node, 0);
 234          }
 235  
 236          return $node;
 237      }
 238  
 239      private function readNode(int $nodeNumber, int $index): int
 240      {
 241          $baseOffset = $nodeNumber * $this->metadata->nodeByteSize;
 242  
 243          switch ($this->metadata->recordSize) {
 244              case 24:
 245                  $bytes = Util::read($this->fileHandle, $baseOffset + $index * 3, 3);
 246                  [, $node] = unpack('N', "\x00" . $bytes);
 247  
 248                  return $node;
 249              case 28:
 250                  $bytes = Util::read($this->fileHandle, $baseOffset + 3 * $index, 4);
 251                  if ($index === 0) {
 252                      $middle = (0xF0 & \ord($bytes[3])) >> 4;
 253                  } else {
 254                      $middle = 0x0F & \ord($bytes[0]);
 255                  }
 256                  [, $node] = unpack('N', \chr($middle) . substr($bytes, $index, 3));
 257  
 258                  return $node;
 259              case 32:
 260                  $bytes = Util::read($this->fileHandle, $baseOffset + $index * 4, 4);
 261                  [, $node] = unpack('N', $bytes);
 262  
 263                  return $node;
 264              default:
 265                  throw new InvalidDatabaseException(
 266                      'Unknown record size: '
 267                      . $this->metadata->recordSize
 268                  );
 269          }
 270      }
 271  
 272      /**
 273       * @return mixed
 274       */
 275      private function resolveDataPointer(int $pointer)
 276      {
 277          $resolved = $pointer - $this->metadata->nodeCount
 278              + $this->metadata->searchTreeSize;
 279          if ($resolved >= $this->fileSize) {
 280              throw new InvalidDatabaseException(
 281                  "The MaxMind DB file's search tree is corrupt"
 282              );
 283          }
 284  
 285          [$data] = $this->decoder->decode($resolved);
 286  
 287          return $data;
 288      }
 289  
 290      /*
 291       * This is an extremely naive but reasonably readable implementation. There
 292       * are much faster algorithms (e.g., Boyer-Moore) for this if speed is ever
 293       * an issue, but I suspect it won't be.
 294       */
 295      private function findMetadataStart(string $filename): int
 296      {
 297          $handle = $this->fileHandle;
 298          $fstat = fstat($handle);
 299          $fileSize = $fstat['size'];
 300          $marker = self::$METADATA_START_MARKER;
 301          $markerLength = self::$METADATA_START_MARKER_LENGTH;
 302  
 303          $minStart = $fileSize - min(self::$METADATA_MAX_SIZE, $fileSize);
 304  
 305          for ($offset = $fileSize - $markerLength; $offset >= $minStart; --$offset) {
 306              if (fseek($handle, $offset) !== 0) {
 307                  break;
 308              }
 309  
 310              $value = fread($handle, $markerLength);
 311              if ($value === $marker) {
 312                  return $offset + $markerLength;
 313              }
 314          }
 315          throw new InvalidDatabaseException(
 316              "Error opening database file ($filename). " .
 317              'Is this a valid MaxMind DB file?'
 318          );
 319      }
 320  
 321      /**
 322       * @throws InvalidArgumentException if arguments are passed to the method
 323       * @throws BadMethodCallException   if the database has been closed
 324       *
 325       * @return Metadata object for the database
 326       */
 327      public function metadata(): Metadata
 328      {
 329          if (\func_num_args()) {
 330              throw new ArgumentCountError(
 331                  sprintf('%s() expects exactly 0 parameters, %d given', __METHOD__, \func_num_args())
 332              );
 333          }
 334  
 335          // Not technically required, but this makes it consistent with
 336          // C extension and it allows us to change our implementation later.
 337          if (!\is_resource($this->fileHandle)) {
 338              throw new BadMethodCallException(
 339                  'Attempt to read from a closed MaxMind DB.'
 340              );
 341          }
 342  
 343          return clone $this->metadata;
 344      }
 345  
 346      /**
 347       * Closes the MaxMind DB and returns resources to the system.
 348       *
 349       * @throws Exception
 350       *                   if an I/O error occurs
 351       */
 352      public function close(): void
 353      {
 354          if (\func_num_args()) {
 355              throw new ArgumentCountError(
 356                  sprintf('%s() expects exactly 0 parameters, %d given', __METHOD__, \func_num_args())
 357              );
 358          }
 359  
 360          if (!\is_resource($this->fileHandle)) {
 361              throw new BadMethodCallException(
 362                  'Attempt to close a closed MaxMind DB.'
 363              );
 364          }
 365          fclose($this->fileHandle);
 366      }
 367  }