Passed
Push — master ( aa52b3...286910 )
by Sam
01:19 queued 10s
created

Parser::makeZone()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 2

Importance

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