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