Passed
Push — master ( f322d1...08a6bc )
by Sam
01:11 queued 12s
created

Parser   C

Complexity

Total Complexity 55

Size/Duplication

Total Lines 415
Duplicated Lines 0 %

Test Coverage

Coverage 98.18%

Importance

Changes 4
Bugs 0 Features 0
Metric Value
eloc 159
dl 0
loc 415
ccs 162
cts 165
cp 0.9818
rs 6
c 4
b 0
f 0
wmc 55

17 Methods

Rating   Name   Duplication   Size   Complexity  
B processEntry() 0 34 6
A populateWithLastStated() 0 18 4
A isClass() 0 15 4
A parse() 0 3 1
A isTTL() 0 19 5
A appendOrigin() 0 15 4
A isControlEntry() 0 3 1
A extractDoubleQuotedText() 0 20 4
A extractRdata() 0 13 3
A isResourceName() 0 23 6
A processZone() 0 6 2
A isType() 0 3 2
A processControlEntry() 0 32 5
A makeZone() 0 9 1
A processLine() 0 23 3
A extractComment() 0 22 3
A __construct() 0 4 1

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.

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 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 2
            $this->zone->addResourceRecord($this->currentResourceRecord);
128
129 2
            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
            if (null !== $this->fetcher) {
256
                //Copy the state of the parser so as to revert back once included file has been parsed.
257 1
                $_lastStatedDomain = $this->lastStatedDomain;
258 1
                $_lastStatedClass = $this->lastStatedClass;
259 1
                $_lastStatedTtl = $this->lastStatedTtl;
260 1
                $_origin = $this->origin;
261 1
                $_ttl = $this->zone->getDefaultTtl() ?? 0;
262
263
                //Parse the included record.
264 1
                $childRecord = $this->fetcher->fetch($iterator->getRemainingAsString());
265 1
                $this->processZone($childRecord);
266
267
                //Revert the parser.
268 1
                $this->lastStatedDomain = $_lastStatedDomain;
269 1
                $this->lastStatedClass = $_lastStatedClass;
270 1
                $this->lastStatedTtl = $_lastStatedTtl;
271 1
                $this->origin = $_origin;
272 1
                $this->zone->setDefaultTtl($_ttl);
273
            }
274
        }
275 14
    }
276
277
    /**
278
     * Determine if iterant is a resource name.
279
     */
280 29
    private function isResourceName(ResourceRecordIterator $iterator): bool
281
    {
282
        // Look ahead and determine if the next token is a TTL, Class, or valid Type.
283 29
        $iterator->next();
284
285 29
        if (!$iterator->valid()) {
286
            return false;
287
        }
288
289 29
        $isName = $this->isTTL($iterator) ||
290 29
            $this->isClass($iterator, 'DOMAIN') ||
291 29
            $this->isType($iterator);
292 29
        $iterator->prev();
293
294 29
        if (!$isName) {
295 27
            return false;
296
        }
297
298 28
        if (0 === $iterator->key()) {
299 28
            return true;
300
        }
301
302 4
        return false;
303
    }
304
305
    /**
306
     * Determine if iterant is a class.
307
     *
308
     * @param string|null $origin the previously assumed resource record parameter, either 'TTL' or NULL
309
     */
310 29
    private function isClass(ResourceRecordIterator $iterator, $origin = null): bool
311
    {
312 29
        if (!Classes::isValid($iterator->current())) {
313 29
            return false;
314
        }
315
316 27
        $iterator->next();
317 27
        if ('TTL' === $origin) {
318 12
            $isClass = $this->isType($iterator);
319
        } else {
320 26
            $isClass = $this->isTTL($iterator, 'CLASS') || $this->isType($iterator);
321
        }
322 27
        $iterator->prev();
323
324 27
        return $isClass;
325
    }
326
327
    /**
328
     * Determine if current iterant is an Rdata type string.
329
     */
