Completed
Push — master ( 08a6bc...8fa8a0 )
by Sam
03:08
created

Parser::extractComment()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 22
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 3

Importance

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