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

Bencode::computeSafeBoundary()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 19
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 10
c 1
b 0
f 0
nc 6
nop 0
dl 0
loc 19
rs 9.6111
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
	}
50
51
	public static function decode(string $bencoded)
52
	{
53
		$decoder = new static($bencoded);
54
		$value   = $decoder->decodeAnything();
55
56
		$decoder->checkCursorPosition();
57
58
		return $value;
59
	}
60
61
	public static function encode($value): string
62
	{
63
		if (is_scalar($value))
64
		{
65
			return self::encodeScalar($value);
66
		}
67
		if (is_array($value))
68
		{
69
			return self::encodeArray($value);
70
		}
71
		if ($value instanceof stdClass)
72
		{
73
			$value = new ArrayObject(get_object_vars($value));
74
		}
75
		if ($value instanceof ArrayObject)
76
		{
77
			return self::encodeArrayObject($value);
78
		}
79
80
		throw new InvalidArgumentException('Unsupported value');
81
	}
82
83
	protected function checkBoundary(): void
84
	{
85
		if ($this->max < 1)
86
		{
87
			if (strpos('-e', $this->bencoded[0]) !== false)
88
			{
89
				throw new RuntimeException('Illegal character found at offset 0');
90
			}
91
92
			throw new RuntimeException('Premature end of data');
93
		}
94
	}
95
96
	protected function checkCursorPosition(): void
97
	{
98
		if ($this->pos !== $this->len)
99
		{
100
			if ($this->pos < $this->len)
101
			{
102
				throw new RuntimeException('Superfluous content found at offset ' . $this->pos);
103
			}
104
105
			throw new RuntimeException('Premature end of data');
106
		}
107
	}
108
109
	/**
110
	* Adjust the rightmost boundary to the last safe character
111
	*
112
	* Will rewind the boundary to skip the rightmost digits, optionally preceded by "i" or "i-"
113
	*/
114
	protected function computeSafeBoundary(): void
115
	{
116
		$boundary = $this->len - 1;
117
		$c = $this->bencoded[$boundary];
118
		while (is_numeric($c) && --$boundary >= 0)
119
		{
120
			$c = $this->bencoded[$boundary];
121
		}
122
		if ($c === '-')
123
		{
124
			$boundary -= 2;
125
		}
126
		elseif ($c === 'i')
127
		{
128
			--$boundary;
129
		}
130
131
		$this->max = $boundary;
132
		$this->checkBoundary();
133
	}
134
135
	protected function decodeAnything()
136
	{
137
		$c = $this->bencoded[$this->pos];
138
		if ($c === 'i')
139
		{
140
			return $this->decodeInteger();
141
		}
142
		if ($c === 'd')
143
		{
144
			return $this->decodeDictionary();
145
		}
146
		if ($c === 'l')
147
		{
148
			return $this->decodeList();
149
		}
150
151
		return $this->decodeString();
152
	}
153
154
	protected function decodeDictionary(): ArrayObject
155
	{
156
		$dictionary = new ArrayObject;
157
		$dictionary->setFlags(ArrayObject::ARRAY_AS_PROPS);
158
159
		++$this->pos;
160
		while ($this->pos <= $this->max)
161
		{
162
			if ($this->bencoded[$this->pos] === 'e')
163
			{
164
				++$this->pos;
165
166
				return $dictionary;
167
			}
168
169
			$pos = $this->pos;
170
			$key = $this->decodeString();
171
			if (isset($dictionary->$key))
172
			{
173
				throw new RuntimeException("Duplicate dictionary entry '" . $key . "' at pos " . $pos);
174
			}
175
			if ($this->pos <= $this->max)
176
			{
177
				$dictionary->$key = $this->decodeAnything();
178
			}
179
		}
180
181
		throw new RuntimeException('Premature end of data');
182
	}
183
184
	protected function decodeDigits(string $terminator): int
185
	{
186
		$spn = strspn($this->bencoded, '1234567890', $this->pos);
187
		if ($spn > 1)
188
		{
189
			if ($this->bencoded[$this->pos] === '0')
190
			{
191
				throw new RuntimeException('Illegal character found at offset ' . $this->pos);
192
			}
193
		}
194
		elseif (!$spn)
195
		{
196
			throw new RuntimeException('Illegal character found at offset ' . $this->pos);
197
		}
198
199
		// Capture the value and cast it as an integer
200
		$value = (int) substr($this->bencoded, $this->pos, $spn);
201
202
		$this->pos += $spn;
203
		if ($this->bencoded[$this->pos] !== $terminator)
204
		{
205
			throw new RuntimeException('Illegal character found at offset ' . $this->pos);
206
		}
207
		++$this->pos;
208
209
		return $value;
210
	}
211
212
	protected function decodeInteger(): int
213
	{
214
		$negative = ($this->bencoded[++$this->pos] === '-');
215
		if ($negative && $this->bencoded[++$this->pos] === '0')
216
		{
217
			throw new RuntimeException('Illegal character found at offset ' . $this->pos);
218
		}
219
220
		$value = $this->decodeDigits('e');
221
		if ($negative)
222
		{
223
			$value = -$value;
224
		}
225
226
		return $value;
227
	}
228
229
	protected function decodeList(): array
230
	{
231
		++$this->pos;
232
233
		$list = [];
234
		while ($this->pos <= $this->max)
235
		{
236
			if ($this->bencoded[$this->pos] === 'e')
237
			{
238
				++$this->pos;
239
240
				return $list;
241
			}
242
243
			$list[] = $this->decodeAnything();
244
		}
245
246
		throw new RuntimeException('Premature end of data');
247
	}
248
249
	protected function decodeString(): string
250
	{
251
		$len        = $this->decodeDigits(':');
252
		$string     = substr($this->bencoded, $this->pos, $len);
253
		$this->pos += $len;
254
255
		return $string;
256
	}
257
258
	/**
259
	* Encode an array into either an array of a dictionary
260
	*/
261
	protected static function encodeArray(array $value): string
262
	{
263
		if (empty($value))
264
		{
265
			return 'le';
266
		}
267
268
		if (array_keys($value) === range(0, count($value) - 1))
269
		{
270
			return 'l' . implode('', array_map([__CLASS__, 'encode'], $value)) . 'e';
271
		}
272
273
		// Encode associative arrays as dictionaries
274
		return self::encodeArrayObject(new ArrayObject($value));
275
	}
276
277
	/**
278
	* Encode given ArrayObject instance into a dictionary
279
	*/
280
	protected static function encodeArrayObject(ArrayObject $dict): string
281
	{
282
		$vars = $dict->getArrayCopy();
283
		ksort($vars);
284
285
		$str = 'd';
286
		foreach ($vars as $k => $v)
287
		{
288
			$str .= strlen($k) . ':' . $k . self::encode($v);
289
		}
290
		$str .= 'e';
291
292
		return $str;
293
	}
294
295
	/**
296
	* Encode a scalar value
297
	*/
298
	protected static function encodeScalar($value): string
299
	{
300
		if (is_int($value) || is_float($value) || is_bool($value))
301
		{
302
			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

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