Completed
Push — 2.0/master ( b53134...462cf8 )
by Josh
02:02
created

Bencode::decodeDigits()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 26
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 12
c 0
b 0
f 0
nc 6
nop 1
dl 0
loc 26
rs 9.5555
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 decodeDigits(string $terminator): int
163
	{
164
		$spn = strspn($this->bencoded, '1234567890', $this->pos);
165
		if ($spn > 1)
166
		{
167
			if ($this->bencoded[$this->pos] === '0')
168
			{
169
				throw new RuntimeException('Illegal character found at offset ' . $this->pos);
170
			}
171
		}
172
		elseif (!$spn)
173
		{
174
			throw new RuntimeException('Illegal character found at offset ' . $this->pos);
175
		}
176
177
		// Capture the value and cast it as an integer
178
		$value = (int) substr($this->bencoded, $this->pos, $spn);
179
180
		$this->pos += $spn;
181
		if ($this->bencoded[$this->pos] !== $terminator)
182
		{
183
			throw new RuntimeException('Illegal character found at offset ' . $this->pos);
184
		}
185
		++$this->pos;
186
187
		return $value;
188
	}
189
190
	protected function decodeInteger(): int
191
	{
192
		$negative = ($this->bencoded[++$this->pos] === '-');
193
		if ($negative && $this->bencoded[++$this->pos] === '0')
194
		{
195
			throw new RuntimeException('Illegal character found at offset ' . $this->pos);
196
		}
197
198
		$value = $this->decodeDigits('e');
199
		if ($negative)
200
		{
201
			$value = -$value;
202
		}
203
204
		return $value;
205
	}
206
207
	protected function decodeList(): array
208
	{
209
		++$this->pos;
210
211
		$list = [];
212
		while ($this->pos <= $this->max)
213
		{
214
			if ($this->bencoded[$this->pos] === 'e')
215
			{
216
				++$this->pos;
217
218
				return $list;
219
			}
220
221
			$list[] = $this->decodeAnything();
222
		}
223
224
		throw new RuntimeException('Premature end of data');
225
	}
226
227
	protected function decodeString(): string
228
	{
229
		$len        = $this->decodeDigits(':');
230
		$string     = substr($this->bencoded, $this->pos, $len);
231
		$this->pos += $len;
232
233
		return $string;
234
	}
235
236
	/**
237
	* Bencode a value
238
	*/
239
	public static function encode($value): string
240
	{
241
		if (is_scalar($value))
242
		{
243
			return self::encodeScalar($value);
244
		}
245
		if (is_array($value))
246
		{
247
			return self::encodeArray($value);
248
		}
249
		if ($value instanceof stdClass)
250
		{
251
			$value = new ArrayObject(get_object_vars($value));
252
		}
253
		if ($value instanceof ArrayObject)
254
		{
255
			return self::encodeArrayObject($value);
256
		}
257
258
		throw new InvalidArgumentException('Unsupported value');
259
	}
260
261
	/**
262
	* Encode an array into either an array of a dictionary
263
	*/
264
	protected static function encodeArray(array $value): string
265
	{
266
		if (empty($value))
267
		{
268
			return 'le';
269
		}
270
271
		if (array_keys($value) === range(0, count($value) - 1))
272
		{
273
			return 'l' . implode('', array_map([__CLASS__, 'encode'], $value)) . 'e';
274
		}
275
276
		// Encode associative arrays as dictionaries
277
		return self::encodeArrayObject(new ArrayObject($value));
278
	}
279
280
	/**
281
	* Encode given ArrayObject instance into a dictionary
282
	*/
283
	protected static function encodeArrayObject(ArrayObject $dict): string
284
	{
285
		$vars = $dict->getArrayCopy();
286
		ksort($vars);
287
288
		$str = 'd';
289
		foreach ($vars as $k => $v)
290
		{
291
			$str .= strlen($k) . ':' . $k . self::encode($v);
292
		}
293
		$str .= 'e';
294
295
		return $str;
296
	}
297
298
	/**
299
	* Encode a scalar value
300
	*/
301
	protected static function encodeScalar($value): string
302
	{
303
		if (is_int($value) || is_float($value) || is_bool($value))
304
		{
305
			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

305
			return sprintf('i%de', round(/** @scrutinizer ignore-type */ $value));
Loading history...
306
		}
307
308
		return strlen($value) . ':' . $value;
309
	}
310
}