Passed
Push — master ( d2c9a0...fde73f )
by Josh
02:10
created

Decoder::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 1

Importance

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