Completed
Push — 2.0/master ( 70e996...99fa9a )
by Josh
02:07
created

Bencode::decodeDictionary()   A

Complexity

Conditions 5
Paths 6

Size

Total Lines 29
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

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