Passed
Push — master ( 1ce22f...41fbb9 )
by Brian
09:44 queued 04:28
created

Reader::findAddressInTree()   B

Complexity

Conditions 9
Paths 19

Size

Total Lines 41
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 21
c 0
b 0
f 0
dl 0
loc 41
rs 8.0555
cc 9
nc 19
nop 1
1
<?php
2
3
namespace MaxMind\Db;
4
5
use BadMethodCallException;
6
use Exception;
7
use InvalidArgumentException;
8
use MaxMind\Db\Reader\Decoder;
9
use MaxMind\Db\Reader\InvalidDatabaseException;
10
use MaxMind\Db\Reader\Metadata;
11
use MaxMind\Db\Reader\Util;
12
use 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
 */
18
class 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
0 ignored issues
show
Bug Best Practice introduced by
The property $searchTreeSize is declared private in MaxMind\Db\Reader\Metadata. Since you implement __get, consider adding a @property or @property-read.
Loading history...
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;
0 ignored issues
show
Bug introduced by
It seems like $rawAddress can also be of type false; however, parameter $var of count() does only seem to accept Countable|array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

153
        $bitCount = \count(/** @scrutinizer ignore-type */ $rawAddress) * 8;
Loading history...
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
269
        $minStart = $fileSize - min(self::$METADATA_MAX_SIZE, $fileSize);
270
271
        for ($offset = $fileSize - $markerLength; $offset >= $minStart; --$offset) {
272
            if (fseek($handle, $offset) !== 0) {
273
                break;
274
            }
275
276
            $value = fread($handle, $markerLength);
277
            if ($value === $marker) {
278
                return $offset + $markerLength;
279
            }
280
        }
281
        throw new InvalidDatabaseException(
282
            "Error opening database file ($filename). " .
283
            'Is this a valid MaxMind DB file?'
284
        );
285
    }
286
287
    /**
288
     * @throws InvalidArgumentException if arguments are passed to the method
289
     * @throws BadMethodCallException   if the database has been closed
290
     *
291
     * @return Metadata object for the database
292
     */
293
    public function metadata()
294
    {
295
        if (\func_num_args()) {
296
            throw new InvalidArgumentException(
297
                'Method takes no arguments.'
298
            );
299
        }
300
301
        // Not technically required, but this makes it consistent with
302
        // C extension and it allows us to change our implementation later.
303
        if (!\is_resource($this->fileHandle)) {
304
            throw new BadMethodCallException(
305
                'Attempt to read from a closed MaxMind DB.'
306
            );
307
        }
308
309
        return $this->metadata;
310
    }
311
312
    /**
313
     * Closes the MaxMind DB and returns resources to the system.
314
     *
315
     * @throws Exception
316
     *                   if an I/O error occurs
317
     */
318
    public function close()
319
    {
320
        if (!\is_resource($this->fileHandle)) {
321
            throw new BadMethodCallException(
322
                'Attempt to close a closed MaxMind DB.'
323
            );
324
        }
325
        fclose($this->fileHandle);
326
    }
327
}
328