index/src/Bencode/Bencode.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);
}
}
}