Passed
Push — master ( 9940e3...71dbb5 )
by Josh
02:47
created

Decoder::decodeFastString()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

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