Passed
Push — master ( 2efbad...d7f265 )
by Josh
13:16
created

Decoder::getSafeBoundary()   A

Complexity

Conditions 3
Paths 1

Size

Total Lines 14
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 3

Importance

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