186 lines
7 KiB
PHP
186 lines
7 KiB
PHP
<?php
|
|
// Bencode.php
|
|
// Created: 2022-01-13
|
|
// Updated: 2024-08-01
|
|
|
|
namespace Index\Bencode;
|
|
|
|
use InvalidArgumentException;
|
|
use RuntimeException;
|
|
use Index\IO\GenericStream;
|
|
use Index\IO\Stream;
|
|
use Index\IO\TempFileStream;
|
|
|
|
/**
|
|
* Provides a Bencode serialiser.
|
|
*/
|
|
final class Bencode {
|
|
/**
|
|
* Default maximum depth.
|
|
*
|
|
* @var int
|
|
*/
|
|
public const DEFAULT_DEPTH = 512;
|
|
|
|
/**
|
|
* Returns the Bencoded representation of a value.
|
|
*
|
|
* @param mixed $value The value being encoded. Can by string, integer, array or an object.
|
|
* @param int $depth Set the maximum depth. Must be greater than zero.
|
|
* @throws RuntimeException If $depth is an invalid value.
|
|
* @return string Returns a Bencoded string.
|
|
*/
|
|
public static function encode(mixed $value, int $depth = self::DEFAULT_DEPTH): string {
|
|
if($depth < 1)
|
|
throw new RuntimeException('Maximum depth reached, structure is too dense.');
|
|
|
|
switch(gettype($value)) {
|
|
case 'string':
|
|
return sprintf('%d:%s', strlen($value), $value);
|
|
|
|
case 'integer':
|
|
return sprintf('i%de', $value);
|
|
|
|
case 'array':
|
|
if(array_is_list($value)) {
|
|
$output = 'l';
|
|
foreach($value as $item)
|
|
$output .= self::encode($item, $depth - 1);
|
|
} else {
|
|
$output = 'd';
|
|
foreach($value as $_key => $_value) {
|
|
$output .= self::encode((string)$_key, $depth - 1);
|
|
$output .= self::encode($_value, $depth - 1);
|
|
}
|
|
}
|
|
|
|
return $output . 'e';
|
|
|
|
case 'object':
|
|
if($value instanceof IBencodeSerialisable)
|
|
return self::encode($value->bencodeSerialise(), $depth - 1);
|
|
|
|
$value = get_object_vars($value);
|
|
$output = 'd';
|
|
|
|
foreach($value as $_key => $_value) {
|
|
$output .= self::encode((string)$_key, $depth - 1);
|
|
$output .= self::encode($_value, $depth - 1);
|
|
}
|
|
|
|
return $output . 'e';
|
|
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decodes a bencoded string.
|
|
*
|
|
* @param mixed $bencoded The bencoded string being decoded. Can be an Index Stream, readable resource or a string.
|
|
* @param int $depth Maximum nesting depth of the structure being decoded. The value must be greater than 0.
|
|
* @param bool $associative When true, Bencoded dictionaries will be returned as associative arrays; when false, Bencoded dictionaries will be returned as objects.
|
|
* @throws InvalidArgumentException If $bencoded string is not set to a valid type.
|
|
* @throws InvalidArgumentException If $depth is not greater than 0.
|
|
* @throws RuntimeException If the Bencoded stream is in an invalid state.
|
|
* @return mixed Returns the bencoded value as an appropriate PHP type.
|
|
*/
|
|
public static function decode(mixed $bencoded, int $depth = self::DEFAULT_DEPTH, bool $associative = true): mixed {
|
|
if(is_string($bencoded)) {
|
|
$bencoded = TempFileStream::fromString($bencoded);
|
|
$bencoded->seek(0);
|
|
} elseif(is_resource($bencoded))
|
|
$bencoded = new GenericStream($bencoded);
|
|
elseif(!($bencoded instanceof Stream))
|
|
throw new InvalidArgumentException('$bencoded must be a string, an Index Stream or a file resource.');
|
|
|
|
if($depth < 1)
|
|
throw new InvalidArgumentException('Maximum depth reached, structure is too dense.');
|
|
|
|
$char = $bencoded->readChar();
|
|
if($char === null)
|
|
throw new RuntimeException('Unexpected end of stream in $bencoded.');
|
|
|
|
switch($char) {
|
|
case 'i':
|
|
$number = '';
|
|
|
|
for(;;) {
|
|
$char = $bencoded->readChar();
|
|
if($char === null)
|
|
throw new RuntimeException('Unexpected end of stream while parsing integer.');
|
|
if($char === 'e')
|
|
break;
|
|
if($char === '-' && $number !== '')
|
|
throw new RuntimeException('Unexpected - (minus) while parsing integer.');
|
|
if($char === '0' && $number === '-')
|
|
throw new RuntimeException('Negative integer zero.');
|
|
if($char === '0' && $number === '0')
|
|
throw new RuntimeException('Integer double zero.');
|
|
if(!ctype_digit($char))
|
|
throw new RuntimeException('Unexpected character while parsing integer.');
|
|
$number .= $char;
|
|
}
|
|
|
|
return (int)$number;
|
|
|
|
case 'l':
|
|
$list = [];
|
|
|
|
for(;;) {
|
|
$char = $bencoded->readChar();
|
|
if($char === null)
|
|
throw new RuntimeException('Unexpected end of stream while parsing list.');
|
|
if($char === 'e')
|
|
break;
|
|
$bencoded->seek(-1, Stream::CURRENT);
|
|
$list[] = self::decode($bencoded, $depth - 1, $associative);
|
|
}
|
|
|
|
return $list;
|
|
|
|
case 'd':
|
|
$dict = [];
|
|
|
|
for(;;) {
|
|
$char = $bencoded->readChar();
|
|
if($char === null)
|
|
throw new RuntimeException('Unexpected end of stream while parsing dictionary');
|
|
if($char === 'e')
|
|
break;
|
|
if(!ctype_digit($char))
|
|
throw new RuntimeException('Unexpected dictionary key type, expected a string.');
|
|
$bencoded->seek(-1, Stream::CURRENT);
|
|
$dict[self::decode($bencoded, $depth - 1, $associative)] = self::decode($bencoded, $depth - 1, $associative);
|
|
}
|
|
|
|
if(!$associative)
|
|
$dict = (object)$dict;
|
|
|
|
return $dict;
|
|
|
|
default:
|
|
if(!ctype_digit($char))
|
|
throw new RuntimeException('Unexpected character while parsing string.');
|
|
|
|
$length = $char;
|
|
|
|
for(;;) {
|
|
$char = $bencoded->readChar();
|
|
if($char === null)
|
|
throw new RuntimeException('Unexpected end of character while parsing string length.');
|
|
if($char === ':')
|
|
break;
|
|
if($char === '0' && $length === '0')
|
|
throw new RuntimeException('Integer double zero while parsing string length.');
|
|
if(!ctype_digit($char))
|
|
throw new RuntimeException('Unexpected character while parsing string length.');
|
|
|
|
$length .= $char;
|
|
}
|
|
|
|
return $bencoded->read((int)$length);
|
|
}
|
|
}
|
|
}
|