├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── docs ├── decoding.rst ├── decoding_callback.rst ├── encoding.rst ├── index.rst └── upgrade.rst └── src ├── Bencode.php ├── Bencode ├── BigInt.php └── Collection.php ├── CallbackDecoder.php ├── Decoder.php ├── Encoder.php ├── Engine ├── CallbackReader.php ├── Reader.php └── Writer.php ├── Exceptions ├── BencodeException.php ├── FileNotReadableException.php ├── FileNotWritableException.php ├── InvalidArgumentException.php ├── ParseErrorException.php ├── RuntimeException.php └── ValueNotSerializableException.php ├── Types ├── BencodeSerializable.php ├── BigIntType.php ├── CallbackHandler.php ├── DictType.php ├── IterableTypeTrait.php └── ListType.php └── Util └── IntUtil.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 4.x 4 | 5 | ### 4.3.1 6 | 7 | *May 20, 2025* 8 | 9 | * Fixed an issue where dictType may resolve to an empty iterable when not iterated immediately 10 | 11 | ### 4.3.0 12 | 13 | *Sep 25, 2024* 14 | 15 | * BcMath\Number support for encoding and decoding 16 | * Encoder now throws instances of `ValueNotSerializableException` (subclass of `UnexpectedValueException`) 17 | 18 | ### 4.2.1 19 | 20 | *Jul 28, 2024* 21 | 22 | * Exception fixes: 23 | * Using $options array now throws `BadFunctionCallException` 24 | * `ParseErrorException` reclassified to `UnexpectedValueException` (runtime instead of logic) 25 | 26 | ### 4.2.0 27 | 28 | *Mar 9, 2024* 29 | 30 | * Callback Decoder (proposed in [gitlab#2]) 31 | 32 | [gitlab#2]: https://gitlab.com/sandfox/bencode/-/issues/2 33 | 34 | ### 4.1.0 35 | 36 | *Jul 14, 2023* 37 | 38 | * Removed implicit conversion of floats to strings. 39 | Since it was unreliable, it's not considered a BC break. 40 | 41 | ### 4.0.0 42 | 43 | *Dec 14, 2022* 44 | 45 | 4.0.0 was branched from 3.1.0 46 | 47 | * The package is now `arokettu/bencode` 48 | * The namespace is now `Arokettu\Bencode\ ` 49 | * New class names can be used with old branches (1.8+, 2.8+, 3.1+) 50 | * $options arrays now generate an exception if they are not empty 51 | * The parameters are kept for param order compatibility 52 | * Passing class names to `listType`, `dictType`, and `bigInt` generates a TypeError 53 | * `listType`, `dictType`, and `bigInt` callbacks receive `iterable` instead of `array` 54 | * Dictionaries are converted to ArrayObject by default 55 | 56 | ## 3.x 57 | 58 | ### 3.1.1 59 | 60 | *Dec 14, 2022* 61 | 62 | * `sandfoxme/bencode` is now provided by the package 63 | 64 | ### 3.1.0 65 | 66 | *Dec 13, 2022* 67 | 68 | * $options arrays are deprecated 69 | * Passing class names to `listType`, `dictType`, and `bigInt` is deprecated 70 | * Aliased all classes in `SandFox\Bencode\*` to `Arokettu\Bencode\*` in preparation for 4.0 71 | 72 | ### 3.0.3 73 | 74 | *Oct 24, 2021* 75 | 76 | * dump() now throws exception if the file is not writable 77 | * load() now throws exception if the file is not readable 78 | 79 | ### 3.0.2 80 | 81 | *Oct 23, 2021* 82 | 83 | * Objects serialized to empty values are now allowed on non-root levels 84 | 85 | ### 3.0.1 86 | 87 | *Sep 25, 2021* 88 | 89 | * Future compatible stream check 90 | 91 | ### 3.0.0 92 | 93 | *Sep 17, 2021* 94 | 95 | 3.0.0 was branched from 2.6.1 96 | 97 | * PHP 8.1 is required 98 | * Decoding: 99 | * Removed deprecated options: ``dictionaryType`` (use ``dictType``), ``useGMP`` (use ``bigInt: Bencode\BigInt::GMP``) 100 | * ``Bencode\BigInt`` and ``Bencode\Collection`` are now enums, 101 | therefore ``dictType``, ``listType``, ``bigInt`` params no longer accept bare string values 102 | (like ``'array'`` or ``'object'`` or ``'gmp'``). 103 | * Encoding: 104 | * Traversables no longer become dictionaries by default. 105 | You need to wrap them with ``DictType``. 106 | * Stringables no longer become strings by default. 107 | Use ``useStringable: true`` to return old behavior. 108 | * ``dump($filename, $data)`` became ``dump($data, $filename)`` for consistency with streams. 109 | * `Decoder` and `Encoder` objects that can be pre-configured and then used with consistent options. 110 | * `bencodeSerialize` now declares `mixed` return type 111 | 112 | ## 2.x 113 | 114 | ### 2.8.1 115 | 116 | *Dec 14, 2022* 117 | 118 | * `sandfoxme/bencode` is now provided by the package 119 | 120 | ### 2.8.0 121 | 122 | *Dec 13, 2022* 123 | 124 | * Alias all classes in `SandFox\Bencode\*` to `Arokettu\Bencode\*` in preparation for 4.0 125 | 126 | ### 2.7.4 127 | 128 | *Nov 30, 2021* 129 | 130 | * symfony/contracts v3 is now allowed 131 | 132 | ### 2.7.3 133 | 134 | *Oct 24, 2021* 135 | 136 | * dump() now throws exception if the file is not writable 137 | * load() now throws exception if the file is not readable 138 | 139 | ### 2.7.2 140 | 141 | *Oct 23, 2021* 142 | 143 | * Objects serialized to empty values are now allowed on non-root levels 144 | 145 | ### 2.7.1 146 | 147 | *Sep 25, 2021* 148 | 149 | * Future compatible stream check 150 | 151 | ### 2.7.0 152 | 153 | *Sep 17, 2021* 154 | 155 | * Decoder and Encoder are backported from 3.x 156 | * `DictType` backported from 3.x 157 | * `useJsonSerializable` backported from 3.x 158 | * `useGMP` is marked as deprecated 159 | * Fixed `'useGMP'` in options array causing crash 160 | 161 | ### 2.6.1 162 | 163 | *Sep 10, 2021* 164 | 165 | * Fixed possible invalid dictionary encoding when traversable returns non unique keys 166 | 167 | ### 2.6.0 168 | 169 | *Feb 14, 2021* 170 | 171 | * Expanded big integer support: 172 | * `brick/math` 173 | * `Math_BigInteger` 174 | * Custom BigIntType numeric string wrapper 175 | * Callback and custom class name 176 | 177 | ### 2.5.0 178 | 179 | *Feb 3, 2021* 180 | 181 | * Added stream API 182 | * Added GMP support 183 | 184 | ### 2.4.0 185 | 186 | *Nov 10, 2020* 187 | 188 | * Make spec compliant BitTorrent code simpler: `null` and `false` values are now skipped on encoding 189 | * Remove deprecation warning for options array 190 | 191 | ### 2.3.0 192 | 193 | *Oct 4, 2020* 194 | 195 | * Shorten `dictionaryType` to `dictType`. `dictionaryType` will be removed in 3.0 196 | * Trigger silent deprecations for deprecated stuff 197 | 198 | ### 2.2.0 199 | 200 | *Oct 3, 2020* 201 | 202 | * Update `dump()` and `load()` signatures to match `encode()` and `decode()` 203 | 204 | ### 2.1.0 205 | 206 | *Aug 5, 2020* 207 | 208 | * Replace Becnode::decode() options array with named parameters. 209 | Options array is now deprecated and will be removed in 3.0 210 | * Engine optimizations 211 | 212 | ### 2.0.0 213 | 214 | *Jun 30, 2020* 215 | 216 | 2.0.0 was branched from 1.3.0 217 | 218 | * PHP 8 is required 219 | * Legacy namespace `SandFoxMe\Bencode` is removed 220 | * Encode now throws an error if it encounters a value that cannot be serialized 221 | 222 | ## 1.x 223 | 224 | ### 1.8.1 225 | 226 | *Dec 14, 2022* 227 | 228 | * `sandfoxme/bencode` is now provided by the package 229 | 230 | ### 1.8.0 231 | 232 | *Dec 13, 2022* 233 | 234 | * Alias all classes in `SandFox\Bencode\*` to `Arokettu\Bencode\*` in preparation for 4.0 235 | 236 | ### 1.7.3 237 | 238 | *Oct 24, 2021* 239 | 240 | * dump() now throws exception if the file is not writable 241 | * load() now throws exception if the file is not readable 242 | 243 | ### 1.7.2 244 | 245 | *Oct 23, 2021* 246 | 247 | * Objects serialized to empty values are now allowed on non-root levels 248 | 249 | ### 1.7.1 250 | 251 | *Sep 25, 2021* 252 | 253 | * Future compatible stream check 254 | 255 | ### 1.7.0 256 | 257 | *Sep 17, 2021* 258 | 259 | * Decoder and Encoder are backported from 3.x 260 | * `DictType` backported from 3.x 261 | * `useJsonSerializable` backported from 3.x 262 | 263 | ### 1.6.2 264 | 265 | *Sep 10, 2021* 266 | 267 | * Fixed possible invalid dictionary encoding when traversable returns non unique keys 268 | 269 | ### 1.6.1 270 | 271 | *Feb 14, 2021* 272 | 273 | * Fixed invalid `BigIntType::assertValidInteger` visibility 274 | * Added missing `@internal` and strict type markings 275 | 276 | ### 1.6.0 277 | 278 | *Feb 14, 2021* 279 | 280 | * Expanded big integer support: 281 | * `brick/math` 282 | * `Math_BigInteger` 283 | * Custom BigIntType numeric string wrapper 284 | * Callback and custom class name 285 | 286 | ### 1.5.0 287 | 288 | *Feb 3, 2021* 289 | 290 | * Added stream API 291 | * Added GMP support 292 | 293 | ### 1.4.0 294 | 295 | *Nov 10, 2020* 296 | 297 | * Made spec compliant BitTorrent code simpler: `null` and `false` values are now skipped on encoding 298 | * Added `'dictType'` alias for `'dictionaryType'` for 2.3 compatibility 299 | 300 | ### 1.3.0 301 | 302 | *Feb 14, 2019* 303 | 304 | * Increased parser speed and reduced memory consumption 305 | * Base namespace is now `SandFox\Bencode`. Compatibility is kept for now 306 | * Fixed tests for PHP 8 307 | 308 | ### 1.2.0 309 | 310 | *Feb 14, 2018* 311 | 312 | * Added `BencodeSerializable` interface 313 | 314 | ### 1.1.2 315 | 316 | *Dec 12, 2017* 317 | 318 | * Throw a Runtime Exception when trying to use the library with Mbstring Function Overloading on 319 | 320 | ### 1.1.1 321 | 322 | *Mar 30, 2017* 323 | 324 | * ListType can now wrap arrays 325 | 326 | ### 1.1.0 327 | 328 | *Mar 29, 2017* 329 | 330 | * boolean is now converted to integer 331 | * `Bencode::dump` now returns success as boolean 332 | * Fixed: decoded junk at the end of the string replaced entire parsed data if it also was valid bencode 333 | * PHP 7.0 is now required instead of PHP 7.1 334 | * Tests! 335 | 336 | ### 1.0.1 337 | 338 | *Mar 22, 2017* 339 | 340 | * Added stdClass as list/dict decoding option 341 | 342 | ### 1.0.0 343 | 344 | *Mar 22, 2017* 345 | 346 | Initial release 347 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright © 2017 Anton Smirnov 5 | 6 | Permission is hereby granted, free of charge, to any person 7 | obtaining a copy of this software and associated documentation 8 | files (the “Software”), to deal in the Software without 9 | restriction, including without limitation the rights to use, 10 | copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. 26 | 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP Bencode Encoder/Decoder 2 | 3 | [![Packagist](https://img.shields.io/packagist/v/arokettu/bencode.svg?style=flat-square)](https://packagist.org/packages/arokettu/bencode) 4 | [![PHP](https://img.shields.io/packagist/php-v/arokettu/bencode.svg?style=flat-square)](https://packagist.org/packages/arokettu/bencode) 5 | [![Packagist](https://img.shields.io/github/license/arokettu/bencode.svg?style=flat-square)](https://opensource.org/licenses/MIT) 6 | [![Gitlab pipeline status](https://img.shields.io/gitlab/pipeline/sandfox/bencode/master.svg?style=flat-square)](https://gitlab.com/sandfox/bencode/-/pipelines) 7 | [![Codecov](https://img.shields.io/codecov/c/gl/sandfox/bencode?style=flat-square)](https://codecov.io/gl/sandfox/bencode/) 8 | 9 | [Bencode] is the encoding used by the peer-to-peer file sharing system 10 | [BitTorrent] for storing and transmitting loosely structured data. 11 | 12 | This is a pure PHP library that allows you to encode and decode Bencode data. 13 | 14 | ## Installation 15 | 16 | ```bash 17 | composer require 'arokettu/bencode' 18 | ``` 19 | 20 | Supported versions: 21 | 22 | * 4.x (current, PHP 8.1+) 23 | 24 | ## Simple use 25 | 26 | ```php 27 | ['length' => 12345, 'name' => 'Bencoded demo']]); 30 | \Arokettu\Bencode\Bencode::decode('d4:infod6:lengthi12345e4:name13:Bencoded demoee'); 31 | ``` 32 | 33 | ## Documentation 34 | 35 | Read full documentation here: 36 | 37 | Documentation for all past major versions can be found on Read the Docs: 38 | 39 | * 1.x: 40 | * 2.x: 41 | * 3.x: 42 | * 4.x: 43 | 44 | ## Support 45 | 46 | Please file issues on our main repo at GitLab: 47 | 48 | Feel free to ask any questions in our room on Gitter: 49 | 50 | ## License 51 | 52 | The library is available as open source under the terms of the [MIT License]. 53 | 54 | [Bencode]: https://en.wikipedia.org/wiki/Bencode 55 | [BitTorrent]: https://en.wikipedia.org/wiki/BitTorrent 56 | [MIT License]: https://opensource.org/licenses/MIT 57 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "arokettu/bencode", 3 | "description": "BitTorrent's Bencode encoder/decoder", 4 | "keywords": ["bittorrent", "torrent", "bencode", "serialize"], 5 | "homepage": "https://sandfox.dev/php/bencode.html", 6 | "type": "library", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Anton Smirnov", 11 | "role": "developer", 12 | "homepage": "https://sandfox.me", 13 | "email": "sandfox@sandfox.me" 14 | } 15 | ], 16 | "support": { 17 | "issues": "https://gitlab.com/sandfox/bencode/-/issues", 18 | "source": "https://gitlab.com/sandfox/bencode", 19 | "docs": "https://bencode.readthedocs.io/", 20 | "chat": "https://gitter.im/arokettu/community" 21 | }, 22 | "config": { 23 | "allow-plugins": { 24 | "dealerdirect/phpcodesniffer-composer-installer": true 25 | }, 26 | "sort-packages": true 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "Arokettu\\Bencode\\": "src" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "Arokettu\\Bencode\\Tests\\": "tests" 36 | } 37 | }, 38 | "require": { 39 | "php": "^8.1", 40 | "arokettu/is-resource": "^1.0" 41 | }, 42 | "require-dev": { 43 | "ext-bcmath": "*", 44 | "ext-gmp": "*", 45 | "ext-json": "*", 46 | "brick/math": "*", 47 | "mikey179/vfsstream": "^1.6.11", 48 | "pear/math_biginteger": "^1.0", 49 | "phpunit/phpunit": "^10.5.28", 50 | "psy/psysh": "*", 51 | "sandfox.dev/code-standard": "^1.2025.05.07", 52 | "squizlabs/php_codesniffer": "*", 53 | "vimeo/psalm": "^6" 54 | }, 55 | "suggest": { 56 | "php-64bit": "Running 64 bit is recommended to prevent integer overflow", 57 | "ext-gmp": "In case you need integers larger than your architecture supports", 58 | "ext-bcmath": "In case you need integers larger than your architecture supports (PHP 8.4 or later)", 59 | "brick/math": "In case you need integers larger than your architecture supports", 60 | "pear/math_biginteger": "In case you need integers larger than your architecture supports" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /docs/decoding.rst: -------------------------------------------------------------------------------- 1 | Decoding 2 | ######## 3 | 4 | .. highlight:: php 5 | .. versionchanged:: 2.0 options array is replaced with named parameters 6 | .. note:: Parameter order is not guaranteed for options, use named parameters 7 | 8 | Scalars 9 | ======= 10 | 11 | Scalars will be converted to their respective types:: 12 | 13 | [1,2,3,4], 27 | // "bool" => 1, 28 | // "int" => 123, 29 | // "string" => "test\0test", 30 | // ] 31 | 32 | Please note that booleans will stay converted because Bencode has no native support for these types. 33 | 34 | Lists and Dictionaries 35 | ====================== 36 | 37 | .. versionchanged:: 3.0 ``Collection`` is now a real enum 38 | .. versionchanged:: 4.0 Passing class names as handlers was removed 39 | 40 | Dictionaries and lists will be arrays by default. 41 | You can change this behavior with options. 42 | Use ``Collection`` enum for built in behaviors:: 43 | 44 | = 4GB file on a 32 bit system, 84 | you can enable big integer support. 85 | 86 | External Libraries 87 | ------------------ 88 | 89 | .. versionadded:: 1.5/2.5 GMP support 90 | .. versionadded:: 1.6/2.6 Pear's Math_BigInteger, brick/math 91 | .. versionchanged:: 3.0 ``BigInt`` is now a real enum 92 | .. versionadded:: 4.3 BCMath support 93 | 94 | .. important:: 95 | These math libraries are not explicit dependencies of this library. 96 | Install them separately before enabling. 97 | 98 | Supported libraries: 99 | 100 | * `GNU Multiple Precision PHP Extension `_ 101 | * BCMath_ (PHP 8.4+) 102 | * `brick/math`_ 103 | * PEAR's `Math_BigInteger`_ 104 | 105 | :: 106 | 107 | gmp_init( 117 | // '79228162514264337593543950336' 118 | // )] 119 | 120 | // BCMath 121 | $data = Bencode::decode( 122 | "d3:inti79228162514264337593543950336ee", 123 | bigInt: Bencode\BigInt::BCMATH, 124 | ); 125 | // ['int' => new \BcMath\Number( 126 | // '79228162514264337593543950336' 127 | // )] 128 | 129 | // brick/math 130 | $data = Bencode::decode( 131 | "d3:inti79228162514264337593543950336ee", 132 | bigInt: Bencode\BigInt::BRICK_MATH, 133 | ); 134 | // ['int' => \Brick\Math\BigInteger::of( 135 | // '79228162514264337593543950336' 136 | // )] 137 | 138 | // Math_BigInteger from PEAR 139 | $data = Bencode::decode( 140 | "d3:inti79228162514264337593543950336ee", 141 | bigInt: Bencode\BigInt::PEAR, 142 | ); 143 | // ['int' => new \Math_BigInteger( 144 | // '79228162514264337593543950336' 145 | // )] 146 | 147 | .. _GMP: https://www.php.net/manual/en/book.gmp.php 148 | .. _brick/math: https://github.com/brick/math 149 | .. _Math_BigInteger: https://pear.php.net/package/Math_BigInteger 150 | .. _BCMath: https://www.php.net/manual/en/book.bc.php 151 | 152 | Internal Type 153 | ------------- 154 | 155 | .. versionadded:: 1.6/2.6 156 | 157 | The library also has built in ``BigIntType``. 158 | It does not require any external dependencies but also does not allow any manipulation:: 159 | 160 | new \Arokettu\Bencode\Types\BigIntType( 169 | // '79228162514264337593543950336' 170 | // )] 171 | 172 | BigIntType is a value object with several getters:: 173 | 174 | value; // readonly property 180 | // converters to the supported libraries: 181 | $obj = $data->toGMP(); 182 | $obj = $data->toBcMath(); 183 | $obj = $data->toPear(); 184 | $obj = $data->toBrickMath(); 185 | 186 | Custom Handling 187 | --------------- 188 | 189 | .. versionadded:: 1.6/2.6 190 | .. versionchanged:: 4.0 Passing class names as handlers was removed 191 | 192 | Like listType and dictType you can use a callable:: 193 | 194 | $value, 201 | ); // ['int' => '79228162514264337593543950336'] 202 | 203 | Working with files 204 | ================== 205 | 206 | Load data from a file:: 207 | 208 | decode($encoded); 241 | $decoder->decodeStream($stream); 242 | $decoder->load($filename); 243 | -------------------------------------------------------------------------------- /docs/decoding_callback.rst: -------------------------------------------------------------------------------- 1 | Decoding with Callbacks 2 | ####################### 3 | 4 | .. highlight:: php 5 | .. versionadded:: 4.2 6 | 7 | Callback decoding may be useful if you don't need a complete decoding result. 8 | Examples: 9 | 10 | * Bencode validation 11 | * Extraction of specific values 12 | 13 | Callback Decoder Object 14 | ======================= 15 | 16 | Decoder object can be configured on creation and used multiple times:: 17 | 18 | decode($encoded, $callback); 28 | $decoder->decodeStream($stream, $callback); 29 | $decoder->load($filename, $callback); 30 | 31 | Callback 32 | ======== 33 | 34 | Callback can be any callable with signature ``(array $keys, mixed $value): ?bool``. 35 | For a callable object this signature can be enforced by the interface ``Arokettu\Bencode\Types\CallbackHandler``. 36 | The callback is called for every encountered scalar. 37 | Empty lists and dictionaries will not trigger the callback. 38 | If the callback returns false, the parser quits. 39 | 40 | Arguments 41 | ========= 42 | 43 | * ``$keys``. 44 | An array of keys of lists and dictionaries. 45 | Int keys refer to lists. 46 | String keys refer to dictionaries. 47 | * ``$value``. 48 | A scalar value nested by ``$keys``. 49 | 50 | Example 51 | ======= 52 | 53 | Count files for v1 torrent:: 54 | 55 | load($file, function (array $keys) use (&$count) { 64 | if ($keys[0] === 'info' && $keys[1] === 'files' && $keys[3] === 'path') { 65 | $count += 1; 66 | } 67 | }); 68 | 69 | echo $count, PHP_EOL; 70 | -------------------------------------------------------------------------------- /docs/encoding.rst: -------------------------------------------------------------------------------- 1 | Encoding 2 | ######## 3 | 4 | .. highlight:: php 5 | .. versionchanged:: 2.0 options array is replaced with named parameters 6 | .. note:: Parameter order is not guaranteed for options, use named parameters 7 | 8 | Scalars and arrays 9 | ================== 10 | 11 | .. versionchanged:: 4.1 floats now throw an exception instead of becoming strings 12 | 13 | :: 14 | 15 | [1,2,3,4], 23 | // integer is stored as is 24 | 'int' => 123, 25 | // true will be an integer 1 26 | 'true' => true, 27 | // false and null values will be skipped 28 | 'false' => false, 29 | // string can contain any binary data 30 | 'string' => "test\0test", 31 | ]); 32 | // "d" . 33 | // "3:arr" . "l" . "i1e" . "i2e" . "i3e" . "i4e" . "e" . 34 | // "3:int" . "i123e" . 35 | // "6:string" . "9:test\0test" . 36 | // "4:true" . "i1e" . 37 | // "e" 38 | 39 | Objects 40 | ======= 41 | 42 | ArrayObject and stdClass 43 | ------------------------ 44 | 45 | .. versionchanged:: 3.0 ``Traversable`` objects no longer become dictionaries automatically 46 | 47 | ArrayObject and stdClass become dictionaries:: 48 | 49 | a = '123'; 59 | $std->b = 456; 60 | $encoded = Bencode::encode($std); 61 | // "d1:a3:1231:bi456ee" 62 | 63 | Big integer support 64 | ------------------- 65 | 66 | .. versionadded:: 1.5/2.5 GMP support 67 | .. versionadded:: 1.6/2.6 Pear's Math_BigInteger, brick/math, BigIntType support 68 | .. versionadded:: 4.3 BCMath support 69 | 70 | .. note:: More in the :ref:`decoding section ` 71 | 72 | .. note:: ``BcMath\Number`` must represent an integer value (scale=0), decimal values will be rejected 73 | 74 | GMP object, BCMath object, Pear's Math_BigInteger, brick/math BigInteger, 75 | and internal type BigIntType (simple numeric string wrapper) will become integers:: 76 | 77 | gmp_pow(2, 96), 86 | 'bcmath' => new Number(2)->pow(96), 87 | 'brick' => BigInteger::of(2)->power(96), 88 | 'pear' => (new Math_BigInteger(1))->bitwise_leftShift(96), 89 | 'internal' => new BigIntType('7922816251426433759354395033'), 90 | ]); // "d6:bcmathi79228162514264337593543950336e5:bricki792..." 91 | 92 | Stringable 93 | ---------- 94 | 95 | .. versionchanged:: 3.0 ``Stringable`` objects no longer become strings automatically 96 | 97 | You can convert ``Stringable`` objects to strings using ``useStringable`` option:: 98 | 99 | 'value1'; 144 | yield 'key2' => 'value2'; 145 | })() 146 | )); // "d4:key16:value14:key26:value2e" 147 | 148 | BencodeSerializable 149 | ------------------- 150 | 151 | .. versionadded:: 1.2 152 | .. versionadded:: 1.7/2.7/3.0 ``JsonSerializable`` handling 153 | 154 | You can also force object representation by implementing BencodeSerializable interface. 155 | This will work exactly like JsonSerializable_ interface:: 156 | 157 | static::class, 168 | 'name' => 'myfile.torrent', 169 | 'size' => 5 * 1024 * 1024, 170 | ]; 171 | } 172 | } 173 | 174 | $file = new MyFile; 175 | 176 | $encoded = Bencode::encode($file); 177 | // "d5:class6:MyFile4:name14:myfile.torrent4:sizei5242880ee" 178 | 179 | Optionally you can use JsonSerializable_ itself too:: 180 | 181 | static::class, 191 | 'name' => 'myfile.torrent', 192 | 'size' => 5 * 1024 * 1024, 193 | ]; 194 | } 195 | } 196 | 197 | $file = new MyFile; 198 | 199 | $encoded = Bencode::encode( 200 | $file, 201 | useJsonSerializable: true, 202 | ); // "d5:class6:MyFile4:name14:myfile.torrent4:sizei5242880ee" 203 | 204 | Working with files 205 | ================== 206 | 207 | .. versionchanged:: 3.0 ``($filename, $data)`` → ``($data, $filename)`` 208 | 209 | Save data to file:: 210 | 211 | encode($data); 245 | $encoder->encodeToStream($data, $stream); 246 | $encoder->dump($data, $filename); 247 | 248 | .. _JsonSerializable: http://php.net/manual/en/class.jsonserializable.php 249 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Bencode 2 | ####### 3 | 4 | |Packagist| |GitLab| |GitHub| |Gitea| 5 | 6 | PHP Bencode Encoder/Decoder 7 | 8 | Bencode_ is the encoding used by the peer-to-peer file sharing system 9 | BitTorrent_ for storing and transmitting loosely structured data. 10 | 11 | This is a pure PHP library that allows you to encode and decode Bencode data. 12 | 13 | Installation 14 | ============ 15 | 16 | .. code-block:: bash 17 | 18 | composer require 'arokettu/bencode' 19 | 20 | Documentation 21 | ============= 22 | 23 | .. toctree:: 24 | :maxdepth: 2 25 | 26 | encoding 27 | decoding 28 | decoding_callback 29 | upgrade 30 | 31 | License 32 | ======= 33 | 34 | The library is available as open source under the terms of the `MIT License`_. 35 | 36 | .. _Bencode: https://en.wikipedia.org/wiki/Bencode 37 | .. _BitTorrent: https://en.wikipedia.org/wiki/BitTorrent 38 | .. _MIT License: https://opensource.org/licenses/MIT 39 | 40 | .. |Packagist| image:: https://img.shields.io/packagist/v/arokettu/bencode.svg?style=flat-square 41 | :target: https://packagist.org/packages/arokettu/bencode 42 | .. |GitHub| image:: https://img.shields.io/badge/get%20on-GitHub-informational.svg?style=flat-square&logo=github 43 | :target: https://github.com/arokettu/bencode 44 | .. |GitLab| image:: https://img.shields.io/badge/get%20on-GitLab-informational.svg?style=flat-square&logo=gitlab 45 | :target: https://gitlab.com/sandfox/bencode 46 | .. |Gitea| image:: https://img.shields.io/badge/get%20on-Gitea-informational.svg?style=flat-square&logo=gitea 47 | :target: https://sandfox.org/sandfox/bencode 48 | -------------------------------------------------------------------------------- /docs/upgrade.rst: -------------------------------------------------------------------------------- 1 | Upgrade Notes 2 | ############# 3 | 4 | .. highlight:: php 5 | 6 | Upgrade from 3.x to 4.x 7 | ======================= 8 | 9 | * The package was renamed from ``sandfoxme/bencode`` to ``arokettu/bencode`` 10 | * The package namespace was changed from ``SandFox\Bencode`` to ``Arokettu\Bencode`` 11 | 12 | * A custom autoloader to alias ``SandFox\Bencode`` to ``Arokettu\Bencode`` was added to 1.8.0, 2.8.0, and 3.1.0 13 | * Dictionaries are now converted to the ArrayObject by default 14 | * $options arrays were removed 15 | * Closures passed to ``listType``, ``dictType``, and ``bigInt`` must handle iterables instead of arrays now 16 | * Class names can no longer be passed to ``listType``, ``dictType``, and ``bigInt`` 17 | 18 | :: 19 | 20 | new CustomHandler([...$list]), 33 | ); 34 | 35 | Upgrade from 2.x to 3.x 36 | ======================= 37 | 38 | Main breaking changes: 39 | 40 | * Required PHP version was bumped to 8.1. 41 | Upgrade your interpreter. 42 | * Decoding: 43 | 44 | * Removed deprecated options: ``dictionaryType`` (use ``dictType``), ``useGMP`` (use ``bigInt: Bencode\BigInt::GMP``) 45 | * ``Bencode\BigInt`` and ``Bencode\Collection`` are now enums, 46 | therefore ``dictType``, ``listType``, ``bigInt`` params no longer accept bare string values 47 | (like ``'array'`` or ``'object'`` or ``'gmp'``). 48 | If you use constants, nothing will change for you. 49 | 50 | * Encoding: 51 | 52 | * Traversables no longer become dictionaries by default. 53 | You need to wrap them with ``DictType``. 54 | * Stringables no longer become strings by default. 55 | Use ``useStringable: true`` to return old behavior. 56 | * ``Bencode::dump($filename, $data)`` became ``Bencode::dump($data, $filename)`` for consistency with streams. 57 | 58 | :: 59 | 60 | dump($data, $filename); 64 | 65 | // Or use named parameters in PHP 8.0+: 66 | $success = \SandFox\Bencode\Bencode::dump( 67 | data: $data, 68 | filename: $filename, 69 | [...$optionsHere] 70 | ); 71 | 72 | * ``bencodeSerialize`` now declares ``mixed`` return type. 73 | 74 | Upgrade from 1.x to 2.x 75 | ======================= 76 | 77 | Main breaking changes: 78 | 79 | * Required PHP version was bumped to 8.0. 80 | Upgrade your interpreter. 81 | * Legacy namespace ``SandFoxMe`` was removed. 82 | You should search and replace ``SandFoxMe\Bencode`` with ``SandFox\Bencode`` in your code if you haven't done it already. 83 | -------------------------------------------------------------------------------- /src/Bencode.php: -------------------------------------------------------------------------------- 1 | decode($bencoded); 28 | } 29 | 30 | /** 31 | * Decode bencoded data from stream 32 | * 33 | * @param resource $readStream Read capable stream 34 | * @param array $options No longer used 35 | * @param Bencode\Collection|callable $listType Type declaration for lists 36 | * @param Bencode\Collection|callable $dictType Type declaration for dictionaries 37 | * @param Bencode\BigInt|callable $bigInt Big integer mode 38 | */ 39 | public static function decodeStream( 40 | $readStream, 41 | array $options = [], 42 | Bencode\Collection|callable $listType = Decoder::DEFAULT_LIST_TYPE, 43 | Bencode\Collection|callable $dictType = Decoder::DEFAULT_DICT_TYPE, 44 | Bencode\BigInt|callable $bigInt = Decoder::DEFAULT_BIG_INT, 45 | ): mixed { 46 | return (new Decoder($options, $listType, $dictType, $bigInt))->decodeStream($readStream); 47 | } 48 | 49 | /** 50 | * Load data from bencoded file 51 | * 52 | * @param array $options No longer used 53 | * @param Bencode\Collection|callable $listType Type declaration for lists 54 | * @param Bencode\Collection|callable $dictType Type declaration for dictionaries 55 | * @param Bencode\BigInt|callable $bigInt Big integer mode 56 | */ 57 | public static function load( 58 | string $filename, 59 | array $options = [], 60 | Bencode\Collection|callable $listType = Decoder::DEFAULT_LIST_TYPE, 61 | Bencode\Collection|callable $dictType = Decoder::DEFAULT_DICT_TYPE, 62 | Bencode\BigInt|callable $bigInt = Decoder::DEFAULT_BIG_INT, 63 | ): mixed { 64 | return (new Decoder($options, $listType, $dictType, $bigInt))->load($filename); 65 | } 66 | 67 | /** 68 | * Encode arbitrary data to bencoded string 69 | * 70 | * @param array $options No longer used 71 | */ 72 | public static function encode( 73 | mixed $data, 74 | array $options = [], 75 | bool $useJsonSerializable = false, 76 | bool $useStringable = false, 77 | ): string { 78 | return (new Encoder($options, $useJsonSerializable, $useStringable))->encode($data); 79 | } 80 | 81 | /** 82 | * Dump data to bencoded stream 83 | * 84 | * @param resource|null $writeStream Write capable stream. If null, a new php://temp will be created 85 | * @param array $options No longer used 86 | * @return resource Original or created stream 87 | */ 88 | public static function encodeToStream( 89 | mixed $data, 90 | $writeStream = null, 91 | array $options = [], 92 | bool $useJsonSerializable = false, 93 | bool $useStringable = false, 94 | ) { 95 | return (new Encoder($options, $useJsonSerializable, $useStringable))->encodeToStream($data, $writeStream); 96 | } 97 | 98 | /** 99 | * Dump data to bencoded file 100 | * 101 | * @param array $options No longer used 102 | * @return bool success of file_put_contents 103 | */ 104 | public static function dump( 105 | mixed $data, 106 | string $filename, 107 | array $options = [], 108 | bool $useJsonSerializable = false, 109 | bool $useStringable = false, 110 | ): bool { 111 | return (new Encoder($options, $useJsonSerializable, $useStringable))->dump($data, $filename); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Bencode/BigInt.php: -------------------------------------------------------------------------------- 1 | fn (string $value) => throw new ParseErrorException( 28 | "Integer overflow: '{$value}'" 29 | ), 30 | self::INTERNAL 31 | => fn (string $value) => new BigIntType($value), 32 | self::GMP 33 | => fn (string $value) => \gmp_init($value), 34 | self::BRICK_MATH 35 | => fn (string $value) => BigInteger::of($value), 36 | self::PEAR 37 | => fn (string $value) => new Math_BigInteger($value), 38 | self::BCMATH 39 | => fn (string $value) => new Number($value), 40 | }; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Bencode/Collection.php: -------------------------------------------------------------------------------- 1 | fn (\Traversable $value) => iterator_to_array($value), 19 | self::ARRAY_OBJECT 20 | => fn (\Traversable $value) => new \ArrayObject( 21 | iterator_to_array($value), 22 | \ArrayObject::ARRAY_AS_PROPS 23 | ), 24 | self::STDCLASS 25 | => fn (\Traversable $value) => (object)iterator_to_array($value), 26 | }; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/CallbackDecoder.php: -------------------------------------------------------------------------------- 1 | bigIntHandler = $bigInt instanceof Bencode\BigInt ? $bigInt->getHandler() : $bigInt(...); 23 | } 24 | 25 | /** 26 | * Decode bencoded data from stream 27 | * 28 | * @param resource $readStream Read capable stream 29 | */ 30 | public function decodeStream($readStream, Types\CallbackHandler|callable $callback): void 31 | { 32 | (new Engine\CallbackReader( 33 | $readStream, 34 | $callback(...), 35 | $this->bigIntHandler, 36 | ))->read(); 37 | } 38 | 39 | /** 40 | * Decode bencoded data from string 41 | * 42 | * @param string $bencoded 43 | */ 44 | public function decode(string $bencoded, Types\CallbackHandler|callable $callback): void 45 | { 46 | $stream = fopen('php://temp', 'r+'); 47 | fwrite($stream, $bencoded); 48 | rewind($stream); 49 | 50 | self::decodeStream($stream, $callback); 51 | 52 | fclose($stream); 53 | } 54 | 55 | /** 56 | * Load data from bencoded file 57 | */ 58 | public function load(string $filename, Types\CallbackHandler|callable $callback): void 59 | { 60 | if (!is_file($filename) || !is_readable($filename)) { 61 | throw new FileNotReadableException('File does not exist or is not readable: ' . $filename); 62 | } 63 | 64 | $stream = fopen($filename, 'r'); 65 | 66 | if ($stream === false) { 67 | throw new FileNotReadableException('Error reading file: ' . $filename); // @codeCoverageIgnore 68 | } 69 | 70 | self::decodeStream($stream, $callback); 71 | 72 | fclose($stream); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Decoder.php: -------------------------------------------------------------------------------- 1 | listHandler = $listType instanceof Bencode\Collection ? $listType->getHandler() : $listType(...); 46 | $this->dictHandler = $dictType instanceof Bencode\Collection ? $dictType->getHandler() : $dictType(...); 47 | $this->bigIntHandler = $bigInt instanceof Bencode\BigInt ? $bigInt->getHandler() : $bigInt(...); 48 | } 49 | 50 | /** 51 | * Decode bencoded data from stream 52 | * 53 | * @param resource $readStream Read capable stream 54 | */ 55 | public function decodeStream($readStream): mixed 56 | { 57 | return (new Engine\Reader( 58 | $readStream, 59 | $this->listHandler, 60 | $this->dictHandler, 61 | $this->bigIntHandler, 62 | ))->read(); 63 | } 64 | 65 | /** 66 | * Decode bencoded data from string 67 | */ 68 | public function decode(string $bencoded): mixed 69 | { 70 | $stream = fopen('php://temp', 'r+'); 71 | fwrite($stream, $bencoded); 72 | rewind($stream); 73 | 74 | $decoded = self::decodeStream($stream); 75 | 76 | fclose($stream); 77 | 78 | return $decoded; 79 | } 80 | 81 | /** 82 | * Load data from bencoded file 83 | */ 84 | public function load(string $filename): mixed 85 | { 86 | if (!is_file($filename) || !is_readable($filename)) { 87 | throw new FileNotReadableException('File does not exist or is not readable: ' . $filename); 88 | } 89 | 90 | $stream = fopen($filename, 'r'); 91 | 92 | if ($stream === false) { 93 | throw new FileNotReadableException('Error reading file: ' . $filename); // @codeCoverageIgnore 94 | } 95 | 96 | $decoded = self::decodeStream($stream); 97 | 98 | fclose($stream); 99 | 100 | return $decoded; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Encoder.php: -------------------------------------------------------------------------------- 1 | useJsonSerializable, $this->useStringable))->write(); 37 | } 38 | 39 | /** 40 | * Encode arbitrary data to bencoded string 41 | */ 42 | public function encode(mixed $data): string 43 | { 44 | $stream = fopen('php://temp', 'r+'); 45 | $this->encodeToStream($data, $stream); 46 | rewind($stream); 47 | 48 | $encoded = stream_get_contents($stream); 49 | 50 | fclose($stream); 51 | 52 | return $encoded; 53 | } 54 | 55 | /** 56 | * Dump data to bencoded file 57 | * 58 | * @return bool always true 59 | */ 60 | public function dump(mixed $data, string $filename): bool 61 | { 62 | $writable = is_file($filename) ? 63 | is_writable($filename) : 64 | is_dir($dirname = \dirname($filename)) && is_writable($dirname); 65 | 66 | if (!$writable) { 67 | throw new FileNotWritableException('The file is not writable: ' . $filename); 68 | } 69 | 70 | $stream = fopen($filename, 'w'); 71 | 72 | if ($stream === false) { 73 | throw new FileNotWritableException('Error writing to file: ' . $filename); // @codeCoverageIgnore 74 | } 75 | 76 | $this->encodeToStream($data, $stream); 77 | 78 | $stat = fstat($stream); 79 | fclose($stream); 80 | 81 | return $stat['size'] > 0; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Engine/CallbackReader.php: -------------------------------------------------------------------------------- 1 | state = self::STATE_ROOT; 48 | $this->stateStack = new SplStack(); 49 | $this->decodeStarted = false; 50 | $this->keyStack = new SplStack(); 51 | 52 | while (!feof($this->stream)) { 53 | $this->processChar(); 54 | } 55 | 56 | /** @psalm-suppress TypeDoesNotContainType too smart! */ 57 | if ($this->state !== self::STATE_ROOT || !$this->decodeStarted) { 58 | throw new ParseErrorException('Unexpected end of file'); 59 | } 60 | } 61 | 62 | private function processChar(): void 63 | { 64 | $c = fread($this->stream, 1); 65 | 66 | if (feof($this->stream) && $c === '') { 67 | return; 68 | } 69 | 70 | if ($this->decodeStarted && $this->state === self::STATE_ROOT) { 71 | throw new ParseErrorException('Probably some junk after the end of the file'); 72 | } 73 | 74 | $this->decodeStarted = true; 75 | 76 | if ($this->state === self::STATE_LIST) { 77 | $index = $this->keyStack->pop(); 78 | $index += 1; 79 | $this->keyStack->push($index); 80 | } 81 | 82 | match ($c) { 83 | 'i' => $this->processInteger(), 84 | 'l' => $this->push(self::STATE_LIST), 85 | 'd' => $this->push(self::STATE_DICT_KEY), 86 | 'e' => $this->finalizeContainer(), 87 | default => $this->processString(), 88 | }; 89 | } 90 | 91 | private function readInteger(string $delimiter): string|false 92 | { 93 | // handling numbers longer than 8092 digits is out of the scope of this library 94 | $result = stream_get_line($this->stream, 8092, $delimiter); 95 | 96 | if ($result === false) { 97 | return false; 98 | } 99 | 100 | // validate the delimiter too 101 | fseek($this->stream, -\strlen($delimiter), SEEK_CUR); 102 | $d = fread($this->stream, \strlen($delimiter)); 103 | 104 | return $d === $delimiter ? $result : false; 105 | } 106 | 107 | private function processInteger(): void 108 | { 109 | if ($this->state === self::STATE_DICT_KEY) { 110 | throw new ParseErrorException('Non string key found in the dictionary'); 111 | } 112 | 113 | $intStr = $this->readInteger('e'); 114 | 115 | if ($intStr === false) { 116 | throw new ParseErrorException('Unexpected end of file while processing integer'); 117 | } 118 | 119 | if (!IntUtil::isValid($intStr)) { 120 | throw new ParseErrorException("Invalid integer format: '{$intStr}'"); 121 | } 122 | 123 | $int = \intval($intStr); 124 | 125 | $this->finalizeScalar( 126 | \strval($int) === $intStr ? // detect overflow 127 | $int : // not overflown: native int 128 | ($this->bigIntHandler)($intStr) // overflown: handle big int 129 | ); 130 | } 131 | 132 | private function processString(): void 133 | { 134 | // rewind back 1 character because it's a part of string length 135 | fseek($this->stream, -1, SEEK_CUR); 136 | 137 | $lenStr = $this->readInteger(':'); 138 | 139 | if ($lenStr === false) { 140 | throw new ParseErrorException('Unexpected end of file while processing string'); 141 | } 142 | 143 | $len = \intval($lenStr); 144 | 145 | if (\strval($len) !== $lenStr || $len < 0) { 146 | throw new ParseErrorException("Invalid string length value: '{$lenStr}'"); 147 | } 148 | 149 | // we have length, just read all string here now 150 | 151 | $str = $len === 0 ? '' : fread($this->stream, $len); 152 | 153 | if (\strlen($str) !== $len) { 154 | throw new ParseErrorException('Unexpected end of file while processing string'); 155 | } 156 | 157 | if ($this->state === self::STATE_DICT_KEY) { 158 | $prevKey = $this->keyStack->pop(); 159 | if ($prevKey && strcmp($prevKey, $str) >= 0) { 160 | throw new ParseErrorException("Invalid order of dictionary keys: '{$str}' after '{$prevKey}'"); 161 | } 162 | $this->keyStack->push($str); 163 | $this->state = self::STATE_DICT; 164 | } else { 165 | $this->finalizeScalar($str); 166 | } 167 | } 168 | 169 | private function finalizeContainer(): void 170 | { 171 | if ($this->state === self::STATE_DICT) { 172 | // dict can't end here 173 | $dictKey = $this->keyStack->pop(); 174 | throw new ParseErrorException("Dictionary key without corresponding value: '{$dictKey}'"); 175 | } 176 | 177 | $this->pop(); 178 | } 179 | 180 | /** 181 | * Send parsed value to the current container 182 | * @param mixed $value 183 | */ 184 | private function finalizeScalar(mixed $value): void 185 | { 186 | switch ($this->state) { 187 | case self::STATE_ROOT: 188 | case self::STATE_LIST: 189 | break; 190 | case self::STATE_DICT: 191 | $this->state = self::STATE_DICT_KEY; 192 | break; 193 | default: 194 | throw new LogicException('Should not happen'); // @codeCoverageIgnore 195 | } 196 | 197 | ($this->callback)(array_reverse(iterator_to_array($this->keyStack)), $value); 198 | } 199 | 200 | /** 201 | * Push previous layer to the stack and set new state 202 | * @param int $newState 203 | */ 204 | private function push(int $newState): void 205 | { 206 | if ($this->state === self::STATE_DICT_KEY) { 207 | throw new ParseErrorException('Non string key found in the dictionary'); 208 | } 209 | 210 | $this->stateStack->push($this->state); 211 | $this->state = $newState; 212 | 213 | $this->keyStack->push(match ($newState) { 214 | self::STATE_LIST => -1, 215 | self::STATE_DICT_KEY => null, 216 | }); 217 | } 218 | 219 | /** 220 | * Pop previous layer from the stack and give it a parsed value 221 | */ 222 | private function pop(): void 223 | { 224 | $this->state = $this->stateStack->pop(); 225 | $this->keyStack->pop(); 226 | 227 | if ($this->state === self::STATE_DICT) { 228 | $this->state = self::STATE_DICT_KEY; 229 | } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/Engine/Reader.php: -------------------------------------------------------------------------------- 1 | state = self::STATE_ROOT; 49 | $this->stateStack = new SplStack(); 50 | $this->decoded = null; 51 | $this->value = null; 52 | $this->valueStack = new SplStack(); 53 | 54 | while (!feof($this->stream)) { 55 | $this->processChar(); 56 | } 57 | 58 | /** @psalm-suppress TypeDoesNotContainType too smart! */ 59 | if ($this->state !== self::STATE_ROOT || $this->decoded === null) { 60 | throw new ParseErrorException('Unexpected end of file'); 61 | } 62 | 63 | /** @psalm-suppress NoValue too smart! */ 64 | return $this->decoded; 65 | } 66 | 67 | private function processChar(): void 68 | { 69 | $c = fread($this->stream, 1); 70 | 71 | if (feof($this->stream) && $c === '') { 72 | return; 73 | } 74 | 75 | if ($this->decoded !== null && $this->state === self::STATE_ROOT) { 76 | throw new ParseErrorException('Probably some junk after the end of the file'); 77 | } 78 | 79 | match ($c) { 80 | 'i' => $this->processInteger(), 81 | 'l' => $this->push(self::STATE_LIST), 82 | 'd' => $this->push(self::STATE_DICT), 83 | 'e' => $this->finalizeContainer(), 84 | default => $this->processString(), 85 | }; 86 | } 87 | 88 | private function readInteger(string $delimiter): string|false 89 | { 90 | // handling numbers longer than 8092 digits is out of the scope of this library 91 | $result = stream_get_line($this->stream, 8092, $delimiter); 92 | 93 | if ($result === false) { 94 | return false; 95 | } 96 | 97 | // validate the delimiter too 98 | fseek($this->stream, -\strlen($delimiter), SEEK_CUR); 99 | $d = fread($this->stream, \strlen($delimiter)); 100 | 101 | return $d === $delimiter ? $result : false; 102 | } 103 | 104 | private function processInteger(): void 105 | { 106 | $intStr = $this->readInteger('e'); 107 | 108 | if ($intStr === false) { 109 | throw new ParseErrorException('Unexpected end of file while processing integer'); 110 | } 111 | 112 | if (!IntUtil::isValid($intStr)) { 113 | throw new ParseErrorException("Invalid integer format: '{$intStr}'"); 114 | } 115 | 116 | $int = \intval($intStr); 117 | 118 | $this->finalizeScalar( 119 | \strval($int) === $intStr ? // detect overflow 120 | $int : // not overflown: native int 121 | ($this->bigIntHandler)($intStr) // overflown: handle big int 122 | ); 123 | } 124 | 125 | private function processString(): void 126 | { 127 | // rewind back 1 character because it's a part of string length 128 | fseek($this->stream, -1, SEEK_CUR); 129 | 130 | $lenStr = $this->readInteger(':'); 131 | 132 | if ($lenStr === false) { 133 | throw new ParseErrorException('Unexpected end of file while processing string'); 134 | } 135 | 136 | $len = \intval($lenStr); 137 | 138 | if (\strval($len) !== $lenStr || $len < 0) { 139 | throw new ParseErrorException("Invalid string length value: '{$lenStr}'"); 140 | } 141 | 142 | // we have length, just read all string here now 143 | 144 | $str = $len === 0 ? '' : fread($this->stream, $len); 145 | 146 | if (\strlen($str) !== $len) { 147 | throw new ParseErrorException('Unexpected end of file while processing string'); 148 | } 149 | 150 | $this->finalizeScalar($str); 151 | } 152 | 153 | private function finalizeContainer(): void 154 | { 155 | match ($this->state) { 156 | self::STATE_LIST => $this->finalizeList(), 157 | self::STATE_DICT => $this->finalizeDict(), 158 | // @codeCoverageIgnoreStart 159 | // This exception means that we have a bug in our own code 160 | default => throw new ParseErrorException('Parser entered invalid state while finalizing container'), 161 | // @codeCoverageIgnoreEnd 162 | }; 163 | } 164 | 165 | private function finalizeList(): void 166 | { 167 | $value = $this->value; // capture the current value object 168 | $this->pop(($this->listHandler)((static fn () => yield from $value)())); 169 | } 170 | 171 | private function finalizeDict(): void 172 | { 173 | $value = $this->value; // capture the current value object 174 | 175 | $dictBuilder = static function () use ($value): \Generator { 176 | $prevKey = null; 177 | 178 | // we have a queue [key1, value1, key2, value2, key3, value3, ...] 179 | while (\count($value)) { 180 | $dictKey = $value->dequeue(); 181 | if (\is_string($dictKey) === false) { 182 | throw new ParseErrorException('Non string key found in the dictionary'); 183 | } 184 | if (\count($value) === 0) { 185 | throw new ParseErrorException("Dictionary key without corresponding value: '{$dictKey}'"); 186 | } 187 | if ($prevKey && strcmp($prevKey, $dictKey) >= 0) { 188 | throw new ParseErrorException("Invalid order of dictionary keys: '{$dictKey}' after '{$prevKey}'"); 189 | } 190 | $dictValue = $value->dequeue(); 191 | 192 | yield $dictKey => $dictValue; 193 | $prevKey = $dictKey; 194 | } 195 | }; 196 | 197 | $this->pop(($this->dictHandler)($dictBuilder())); 198 | } 199 | 200 | /** 201 | * Send parsed value to the current container 202 | * @param mixed $value 203 | */ 204 | private function finalizeScalar(mixed $value): void 205 | { 206 | if ($this->state !== self::STATE_ROOT) { 207 | $this->value->enqueue($value); 208 | } else { 209 | // we have final result 210 | $this->decoded = $value; 211 | } 212 | } 213 | 214 | /** 215 | * Push previous layer to the stack and set new state 216 | * @param int $newState 217 | */ 218 | private function push(int $newState): void 219 | { 220 | $this->stateStack->push($this->state); 221 | $this->state = $newState; 222 | 223 | $this->valueStack->push($this->value); 224 | $this->value = new SplQueue(); 225 | } 226 | 227 | /** 228 | * Pop previous layer from the stack and give it a parsed value 229 | * @param mixed $valueToPrevLevel 230 | */ 231 | private function pop(mixed $valueToPrevLevel): void 232 | { 233 | $this->state = $this->stateStack->pop(); 234 | 235 | if ($this->state !== self::STATE_ROOT) { 236 | $this->value = $this->valueStack->pop(); 237 | $this->value->enqueue($valueToPrevLevel); 238 | } else { 239 | // we have final result 240 | $this->decoded = $valueToPrevLevel; 241 | } 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /src/Engine/Writer.php: -------------------------------------------------------------------------------- 1 | stream) !== 'stream') { 39 | throw new InvalidArgumentException('Output is not a valid stream'); 40 | } 41 | } 42 | 43 | /** 44 | * @return resource 45 | */ 46 | public function write() 47 | { 48 | $this->encodeValue($this->resolveSerializable($this->data)); 49 | 50 | return $this->stream; 51 | } 52 | 53 | private function encodeValue(mixed $value): void 54 | { 55 | match (true) { 56 | // first check if we have integer 57 | // true is converted to integer 1 58 | \is_int($value), 59 | $value === true, 60 | $value instanceof BigIntType, 61 | $value instanceof GMP, 62 | $value instanceof BigInteger, 63 | $value instanceof Math_BigInteger, 64 | => $this->encodeInteger($value), 65 | // BcMath can be both decimal and integer, check scale 66 | $value instanceof Number, 67 | => $this->encodeBcMath($value), 68 | // process strings 69 | \is_string($value) => $this->encodeString($value), 70 | // process arrays 71 | \is_array($value) => $this->encodeArray($value), 72 | // process objects 73 | \is_object($value) => $this->encodeObject($value), 74 | // empty values 75 | $value === false, 76 | $value === null, 77 | => throw new ValueNotSerializableException('Unable to encode an empty value'), 78 | // other types like resources 79 | default 80 | => throw new ValueNotSerializableException( 81 | \sprintf("Bencode doesn't know how to serialize an instance of %s", get_debug_type($value)) 82 | ), 83 | }; 84 | } 85 | 86 | private function encodeArray(array $value): void 87 | { 88 | array_is_list($value) ? 89 | $this->encodeList($value) : 90 | $this->encodeDictionary($value); 91 | } 92 | 93 | private function encodeObject(object $value): void 94 | { 95 | match (true) { 96 | // traversables 97 | // ListType forces traversable object to be list 98 | $value instanceof ListType, 99 | => $this->encodeList($value), 100 | // all other traversables are dictionaries 101 | // also treat stdClass as a dictionary 102 | $value instanceof DictType, 103 | $value instanceof ArrayObject, 104 | $value instanceof stdClass, 105 | => $this->encodeDictionary($value), 106 | // other classes 107 | default => 108 | throw new ValueNotSerializableException( 109 | \sprintf("Bencode doesn't know how to serialize an instance of %s", get_debug_type($value)) 110 | ), 111 | }; 112 | } 113 | 114 | private function encodeInteger(int|bool|BigIntType|GMP|BigInteger|Math_BigInteger $integer): void 115 | { 116 | fwrite($this->stream, 'i'); 117 | fwrite($this->stream, \strval($integer)); 118 | fwrite($this->stream, 'e'); 119 | } 120 | 121 | private function encodeBcMath(Number $integer): void 122 | { 123 | if ($integer->scale > 0) { 124 | throw new ValueNotSerializableException( 125 | \sprintf('BcMath\\Number does not represent an integer value: "%s"', $integer) 126 | ); 127 | } 128 | 129 | fwrite($this->stream, 'i'); 130 | fwrite($this->stream, \strval($integer)); 131 | fwrite($this->stream, 'e'); 132 | } 133 | 134 | private function encodeString(string $string): void 135 | { 136 | fwrite($this->stream, \strval(\strlen($string))); 137 | fwrite($this->stream, ':'); 138 | fwrite($this->stream, $string); 139 | } 140 | 141 | private function encodeList(iterable $array): void 142 | { 143 | fwrite($this->stream, 'l'); 144 | 145 | foreach ($array as $value) { 146 | $value = $this->resolveSerializable($value); 147 | 148 | if ($value === false || $value === null) { 149 | continue; 150 | } 151 | 152 | $this->encodeValue($value); 153 | } 154 | 155 | fwrite($this->stream, 'e'); 156 | } 157 | 158 | private function encodeDictionary(iterable|stdClass $array): void 159 | { 160 | $dictData = []; 161 | 162 | foreach ($array as $key => $value) { 163 | $value = $this->resolveSerializable($value); 164 | 165 | if ($value === false || $value === null) { 166 | continue; 167 | } 168 | 169 | // do not use php array keys here to prevent numeric strings becoming integers again 170 | $dictData[] = [\strval($key), $value]; 171 | } 172 | 173 | // sort by keys - rfc requirement 174 | usort($dictData, fn($a, $b): int => ( 175 | strcmp($a[0], $b[0]) ?: throw new ValueNotSerializableException( 176 | "Dictionary contains repeated keys: '{$a[0]}'" 177 | ) 178 | )); 179 | 180 | fwrite($this->stream, 'd'); 181 | 182 | foreach ($dictData as [$key, $value]) { 183 | $this->encodeString($key); // key is always a string 184 | $this->encodeValue($value); 185 | } 186 | 187 | fwrite($this->stream, 'e'); 188 | } 189 | 190 | private function resolveSerializable(mixed $value): mixed 191 | { 192 | if (!\is_object($value)) { 193 | return $value; 194 | } 195 | 196 | if ($value instanceof BencodeSerializable) { 197 | return $this->resolveSerializable($value->bencodeSerialize()); 198 | } 199 | 200 | if ($this->useJsonSerializable && $value instanceof JsonSerializable) { 201 | return $this->resolveSerializable($value->jsonSerialize()); 202 | } 203 | 204 | if ($this->useStringable && $value instanceof Stringable) { 205 | return $value->__toString(); 206 | } 207 | 208 | return $value; 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/Exceptions/BencodeException.php: -------------------------------------------------------------------------------- 1 | assertValidInteger($value); 18 | } 19 | 20 | private function assertValidInteger(string $value): void 21 | { 22 | if (!IntUtil::isValid($value)) { 23 | throw new InvalidArgumentException("Invalid integer string: '{$value}'"); 24 | } 25 | } 26 | 27 | /** 28 | * @psalm-api 29 | */ 30 | public function getValue(): string 31 | { 32 | return $this->value; 33 | } 34 | 35 | public function __toString(): string 36 | { 37 | return $this->value; 38 | } 39 | 40 | /** 41 | * @psalm-api 42 | */ 43 | public function toGMP(): \GMP 44 | { 45 | return \gmp_init($this->value); 46 | } 47 | 48 | /** 49 | * @psalm-api 50 | */ 51 | public function toPear(): Math_BigInteger 52 | { 53 | /** @psalm-suppress InvalidArgument bad annotation in Math_BigInteger */ 54 | return new Math_BigInteger($this->value); 55 | } 56 | 57 | /** 58 | * @psalm-api 59 | */ 60 | public function toBrickMath(): BigInteger 61 | { 62 | return BigInteger::of($this->value); 63 | } 64 | 65 | /** 66 | * @psalm-api 67 | */ 68 | public function toBcMath(): Number 69 | { 70 | return new Number($this->value); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Types/CallbackHandler.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | final class DictType implements IteratorAggregate 13 | { 14 | /** @use IterableTypeTrait */ 15 | use IterableTypeTrait; 16 | } 17 | -------------------------------------------------------------------------------- /src/Types/IterableTypeTrait.php: -------------------------------------------------------------------------------- 1 | traversable = $iterable; 28 | } 29 | 30 | /** 31 | * @psalm-suppress ImplementedReturnTypeMismatch weird templated trait error 32 | * @return Traversable 33 | */ 34 | public function getIterator(): Traversable 35 | { 36 | return $this->traversable; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Types/ListType.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | final class ListType implements IteratorAggregate 13 | { 14 | /** @use IterableTypeTrait */ 15 | use IterableTypeTrait; 16 | } 17 | -------------------------------------------------------------------------------- /src/Util/IntUtil.php: -------------------------------------------------------------------------------- 1 |