Passed
Push — master ( 8fa8a0...efe996 )
by Sam
03:11
created

Parser::populateNullValues()   A

Complexity

Conditions 4
Paths 8

Size

Total Lines 18
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 4.016

Importance

Changes 0
Metric Value
cc 4
eloc 12
nc 8
nop 0
dl 0
loc 18
ccs 9
cts 10
cp 0.9
crap 4.016
rs 9.8666
c 0
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 30
    public function __construct(array $rdataHandlers = [], ?ZoneFileFetcherInterface $fetcher = null)
82
    {
83 30
        $this->rdataHandlers = $rdataHandlers;
84 30
        $this->fetcher = $fetcher;
85 30
    }
86
87
    /**
88
     * @throws ParseException
89
     */
90 27
    public static function parse(string $name, string $zone, int $commentOptions = Comments::NONE): Zone
91
    {
92 27
        return (new self())->makeZone($name, $zone, $commentOptions);
93
    }
94
95
    /**
96
     * @throws ParseException
97
     */
98 30
    public function makeZone(string $name, string $string, int $commentOptions = Comments::NONE): Zone
99
    {
100 30
        $this->zone = new Zone($name);
101 30
        $this->origin = $name;
102 30
        $this->lastStatedDomain = $name;
103 30
        $this->commentOptions = $commentOptions;
104 30
        $this->processZone($string);
105
106 27
        return $this->zone;
107
    }
108
109
    /**
110
     * @throws ParseException
111
     */
112 30
    private function processZone(string $zone): void
113
    {
114 30
        $normalisedZone = Normaliser::normalise($zone, $this->commentOptions);
115
116 30
        foreach (explode(Tokens::LINE_FEED, $normalisedZone) as $line) {
117 30
            $this->processLine($line);
118
        }
119 27
    }
120
121
    /**
122
     * @throws ParseException
123
     */
124 30
    private function processLine(string $line): void
125
    {
126 30
        list($entry, $comment) = $this->extractComment($line);
127
128 30
        $this->currentResourceRecord = new ResourceRecord();
129 30
        $this->currentResourceRecord->setComment($comment);
130
131 30
        if ('' === $entry) {
132 3
            $this->zone->addResourceRecord($this->currentResourceRecord);
133
134 3
            return;
135
        }
136
137 30
        $iterator = new ResourceRecordIterator($entry);
138
139 30
        if ($this->isControlEntry($iterator)) {
140 15
            $this->processControlEntry($iterator);
141
142 15
            return;
143
        }
144
145 30
        $this->processEntry($iterator);
146 27
        $this->zone->addResourceRecord($this->currentResourceRecord);
147 27
    }
148
149
    /**
150
     * @throws ParseException
151
     */
152 30
    private function processEntry(ResourceRecordIterator $iterator): void
153
    {
154 30
        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 30
        if ($this->isClass($iterator)) {
163 27
            $this->currentResourceRecord->setClass(strtoupper($iterator->current()));
164 27
            $iterator->next();
165 27
            $this->processEntry($iterator);
166
167 25
            return;
168
        }
169
170 30
        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 29
            $this->currentResourceRecord->setName($this->appendOrigin($iterator->current()));
172 29
            $iterator->next();
173 29
            $this->processEntry($iterator);
174
175 27
            return;
176
        }
