Completed
Pull Request — master (#90)
by
unknown
04:12
created

Parser::processEntry()   B

Complexity

Conditions 6
Paths 5

Size

Total Lines 34
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 20
nc 5
nop 1
dl 0
loc 34
ccs 21
cts 21
cp 1
crap 6
rs 8.9777
c 1
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Badcow DNS Library.
7
 *
8
 * (c) Samuel Williams <[email protected]>
9
 *
10
 * For the full copyright and license information, please view the LICENSE
11
 * file that was distributed with this source code.
12
 */
13
14
namespace Badcow\DNS\Parser;
15
16
use Badcow\DNS\Classes;
17
use Badcow\DNS\Rdata\Factory;
18
use Badcow\DNS\Rdata\RdataInterface;
19
use Badcow\DNS\Rdata\Types;
20
use Badcow\DNS\ResourceRecord;
21
use Badcow\DNS\Zone;
22
use Exception;
23
24
class Parser
25
{
26
    /**
27
     * @var Zone
28
     */
29
    private $zone;
30
31
    /**
32
     * Array of methods that take an ArrayIterator and return an Rdata object. The array key is the Rdata type.
33
     *
34
     * @var callable[]
35
     */
36
    private $rdataHandlers = [];
37
38
    /**
39
     * @var ResourceRecord
40
     */
41
    private $currentResourceRecord;
42
43
    /**
44
     * @var string
45
     */
46
    private $lastStatedDomain;
47
48
    /**
49
     * @var int
50
     */
51
    private $lastStatedTtl;
52
53
    /**
54
     * @var string
55
     */
56
    private $lastStatedClass;
57
58
    /**
59
     * @var string the current ORIGIN value, defaults to the Zone name
60
     */
61
    private $origin;
62
63
    /**
64
     * @var int the currently defined default TTL
65
     */
66
    private $ttl;
67
68
    /**
69
     * @var ZoneFileFetcherInterface|null Used to get the contents of files included through the directive
70
     */
71
    private $fetcher;
72
73
    /**
74
     * @var int
75
     */
76
    private $commentOptions;
77
78
    /**
79
     * Parser constructor.
80
     */
81 31
    public function __construct(array $rdataHandlers = [], ?ZoneFileFetcherInterface $fetcher = null)
82
    {
83 31
        $this->rdataHandlers = $rdataHandlers;
84 31
        $this->fetcher = $fetcher;
85 31
    }
86
87
    /**
88
     * @throws ParseException
89
     */
90 28
    public static function parse(string $name, string $zone, int $commentOptions = Comments::NONE): Zone
91
    {
92 28
        return (new self())->makeZone($name, $zone, $commentOptions);
93
    }
94
95
    /**
96
     * @throws ParseException
97
     */
98 31
    public function makeZone(string $name, string $string, int $commentOptions = Comments::NONE): Zone
99
    {
100 31
        $this->zone = new Zone($name);
101 31
        $this->origin = $name;
102 31
        $this->lastStatedDomain = $name;
103 31
        $this->commentOptions = $commentOptions;
104 31
        $this->processZone($string);
105
106 28
        return $this->zone;
107
    }
108
109
    /**
110
     * @throws ParseException
111
     */
112 31
    private function processZone(string $zone): void
113
    {
114 31
        $normalisedZone = Normaliser::normalise($zone, $this->commentOptions);
115
116 31
        foreach (explode(Tokens::LINE_FEED, $normalisedZone) as $line) {
117 31
            $this->processLine($line);
118
        }
119 28
    }
120
121
    /**
122
     * @throws ParseException
123
     */
124 31
    private function processLine(string $line): void
125
    {
126 31
        list($entry, $comment) = $this->extractComment($line);
127
128 31
        $this->currentResourceRecord = new ResourceRecord();
129 31
        $this->currentResourceRecord->setComment($comment);
130
131 31
        if ('' === $entry) {
132 3
            $this->zone->addResourceRecord($this->currentResourceRecord);
133
134 3
            return;
135
        }
136
137 31
        $iterator = new ResourceRecordIterator($entry);
138
139 31
        if ($this->isControlEntry($iterator)) {
140 16
            $this->processControlEntry($iterator);
141
142 16
            return;
143
        }
144
145 31
        $this->processEntry($iterator);
146 28
        $this->zone->addResourceRecord($this->currentResourceRecord);
147 28
    }
148
149
    /**
150
     * @throws ParseException
151
     */
152 31
    private function processEntry(ResourceRecordIterator $iterator): void
153
    {
154 31
        if ($this->isTTL($iterator)) {
155 15
            $this->currentResourceRecord->setTtl(TimeFormat::toSeconds($iterator->current()));
156 15
            $iterator->next();
157 15
            $this->processEntry($iterator);
158
159 13
            return;
160
        }
161
162 31
        if ($this->isClass($iterator)) {
163 28
            $this->currentResourceRecord->setClass(strtoupper($iterator->current()));
164 28
            $iterator->next();
165 28
            $this->processEntry($iterator);
166
167 26
            return;
168
        }
169
170 31
        if ($this->isResourceName($iterator) && null === $this->currentResourceRecord->getName()) {
0 ignored issues
show
introduced by
The condition null === $this->currentResourceRecord->getName() is always false.
Loading history...
171 30
            $this->currentResourceRecord->setName($this->appendOrigin($iterator->current()));
172 30
            $iterator->next();
173 30
            $this->processEntry($iterator);
174
175 28
            return;
176
        }
177
178 31
        if ($this->isType($iterator)) {
179 30
            $this->currentResourceRecord->setRdata($this->extractRdata($iterator));
180 28
            $this->populateNullValues();
181
182 28
            return;
183
        }
184
185 1
        throw new ParseException(sprintf('Could not parse entry "%s".', (string) $iterator));
186
    }
187
188
    /**
189
     * If no domain-name, TTL, or class is set on the record, populate object with last stated value (RFC-1035).
190
     * If $TTL has been set, then that value will fill the resource records TTL (RFC-2308).
191
     *
192
     * @see https://www.ietf.org/rfc/rfc1035 Section 5.1
193
     * @see https://tools.ietf.org/html/rfc2308 Section 4
194
     */
195 28
    private function populateNullValues(): void
196
    {
197 28
        if (empty($this->currentResourceRecord->getName())) {
198 4
            $this->currentResourceRecord->setName($this->lastStatedDomain);
199
        } else {
200 27
            $this->lastStatedDomain = $this->currentResourceRecord->getName();
201
        }
202
203 28
        if (null === $this->currentResourceRecord->getTtl()) {
204 18
            $this->currentResourceRecord->setTtl($this->ttl ?? $this->lastStatedTtl);
205
        } else {
206 13
            $this->lastStatedTtl = $this->currentResourceRecord->getTtl();
207
        }
208
209 28
        if (null === $this->currentResourceRecord->getClass()) {
210
            $this->currentResourceRecord->setClass($this->lastStatedClass);
211
        } else {
212 28
            $this->lastStatedClass = $this->currentResourceRecord->getClass();
213
        }
214 28
    }
215
216
    /**
217
     * Append the $ORIGIN to a subdomain if:
218
     *  1) the current $ORIGIN is different, and
219
     *  2) the subdomain is not already fully qualified, or
220
     *  3) the subdomain is '@'.
221
     *
222
     * @param string $subdomain the subdomain to which the $ORIGIN needs to be appended
223
     *
224
     * @return string The concatenated string of the subdomain.$ORIGIN
225
     */
226 30
    private function appendOrigin(string $subdomain): string
227
    {
228 30
        if ($this->origin === $this->zone->getName()) {
229 27
            return $subdomain;
230
        }
231
232 5
        if ('.' === substr($subdomain, -1, 1)) {
233
            return $subdomain;
234
        }
235
236 5
        if ($this->origin === '.') {
237 1
            return $subdomain.'.';
238
        }
239
240 4
        if ('@' === $subdomain) {
241 4
            return $this->origin;
242
        }
243
244 3
        return $subdomain.'.'.$this->origin;
245
    }
246
247
    /**
248
     * Processes control entries at the top of a BIND record, i.e. $ORIGIN, $TTL, $INCLUDE, etc.
249
     *
250
     * @throws ParseException
251
     */
252 16
    private function processControlEntry(ResourceRecordIterator $iterator): void
253
    {
254 16
        if ('$TTL' === strtoupper($iterator->current())) {
255 14
            $iterator->next();
256 14
            $this->ttl = TimeFormat::toSeconds($iterator->current());
257 14
            if (null === $this->zone->getDefaultTtl()) {
0 ignored issues
show
introduced by
The condition null === $this->zone->getDefaultTtl() is always false.
Loading history...
258 14
                $this->zone->setDefaultTtl($this->ttl);
259
            }
260
        }
261
262 16
        if ('$ORIGIN' === strtoupper($iterator->current())) {
263 15
            $iterator->next();
264 15
            $this->origin = (string) $iterator->current();
265
        }
266
267 16
        if ('$INCLUDE' === strtoupper($iterator->current())) {
268 7
            $iterator->next();
269 7
            $this->includeFile($iterator);
270
        }
271 16
    }
272
273
    /**
274
     * @throws ParseException
275
     */
276 7
    private function includeFile(ResourceRecordIterator $iterator): void
277
    {
278 7
        if (null === $this->fetcher) {
279 5
            return;
280
        }
281
282 2
        list($path, $domain) = $this->extractIncludeArguments($iterator->getRemainingAsString());
283
284
        //Copy the state of the parser so as to revert back once included file has been parsed.
285 2
        $_lastStatedDomain = $this->lastStatedDomain;
286 2
        $_lastStatedClass = $this->lastStatedClass;
287 2
        $_lastStatedTtl = $this->lastStatedTtl;
288 2
        $_origin = $this->origin;
289 2
        $_ttl = $this->ttl;
290
291
        //Parse the included record.
292 2
        $this->origin = $domain ?? $_origin;
293 2
        $childRecord = $this->fetcher->fetch($path);
294
295 2
        if (null !== $this->currentResourceRecord->getComment()) {
296 1
            $childRecord = Tokens::SEMICOLON.$this->currentResourceRecord->getComment().Tokens::LINE_FEED.$childRecord;
297
        }
298
299 2
        $this->processZone($childRecord);
300
301
        //Revert the parser.
302 2
        $this->lastStatedDomain = $_lastStatedDomain;
303 2
        $this->lastStatedClass = $_lastStatedClass;
304 2
        $this->lastStatedTtl = $_lastStatedTtl;
305 2
        $this->origin = $_origin;
306 2
        $this->ttl = $_ttl;
307 2
    }
308
309
    /**
310
     * @param string $string the string proceeding the $INCLUDE directive
311
     *
312
     * @return array an array containing [$path, $domain]
313
     */
314 2
    private function extractIncludeArguments(string $string): array
315
    {
316 2
        $s = new StringIterator($string);
317 2
        $path = '';
318 2
        $domain = null;
319 2
        while ($s->valid()) {
320 2
            $path .= $s->current();
321 2
            $s->next();
322 2
            if ($s->is(Tokens::SPACE)) {
323 1
                $s->next();
324 1
                $domain = $s->getRemainingAsString();
325
            }
326 2
            if ($s->is(Tokens::BACKSLASH)) {
327 1
                $s->next();
328
            }
329
        }
330
331 2
        return [$path, $domain];
332
    }
333
334
    /**
335
     * Determine if iterant is a resource name.
336
     */
337 31
    private function isResourceName(ResourceRecordIterator $iterator): bool
338
    {
339
        // Look ahead and determine if the next token is a TTL, Class, or valid Type.
340 31
        $iterator->next();
341
342 31
        if (!$iterator->valid()) {
343
            return false;
344
        }
345
346 31
        $isName = $this->isTTL($iterator) ||
347 31
            $this->isClass($iterator, 'DOMAIN') ||
348 31
            $this->isType($iterator);
349 31
        $iterator->prev();
350
351 31
        if (!$isName) {
352 29
            return false;
353
        }
354
355 30
        if (0 === $iterator->key()) {
356 30
            return true;
357
        }
358
359 4
        return false;
360
    }
361
362
    /**
363
     * Determine if iterant is a class.
364
     *
365
     * @param string|null $origin the previously assumed resource record parameter, either 'TTL' or NULL
366
     */
367 31
    private function isClass(ResourceRecordIterator $iterator, $origin = null): bool
368
    {
369 31
        if (!Classes::isValid($iterator->current())) {
370 31
            return false;
371
        }
372
373 29
        $iterator->next();
374 29
        if ('TTL' === $origin) {
375 12
            $isClass = $this->isType($iterator);
376
        } else {
377 28
            $isClass = $this->isTTL($iterator, 'CLASS') || $this->isType($iterator);
378
        }
379 29
        $iterator->prev();
380
381 29
        return $isClass;
382
    }
383
384
    /**
385
     * Determine if current iterant is an Rdata type string.
386
     */
387 31
    private function isType(ResourceRecordIterator $iterator): bool
388
    {
389 31
        return Types::isValid(strtoupper($iterator->current())) || array_key_exists($iterator->current(), $this->rdataHandlers);
390
    }
391
392
    /**
393
     * Determine if iterant is a control entry such as $TTL, $ORIGIN, $INCLUDE, etcetera.
394
     */
395 31
    private function isControlEntry(ResourceRecordIterator $iterator): bool
396
    {
397 31
        return 1 === preg_match('/^\$[A-Z0-9]+/i', $iterator->current());
398
    }
399
400
    /**
401
     * Determine if the iterant is a TTL (i.e. it is an integer after domain-name).
402
     *
403
     * @param string $origin the previously assumed resource record parameter, either 'CLASS' or NULL
404
     */
405 31
    private function isTTL(ResourceRecordIterator $iterator, $origin = null): bool
406
    {
407 31
        if (!TimeFormat::isTimeFormat($iterator->current())) {
408 31
            return false;
409
        }
410
411 29
        if ($iterator->key() < 1) {
412 6
            return false;
413
        }
414
415 28
        $iterator->next();
416 28
        if ('CLASS' === $origin) {
417 5
            $isTtl = $this->isType($iterator);
418
        } else {
419 28
            $isTtl = $this->isClass($iterator, 'TTL') || $this->isType($iterator);
420
        }
421 28
        $iterator->prev();
422
423 28
        return $isTtl;
424
    }
425
426
    /**
427
     * Split a DNS zone line into a resource record and a comment.
428
     *
429
     * @return array [$entry, $comment]
430
     */
431 31
    private function extractComment(string $rr): array
432
    {
433 31
        $string = new StringIterator($rr);
434 31
        $entry = '';
435 31
        $comment = null;
436
437 31
        while ($string->valid()) {
438
            //If a semicolon is within double quotes, it will not be treated as the beginning of a comment.
439 31
            $entry .= $this->extractDoubleQuotedText($string);
440
441 31
            if ($string->is(Tokens::SEMICOLON)) {
442 5
                $string->next();
443 5
                $comment = $string->getRemainingAsString();
444
445 5
                break;
446
            }
447
448 31
            $entry .= $string->current();
449 31
            $string->next();
450
        }
451
452 31
        return [$entry, $comment];
453
    }
454
455
    /**
456
     * Extract text within double quotation context.
457
     */
458 31
    private function extractDoubleQuotedText(StringIterator $string): string
459
    {
460 31
        if ($string->isNot(Tokens::DOUBLE_QUOTES)) {
461 31
            return '';
462
        }
463
464 16
        $entry = $string->current();
465 16
        $string->next();
466
467 16
        while ($string->isNot(Tokens::DOUBLE_QUOTES)) {
468
            //If the current char is a backslash, treat the next char as being escaped.
469 16
            if ($string->is(Tokens::BACKSLASH)) {
470 10
                $entry .= $string->current();
471 10
                $string->next();
472
            }
473 16
            $entry .= $string->current();
474 16
            $string->next();
475
        }
476
477 16
        return $entry;
478
    }
479
480
    /**
481
     * @throws ParseException
482
     */
483 30
    private function extractRdata(ResourceRecordIterator $iterator): RdataInterface
484
    {
485 30
        $type = strtoupper($iterator->current());
486 30
        $iterator->next();
487
488 30
        if (array_key_exists($type, $this->rdataHandlers)) {
489 1
            return call_user_func($this->rdataHandlers[$type], $iterator);
490
        }
491
492
        try {
493 29
            return Factory::textToRdataType($type, $iterator->getRemainingAsString());
494 2
        } catch (Exception $exception) {
495 2
            throw new ParseException(sprintf('Could not extract Rdata from resource record "%s".', (string) $iterator), null, $exception);
496
        }
497
    }
498
}
499