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