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