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