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