Passed
Push — master ( 923e94...00c6d3 )
by Josh
09:54
created

Decoder::castInteger()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 20
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 4

Importance

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