Passed
Push — DictionaryOrder ( d89a7c )
by Josh
02:55
created

Decoder::dictionaryError()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
eloc 4
c 0
b 0
f 0
nc 3
nop 3
dl 0
loc 9
ccs 5
cts 5
cp 1
crap 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 61
	public static function decode(string $bencoded)
37
	{
38 61
		$decoder = new static($bencoded);
39 50
		$value   = $decoder->decodeAnything();
40
41 26
		$decoder->checkCursorPosition();
42
43 18
		return $value;
44
	}
45
46 61
	protected function __construct(string $bencoded)
47
	{
48 61
		if ($bencoded === '')
49
		{
50 1
			throw new InvalidArgumentException;
51
		}
52
53 60
		$this->bencoded = $bencoded;
54 60
		$this->len      = strlen($bencoded);
55 60
		$this->pos      = 0;
56
57 60
		$this->computeSafeBoundary();
58
	}
59
60 60
	protected function checkBoundary(): void
61
	{
62 60
		if ($this->max < 1)
63
		{
64 10
			if (strpos('-e', $this->bencoded[0]) !== false)
65
			{
66 2
				throw new RuntimeException('Illegal character found at offset 0');
67
			}
68
69 8
			throw new RuntimeException('Premature end of data');
70
		}
71
	}
72
73
	/**
74
	* Check the cursor's position after decoding is done
75
	*/
76 26
	protected function checkCursorPosition(): void
77
	{
78 26
		if ($this->pos !== $this->len)
79
		{
80 8
			if ($this->pos > $this->len)
81
			{
82 3
				throw new RuntimeException('Premature end of data');
83
			}
84
85 5
			$this->complianceError('Superfluous content found at offset ' . $this->pos);
86
		}
87
	}
88
89 12
	protected function complianceError(string $message): void
90
	{
91 12
		throw new RuntimeException($message);
92
	}
93
94
	/**
95
	* Adjust the rightmost boundary to the last safe character that can start a value
96
	*
97
	* Will rewind the boundary to skip the rightmost digits, optionally preceded by "i" or "i-"
98
	*/
99 60
	protected function computeSafeBoundary(): void
100
	{
101 60
		$boundary = $this->len - 1;
102 60
		$c = $this->bencoded[$boundary];
103 60
		while (is_numeric($c) && --$boundary >= 0)
104
		{
105 7
			$c = $this->bencoded[$boundary];
106
		}
107 60
		if ($c === '-')
108
		{
109 6
			$boundary -= 2;
110
		}
111 54
		elseif ($c === 'i')
112
		{
113 6
			--$boundary;
114
		}
115
116 60
		$this->max = $boundary;
117 60
		$this->checkBoundary();
118
	}
119
120 50
	protected function decodeAnything()
121
	{
122 50
		$c = $this->bencoded[$this->pos];
123 50
		if ($c === 'i')
124
		{
125 17
			return $this->decodeInteger();
126
		}
127 41
		if ($c === 'd')
128
		{
129 15
			return $this->decodeDictionary();
130
		}
131 32
		if ($c === 'l')
132
		{
133 12
			return $this->decodeList();
134
		}
135
136 25
		return $this->decodeString();
137
	}
138
139 15
	protected function decodeDictionary(): ArrayObject
140
	{
141 15
		$values  = [];
142 15
		$lastKey = null;
143
144 15
		++$this->pos;
145 15
		while ($this->pos <= $this->max)
146
		{
147 15
			if ($this->bencoded[$this->pos] === 'e')
148
			{
149 7
				++$this->pos;
150
151 7
				return new ArrayObject($values, ArrayObject::ARRAY_AS_PROPS);
152
			}
153
154 14
			$pos = $this->pos;
155 14
			$key = $this->decodeString();
156 13
			if ($key <= $lastKey)
157
			{
158 4
				$this->dictionaryError($pos, $key, $lastKey);
159
			}
160 13
			if ($this->pos > $this->max)
161
			{
162 1
				break;
163
			}
164 12
			$values[$key] = $this->decodeAnything();
165 9
			$lastKey      = $key;
166
		}
167
168 1
		throw new RuntimeException('Premature end of data');
169
	}
170
171 43
	protected function decodeDigits(string $terminator): int
172
	{
173
		// Digits sorted by decreasing frequency as observed on a random sample of torrent files
174 43
		$spn = strspn($this->bencoded, '4615302879', $this->pos);
175 43
		if (!$spn)
176
		{
177 7
			throw new RuntimeException('Illegal character found at offset ' . $this->pos);
178
		}
179 38
		if ($this->bencoded[$this->pos] === '0' && $spn > 1)
180
		{
181 3
			$this->complianceError('Illegal character found at offset ' . (1 + $this->pos));
182
		}
183
184
		// Capture the value and cast it as an integer
185 35
		$value = (int) substr($this->bencoded, $this->pos, $spn);
186
187 35
		$this->pos += $spn;
188 35
		if ($this->bencoded[$this->pos] !== $terminator)
189
		{
190 5
			throw new RuntimeException('Illegal character found at offset ' . $this->pos);
191
		}
192 30
		++$this->pos;
193
194 30
		return $value;
195
	}
196
197 17
	protected function decodeInteger(): int
198
	{
199 17
		$negative = ($this->bencoded[++$this->pos] === '-');
200 17
		if ($negative && $this->bencoded[++$this->pos] === '0')
201
		{
202 1
			$this->complianceError('Illegal character found at offset ' . $this->pos);
203
		}
204
205 16
		$value = $this->decodeDigits('e');
206
207 11
		return ($negative) ? -$value : $value;
208
	}
209
210 12
	protected function decodeList(): array
211
	{
212 12
		++$this->pos;
213
214 12
		$list = [];
215 12
		while ($this->pos <= $this->max)
216
		{
217 11
			if ($this->bencoded[$this->pos] === 'e')
218
			{
219 7
				++$this->pos;
220
221 7
				return $list;
222
			}
223
224 8
			$list[] = $this->decodeAnything();
225
		}
226
227 4
		throw new RuntimeException('Premature end of data');
228
	}
229
230 34
	protected function decodeString(): string
231
	{
232 34
		$len        = $this->decodeDigits(':');
233 26
		$string     = substr($this->bencoded, $this->pos, $len);
234 26
		$this->pos += $len;
235
236 26
		return $string;
237
	}
238
239 4
	protected function dictionaryError(int $pos, string $key, ?string $lastKey)
240
	{
241 4
		if ($key === $lastKey)
242
		{
243 2
			$this->complianceError("Duplicate dictionary entry '" . $key . "' at pos " . $pos);
244
		}
245 2
		elseif ($key < $lastKey)
246
		{
247 1
			$this->complianceError("Out-of-order dictionary entry '" . $key . "' at pos " . $pos);
248
		}
249
	}
250
}