Passed
Push — master ( 94dc81...d2c9a0 )
by Josh
10:43
created

Decoder::complianceError()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
nc 1
nop 2
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
c 1
b 0
f 0
1
<?php declare(strict_types=1);
2
3
/**
4
* @package   s9e\Bencode
5
* @copyright Copyright (c) 2014-2021 The s9e authors
6
* @license   http://www.opensource.org/licenses/mit-license.php The MIT License
7
*/
8
namespace s9e\Bencode;
9
10
use ArrayObject;
11
use const PHP_INT_MAX, PHP_INT_MIN, false;
12
use function is_float, str_contains, strcmp, strlen, strspn, substr, substr_compare;
13
use s9e\Bencode\Exceptions\ComplianceError;
14
use s9e\Bencode\Exceptions\DecodingException;
15
16
class Decoder
17
{
18
	/**
19
	* @var string Bencoded string being decoded
20
	*/
21
	protected string $bencoded;
22
23
	/**
24
	* @var int Length of the bencoded string
25
	*/
26
	protected int $len;
27
28
	/**
29
	* @var int Safe rightmost boundary
30
	*/
31
	protected int $max;
32
33
	/**
34
	* @var int Position of the cursor while decoding
35
	*/
36
	protected int $offset = 0;
37
38 83
	public static function decode(string $bencoded): ArrayObject|array|int|string
39
	{
40 83
		$decoder = new static($bencoded);
41 71
		$value   = $decoder->decodeAnything();
42
43 42
		$decoder->checkCursorPosition();
44
45 34
		return $value;
46
	}
47
48 83
	protected function __construct(string $bencoded)
49
	{
50 83
		$this->bencoded = $bencoded;
51 83
		$this->len      = strlen($bencoded);
52
53 83
		$this->computeSafeBoundary();
54 83
		$this->checkBoundary();
55 71
	}
56
57
	/**
58
	* Cast given string as an integer and check for clamping
59
	*/
60 25
	protected function castInteger(string $string, bool $negative): int
61
	{
62 25
		if ($negative)
63
		{
64 4
			$string = "-$string";
65 4
			$clamp  = PHP_INT_MIN;
66
		}
67
		else
68
		{
69 21
			$clamp = PHP_INT_MAX;
70
		}
71
72 25
		$value = (int) $string;
73 25
		if ($value === $clamp)
74
		{
75 4
			$this->checkIntegerOverflow($string);
76
		}
77
78 23
		return $value;
79
	}
80
81 83
	protected function checkBoundary(): void
82
	{
83 83
		if ($this->max < 1)
84
		{
85 12
			throw match (substr($this->bencoded, 0, 1))
86
			{
87 2
				'-', 'e' => new DecodingException('Illegal character',     0),
88 1
				''       => new DecodingException('Premature end of data', 0),
89 9
				default  => new DecodingException('Premature end of data', $this->len - 1)
90
			};
91
		}
92 71
	}
93
94
	/**
95
	* Check the cursor's position after decoding is done
96
	*/
97 42
	protected function checkCursorPosition(): void
98
	{
99 42
		if ($this->offset !== $this->len)
100
		{
101 11
			if ($this->offset > $this->len)
102
			{
103 3
				throw new DecodingException('Premature end of data', $this->len - 1);
104
			}
105
106 8
			$this->complianceError('Superfluous content', $this->offset);
107
		}
108 34
	}
109
110 4
	protected function checkIntegerOverflow(string $str): void
111
	{
112 4
		if (is_float(+$str))
113
		{
114 2
			throw new DecodingException('Integer overflow', $this->offset - 1 - strlen($str));
115
		}
116 2
	}
117
118 13
	protected function complianceError(string $message, int $offset): void
119
	{
120 13
		throw new ComplianceError($message, $offset);
121
	}
122
123
	/**
124
	* Adjust the rightmost boundary to the last safe character that can start a value
125
	*
126
	* Will rewind the boundary to skip the rightmost digits, optionally preceded by "i" or "i-"
127
	*/
128 83
	protected function computeSafeBoundary(): void
129
	{
130 83
		$boundary = $this->len - 1;
131
		do
132
		{
133 83
			$c = substr($this->bencoded, $boundary, 1);
134
		}
135 83
		while (str_contains('0123456789', $c) && --$boundary >= 0);
136
137 83
		$this->max = match ($c)
138
		{
139 8
			'-'     => $boundary - 2,
140 7
			'i'     => $boundary - 1,
141 68
			default => $boundary
142
		};
143 83
	}
144
145 71
	protected function decodeAnything(): ArrayObject|array|int|string
146
	{
147 71
		return match ($this->bencoded[$this->offset])
148
		{
149 31
			'i'     => $this->decodeInteger(),
150 21
			'd'     => $this->decodeDictionary(),
151 16
			'l'     => $this->decodeList(),
152 58
			default => $this->decodeString()
153
		};
154
	}
155
156 21
	protected function decodeDictionary(): ArrayObject
157
	{
158 21
		$values  = [];
159 21
		$lastKey = null;
160
161 21
		++$this->offset;
162 21
		while ($this->offset <= $this->max)
163
		{
164 21
			$c = $this->bencoded[$this->offset];
165 21
			if ($c === 'e')
166
			{
167 12
				++$this->offset;
168
169 12
				return new ArrayObject($values, ArrayObject::ARRAY_AS_PROPS);
170
			}
171
172
			// Quickly match the most common keys found in dictionaries
173 20
			$key = match ($c)
174
			{
175 3
				'4'     => $this->decodeFastString('4:path',   6, 'path'),
176 1
				'6'     => $this->decodeFastString('6:length', 8, 'length'),
177 17
				default => $this->decodeString()
178
			};
179 19
			if (isset($lastKey) && strcmp($key, $lastKey) <= 0)
180
			{
181 8
				$this->dictionaryComplianceError($key, $lastKey);
182
			}
183 19
			if ($this->offset > $this->max)
184
			{
185 1
				break;
186
			}
187 18
			$values[$key] = $this->decodeAnything();
188 15
			$lastKey      = $key;
189
		}
190
191 1
		throw new DecodingException('Premature end of data', $this->len - 1);
192
	}
193
194
	/**
195
	* @param string $match Bencoded string to match
196
	* @param int    $len   Length of the bencoded string
197
	* @param string $value String value to return if the string matches
198
	*/
199 3
	protected function decodeFastString(string $match, int $len, string $value): string
200
	{
201 3
		if (substr_compare($this->bencoded, $match, $this->offset, $len, false) === 0)
202
		{
203 1
			$this->offset += $len;
204
205 1
			return $value;
206
		}
207
208 3
		return $this->decodeString();
209
	}
210
211 31
	protected function decodeInteger(): int
212
	{
213 31
		$negative = ($this->bencoded[++$this->offset] === '-');
214 31
		if ($negative && $this->bencoded[++$this->offset] === '0')
215
		{
216 2
			$this->complianceError('Illegal character', $this->offset);
217
		}
218
219 30
		return $this->castInteger($this->readDigits('e'), $negative);
220
	}
221
222 16
	protected function decodeList(): array
223
	{
224 16
		++$this->offset;
225
226 16
		$list = [];
227 16
		while ($this->offset <= $this->max)
228
		{
229 15
			if ($this->bencoded[$this->offset] === 'e')
230
			{
231 9
				++$this->offset;
232
233 9
				return $list;
234
			}
235
236 12
			$list[] = $this->decodeAnything();
237
		}
238
239 5
		throw new DecodingException('Premature end of data', $this->len - 1);
240
	}
241
242 46
	protected function decodeString(): string
243
	{
244 46
		$len = (int) $this->readDigits(':');
245 38
		if ($this->offset + $len >= PHP_INT_MAX)
246
		{
247 1
			throw new DecodingException('String length overflow', $this->offset - 1 - strlen((string) $len));
248
		}
249
250 37
		$string = substr($this->bencoded, $this->offset, $len);
251 37
		$this->offset += $len;
252
253 37
		return $string;
254
	}
255
256 4
	protected function dictionaryComplianceError(string $key, string $lastKey): void
257
	{
258
		// Compute the offset of the start of the string used as key
259 4
		$offset = $this->offset - strlen(strlen($key) . ':') - strlen($key);
260
261 4
		$msg = ($key === $lastKey) ? 'Duplicate' : 'Out of order';
262 4
		$this->complianceError($msg . " dictionary entry '" . $key . "'", $offset);
263
	}
264
265 63
	protected function readDigits(string $terminator): string
266
	{
267
		// Digits sorted by decreasing frequency as observed on a random sample of torrent files
268 63
		$spn = strspn($this->bencoded, '1463720859', $this->offset);
269 63
		if (!$spn)
270
		{
271 7
			throw new DecodingException('Illegal character', $this->offset);
272
		}
273 58
		if ($this->bencoded[$this->offset] === '0' && $spn !== 1)
274
		{
275 6
			$this->complianceError('Illegal character', 1 + $this->offset);
276
		}
277
278 55
		$string = substr($this->bencoded, $this->offset, $spn);
279
280 55
		$this->offset += $spn;
281 55
		if ($this->bencoded[$this->offset] !== $terminator)
282
		{
283 5
			throw new DecodingException('Illegal character', $this->offset);
284
		}
285 50
		++$this->offset;
286
287 50
		return $string;
288
	}
289
}