Passed
Push — master ( 6e004f...64f73c )
by Josh
09:31
created

Decoder::checkIntegerOverflow()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 2

Importance

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