Passed
Push — master ( f536e5...5fa974 )
by Josh
01:56
created

Decoder::castInteger()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 3

Importance

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