Reader.php 10 KB

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