330 29
    private function isType(ResourceRecordIterator $iterator): bool
331
    {
332 29
        return Types::isValid(strtoupper($iterator->current())) || array_key_exists($iterator->current(), $this->rdataHandlers);
333
    }
334
335
    /**
336
     * Determine if iterant is a control entry such as $TTL, $ORIGIN, $INCLUDE, etcetera.
337
     */
338 29
    private function isControlEntry(ResourceRecordIterator $iterator): bool
339
    {
340 29
        return 1 === preg_match('/^\$[A-Z0-9]+/i', $iterator->current());
341
    }
342
343
    /**
344
     * Determine if the iterant is a TTL (i.e. it is an integer after domain-name).
345
     *
346
     * @param string $origin the previously assumed resource record parameter, either 'CLASS' or NULL
347
     */
348 29
    private function isTTL(ResourceRecordIterator $iterator, $origin = null): bool
349
    {
350 29
        if (!TimeFormat::isTimeFormat($iterator->current())) {
351 29
            return false;
352
        }
353
354 27
        if ($iterator->key() < 1) {
355 6
            return false;
356
        }
357
358 26
        $iterator->next();
359 26
        if ('CLASS' === $origin) {
360 5
            $isTtl = $this->isType($iterator);
361
        } else {
362 26
            $isTtl = $this->isClass($iterator, 'TTL') || $this->isType($iterator);
363
        }
364 26
        $iterator->prev();
365
366 26
        return $isTtl;
367
    }
368
369
    /**
370
     * Split a DNS zone line into a resource record and a comment.
371
     *
372
     * @return array [$entry, $comment]
373
     */
374 29
    private function extractComment(string $rr): array
375
    {
376 29
        $string = new StringIterator($rr);
377 29
        $entry = '';
378 29
        $comment = null;
379
380 29
        while ($string->valid()) {
381
            //If a semicolon is within double quotes, it will not be treated as the beginning of a comment.
382 29
            $entry .= $this->extractDoubleQuotedText($string);
383
384 29
            if ($string->is(Tokens::SEMICOLON)) {
385 4
                $string->next();
386 4
                $comment = $string->getRemainingAsString();
387
388 4
                break;
389
            }
390
391 29
            $entry .= $string->current();
392 29
            $string->next();
393
        }
394
395 29
        return [$entry, $comment];
396
    }
397
398
    /**
399
     * Extract text within double quotation context.
400
     */
401 29
    private function extractDoubleQuotedText(StringIterator $string): string
402
    {
403 29
        if ($string->isNot(Tokens::DOUBLE_QUOTES)) {
404 29
            return '';
405
        }
406
407 14
        $entry = $string->current();
408 14
        $string->next();
409
410 14
        while ($string->isNot(Tokens::DOUBLE_QUOTES)) {
411
            //If the current char is a backslash, treat the next char as being escaped.
412 14
            if ($string->is(Tokens::BACKSLASH)) {
413 8
                $entry .= $string->current();
414 8
                $string->next();
415
            }
416 14
            $entry .= $string->current();
417 14
            $string->next();
418
        }
419
420 14
        return $entry;
421
    }
422
423
    /**
424
     * @throws ParseException
425
     */
426 28
    private function extractRdata(ResourceRecordIterator $iterator): RdataInterface
427
    {
428 28
        $type = strtoupper($iterator->current());
429 28
        $iterator->next();
430
431 28
        if (array_key_exists($type, $this->rdataHandlers)) {
432 1
            return call_user_func($this->rdataHandlers[$type], $iterator);
433
        }
434
435
        try {
436 27
            return Factory::textToRdataType($type, $iterator->getRemainingAsString());
437 2
        } catch (Exception $exception) {
438 2
            throw new ParseException(sprintf('Could not extract Rdata from resource record "%s".', (string) $iterator), null, $exception);
439
        }
440
    }
441
}
442