Completed
Push — 2.0/master ( 68076f...9cf05b )
by Josh
01:40
created

Decoder::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
14
class Decoder
15
{
16
	/**
17
	* @var string Bencoded string being decoded
18
	*/
19
	protected string $bencoded;
20
21
	/**
22
	* @var int Length of the bencoded string
23
	*/
24
	protected int $len;
25
26
	/**
27
	* @var int Safe rightmost boundary
28
	*/
29
	protected int $max;
30
31
	/**
32
	* @var int Position of the cursor while decoding
33
	*/
34
	protected int $pos;
35
36
	protected function __construct(string $bencoded)
37
	{
38
		if ($bencoded === '')
39
		{
40
			throw new InvalidArgumentException;
41
		}
42
43
		$this->bencoded = $bencoded;
44
		$this->len      = strlen($bencoded);
45
		$this->pos      = 0;
46
47
		$this->computeSafeBoundary();
48
	}
49
50
	public static function decode(string $bencoded)
51
	{
52
		$decoder = new static($bencoded);
53
		$value   = $decoder->decodeAnything();
54
55
		$decoder->checkCursorPosition();
56
57
		return $value;
58
	}
59
60
	protected function checkBoundary(): void
61
	{
62
		if ($this->max < 1)
63
		{
64
			if (strpos('-e', $this->bencoded[0]) !== false)
65
			{
66
				throw new RuntimeException('Illegal character found at offset 0');
67
			}
68
69
			throw new RuntimeException('Premature end of data');
70
		}
71
	}
72
73
	protected function checkCursorPosition(): void
74
	{
75
		if ($this->pos !== $this->len)
76
		{
77
			if ($this->pos > $this->len)
78
			{
79
				throw new RuntimeException('Premature end of data');
80
			}
81
82
			$this->nonFatalError('Superfluous content found at offset ' . $this->pos);
83
		}
84
	}
85
86
	/**
87
	* Adjust the rightmost boundary to the last safe character
88
	*
89
	* Will rewind the boundary to skip the rightmost digits, optionally preceded by "i" or "i-"
90
	*/
91
	protected function computeSafeBoundary(): void
92
	{
93
		$boundary = $this->len - 1;
94
		$c = $this->bencoded[$boundary];
95
		while (is_numeric($c) && --$boundary >= 0)
96
		{
97
			$c = $this->bencoded[$boundary];
98
		}
99
		if ($c === '-')
100
		{
101
			$boundary -= 2;
102
		}
103
		elseif ($c === 'i')
104
		{
105
			--$boundary;
106
		}
107
108
		$this->max = $boundary;
109
		$this->checkBoundary();
110
	}
111
112
	protected function decodeAnything()
113
	{
114
		$c = $this->bencoded[$this->pos];
115
		if ($c === 'i')
116
		{
117
			return $this->decodeInteger();
118
		}
119
		if ($c === 'd')
120
		{
121
			return $this->decodeDictionary();
122
		}
123
		if ($c === 'l')
124
		{
125
			return $this->decodeList();
126
		}
127
128
		return $this->decodeString();
129
	}
130
131
	protected function decodeDictionary(): ArrayObject
132
	{
133
		$dictionary = new ArrayObject;
134
		$dictionary->setFlags(ArrayObject::ARRAY_AS_PROPS);
135
136
		++$this->pos;
137
		while ($this->pos <= $this->max)
138
		{
139
			if ($this->bencoded[$this->pos] === 'e')
140
			{
141
				++$this->pos;
142
143
				return $dictionary;
144
			}
145
146
			$pos = $this->pos;
147
			$key = $this->decodeString();
148
			if (isset($dictionary->$key))
149
			{
150
				$this->nonFatalError("Duplicate dictionary entry '" . $key . "' at pos " . $pos);
151
			}
152
			if ($this->pos > $this->max)
153
			{
154
				break;
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
		// Digits sorted by decreasing frequency as observed on a random sample of torrent files
165
		$spn = strspn($this->bencoded, '4615302879', $this->pos);
166
		if (!$spn)
167
		{
168
			throw new RuntimeException('Illegal character found at offset ' . $this->pos);
169
		}
170
		elseif ($spn > 1 && $this->bencoded[$this->pos] === '0')
171
		{
172
			$this->nonFatalError('Illegal character found at offset ' . $this->pos);
173
		}
174
175
		// Capture the value and cast it as an integer
176
		$value = (int) substr($this->bencoded, $this->pos, $spn);
177
178
		$this->pos += $spn;
179
		if ($this->bencoded[$this->pos] !== $terminator)
180
		{
181
			throw new RuntimeException('Illegal character found at offset ' . $this->pos);
182
		}
183
		++$this->pos;
184
185
		return $value;
186
	}
187
188
	protected function decodeInteger(): int
189
	{
190
		$negative = ($this->bencoded[++$this->pos] === '-');
191
		if ($negative && $this->bencoded[++$this->pos] === '0')
192
		{
193
			$this->nonFatalError('Illegal character found at offset ' . $this->pos);
194
		}
195
196
		$value = $this->decodeDigits('e');
197
198
		return ($negative) ? -$value : $value;
199
	}
200
201
	protected function decodeList(): array
202
	{
203
		++$this->pos;
204
205
		$list = [];
206
		while ($this->pos <= $this->max)
207
		{
208
			if ($this->bencoded[$this->pos] === 'e')
209
			{
210
				++$this->pos;
211
212
				return $list;
213
			}
214
215
			$list[] = $this->decodeAnything();
216
		}
217
218
		throw new RuntimeException('Premature end of data');
219
	}
220
221
	protected function decodeString(): string
222
	{
223
		$len        = $this->decodeDigits(':');
224
		$string     = substr($this->bencoded, $this->pos, $len);
225
		$this->pos += $len;
226
227
		return $string;
228
	}
229
230
	protected function nonFatalError(string $message): void
231
	{
232
		throw new RuntimeException($message);
233
	}
234
}