Completed
Branch 2.0/master (b53134)
by Josh
07:57
created

Bencode::decodeList()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 18
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 8
nc 3
nop 0
dl 0
loc 18
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-2020 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 InvalidArgumentException;
12
use RuntimeException;
13
use stdClass;
14
15
class Bencode
16
{
17
	/**
18
	* @var string Bencoded string being decoded
19
	*/
20
	protected string $bencoded;
21
22
	/**
23
	* @var int Length of the bencoded string
24
	*/
25
	protected int $len;
26
27
	/**
28
	* @var int Safe rightmost boundary
29
	*/
30
	protected int $max;
31
32
	/**
33
	* @var int Position of the cursor while decoding
34
	*/
35
	protected int $pos;
36
37
	protected function __construct(string $bencoded)
38
	{
39
		if ($bencoded === '')
40
		{
41
			throw new InvalidArgumentException;
42
		}
43
44
		$this->bencoded = $bencoded;
45
		$this->len      = strlen($bencoded);
46
		$this->pos      = 0;
47
48
		$this->computeSafeBoundary();
49
		$this->checkBoundary();
50
	}
51
52
	public static function decode(string $bencoded)
53
	{
54
		$decoder = new static($bencoded);
55
		$value   = $decoder->decodeAnything();
56
57
		$decoder->checkCursorPosition();
58
59
		return $value;
60
	}
61
62
	protected function checkBoundary(): void
63
	{
64
		if ($this->max < 1)
65
		{
66
			if (strpos('-e', $this->bencoded[0]) === false)
67
			{
68
				throw new RuntimeException('Premature end of data');
69
			}
70
			else
71
			{
72
				throw new RuntimeException('Illegal character found at offset 0');
73
			}
74
		}
75
	}
76
77
	protected function checkCursorPosition(): void
78
	{
79
		if ($this->pos !== $this->len)
80
		{
81
			if ($this->pos < $this->len)
82
			{
83
				throw new RuntimeException('Superfluous content found at offset ' . $this->pos);
84
			}
85
86
			throw new RuntimeException('Premature end of data');
87
		}
88
	}
89
90
	/**
91
	* Adjust the rightmost boundary to the last safe character
92
	*
93
	* Will rewind the boundary to skip the rightmost digits, optionally preceded by "i" or "i-"
94
	*/
95
	protected function computeSafeBoundary(): void
96
	{
97
		$boundary = $this->len - 1;
98
		$c = $this->bencoded[$boundary];
99
		while (is_numeric($c) && --$boundary >= 0)
100
		{
101
			$c = $this->bencoded[$boundary];
102
		}
103
		if ($c === '-' && $boundary > 0 && $this->bencoded[$boundary - 1] === 'i')
104
		{
105
			$boundary -= 2;
106
		}
107
		elseif ($c === 'i')
108
		{
109
			--$boundary;
110
		}
111
112
		$this->max = $boundary;
113
	}
114
115
	protected function decodeAnything()
116
	{
117
		$c = $this->bencoded[$this->pos];
118
		if ($c === 'i')
119
		{
120
			return $this->decodeInteger();
121
		}
122
		if ($c === 'd')
123
		{
124
			return $this->decodeDictionary();
125
		}
126
		if ($c === 'l')
127
		{
128
			return $this->decodeList();
129
		}
130
131
		return $this->decodeString();
132
	}
133
134
	protected function decodeDictionary(): ArrayObject
135
	{
136
		$dictionary = new ArrayObject;
137
		$dictionary->setFlags(ArrayObject::ARRAY_AS_PROPS);
138
139
		++$this->pos;
140
		while ($this->pos <= $this->max)
141
		{
142
			if ($this->bencoded[$this->pos] === 'e')
143
			{
144
				++$this->pos;
145
146
				return $dictionary;
147
			}
148
149
			$pos = $this->pos;
150
			$key = $this->decodeString();
151
			if (isset($dictionary->$key))
152
			{
153
				throw new RuntimeException("Duplicate dictionary entry '" . $key . "' at pos " . $pos);
154
			}
155
156
			$dictionary->$key = $this->decodeAnything();
157
		}
158
159
		throw new RuntimeException('Premature end of data');
160
	}
161
162
	protected function decodeInteger(): int
163
	{
164
		$negative = ($this->bencoded[++$this->pos] === '-');
165
		if ($negative && $this->bencoded[++$this->pos] === '0')
166
		{
167
			throw new RuntimeException('Illegal character found at offset ' . $this->pos);
168
		}
169
170
		$spn = strspn($this->bencoded, '1234567890', $this->pos);
171
		if ($spn > 1)
172
		{
173
			if ($this->bencoded[$this->pos] === '0')
174
			{
175
				throw new RuntimeException('Illegal character found at offset ' . $this->pos);
176
			}
177
		}
178
		elseif (!$spn)
179
		{
180
			$this->pos -= ($negative) ? 2 : 1;
181
182
			throw new RuntimeException('Invalid integer found at offset ' . $this->pos);
183
		}
184
185
		// Capture the value and cast it as an integer
186
		$value = (int) substr($this->bencoded, $this->pos, $spn);
187
		if ($negative)
188
		{
189
			$value = -$value;
190
		}
191
192
		$this->pos += $spn;
193
		if ($this->bencoded[$this->pos] !== 'e')
194
		{
195
			$this->pos -= $spn;
196
			$this->pos -= ($negative) ? 2 : 1;
197
198
			throw new RuntimeException('Invalid integer found at offset ' . $this->pos);
199
		}
200
201
		++$this->pos;
202
203
		return $value;
204
	}
205
206
	protected function decodeList(): array
207
	{
208
		++$this->pos;
209
210
		$list = [];
211
		while ($this->pos <= $this->max)
212
		{
213
			if ($this->bencoded[$this->pos] === 'e')
214
			{
215
				++$this->pos;
216
217
				return $list;
218
			}
219
220
			$list[] = $this->decodeAnything();
221
		}
222
223
		throw new RuntimeException('Premature end of data');
224
	}
225
226
	protected function decodeString(): string
227
	{
228
		$spn = strspn($this->bencoded, '1234567890', $this->pos);
229
		if ($spn > 1)
230
		{
231
			if ($this->bencoded[$this->pos] === '0')
232
			{
233
				throw new RuntimeException('Illegal character found at offset ' . $this->pos);
234
			}
235
		}
236
		elseif (!$spn)
237
		{
238
			throw new RuntimeException('Illegal character found at offset ' . $this->pos);
239
		}
240
241
		$len        = (int) substr($this->bencoded, $this->pos, $spn);
242
		$this->pos += $spn;
243
		if ($this->bencoded[$this->pos] !== ':')
244
		{
245
			throw new RuntimeException('Illegal character found at offset ' . $this->pos);
246
		}
247
248
		$string     = substr($this->bencoded, ++$this->pos, $len);
249
		$this->pos += $len;
250
251
		return $string;
252
	}
253
254
	/**
255
	* Bencode a value
256
	*/
257
	public static function encode($value): string
258
	{
259
		if (is_scalar($value))
260
		{
261
			return self::encodeScalar($value);
262
		}
263
		if (is_array($value))
264
		{
265
			return self::encodeArray($value);
266
		}
267
		if ($value instanceof stdClass)
268
		{
269
			$value = new ArrayObject(get_object_vars($value));
270
		}
271
		if ($value instanceof ArrayObject)
272
		{
273
			return self::encodeArrayObject($value);
274
		}
275
276
		throw new InvalidArgumentException('Unsupported value');
277
	}
278
279
	/**
280
	* Encode an array into either an array of a dictionary
281
	*/
282
	protected static function encodeArray(array $value): string
283
	{
284
		if (empty($value))
285
		{
286
			return 'le';
287
		}
288
289
		if (array_keys($value) === range(0, count($value) - 1))
290
		{
291
			return 'l' . implode('', array_map([__CLASS__, 'encode'], $value)) . 'e';
292
		}
293
294
		// Encode associative arrays as dictionaries
295
		return self::encodeArrayObject(new ArrayObject($value));
296
	}
297
298
	/**
299
	* Encode given ArrayObject instance into a dictionary
300
	*/
301
	protected static function encodeArrayObject(ArrayObject $dict): string
302
	{
303
		$vars = $dict->getArrayCopy();
304
		ksort($vars);
305
306
		$str = 'd';
307
		foreach ($vars as $k => $v)
308
		{
309
			$str .= strlen($k) . ':' . $k . self::encode($v);
310
		}
311
		$str .= 'e';
312
313
		return $str;
314
	}
315
316
	/**
317
	* Encode a scalar value
318
	*/
319
	protected static function encodeScalar($value): string
320
	{
321
		if (is_int($value) || is_float($value) || is_bool($value))
322
		{
323
			return sprintf('i%de', round($value));
0 ignored issues
show
Bug introduced by
It seems like $value can also be of type boolean; however, parameter $val of round() does only seem to accept double, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

323
			return sprintf('i%de', round(/** @scrutinizer ignore-type */ $value));
Loading history...
324
		}
325
326
		return strlen($value) . ':' . $value;
327
	}
328
}