177
178 30
        if ($this->isType($iterator)) {
179 29
            $this->currentResourceRecord->setRdata($this->extractRdata($iterator));
180 27
            $this->populateNullValues();
181
182 27
            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 27
    private function populateNullValues(): void
196
    {
197 27
        if (empty($this->currentResourceRecord->getName())) {
198 4
            $this->currentResourceRecord->setName($this->lastStatedDomain);
199
        } else {
200 26
            $this->lastStatedDomain = $this->currentResourceRecord->getName();
201
        }
202
203 27
        if (null === $this->currentResourceRecord->getTtl()) {
204 17
            $this->currentResourceRecord->setTtl($this->ttl ?? $this->lastStatedTtl);
205
        } else {
206 13
            $this->lastStatedTtl = $this->currentResourceRecord->getTtl();
207
        }
208
209 27
        if (null === $this->currentResourceRecord->getClass()) {
210
            $this->currentResourceRecord->setClass($this->lastStatedClass);
211
        } else {
212 27
            $this->lastStatedClass = $this->currentResourceRecord->getClass();
213
        }
214 27
    }
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 29
    private function appendOrigin(string $subdomain): string
227
    {
228 29
        if ($this->origin === $this->zone->getName()) {
229 27
            return $subdomain;
230
        }
231
232 4
        if ('.' === substr($subdomain, -1, 1)) {
233
            return $subdomain;
234
        }
235
236 4
        if ('@' === $subdomain) {
237 4
            return $this->origin;
238
        }
239
240 3
        return $subdomain.'.'.$this->origin;
241
    }
242
243
    /**
244
     * Processes control entries at the top of a BIND record, i.e. $ORIGIN, $TTL, $INCLUDE, etc.
245
     *
246
     * @throws ParseException
247
     */
248 15
    private function processControlEntry(ResourceRecordIterator $iterator): void
249
    {
250 15
        if ('$TTL' === strtoupper($iterator->current())) {
251 13
            $iterator->next();
252 13
            $this->ttl = TimeFormat::toSeconds($iterator->current());
253 13
            if (null === $this->zone->getDefaultTtl()) {
0 ignored issues
show
introduced by
The condition null === $this->zone->getDefaultTtl() is always false.
Loading history...
254 13
                $this->zone->setDefaultTtl($this->ttl);
255
            }
256
        }
257
258 15
        if ('$ORIGIN' === strtoupper($iterator->current())) {
259 14
            $iterator->next();
260 14
            $this->origin = (string) $iterator->current();
261
        }
262
263 15
        if ('$INCLUDE' === strtoupper($iterator->current())) {
264 7
            $iterator->next();
265 7
            $this->includeFile($iterator);
266
        }
267 15
    }
268
269
    /**
270
     * @throws ParseException
271
     */
272 7
    private function includeFile(ResourceRecordIterator $iterator): void
273
    {
274 7
        if (null === $this->fetcher) {
275 5
            return;
276
        }
277
278 2
        list($path, $domain) = $this->extractIncludeArguments($iterator->getRemainingAsString());
279
280
        //Copy the state of the parser so as to revert back once included file has been parsed.
281 2
        $_lastStatedDomain = $this->lastStatedDomain;
282 2
        $_lastStatedClass = $this->lastStatedClass;
283 2
        $_lastStatedTtl = $this->lastStatedTtl;
284 2
        $_origin = $this->origin;
285 2
        $_ttl = $this->ttl;
286
287
        //Parse the included record.
288 2
        $this->origin = $domain ?? $_origin;
289 2
        $childRecord = $this->fetcher->fetch($path);
290
291 2
        if (null !== $this->currentResourceRecord->getComment()) {
292 1
            $childRecord = Tokens::SEMICOLON.$this->currentResourceRecord->getComment().Tokens::LINE_FEED.$childRecord;
293
        }
294
295 2
        $this->processZone($childRecord);
296
297
        //Revert the parser.
298 2
        $this->lastStatedDomain = $_lastStatedDomain;
299 2
        $this->lastStatedClass = $_lastStatedClass;
300 2
        $this->lastStatedTtl = $_lastStatedTtl;
301 2
        $this->origin = $_origin;
302 2
        $this->ttl = $_ttl;
303 2
    }
304
305
    /**
306
     * @param string $string the string proceeding the $INCLUDE directive
307
     *
308
     * @return array an array containing [$path, $domain]
309
     */
310 2
    private function extractIncludeArguments(string $string): array
311
    {
312 2
        $s = new StringIterator($string);
313 2
        $path = '';
314 2
        $domain = null;
315 2
        while ($s->valid()) {
316 2
            $path .= $s->current();
317 2
            $s->next();
318 2
            if ($s->is(Tokens::SPACE)) {
319 1
                $s->next();
320 1
                $domain = $s->getRemainingAsString();
321
            }
322 2
            if ($s->is(Tokens::BACKSLASH)) {
323 1
                $s->next();
324
            }
325
        }
326
327 2
        return [$path, $domain];
328
    }
329
330
    /**
331
     * Determine if iterant is a resource name.
332
     */
333 30
    private function isResourceName(ResourceRecordIterator $iterator): bool
334
    {
335
        // Look ahead and determine if the next token is a TTL, Class, or valid Type.
336 30
        $iterator->next();
337
338 30
        if (!$iterator->valid()) {
339
            return false;
340
        }
341
342 30
        $isName = $this->isTTL($iterator) ||
343 30
            $this->isClass($iterator, 'DOMAIN') ||
344 30
            $this->isType($iterator);
345 30
        $iterator->prev();
346
347 30
        if (!$isName) {
348 28
            return false;
349
        }
350
351 29
        if (0 === $iterator->key()) {
352 29
            return true;
353
        }
354
355 4
        return false;
356
    }
357
358
    /**
359
     * Determine if iterant is a class.
360
     *
361
     * @param string|null $origin the previously assumed resource record parameter, either 'TTL' or NULL
362
     */
363 30
    private function isClass(ResourceRecordIterator $iterator, $origin = null): bool
364
    {
365 30
        if (!Classes::isValid($iterator->current())) {
366 30
            return false;
367
        }
368
369 28
        $iterator->next();
370 28
        if ('TTL' === $origin) {
371 12
            $isClass = $this->isType($iterator);
372
        } else {
373 27
            $isClass = $this->isTTL($iterator, 'CLASS') || $this->isType($iterator);
374
        }
375 28
        $iterator->prev();
376
377 28
        return $isClass;
378
    }
379
380
    /**
381
     * Determine if current iterant is an Rdata type string.
382
     */
383 30
    private function isType(ResourceRecordIterator $iterator): bool
384
    {
385 30
        return Types::isValid(strtoupper($iterator->current())) || array_key_exists($iterator->current(), $this->rdataHandlers);
386
    }
387
388
    /**
389
     * Determine if iterant is a control entry such as $TTL, $ORIGIN, $INCLUDE, etcetera.
390
     */
391 30
    private function isControlEntry(ResourceRecordIterator $iterator): bool
392
    {
393 30
        return 1 === preg_match('/^\$[A-Z0-9]+/i', $iterator->current());
394
    }
395
396
    /**
397
     * Determine if the iterant is a TTL (i.e. it is an integer after domain-name).
398
     *
399
     * @param string $origin the previously assumed resource record parameter, either 'CLASS' or NULL
400
     */
401 30
    private function isTTL(ResourceRecordIterator $iterator, $origin = null): bool
402
    {
403 30
        if (!TimeFormat::isTimeFormat($iterator->current())) {
404 30
            return false;
405
        }
406
407 28
        if ($iterator->key() < 1) {
408 6
            return false;
409
        }
410
411 27
        $iterator->next();
412 27
        if ('CLASS' === $origin) {
413 5
            $isTtl = $this->isType($iterator);
414
        } else {
415 27
            $isTtl = $this->isClass($iterator, 'TTL') || $this->isType($iterator);
416
        }
417 27
        $iterator->prev();
418
419 27
        return $isTtl;
420
    }
421
422
    /**
423
     * Split a DNS zone line into a resource record and a comment.
424
     *
425
     * @return array [$entry, $comment]
426
     */
427 30
    private function extractComment(string $rr): array
428
    {
429 30
        $string = new StringIterator($rr);
430 30
        $entry = '';
431 30
        $comment = null;
432
433 30
        while ($string->valid()) {
434
            //If a semicolon is within double quotes, it will not be treated as the beginning of a comment.
435 30
            $entry .= $this->extractDoubleQuotedText($string);
436
437 30
            if ($string->is(Tokens::SEMICOLON)) {
438 5
                $string->next();
439 5
                $comment = $string->getRemainingAsString();
440
441 5
                break;
442
            }
443
444 30
            $entry .= $string->current();
445 30
            $string->next();
446
        }
447
448 30
        return [$entry, $comment];
449
    }
450
451
    /**
452
     * Extract text within double quotation context.
453
     */
454 30
    private function extractDoubleQuotedText(StringIterator $string): string
455
    {
456 30
        if ($string->isNot(Tokens::DOUBLE_QUOTES)) {
457 30
            return '';
458
        }
459
460 15
        $entry = $string->current();
461 15
        $string->next();
462
463 15
        while ($string->isNot(Tokens::DOUBLE_QUOTES)) {
464
            //If the current char is a backslash, treat the next char as being escaped.
465 15
            if ($string->is(Tokens::BACKSLASH)) {
466 9
                $entry .= $string->current();
467 9
                $string->next();
468
            }
469 15
            $entry .= $string->current();
470 15
            $string->next();
471
        }
472
473 15
        return $entry;
474
    }
475
476
    /**
477
     * @throws ParseException
478
     */
479 29
    private function extractRdata(ResourceRecordIterator $iterator): RdataInterface
480
    {
481 29
        $type = strtoupper($iterator->current());
482 29
        $iterator->next();
483
484 29
        if (array_key_exists($type, $this->rdataHandlers)) {
485 1
            return call_user_func($this->rdataHandlers[$type], $iterator);
486
        }
487
488
        try {
489 28
            return Factory::textToRdataType($type, $iterator->getRemainingAsString());
490 2
        } catch (Exception $exception) {
491 2
            throw new ParseException(sprintf('Could not extract Rdata from resource record "%s".', (string) $iterator), null, $exception);
492
        }
493
    }
494
}
495