Passed
Push — 2.0/master ( 9cf05b...3441b5 )
by Josh
01:59
created

Decoder::complianceError()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 1
dl 0
loc 3
rs 10
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->complianceError('Superfluous content found at offset ' . $this->pos);
83
		}
84
	}
85
86
	protected function complianceError(string $message): void
87
	{
88
		throw new RuntimeException($message);
89
	}
90
91
	/**
92
	* Adjust the rightmost boundary to the last safe character
93
	*
94
	* Will rewind the boundary to skip the rightmost digits, optionally preceded by "i" or "i-"
95
	*/
96
	protected function computeSafeBoundary(): void
97
	{
98
		$boundary = $this->len - 1;
99
		$c = $this->bencoded[$boundary];
100
		while (is_numeric($c) && --$boundary >= 0)
101
		{
102
			$c = $this->bencoded[$boundary];
103
		}
104
		if ($c === '-')
105
		{
106
			$boundary -= 2;
107
		}
108
		elseif ($c === 'i')
109
		{
110
			--$boundary;
111
		}
112
113
		$this->max = $boundary;
114
		$this->checkBoundary();
115
	}
116
117
	protected function decodeAnything()
118
	{
119
		$c = $this->bencoded[$this->pos];
120
		if ($c === 'i')
121
		{
122
			return $this->decodeInteger();
123
		}
124
		if ($c === 'd')
125
		{
126
			return $this->decodeDictionary();
127
		}
128
		if ($c === 'l')
129
		{
130
			return $this->decodeList();
131
		}
132
133
		return $this->decodeString();
134
	}
135
136
	protected function decodeDictionary(): ArrayObject
137
	{
138
		$dictionary = new ArrayObject;
139
		$dictionary->setFlags(ArrayObject::ARRAY_AS_PROPS);
140
141
		++$this->pos;
142
		while ($this->pos <= $this->max)
143
		{
144
			if ($this->bencoded[$this->pos] === 'e')
145
			{
146
				++$this->pos;
147
148
				return $dictionary;
149
			}
150
151
			$pos = $this->pos;
152
			$key = $this->decodeString();
153
			if (isset($dictionary->$key))
154
			{
155
				$this->complianceError("Duplicate dictionary entry '" . $key . "' at pos " . $pos);
156
			}
157
			if ($this->pos > $this->max)
158
			{
159
				break;
160
			}
161
			$dictionary->$key = $this->decodeAnything();
162
		}
163
164
		throw new RuntimeException('Premature end of data');
165
	}
166
167
	protected function decodeDigits(string $terminator): int
168
	{
169
		// Digits sorted by decreasing frequency as observed on a random sample of torrent files
170
		$spn = strspn($this->bencoded, '4615302879', $this->pos);
171
		if (!$spn)
172
		{
173
			throw new RuntimeException('Illegal character found at offset ' . $this->pos);
174
		}
175
		if ($this->bencoded[$this->pos] === '0' && $spn > 1)
176
		{
177
			$this->complianceError('Illegal character found at offset ' . (1 + $this->pos));
178
		}
179
180
		// Capture the value and cast it as an integer
181
		$value = (int) substr($this->bencoded, $this->pos, $spn);
182
183
		$this->pos += $spn;
184
		if ($this->bencoded[$this->pos] !== $terminator)
185
		{
186
			throw new RuntimeException('Illegal character found at offset ' . $this->pos);
187
		}
188
		++$this->pos;
189
190
		return $value;
191
	}
192
193
	protected function decodeInteger(): int
194
	{
195
		$negative = ($this->bencoded[++$this->pos] === '-');
196
		if ($negative && $this->bencoded[++$this->pos] === '0')
197
		{
198
			$this->complianceError('Illegal character found at offset ' . $this->pos);
199
		}
200
201
		$value = $this->decodeDigits('e');
202
203
		return ($negative) ? -$value : $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
		$len        = $this->decodeDigits(':');
229
		$string     = substr($this->bencoded, $this->pos, $len);
230
		$this->pos += $len;
231
232
		return $string;
233
	}
234
}