DNParser::escapeString()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 19
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 13
dl 0
loc 19
ccs 12
cts 12
cp 1
rs 9.8333
c 0
b 0
f 0
cc 1
nc 1
nop 1
crap 1
1
<?php
2
3
declare(strict_types = 1);
4
5
namespace Sop\X501\DN;
6
7
use Sop\ASN1\Element;
8
use Sop\ASN1\Exception\DecodeException;
9
10
/**
11
 * Distinguished Name parsing conforming to RFC 2253 and RFC 1779.
12
 *
13
 * @see https://tools.ietf.org/html/rfc1779
14
 * @see https://tools.ietf.org/html/rfc2253
15
 */
16
class DNParser
17
{
18
    /**
19
     * RFC 2253 special characters.
20
     *
21
     * @var string
22
     */
23
    const SPECIAL_CHARS = ',=+<>#;';
24
25
    /**
26
     * DN string.
27
     *
28
     * @var string
29
     */
30
    private $_dn;
31
32
    /**
33
     * DN string length.
34
     *
35
     * @var int
36
     */
37
    private $_len;
38
39
    /**
40
     * Constructor.
41
     *
42
     * @param string $dn Distinguised name
43
     */
44 70
    protected function __construct(string $dn)
45
    {
46 70
        $this->_dn = $dn;
47 70
        $this->_len = strlen($dn);
48 70
    }
49
50
    /**
51
     * Parse distinguished name string to name-components.
52
     *
53
     * @param string $dn
54
     *
55
     * @return array
56
     */
57 70
    public static function parseString(string $dn): array
58
    {
59 70
        $parser = new self($dn);
60 70
        return $parser->parse();
61
    }
62
63
    /**
64
     * Escape a AttributeValue string conforming to RFC 2253.
65
     *
66
     * @see https://tools.ietf.org/html/rfc2253#section-2.4
67
     *
68
     * @param string $str
69
     *
70
     * @return string
71
     */
72 31
    public static function escapeString(string $str): string
73
    {
74
        // one of the characters ",", "+", """, "\", "<", ">" or ";"
75 31
        $str = preg_replace('/([,\+"\\\<\>;])/u', '\\\\$1', $str);
76
        // a space character occurring at the end of the string
77 31
        $str = preg_replace('/( )$/u', '\\\\$1', $str);
78
        // a space or "#" character occurring at the beginning of the string
79 31
        $str = preg_replace('/^([ #])/u', '\\\\$1', $str);
80
        // implementation specific special characters
81 31
        $str = preg_replace_callback('/([\pC])/u',
82
            function ($m) {
83 2
                $octets = str_split(bin2hex($m[1]), 2);
84 2
                return implode('',
85 2
                    array_map(
86
                        function ($octet) {
87 2
                            return '\\' . strtoupper($octet);
88 2
                        }, $octets));
89 31
            }, $str);
90 31
        return $str;
91
    }
92
93
    /**
94
     * Parse DN to name-components.
95
     *
96
     * @throws \RuntimeException
97
     *
98
     * @return array
99
     */
100 70
    protected function parse(): array
101
    {
102 70
        $offset = 0;
103 70
        $name = $this->_parseName($offset);
104 62
        if ($offset < $this->_len) {
105 1
            $remains = substr($this->_dn, $offset);
106 1
            throw new \UnexpectedValueException(sprintf(
107 1
                'Parser finished before the end of string, remaining: %s',
108 1
                $remains));
109
        }
110 61
        return $name;
111
    }
112
113
    /**
114
     * Parse 'name'.
115
     *
116
     * name-component *("," name-component)
117
     *
118
     * @param int $offset
119
     *
120
     * @return array Array of name-components
121
     */
122 70
    private function _parseName(int &$offset): array
123
    {
124 70
        $idx = $offset;
125 70
        $names = [];
126 70
        while ($idx < $this->_len) {
127 70
            $names[] = $this->_parseNameComponent($idx);
128 62
            if ($idx >= $this->_len) {
129 61
                break;
130
            }
131 20
            $this->_skipWs($idx);
132 20
            if (',' != $this->_dn[$idx] && ';' != $this->_dn[$idx]) {
133 1
                break;
134
            }
135 19
            ++$idx;
136 19
            $this->_skipWs($idx);
137
        }
138 62
        $offset = $idx;
139 62
        return array_reverse($names);
140
    }
141
142
    /**
143
     * Parse 'name-component'.
144
     *
145
     * attributeTypeAndValue *("+" attributeTypeAndValue)
146
     *
147
     * @param int $offset
148
     *
149
     * @return array Array of [type, value] tuples
150
     */
151 70
    private function _parseNameComponent(int &$offset): array
152
    {
153 70
        $idx = $offset;
154 70
        $tvpairs = [];
155 70
        while ($idx < $this->_len) {
156 70
            $tvpairs[] = $this->_parseAttrTypeAndValue($idx);
157 62
            $this->_skipWs($idx);
158 62
            if ($idx >= $this->_len || '+' != $this->_dn[$idx]) {
159 62
                break;
160
            }
161 10
            ++$idx;
162 10
            $this->_skipWs($idx);
163
        }
164 62
        $offset = $idx;
165 62
        return $tvpairs;
166
    }
167
168
    /**
169
     * Parse 'attributeTypeAndValue'.
170
     *
171
     * attributeType "=" attributeValue
172
     *
173
     * @param int $offset
174
     *
175
     * @throws \UnexpectedValueException
176
     *
177
     * @return array A tuple of [type, value]. Value may be either a string or
178
     *               an Element, if it's encoded as hexstring.
179
     */
180 70
    private function _parseAttrTypeAndValue(int &$offset): array
181
    {
182 70
        $idx = $offset;
183 70
        $type = $this->_parseAttrType($idx);
184 69
        $this->_skipWs($idx);
185 69
        if ($idx >= $this->_len || '=' != $this->_dn[$idx++]) {
186 1
            throw new \UnexpectedValueException('Invalid type and value pair.');
187
        }
188 68
        $this->_skipWs($idx);
189
        // hexstring
190 68
        if ($idx < $this->_len && '#' == $this->_dn[$idx]) {
191 8
            ++$idx;
192 8
            $data = $this->_parseAttrHexValue($idx);
193
            try {
194 7
                $value = Element::fromDER($data);
195 1
            } catch (DecodeException $e) {
196 1
                throw new \UnexpectedValueException(
197 7
                    'Invalid DER encoding from hexstring.', 0, $e);
198
            }
199
        } else {
200 60
            $value = $this->_parseAttrStringValue($idx);
201
        }
202 62
        $offset = $idx;
203 62
        return [$type, $value];
204
    }
205
206
    /**
207
     * Parse 'attributeType'.
208
     *
209
     * (ALPHA 1*keychar) / oid
210
     *
211
     * @param int $offset
212
     *
213
     * @throws \UnexpectedValueException
214
     *
215
     * @return string
216
     */
217 70
    private function _parseAttrType(int &$offset): string
218
    {
219 70
        $idx = $offset;
220
        // dotted OID
221 70
        $type = $this->_regexMatch('/^(?:oid\.)?([0-9]+(?:\.[0-9]+)*)/i', $idx);
222 70
        if (null === $type) {
223
            // name
224 63
            $type = $this->_regexMatch('/^[a-z][a-z0-9\-]*/i', $idx);
225 63
            if (null === $type) {
226 1
                throw new \UnexpectedValueException('Invalid attribute type.');
227
            }
228
        }
229 69
        $offset = $idx;
230 69
        return $type;
231
    }
232
233
    /**
234
     * Parse 'attributeValue' of string type.
235
     *
236
     * @param int $offset
237
     *
238
     * @throws \UnexpectedValueException
239
     *
240
     * @return string
241
     */
242 60
    private function _parseAttrStringValue(int &$offset): string
243
    {
244 60
        $idx = $offset;
245 60
        if ($idx >= $this->_len) {
246 2
            return '';
247
        }
248 58
        if ('"' == $this->_dn[$idx]) { // quoted string
249 4
            $val = $this->_parseQuotedAttrString($idx);
250
        } else { // string
251 54
            $val = $this->_parseAttrString($idx);
252
        }
253 54
        $offset = $idx;
254 54
        return $val;
255
    }
256
257
    /**
258
     * Parse plain 'attributeValue' string.
259
     *
260
     * @param int $offset
261
     *
262
     * @throws \UnexpectedValueException
263
     *
264
     * @return string
265
     */
266 54
    private function _parseAttrString(int &$offset): string
267
    {
268 54
        $idx = $offset;
269 54
        $val = '';
270 54
        $wsidx = null;
271 54
        while ($idx < $this->_len) {
272 54
            $c = $this->_dn[$idx];
273
            // pair (escape sequence)
274 54
            if ('\\' == $c) {
275 10
                ++$idx;
276 10
                $val .= $this->_parsePairAfterSlash($idx);
277 7
                $wsidx = null;
278 7
                continue;
279
            }
280 49
            if ('"' == $c) {
281 1
                throw new \UnexpectedValueException('Unexpected quotation.');
282
            }
283 49
            if (false !== strpos(self::SPECIAL_CHARS, $c)) {
284 27
                break;
285
            }
286
            // keep track of the first consecutive whitespace
287 49
            if (' ' == $c) {
288 8
                if (null === $wsidx) {
289 8
                    $wsidx = $idx;
290
                }
291
            } else {
292 49
                $wsidx = null;
293
            }
294
            // stringchar
295 49
            $val .= $c;
296 49
            ++$idx;
297
        }
298
        // if there was non-escaped whitespace in the end of the value
299 50
        if (null !== $wsidx) {
300 5
            $val = substr($val, 0, -($idx - $wsidx));
301
        }
302 50
        $offset = $idx;
303 50
        return $val;
304
    }
305
306
    /**
307
     * Parse quoted 'attributeValue' string.
308
     *
309
     * @param int $offset Offset to starting quote
310
     *
311
     * @throws \UnexpectedValueException
312
     *
313
     * @return string
314
     */
315 4
    private function _parseQuotedAttrString(int &$offset): string
316
    {
317 4
        $idx = $offset + 1;
318 4
        $val = '';
319 4
        while ($idx < $this->_len) {
320 4
            $c = $this->_dn[$idx];
321 4
            if ('\\' == $c) { // pair
322 1
                ++$idx;
323 1
                $val .= $this->_parsePairAfterSlash($idx);
324 1
                continue;
325
            }
326 4
            if ('"' == $c) {
327 4
                ++$idx;
328 4
                break;
329
            }
330 4
            $val .= $c;
331 4
            ++$idx;
332
        }
333 4
        $offset = $idx;
334 4
        return $val;
335
    }
336
337
    /**
338
     * Parse 'attributeValue' of binary type.
339
     *
340
     * @param int $offset
341
     *
342
     * @throws \UnexpectedValueException
343
     *
344
     * @return string
345
     */
346 8
    private function _parseAttrHexValue(int &$offset): string
347
    {
348 8
        $idx = $offset;
349 8
        $hexstr = $this->_regexMatch('/^(?:[0-9a-f]{2})+/i', $idx);
350 8
        if (null === $hexstr) {
351 1
            throw new \UnexpectedValueException('Invalid hexstring.');
352
        }
353 7
        $data = hex2bin($hexstr);
354 7
        $offset = $idx;
355 7
        return $data;
356
    }
357
358
    /**
359
     * Parse 'pair' after leading slash.
360
     *
361
     * @param int $offset
362
     *
363
     * @throws \UnexpectedValueException
364
     *
365
     * @return string
366
     */
367 11
    private function _parsePairAfterSlash(int &$offset): string
368
    {
369 11
        $idx = $offset;
370 11
        if ($idx >= $this->_len) {
371 1
            throw new \UnexpectedValueException(
372 1
                'Unexpected end of escape sequence.');
373
        }
374 10
        $c = $this->_dn[$idx++];
375
        // special | \ | " | SPACE
376 10
        if (false !== strpos(self::SPECIAL_CHARS . '\\" ', $c)) {
377 7
            $val = $c;
378
        } else { // hexpair
379 3
            if ($idx >= $this->_len) {
380 1
                throw new \UnexpectedValueException('Unexpected end of hexpair.');
381
            }
382 2
            $val = @hex2bin($c . $this->_dn[$idx++]);
383 2
            if (false === $val) {
384 1
                throw new \UnexpectedValueException('Invalid hexpair.');
385
            }
386
        }
387 8
        $offset = $idx;
388 8
        return $val;
389
    }
390
391
    /**
392
     * Match DN to pattern and extract the last capture group.
393
     *
394
     * Updates offset to fully matched pattern.
395
     *
396
     * @param string $pattern
397
     * @param int    $offset
398
     *
399
     * @return null|string Null if pattern doesn't match
400
     */
401 70
    private function _regexMatch(string $pattern, int &$offset): ?string
402
    {
403 70
        $idx = $offset;
404 70
        if (!preg_match($pattern, substr($this->_dn, $idx), $match)) {
405 63
            return null;
406
        }
407 69
        $idx += strlen($match[0]);
408 69
        $offset = $idx;
409 69
        return end($match);
410
    }
411
412
    /**
413
     * Skip consecutive spaces.
414
     *
415
     * @param int $offset
416
     */
417 69
    private function _skipWs(int &$offset): void
418
    {
419 69
        $idx = $offset;
420 69
        while ($idx < $this->_len) {
421 68
            if (' ' != $this->_dn[$idx]) {
422 68
                break;
423
            }
424 5
            ++$idx;
425
        }
426 69
        $offset = $idx;
427 69
    }
428
}
429