Parser   F
last analyzed

Complexity

Total Complexity 67

Size/Duplication

Total Lines 519
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 11

Test Coverage

Coverage 98.4%

Importance

Changes 0
Metric Value
wmc 67
lcom 1
cbo 11
dl 0
loc 519
ccs 185
cts 188
cp 0.984
rs 3.04
c 0
b 0
f 0

19 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 5 1
A parse() 0 4 1
A makeZone() 0 10 1
A processZone() 0 8 2
A processLine() 0 28 3
B processEntry() 0 39 6
A populateNullValues() 0 20 4
A appendOrigin() 0 20 5
A processControlEntry() 0 20 5
A includeFile() 0 32 3
A extractIncludeArguments() 0 19 4
B isResourceName() 0 28 7
A isClass() 0 20 5
A isType() 0 8 3
A isControlEntry() 0 4 1
B isTTL() 0 24 6
A extractComment() 0 23 3
A extractDoubleQuotedText() 0 21 4
A extractRdata() 0 15 3

How to fix   Complexity   

Complex Class

Complex classes like Parser often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Parser, and based on these observations, apply Extract Interface, too.

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