Passed
Branch test (3b2ced)
by Josh
01:57
created

Decoder::checkDictionaryCompliance()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 3

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 4
nc 3
nop 3
dl 0
loc 7
ccs 5
cts 5
cp 1
crap 3
rs 10
c 1
b 0
f 0
1
<?php declare(strict_types=1);
2
3
/**
4
* @package   s9e\Bencode
5
* @copyright Copyright (c) 2014-2021 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 function str_contains, strcmp, strlen, strspn, substr;
12
use s9e\Bencode\Exceptions\ComplianceError;
13
use s9e\Bencode\Exceptions\DecodingException;
14
15
class Decoder
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 $offset = 0;
36
37 64
	public static function decode(string $bencoded): ArrayObject|array|int|string
38
	{
39 64
		$decoder = new static($bencoded);
40 53
		$value   = $decoder->decodeAnything();
41
42 28
		$decoder->checkCursorPosition();
43
44 20
		return $value;
45
	}
46
47 64
	protected function __construct(string $bencoded)
48
	{
49 64
		$this->bencoded = $bencoded;
50 64
		$this->len      = strlen($bencoded);
51
52 64
		if ($bencoded === '')
53
		{
54 1
			throw new DecodingException('Premature end of data', 0);
55
		}
56 63
		$this->computeSafeBoundary();
57 53
	}
58
59 63
	protected function checkBoundary(): void
60
	{
61 63
		if ($this->max < 1)
62
		{
63 10
			throw match ($this->bencoded[0])
64
			{
65 2
				'-', 'e' => new DecodingException('Illegal character', 0),
66 8
				default  => new DecodingException('Premature end of data', $this->len - 1)
67
			};
68
		}
69 53
	}
70
71
	/**
72
	* Check the cursor's position after decoding is done
73
	*/
74 28
	protected function checkCursorPosition(): void
75
	{
76 28
		if (false)
77
		{
78
			die('?');
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
79
		}
80 28
		if ($this->offset !== $this->len)
81
		{
82 8
			if ($this->offset > $this->len)
83
			{
84 3
				throw new DecodingException('Premature end of data', $this->len - 1);
85
			}
86
87 5
			$this->complianceError('Superfluous content', $this->offset);
88
		}
89 20
	}
90
91 7
	protected function checkDictionaryCompliance(int $offset, string $key, string $lastKey): void
92
	{
93 7
		$cmp = strcmp($key, $lastKey);
94 7
		if ($cmp <= 0)
95
		{
96 4
			$msg = ($cmp === 0) ? 'Duplicate' : 'Out of order';
97 4
			$this->complianceError($msg . " dictionary entry '" . $key . "'", $offset);
98
		}
99 3
	}
100
101 13
	protected function complianceError(string $message, int $offset): void
102
	{
103 13
		throw new ComplianceError($message, $offset);
104
	}
105
106
	/**
107
	* Adjust the rightmost boundary to the last safe character that can start a value
108
	*
109
	* Will rewind the boundary to skip the rightmost digits, optionally preceded by "i" or "i-"
110
	*/
111 63
	protected function computeSafeBoundary(): void
112
	{
113 63
		$boundary = $this->len - 1;
114
		do
115
		{
116 63
			$c = $this->bencoded[$boundary];
117
		}
118 63
		while (str_contains('0123456789', $c) && --$boundary >= 0);
119
120 63
		$this->max = match ($c)
121
		{
122 6
			'-'     => $boundary - 2,
123 6
			'i'     => $boundary - 1,
124 51
			default => $boundary
125
		};
126 63
		$this->checkBoundary();
127 53
	}
128
129 53
	protected function decodeAnything(): ArrayObject|array|int|string
130
	{
131 53
		return match ($this->bencoded[$this->offset])
132
		{
133 16
			'd'     => $this->decodeDictionary(),
134 19
			'i'     => $this->decodeInteger(),
135 13
			'l'     => $this->decodeList(),
136 43
			default => $this->decodeString()
137
		};
138
	}
139
140 16
	protected function decodeDictionary(): ArrayObject
141
	{
142 16
		$values  = [];
143 16
		$lastKey = null;
144
145 16
		++$this->offset;
146 16
		while ($this->offset <= $this->max)
147
		{
148 16
			if ($this->bencoded[$this->offset] === 'e')
149
			{
150 7
				++$this->offset;
151
152 7
				return new ArrayObject($values, ArrayObject::ARRAY_AS_PROPS);
153
			}
154
155 15
			$offset = $this->offset;
156 15
			$key    = $this->decodeString();
157 14
			if (isset($lastKey))
158
			{
159 7
				$this->checkDictionaryCompliance($offset, $key, $lastKey);
160
			}
161 14
			if ($this->offset > $this->max)
162
			{
163 1
				break;
164
			}
165 13
			$values[$key] = $this->decodeAnything();
166 10
			$lastKey      = $key;
167
		}
168
169 1
		throw new DecodingException('Premature end of data', $this->len - 1);
170
	}
171
172 46
	protected function decodeDigits(string $terminator): int
173
	{
174
		// Digits sorted by decreasing frequency as observed on a random sample of torrent files
175 46
		$spn = strspn($this->bencoded, '4615302879', $this->offset);
176 46
		if (!$spn)
177
		{
178 7
			throw new DecodingException('Illegal character', $this->offset);
179
		}
180 41
		if ($this->bencoded[$this->offset] === '0' && $spn !== 1)
181
		{
182 3
			$this->complianceError('Illegal character', 1 + $this->offset);
183
		}
184
185
		// Capture the value and cast it as an integer
186 38
		$value = (int) substr($this->bencoded, $this->offset, $spn);
187
188 38
		$this->offset += $spn;
189 38
		if ($this->bencoded[$this->offset] !== $terminator)
190
		{
191 5
			throw new DecodingException('Illegal character', $this->offset);
192
		}
193 33
		++$this->offset;
194
195 33
		return $value;
196
	}
197
198 19
	protected function decodeInteger(): int
199
	{
200 19
		$negative = ($this->bencoded[++$this->offset] === '-');
201 19
		if ($negative && $this->bencoded[++$this->offset] === '0')
202
		{
203 1
			$this->complianceError('Illegal character', $this->offset);
204
		}
205
206 18
		$value = $this->decodeDigits('e');
207
208 13
		return ($negative) ? -$value : $value;
209
	}
210
211 13
	protected function decodeList(): array
212
	{
213 13
		++$this->offset;
214
215 13
		$list = [];
216 13
		while ($this->offset <= $this->max)
217
		{
218 12
			if ($this->bencoded[$this->offset] === 'e')
219
			{
220 8
				++$this->offset;
221
222 8
				return $list;
223
			}
224
225 9
			$list[] = $this->decodeAnything();
226
		}
227
228 4
		throw new DecodingException('Premature end of data', $this->len - 1);
229
	}
230
231 36
	protected function decodeString(): string
232
	{
233 36
		$len           = $this->decodeDigits(':');
234 28
		$string        = substr($this->bencoded, $this->offset, $len);
235 28
		$this->offset += $len;
236
237 28
		return $string;
238
	}
239
}