<?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); } } }