├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml ├── src ├── Bencode.php ├── ParseException.php └── TorrentFile.php └── tests ├── DecodeTest.php ├── EncodeTest.php ├── TorrentFileTreeSortTest.php ├── TorrentHybridMultiTest.php ├── TorrentHybridSingleTest.php ├── TorrentV1MultiTest.php ├── TorrentV1SingleTest.php ├── TorrentV2MultiTest.php ├── TorrentV2SingleTest.php ├── asserts ├── hybrid-multi.torrent ├── hybrid-single.torrent ├── test-tree-sort.torrent ├── v1-multi.torrent ├── v1-single.torrent ├── v2-multi.torrent └── v2-single.torrent └── traits ├── TorrentFileCommonTrait.php ├── TorrentFileV1Trait.php └── TorrentFileV2Trait.php /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: composer 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "21:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | tests: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | php-versions: [ 7.3, 7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5 ] 18 | steps: 19 | - uses: actions/checkout@master 20 | 21 | - name: Setup PHP 22 | uses: shivammathur/setup-php@v2 23 | with: 24 | php-version: ${{ matrix.php-versions }} 25 | 26 | - name: Get Composer Cache Directory 27 | id: composer-cache 28 | run: | 29 | echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 30 | 31 | - name: Cache dependencies 32 | uses: actions/cache@v4 33 | with: 34 | path: ${{ steps.composer-cache.outputs.dir }} 35 | key: ${{ runner.os }}-composer-${{ matrix.php-versions }}-${{ hashFiles('**/composer.json') }} 36 | restore-keys: ${{ runner.os }}-composer-${{ matrix.php-versions }}- 37 | 38 | - name: Install dependencies 39 | run: composer install --no-interaction --dev 40 | 41 | - name: Run PHPUnit 42 | run: vendor/bin/phpunit tests/ 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Composer template 3 | composer.phar 4 | /vendor/ 5 | composer.lock 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Rhilip 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP Bencode Library 2 | 3 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/14bb9525a5a343079e45d9501dac1b4c)](https://app.codacy.com/manual/rhilipruan/Bencode?utm_source=github.com&utm_medium=referral&utm_content=Rhilip/Bencode&utm_campaign=Badge_Grade_Dashboard) 4 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FRhilip%2FBencode.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2FRhilip%2FBencode?ref=badge_shield) 5 | 6 | [Bencode](https://en.wikipedia.org/wiki/Bencode) is the encoding used by the peer-to-peer file sharing system 7 | [BitTorrent](https://opensource.org/licenses/MIT) for storing and transmitting loosely structured data. 8 | 9 | This is a pure PHP library that allows you to encode and decode Bencode data, with torrent file parse and vaildate. 10 | 11 | This library is fork from [OPSnet/bencode-torrent](https://github.com/OPSnet/bencode-torrent), 12 | with same method like [sandfoxme/bencode](https://github.com/arokettu/bencode), [sandfoxme/torrent-file](https://github.com/arokettu/torrent-file) 13 | 14 | ## Installation 15 | 16 | ```shell script 17 | composer require rhilip/bencode 18 | ``` 19 | 20 | if you don't use `Rhilip\Bencode\TorrentFile` class, you can specific version to `1.x.x` 21 | and if your PHP version is `5.6` or `7.0-7.2`, please stop at version `1.2.0` and `2.0.0` 22 | 23 | ```shell script 24 | composer require rhilip/bencode:1.2.0 25 | ``` 26 | 27 | **The only Break Change is `ParseErrorException` in `1.x.x` rename to `ParseException` in `2.x.x`.** 28 | 29 | ## Usage 30 | 31 | ### Class `Rhilip\Bencode\Bencode` 32 | 33 | A pure PHP class to encode and decode Bencode data from file path and string. 34 | 35 | ```php 36 | dump($path); 94 | print($torrent->dumpToString()); 95 | 96 | // 3. Work with Root Fields 97 | $torrent->getRootData(); // $root; 98 | $rootField = $torrent->getRootField($field, ?$default); // $root[$field] ?? $default; 99 | $torrent->setRootField($field, $value); // $root[$field] = $value; 100 | $torrent->unsetRootField($field); // unset($root[$field]); 101 | $torrent->cleanRootFields(?$allowedKeys); // remove fields which is not allowed in root 102 | 103 | $torrent->setAnnounce('udp://example.com/announce'); 104 | $announce = $torrent->getAnnounce(); 105 | 106 | $torrent->setAnnounceList([['https://example1.com/announce'], ['https://example2.com/announce', 'https://example3.com/announce']]); 107 | $announceList = $torrent->getAnnounceList(); 108 | 109 | $torrent->setComment('Rhilip\'s Torrent'); 110 | $comment = $torrent->getComment(); 111 | 112 | $torrent->setCreatedBy('Rhilip'); 113 | $createdBy = $torrent->getCreatedBy(); 114 | 115 | $torrent->setCreationDate(time()); 116 | $creationDate = $torrent->getCreationDate(); 117 | 118 | $torrent->setHttpSeeds(['udp://example.com/seed']); 119 | $httpSeeds = $torrent->getHttpSeeds(); 120 | 121 | $torrent->setNodes(['udp://example.com/seed']); 122 | $nodes = $torrent->getNodes(); 123 | 124 | $torrent->setUrlList(['udp://example.com/seed']); 125 | $urlList = $torrent->getUrlList(); 126 | 127 | // 4. Work with Info Field 128 | $torrent->getInfoData(); // $root['info']; 129 | $infoField = $torrent->getInfoField($field, ?$default); // $info[$field] ?? $default; 130 | $torrent->setInfoField($field, $value); // $info[$field] = $value; 131 | $torrent->unsetInfoField($field); // unset($info[$field]); 132 | $torrent->cleanInfoFields(?$allowedKeys); // remove fields which is not allowed in info 133 | 134 | $protocol = $torrent->getProtocol(); // TorrentFile::PROTOCOL_{V1,V2,HYBRID} 135 | $fileMode = $torrent->getFileMode(); // TorrentFile::FILEMODE_{SINGLE,MULTI} 136 | 137 | /** 138 | * @note since we may edit $root['info'], so when call ->getInfoHash* method, 139 | * we will calculate it each call without cache return-value. 140 | */ 141 | $torrent->getInfoHashV1(?$binary); // If $binary is true return 20-bytes string, otherwise 40-character hexadecimal number 142 | $torrent->getInfoHashV2(?$binary); // If $binary is true return 32-bytes string, otherwise 64-character hexadecimal number 143 | $torrent->getInfoHash(?$binary); // return v2-infohash if there is one, otherwise return v1-infohash 144 | $torrent->getInfoHashs(?$binary); // return [TorrentFile::PROTOCOL_V1 => v1-infohash, TorrentFile::PROTOCOL_V2 => v2-infohash] 145 | $torrent->getInfoHashV1ForAnnounce(); // return the v1 info-hash in announce ( 20-bytes string ) 146 | $torrent->getInfoHashV2ForAnnounce(); // return the v2 (truncated) info-hash in announce 147 | $torrent->getInfoHashsForAnnounce(); // same as getInfoHashs() but in announce 148 | 149 | $torrent->getPieceLength(); // int 150 | 151 | try { 152 | $torrent->setName($name); 153 | } catch(\InvalidArgumentException $e) { 154 | // Do something 155 | } 156 | $name = $torrent->getName(); 157 | 158 | $torrent->setSource('Rhilip\'s blog'); 159 | $source = $torrent->getSource(); 160 | 161 | $private = $torrent->isPrivate(); // true or false 162 | $torrent->setPrivate(true); 163 | 164 | $magnetLink = $torrent->getMagnetLink(); 165 | 166 | // 5. Work with torrent, it will try to parse torrent ( cost time ) 167 | $torrent->setParseValidator(function ($filename, $paths) { 168 | /** 169 | * Before parse torrent ( call getSize, getFileCount, getFileList, getFileTree method ), 170 | * you can set a validator to test if filename or paths is valid, 171 | * And break parse process by any throw Exception. 172 | */ 173 | print_r([$filename, $paths]); 174 | if (str_contains($filename, 'F**k')) { 175 | throw new ParseException('Not allowed filename in torrent'); 176 | } 177 | }); 178 | 179 | /** 180 | * parse method will automatically called when use getSize, getFileCount, getFileList, getFileTree method, 181 | * However you can also call parse method manually. 182 | */ 183 | $torrent->parse(); // ['total_size' => $totalSize, 'count' => $count, 'files' => $fileList, 'fileTree' => $fileTree] 184 | 185 | /** 186 | * Note: Since we prefer to parse `file tree` in info dict in v2 or hybrid torrent, 187 | * The padding file may not count in size, fileCount, fileList and fileTree. 188 | */ 189 | $size = $torrent->getSize(); 190 | $count = $torrent->getFileCount(); 191 | 192 | /** 193 | * Return a list like: 194 | * [ 195 | * ["path" => "filename1", "size" => 123], // 123 is file size 196 | * ["path" => "directory/filename2", "size" => 2345] 197 | * ] 198 | * 199 | */ 200 | $fileList = $torrent->getFileList(); 201 | 202 | 203 | /** 204 | * Return a dict like: 205 | * [ 206 | * "torrentname" => [ 207 | * "directory" => [ 208 | * "filename2" => 2345 209 | * ], 210 | * "filename1" => 123 211 | * ] 212 | * ] 213 | * 214 | * > Add in v2.4.0 215 | * You can now pass argument to control the fileTree sort type. By default, this argument is TorrentFile::FILETREE_SORT_NORMAL. 216 | * Control Const (see different in `tests/TorrentFileTreeSortTest.php` file): 217 | * - TorrentFile::FILETREE_SORT_NORMAL : not sort, also means sort by torrent file parsed order 218 | * - TorrentFile::FILETREE_SORT_STRING : sort by filename ASC ("natural ordering" and "case-insensitively") 219 | * - TorrentFile::FILETREE_SORT_FOLDER : sort by filetype (first folder, then file) 220 | * - TorrentFile::FILETREE_SORT_NATURAL: sort by both filetype and filename ( same as `TorrentFile::FILETREE_SORT_STRING | TorrentFile::FILETREE_SORT_FOLDER` ) 221 | * 222 | */ 223 | $fileTree = $torrent->getFileTree(?$sortType = TorrentFile::FILETREE_SORT_NORMAL); 224 | 225 | // 6. Other method 226 | $torrent->cleanCache(); 227 | 228 | // Note 1: clean,set,unset method are chaining 229 | $torrent 230 | ->clean() 231 | ->setAnnounce('https://example.com/announce') 232 | ->setAnnounceList([ 233 | ['https://example.com/announce'], 234 | ['https://example1.com/announce'] 235 | ]) 236 | ->setSource('example.com') 237 | ->setPrivate(true); 238 | 239 | // Note 2: parse method may fail when get a deep invalid torrent, so it can wrapper like this 240 | try { 241 | $torrent = TorrentFile::load($_POST['torrent']['tmp_name']); 242 | $torrent/** ->setParseValidator(function () {}) */->parse(); 243 | } catch (ParseException $e) { 244 | // do something to notice user. 245 | } 246 | print($torrent->getFileCount()); // safe to use other method without any ParseException 247 | ``` 248 | 249 | ## License 250 | 251 | The library is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 252 | 253 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2FRhilip%2FBencode.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2FRhilip%2FBencode?ref=badge_large) 254 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rhilip/bencode", 3 | "description": "A pure and simple PHP library for encoding and decoding Bencode data", 4 | "keywords": ["bittorrent", "torrent", "bencode"], 5 | "type": "library", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Rhilip", 10 | "email": "rhilipruan@gmail.com" 11 | } 12 | ], 13 | "require": { 14 | "php": "^7.3|>=8.0" 15 | }, 16 | "require-dev": { 17 | "ext-json": "*", 18 | "phpunit/phpunit": "^9.0" 19 | }, 20 | "autoload": { 21 | "psr-4": { 22 | "Rhilip\\Bencode\\": "src" 23 | } 24 | }, 25 | "suggest": { 26 | "php-64bit": "Running 64 bit is recommended to prevent integer overflow" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./src 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/Bencode.php: -------------------------------------------------------------------------------- 1 | $v) { 20 | if ($k !== ++$nextKey) { 21 | return false; 22 | } 23 | } 24 | 25 | return true; 26 | } 27 | } 28 | 29 | /** 30 | * Class Bencode 31 | * 32 | * Convert Map: 33 | * - Dictionary (starts with d, ends with e, with key sort) 34 | * - List (starts with l, ends with e) 35 | * - Integer (starts with i, ends with e) 36 | * - String (starts with number denoting number of characters followed by : and then the string) 37 | * 38 | * @package Rhilip\Bencode 39 | * @author Rhilip 40 | * @license MIT 41 | * 42 | * @see https://en.wikipedia.org/wiki/Bencode 43 | * @see https://wiki.theory.org/index.php/BitTorrentSpecification 44 | */ 45 | class Bencode 46 | { 47 | /** 48 | * Decode bencoded data from string 49 | * 50 | * @param string $data 51 | * @param int $pos 52 | * @return mixed 53 | * @throws ParseException 54 | */ 55 | public static function decode($data, &$pos = 0) 56 | { 57 | $start_decode = ($pos === 0); // If it is the root call ? 58 | if ($start_decode && (!is_string($data) || strlen($data) == 0)) { 59 | throw new ParseException('Decode Input is not valid String'); 60 | } 61 | 62 | if ($pos >= strlen($data)) { 63 | throw new ParseException('Unterminated bencode string literal'); 64 | } 65 | 66 | if ($data[$pos] === 'd') { 67 | $pos++; 68 | $return = []; 69 | while ($data[$pos] !== 'e') { 70 | $key = self::decode($data, $pos); 71 | $value = self::decode($data, $pos); 72 | if ($key === null || $value === null) { 73 | break; 74 | } 75 | if (!is_string($key)) { 76 | throw new ParseException('Non string key found in the dictionary'); 77 | } elseif (array_key_exists($key, $return)) { 78 | throw new ParseException('Duplicate Dictionary key exist before: ' . $key); 79 | } 80 | $return[$key] = $value; 81 | } 82 | ksort($return, SORT_STRING); 83 | $pos++; 84 | } elseif ($data[$pos] === 'l') { 85 | $pos++; 86 | $return = []; 87 | while ($data[$pos] !== 'e') { 88 | $value = self::decode($data, $pos); 89 | $return[] = $value; 90 | } 91 | $pos++; 92 | } elseif ($data[$pos] === 'i') { 93 | $pos++; 94 | $digits = strpos($data, 'e', $pos) - $pos; 95 | $value = substr($data, $pos, $digits); 96 | $return = self::checkInteger($value); 97 | $pos += $digits + 1; 98 | } else { 99 | $digits = strpos($data, ':', $pos) - $pos; 100 | $len = self::checkInteger(substr($data, $pos, $digits)); 101 | if ($len < 0) { 102 | throw new ParseException('Cannot have non-digit values for String length'); 103 | } 104 | 105 | $pos += ($digits + 1); 106 | $return = substr($data, $pos, $len); 107 | 108 | if (strlen($return) != $len) { // Check for String length is match or not 109 | throw new ParseException('String length is not match for: ' . $return . ', want ' . $len); 110 | } 111 | 112 | $pos += $len; 113 | } 114 | 115 | if ($start_decode && $pos !== strlen($data)) { 116 | throw new ParseException('Could not fully decode bencode string'); 117 | } 118 | return $return; 119 | } 120 | 121 | /** 122 | * This private function help us filter value like `-13` `13` will pass the filter and return it's int value 123 | * Other value like ``,`-0`, `013`, `-013`, `2.127`, `six` will throw A ParseException 124 | * 125 | * @param string $value 126 | * @return int 127 | * @throws ParseException 128 | */ 129 | private static function checkInteger($value) 130 | { 131 | $int = (int)$value; 132 | if ((string)$int !== $value) { 133 | throw new ParseException('Invalid integer format or integer overflow: ' . $value); 134 | } 135 | return $int; 136 | } 137 | 138 | /** 139 | * Encode arbitrary data to bencode string 140 | * 141 | * @param mixed $data 142 | * @return string 143 | */ 144 | public static function encode($data) 145 | { 146 | if (is_array($data)) { 147 | $return = ''; 148 | if (array_is_list($data)) { 149 | $return .= 'l'; 150 | foreach ($data as $value) { 151 | $return .= self::encode($value); 152 | } 153 | } else { 154 | $return .= 'd'; 155 | ksort($data, SORT_STRING); 156 | foreach ($data as $key => $value) { 157 | $return .= self::encode((string)$key); 158 | $return .= self::encode($value); 159 | } 160 | } 161 | $return .= 'e'; 162 | } elseif (is_integer($data)) { 163 | $return = 'i' . $data . 'e'; 164 | } else { 165 | $return = strlen($data) . ':' . $data; 166 | } 167 | return $return; 168 | } 169 | 170 | /** 171 | * Load data from bencoded file 172 | * 173 | * @param string $path 174 | * @return mixed 175 | * @throws ParseException 176 | */ 177 | public static function load($path) 178 | { 179 | return self::decode(file_get_contents($path)); 180 | } 181 | 182 | /** 183 | * Dump data to bencoded file 184 | * 185 | * @param string $path 186 | * @param $data 187 | * @return mixed 188 | */ 189 | public static function dump($path, $data) 190 | { 191 | return file_put_contents($path, self::encode($data)); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/ParseException.php: -------------------------------------------------------------------------------- 1 | getFileTree() output, by default it is normal as parsed order 40 | public const FILETREE_SORT_NORMAL = 0x00; 41 | public const FILETREE_SORT_STRING = 0x01; 42 | public const FILETREE_SORT_FOLDER = 0x10; 43 | public const FILETREE_SORT_NATURAL = 0x11; // same as `self::FILETREE_SORT_STRING | self::FILETREE_SORT_FOLDER` 44 | 45 | // store torrent dict 46 | private $data; 47 | 48 | // we may have some tmp data when parsed, so we store here to avoid regenerate 49 | private $cache = []; 50 | 51 | // Custom validator for parse torrent 52 | private $parseValidator; 53 | private $useParseValidator; 54 | 55 | /** 56 | * Help utils to check torrent dick 57 | * @param mixed $dict 58 | * @param string $key 59 | * @param string|null $type 60 | */ 61 | protected static function checkTorrentDict($dict, $key, $type = null) 62 | { 63 | if (!is_array($dict)) { 64 | throw new ParseException('Checking non-dictionary value'); 65 | } 66 | 67 | if (!isset($dict[$key])) { 68 | throw new ParseException("Checking Dictionary missing key: {$key}"); 69 | } 70 | 71 | $value = $dict[$key]; 72 | 73 | if (!is_null($type)) { 74 | $isFunction = 'is_' . $type; 75 | if (function_exists($isFunction) && !$isFunction($value)) { 76 | $valueType = gettype($value); 77 | throw new ParseException("Invalid entry type in dictionary, want : {$type}, current: {$valueType}"); 78 | } 79 | } 80 | 81 | return $value; 82 | } 83 | 84 | /** 85 | * Use as Singleton, so user can only call TorrentFile::load() or 86 | * TorrentFile::loadFromString() method to create instance. 87 | */ 88 | protected function __construct($data) 89 | { 90 | // Valid must exist key. 91 | $info = self::checkTorrentDict($data, 'info', 'array'); 92 | self::checkTorrentDict($info, 'piece length', 'integer'); 93 | self::checkTorrentDict($info, 'name', 'string'); 94 | 95 | // Store base data 96 | $this->data = $data; 97 | } 98 | 99 | /** 100 | * 1. load and dump method for torrent file 101 | * 102 | * - Only load and loadFromString function are static in whole class 103 | * - dump and dumpToString are just wrapper of Bencode 104 | */ 105 | 106 | public static function load($path) 107 | { 108 | return new self(Bencode::load($path)); 109 | } 110 | 111 | public static function loadFromString($string) 112 | { 113 | return new self(Bencode::decode($string)); 114 | } 115 | 116 | public function dump($path) 117 | { 118 | return Bencode::dump($path, $this->data); 119 | } 120 | 121 | public function dumpToString() 122 | { 123 | return Bencode::encode($this->data); 124 | } 125 | 126 | /** 127 | * 2. methods For torrent root dict 128 | */ 129 | 130 | public function getRootData() 131 | { 132 | return $this->data; 133 | } 134 | 135 | public function getRootField($field, $default = null) 136 | { 137 | return $this->data[$field] ?? $default; 138 | } 139 | 140 | public function setRootField($field, $value) 141 | { 142 | $this->data[$field] = $value; 143 | return $this; 144 | } 145 | 146 | public function unsetRootField($field) 147 | { 148 | unset($this->data[$field]); 149 | return $this; 150 | } 151 | 152 | /** 153 | * Clean out keys within the data dictionary that are not strictly necessary or will be 154 | * overwritten dynamically on any downloaded torrent (like announce or comment), so that we 155 | * store the smallest encoded string within the database and cuts down on potential waste. 156 | */ 157 | public function cleanRootFields($allowedKeys = [ 158 | 'comment', 'created by', 'creation date', 'encoding' // Other keys 159 | ]) 160 | { 161 | $allowedKeys = array_merge([ 162 | 'announce', 'info', // main part 163 | 'piece layers', // v2 need 164 | ], $allowedKeys); 165 | foreach ($this->data as $key => $value) { 166 | if (!in_array($key, $allowedKeys)) { 167 | $this->unsetRootField($key); 168 | } 169 | } 170 | 171 | return $this; 172 | } 173 | 174 | /** 175 | * 3. getters and setters For torrent root dict 176 | */ 177 | 178 | // Announce 179 | public function getAnnounce() 180 | { 181 | return $this->getRootField('announce'); 182 | } 183 | 184 | public function setAnnounce($value) 185 | { 186 | return $this->setRootField('announce', $value); 187 | } 188 | 189 | // Announce List, see https://www.bittorrent.org/beps/bep_0012.html 190 | public function getAnnounceList() 191 | { 192 | return $this->getRootField('announce-list'); 193 | } 194 | 195 | public function setAnnounceList($value) 196 | { 197 | return $this->setRootField('announce-list', $value); 198 | } 199 | 200 | // Comment, Optional description. 201 | public function getComment() 202 | { 203 | return $this->getRootField('comment'); 204 | } 205 | 206 | public function setComment($value) 207 | { 208 | return $this->setRootField('comment', $value); 209 | } 210 | 211 | // Created By 212 | public function getCreatedBy() 213 | { 214 | return $this->getRootField('created by'); 215 | } 216 | 217 | public function setCreatedBy($value) 218 | { 219 | return $this->setRootField('created by', $value); 220 | } 221 | 222 | // Creation Date 223 | public function getCreationDate() 224 | { 225 | return $this->getRootField('creation date'); 226 | } 227 | 228 | public function setCreationDate($value) 229 | { 230 | return $this->setRootField('creation date', $value); 231 | } 232 | 233 | // Http Seeds, see: https://www.bittorrent.org/beps/bep_0017.html 234 | public function getHttpSeeds() 235 | { 236 | return $this->getRootField('httpseeds'); 237 | } 238 | 239 | public function setHttpSeeds($value) 240 | { 241 | return $this->setRootField('httpseeds', $value); 242 | } 243 | 244 | // Nodes, see: https://www.bittorrent.org/beps/bep_0005.html 245 | public function getNodes() 246 | { 247 | return $this->getRootField('nodes'); 248 | } 249 | 250 | public function setNodes($value) 251 | { 252 | return $this->setRootField('nodes', $value); 253 | } 254 | 255 | // UrlList, see: https://www.bittorrent.org/beps/bep_0019.html 256 | public function getUrlList() 257 | { 258 | return $this->getRootField('url-list'); 259 | } 260 | 261 | public function setUrlList($value) 262 | { 263 | return $this->setRootField('url-list', $value); 264 | } 265 | 266 | /** 267 | * 4. methods For torrent info dict 268 | */ 269 | 270 | public function getInfoData() 271 | { 272 | return $this->data['info']; 273 | } 274 | 275 | protected function getInfoString() 276 | { 277 | return Bencode::encode($this->data['info']); 278 | } 279 | 280 | public function getInfoField($field, $default = null) 281 | { 282 | return $this->data['info'][$field] ?? $default; 283 | } 284 | 285 | public function setInfoField($field, $value) 286 | { 287 | $this->data['info'][$field] = $value; 288 | return $this; 289 | } 290 | 291 | public function unsetInfoField($field) 292 | { 293 | unset($this->data['info'][$field]); 294 | return $this; 295 | } 296 | 297 | /** 298 | * Cleans out keys within the info dictionary (and would affect the generated info_hash) 299 | * that are not standard or expected. We do allow some keys that are not strictly necessary 300 | * (primarily the two below), but that's because it's better to just have the extra bits in 301 | * the dictionary than having to force a user to re-download the torrent file for something 302 | * that they might have no idea their client is doing nor how to stop it. 303 | * 304 | * x_cross_seed is added by PyroCor (@see https://github.com/pyroscope/pyrocore) 305 | * unique is added by xseed (@see https://whatbox.ca/wiki/xseed) 306 | * 307 | */ 308 | public function cleanInfoFields($allowedKeys = [ 309 | // Other not standard keys 310 | 'name.utf8', 'name.utf-8', 'md5sum', 'sha1', 'source', 311 | 'file-duration', 'file-media', 'profiles', 312 | 'x_cross_seed', 'unique' 313 | ]) 314 | { 315 | $allowedKeys = array_merge([ 316 | 'name', 'private', 'piece length', // Common key 317 | 'files', 'pieces', 'length', // v1 318 | 'file tree', 'meta version', // v2 319 | ], $allowedKeys); 320 | foreach ($this->data['info'] as $key => $value) { 321 | if (!in_array($key, $allowedKeys)) { 322 | $this->unsetInfoField($key); 323 | } 324 | } 325 | 326 | return $this; 327 | } 328 | 329 | /** 330 | * 5. getters and setters For torrent info dict 331 | */ 332 | 333 | public function getProtocol() 334 | { 335 | if (!isset($this->cache['protocol'])) { 336 | $version = $this->getInfoField('meta version', 1); 337 | 338 | if ($version === 2) { 339 | $this->cache['protocol'] = $this->getInfoField('pieces') ? self::PROTOCOL_HYBRID : self::PROTOCOL_V2; 340 | } else { 341 | $this->cache['protocol'] = self::PROTOCOL_V1; 342 | } 343 | } 344 | return $this->cache['protocol']; 345 | } 346 | 347 | public function getFileMode() 348 | { 349 | if (!isset($this->cache['filemode'])) { 350 | if ($this->getProtocol() !== self::PROTOCOL_V2) { 351 | $this->cache['filemode'] = $this->getInfoField('length') ? self::FILEMODE_SINGLE : self::FILEMODE_MULTI; 352 | } else { 353 | $fileTree = $this->getInfoField('file tree'); 354 | 355 | if (\count($fileTree) !== 1) { 356 | $this->cache['filemode'] = self::FILEMODE_MULTI; 357 | } else { 358 | $file = reset($fileTree); 359 | 360 | if (isset($file['']['length'])) { 361 | $this->cache['filemode'] = self::FILEMODE_SINGLE; 362 | } 363 | } 364 | } 365 | } 366 | 367 | return $this->cache['filemode']; 368 | } 369 | 370 | /** 371 | * Get V1 info hash if V1 metadata is present or null if not. 372 | * 373 | * note: 374 | * - getInfoHashV1(true) is same as pack("H*", sha1($infohashString)) 375 | * - getInfoHashv1(false) is same as bin2hex(sha1($infohashString, true)) 376 | */ 377 | public function getInfoHashV1($binary = false) 378 | { 379 | if ($this->getProtocol() !== self::PROTOCOL_V2) { 380 | return sha1($this->getInfoString(), $binary); 381 | } 382 | } 383 | 384 | /** 385 | * Get V2 info hash if V2 metadata is present or null if not. 386 | * 387 | * @param bool $binary 388 | * @throws ParseException 389 | */ 390 | public function getInfoHashV2($binary = false) 391 | { 392 | if ($this->getProtocol() !== self::PROTOCOL_V1) { 393 | return hash('sha256', $this->getInfoString(), $binary); 394 | } 395 | } 396 | 397 | /** 398 | * The method returns V2 info hash if the metadata is present. 399 | * Get V2 info hash if V2 metadata is present, fall back to V1 info hash. 400 | */ 401 | public function getInfoHash($binary = false) 402 | { 403 | return $this->getInfoHashV2($binary) ?: $this->getInfoHashV1($binary); 404 | } 405 | 406 | /** 407 | * Get all available hashes as array. 408 | */ 409 | public function getInfoHashs($binary = false) 410 | { 411 | return [ 412 | self::PROTOCOL_V1 => $this->getInfoHashV1($binary), 413 | self::PROTOCOL_V2 => $this->getInfoHashV2($binary) 414 | ]; 415 | } 416 | 417 | public function getInfoHashV1ForAnnounce() 418 | { 419 | return $this->getInfoHashV1(true); 420 | } 421 | 422 | public function getInfoHashV2ForAnnounce() 423 | { 424 | $infoHash = $this->getInfoHashV2(true); 425 | if ($infoHash) { 426 | return substr($infoHash, 0, 20); 427 | } 428 | } 429 | 430 | /** 431 | * The 20-bytes truncated infohash 432 | */ 433 | public function getInfoHashsForAnnounce() 434 | { 435 | return [ 436 | self::PROTOCOL_V1 => $this->getInfoHashV1ForAnnounce(), 437 | self::PROTOCOL_V2 => $this->getInfoHashV2ForAnnounce() 438 | ]; 439 | } 440 | 441 | public function getPieceLength() 442 | { 443 | return $this->getInfoField('piece length'); 444 | } 445 | 446 | public function getName() 447 | { 448 | return $this->getInfoField('name.utf8', $this->getInfoField('name')); 449 | } 450 | 451 | public function setName($name) 452 | { 453 | if ($name === '') { 454 | throw new \InvalidArgumentException('$name must not be empty'); 455 | } 456 | if (str_contains($name, '/') || str_contains($name, "\0")) { 457 | throw new \InvalidArgumentException('$name must not contain slashes and zero bytes'); 458 | } 459 | 460 | return $this->setInfoField('name', $name); 461 | } 462 | 463 | /** 464 | * Get the source flag if one has been set 465 | */ 466 | public function getSource() 467 | { 468 | return $this->getInfoField('source'); 469 | } 470 | 471 | /** 472 | * Set the source flag in the info dictionary equal to $source. This can be used to ensure a 473 | * unique info hash across sites so long as all sites use the source flag. This isn't an 474 | * 'official' flag (no accepted BEP on it), but it has become the defacto standard with more 475 | * clients supporting it natively. Returns a boolean on whether or not the source was changed 476 | * so that an appropriate screen can be shown to the user. 477 | */ 478 | public function setSource($source) 479 | { 480 | $this->unsetInfoField('x_cross_seed')->unsetInfoField('unique'); 481 | return $this->setInfoField('source', $source); 482 | } 483 | 484 | public function isPrivate() 485 | { 486 | return $this->getInfoField('private') === 1; 487 | } 488 | 489 | /** 490 | * @param bool $private 491 | */ 492 | public function setPrivate($private) 493 | { 494 | return $private ? $this->setInfoField('private', 1) : $this->unsetInfoField('private'); 495 | } 496 | 497 | /** 498 | * Get Torrent Magnet URI 499 | * @return string 500 | */ 501 | public function getMagnetLink($dn = true, $tr = true) 502 | { 503 | $urlSearchParams = []; 504 | 505 | $infoHashV1 = $this->getInfoHashV1(); 506 | if ($infoHashV1) { 507 | $urlSearchParams[] = 'xt=urn:btih:' . $infoHashV1; 508 | } 509 | 510 | $infoHashV2 = $this->getInfoHashV2(); 511 | if ($infoHashV2) { 512 | $urlSearchParams[] = 'xt=urn:btmh:' . '1220' . $infoHashV2; // 1220 is magic number 513 | } 514 | 515 | if ($dn) { 516 | $name = $this->getName() ?? ''; 517 | if ($name !== '') { 518 | $urlSearchParams[] = 'dn=' . rawurlencode($name); 519 | } 520 | } 521 | 522 | if ($tr) { 523 | $trackers = []; 524 | 525 | $announceList = $this->getAnnounceList(); 526 | if ($announceList) { 527 | foreach ($announceList as $tier) { 528 | foreach ($tier as $tracker) { 529 | $trackers[] = $tracker; 530 | } 531 | } 532 | } else { 533 | $rootTracker = $this->getAnnounce(); 534 | if ($rootTracker) { 535 | $trackers[] = $rootTracker; 536 | } 537 | } 538 | 539 | foreach (array_unique($trackers) as $tracker) { 540 | $urlSearchParams[] = 'tr=' . rawurlencode($tracker); 541 | } 542 | } 543 | 544 | return 'magnet:?' . implode('&', $urlSearchParams); 545 | } 546 | 547 | /** 548 | * Utility function to clean out keys in the data and info dictionaries that we don't need in 549 | * our torrent file when we go to store it in the DB or serve it up to the user (with the 550 | * expectation that we'll be calling at least setAnnounceUrl(...) when a user asks for a valid 551 | * torrent file). 552 | */ 553 | public function clean() 554 | { 555 | return $this->cleanRootFields()->cleanInfoFields(); 556 | } 557 | 558 | public function setParseValidator($validator = null) 559 | { 560 | $this->parseValidator = $validator; 561 | $this->useParseValidator = $this->parseValidator instanceof \Closure; 562 | return $this; 563 | } 564 | 565 | /** 566 | * 6. other method that we used to get size, filelist or filetree, 567 | * 568 | */ 569 | protected function addFileToList($paths, $size) 570 | { 571 | if ($this->useParseValidator) { 572 | call_user_func($this->parseValidator, self::arrayEnd($paths), $paths); 573 | } 574 | $this->cache['files'][] = ['path' => implode('/', $paths), 'size' => $size]; 575 | } 576 | 577 | protected function parseV1SingleTorrent() 578 | { 579 | $size = $this->getInfoField('length'); 580 | $name = $this->getName(); 581 | 582 | $this->addFileToList([$name], $size); 583 | $this->cache['fileTree'][$name] = $size; 584 | } 585 | 586 | protected function parseV1MultiTorrent() 587 | { 588 | $torrentFiles = self::checkTorrentDict($this->data['info'], 'files', 'array'); 589 | 590 | foreach ($torrentFiles as $file) { 591 | $length = self::checkTorrentDict($file, 'length', 'integer'); 592 | $path_key = isset($file['path.utf-8']) ? 'path.utf-8' : 'path'; 593 | $paths = self::checkTorrentDict($file, $path_key, 'array'); 594 | 595 | foreach ($paths as $path) { 596 | if (!is_string($path) || $path === '') { 597 | throw new ParseException('Invalid path with non-string or empty-string value'); 598 | } 599 | } 600 | 601 | $this->addFileToList($paths, $length); 602 | 603 | // Built fileTree for v1-multi torrent 604 | $leafPart = array_pop($paths); 605 | $parentArr = &$this->cache['fileTree']; 606 | foreach ($paths as $path) { 607 | if (!isset($parentArr[$path])) { 608 | $parentArr[$path] = []; 609 | } elseif (!is_array($parentArr[$path])) { 610 | $parentArr[$path] = []; 611 | } 612 | $parentArr = &$parentArr[$path]; 613 | } 614 | if (empty($parentArr[$leafPart])) { 615 | $parentArr[$leafPart] = $length; 616 | } 617 | } 618 | } 619 | 620 | private function loopMerkleTree(&$merkleTree, &$paths = []) 621 | { 622 | if (isset($merkleTree[''])) { // reach file 623 | $file = $merkleTree['']; 624 | 625 | $piecesRoot = self::checkTorrentDict($file, 'pieces root', 'string'); 626 | if (strlen($piecesRoot) != 32) { 627 | throw new ParseException('Invalid pieces_root length.'); 628 | } 629 | 630 | $length = self::checkTorrentDict($file, 'length', 'integer'); 631 | if ($length > $this->getPieceLength()) { // check pieces root of large file is exist in $root['piece layers'] or not 632 | if (!array_key_exists($piecesRoot, $this->getRootField('piece layers'))) { 633 | throw new ParseException('Pieces not exist in piece layers'); 634 | } 635 | } 636 | 637 | $this->addFileToList($paths, $length); 638 | $merkleTree = $length; // rewrite merkleTree to size, it's safe since it not affect $data['info']['file tree'] 639 | } else { 640 | $parent_path = $paths; // store parent paths 641 | foreach ($merkleTree as $k => &$v) { // Loop tree 642 | $paths[] = $k; // push current path into paths 643 | $this->loopMerkleTree($v, $paths); // Loop check 644 | $paths = $parent_path; // restore parent paths 645 | } 646 | } 647 | } 648 | 649 | protected function parseV2Torrent() 650 | { 651 | $fileTree = self::checkTorrentDict($this->getInfoData(), 'file tree', 'array'); 652 | $this->loopMerkleTree($fileTree); 653 | $this->cache['fileTree'] = $fileTree; 654 | } 655 | 656 | public function parse() 657 | { 658 | $this->cache['files'] = []; 659 | $this->cache['fileTree'] = []; 660 | 661 | // Call main parse function 662 | if ($this->getProtocol() === self::PROTOCOL_V1) { // Do what we do in protocol v1 663 | $pieces = self::checkTorrentDict($this->getInfoData(), 'pieces', 'string'); 664 | if (strlen($pieces) % 20 != 0) { 665 | throw new ParseException('Invalid pieces length'); 666 | } 667 | 668 | if ($this->getFileMode() === self::FILEMODE_SINGLE) { 669 | $this->parseV1SingleTorrent(); 670 | } else { 671 | $this->parseV1MultiTorrent(); 672 | } 673 | } else { 674 | self::checkTorrentDict($this->getRootData(), 'piece layers', 'array'); 675 | $this->parseV2Torrent(); 676 | } 677 | 678 | // count torrent files and total_size 679 | $this->cache['count'] = count($this->cache['files']); 680 | $this->cache['total_size'] = array_sum(array_column($this->cache['files'], 'size')); 681 | 682 | // Fix fileTree for multi torrent 683 | if ($this->getFileMode() === self::FILEMODE_MULTI) { 684 | $torrentName = $this->getName(); 685 | $this->cache['fileTree'] = [$torrentName => $this->cache['fileTree']]; 686 | } 687 | 688 | return array_intersect_key($this->cache, 689 | array_flip(['total_size', 'count', 'files', 'fileTree']) 690 | ); 691 | } 692 | 693 | /** 694 | * Return a list like: 695 | * [ 696 | * ["path" => "filename1", "size" => 123], // 123 is file size 697 | * ["path" => "directory/filename2", "size" => 2345] 698 | * ] 699 | * 700 | */ 701 | public function getFileList() 702 | { 703 | if (!isset($this->cache['files'])) { 704 | $this->parse(); 705 | } 706 | 707 | return $this->cache['files']; 708 | } 709 | 710 | public function getSize() 711 | { 712 | if (!isset($this->cache['total_size'])) { 713 | $this->parse(); 714 | } 715 | 716 | return $this->cache['total_size']; 717 | } 718 | 719 | public function getFileCount() 720 | { 721 | if (!isset($this->cache['count'])) { 722 | $this->parse(); 723 | } 724 | 725 | return $this->cache['count']; 726 | } 727 | 728 | private static function sortFileTreeRecursive(array &$fileTree, $sortByString = false, $sortByFolder = false) 729 | { 730 | if ($sortByString) { 731 | ksort($fileTree, SORT_NATURAL | SORT_FLAG_CASE); 732 | } 733 | 734 | $isoFile = []; 735 | foreach ($fileTree as $key => &$item) { 736 | if (is_array($item)) { 737 | self::sortFileTreeRecursive($item, $sortByString, $sortByFolder); 738 | } elseif ($sortByFolder) { 739 | $isoFile[$key] = $item; 740 | unset($fileTree[$key]); 741 | } 742 | } 743 | if ($sortByFolder && !empty($isoFile)) { 744 | $fileTree = array_merge($fileTree, $isoFile); 745 | } 746 | } 747 | 748 | /** 749 | * Return a dict like: 750 | * [ 751 | * "torrentname" => [ 752 | * "directory" => [ 753 | * "filename2" => 2345 754 | * ], 755 | * "filename1" => 123 // 123 is file size 756 | * ] 757 | * ] 758 | */ 759 | public function getFileTree($sortType = self::FILETREE_SORT_NORMAL) 760 | { 761 | if (!isset($this->cache['fileTree'])) { 762 | $this->parse(); 763 | } 764 | 765 | $fileTree = $this->cache['fileTree']; 766 | 767 | $sortByString = ($sortType & self::FILETREE_SORT_STRING) === self::FILETREE_SORT_STRING; 768 | $sortByFolder = ($sortType & self::FILETREE_SORT_FOLDER) === self::FILETREE_SORT_FOLDER; 769 | if ($sortByString || $sortByFolder) { 770 | self::sortFileTreeRecursive($fileTree, $sortByString, $sortByFolder); 771 | } 772 | 773 | return $fileTree; 774 | } 775 | 776 | public function cleanCache() 777 | { 778 | $this->cache = []; 779 | return $this; 780 | } 781 | 782 | // Wrapper end function to avoid change the internal pointer of $path, 783 | private static function arrayEnd($array) 784 | { 785 | return end($array); 786 | } 787 | } 788 | -------------------------------------------------------------------------------- /tests/DecodeTest.php: -------------------------------------------------------------------------------- 1 | assertEquals(213, Bencode::decode('i213e')); 16 | $this->assertEquals(-314, Bencode::decode('i-314e')); 17 | $this->assertEquals(0, Bencode::decode('i0e')); 18 | 19 | $this->assertEquals(PHP_INT_MAX, Bencode::decode('i' . PHP_INT_MAX . 'e')); 20 | $this->assertEquals(PHP_INT_MIN, Bencode::decode('i' . PHP_INT_MIN . 'e')); 21 | } 22 | 23 | /** 24 | * @group integer 25 | */ 26 | public function testDecodeIntegerEmpty() 27 | { 28 | $this->expectException(ParseException::class); 29 | $this->expectExceptionMessage('Invalid integer format or integer overflow'); 30 | Bencode::decode('ie'); 31 | } 32 | 33 | /** 34 | * All encodings with a leading zero, such as i03e, are invalid, 35 | * other than i0e, which of course corresponds to the integer "0". 36 | * 37 | * @group integer 38 | */ 39 | public function testDecodeIntegerLeadingZero() 40 | { 41 | $this->expectException(ParseException::class); 42 | $this->expectExceptionMessage('Invalid integer format or integer overflow'); 43 | Bencode::decode('i013e'); 44 | } 45 | 46 | /** 47 | * @group integer 48 | */ 49 | public function testDecodeIntegerZeroNegative() 50 | { 51 | $this->expectException(ParseException::class); 52 | $this->expectExceptionMessage('Invalid integer format or integer overflow'); 53 | 54 | Bencode::decode('i-013e'); 55 | } 56 | 57 | /** 58 | * i-0e is invalid. 59 | * 60 | * @group integer 61 | */ 62 | public function testDecodeIntegerMinusZero() 63 | { 64 | $this->expectException(ParseException::class); 65 | $this->expectExceptionMessage('Invalid integer format or integer overflow'); 66 | Bencode::decode('i-0e'); 67 | } 68 | 69 | public function testDecodeIntegerWithMinusInsertedIn() 70 | { 71 | $this->expectException(ParseException::class); 72 | $this->expectExceptionMessage('Invalid integer format or integer overflow'); 73 | Bencode::decode('i35412-5633e'); 74 | } 75 | 76 | public function testDecodeIntegerWithInvalidDigitsInsertedIn() 77 | { 78 | $this->expectException(ParseException::class); 79 | $this->expectExceptionMessage('Invalid integer format or integer overflow'); 80 | Bencode::decode('i92337203t854775807e'); 81 | } 82 | 83 | /** 84 | * Float shouldn't pass into integer format 85 | * 86 | * @group integer 87 | */ 88 | public function testDecodeIntegerFakeFloat() 89 | { 90 | $this->expectException(ParseException::class); 91 | $this->expectExceptionMessage('Invalid integer format or integer overflow'); 92 | 93 | Bencode::decode('i2.71828e'); 94 | } 95 | 96 | /** 97 | * String shouldn't pass into integer format 98 | * 99 | * @group integer 100 | */ 101 | public function testDecodeIntegerFakeString() 102 | { 103 | $this->expectException(ParseException::class); 104 | $this->expectExceptionMessage('Invalid integer format or integer overflow'); 105 | 106 | Bencode::decode('iffafwe'); 107 | } 108 | 109 | /** 110 | * String shouldn't pass into integer format 111 | * 112 | * @group integer 113 | */ 114 | public function testDecodeIntegerOverflow() 115 | { 116 | $this->expectException(ParseException::class); 117 | $this->expectExceptionMessage('Invalid integer format or integer overflow'); 118 | 119 | $value = PHP_INT_MAX . '0000'; // PHP_INT_MAX * 10000 120 | $encoded = "i{$value}e"; 121 | 122 | Bencode::decode($encoded); 123 | } 124 | 125 | /** 126 | * @group string 127 | */ 128 | public function testDecodeString() 129 | { 130 | // simple string 131 | $this->assertEquals('abcdefghijklmnopqrstuvwxyz', Bencode::decode('26:abcdefghijklmnopqrstuvwxyz')); 132 | // empty string 133 | $this->assertEquals('', Bencode::decode('0:')); 134 | // special chars 135 | $this->assertEquals("zero\0newline\nsymblol05\x05ok", Bencode::decode("25:zero\0newline\nsymblol05\x05ok")); 136 | // unicode 137 | $this->assertEquals('日本語', Bencode::decode('9:日本語')); 138 | } 139 | 140 | /** 141 | * @group string 142 | */ 143 | public function testDecodeStringIncorrectLengthZeroPrefix() 144 | { 145 | $this->expectException(ParseException::class); 146 | $this->expectExceptionMessage('Invalid integer format or integer overflow'); 147 | 148 | Bencode::decode('06:String'); 149 | } 150 | 151 | /** 152 | * @group string 153 | */ 154 | public function testDecodeStringIncorrectLengthFloat() 155 | { 156 | $this->expectException(ParseException::class); 157 | $this->expectExceptionMessage('Invalid integer format or integer overflow'); 158 | 159 | Bencode::decode('6.0:String'); 160 | } 161 | 162 | /** 163 | * @group string 164 | */ 165 | public function testDecodeStringIncorrectLengthNotNumeric() 166 | { 167 | $this->expectException(ParseException::class); 168 | $this->expectExceptionMessage('Invalid integer format or integer overflow'); 169 | 170 | Bencode::decode('six:String'); 171 | } 172 | 173 | /** 174 | * @group string 175 | */ 176 | public function testDecodeStringIncorrectLengthNegative() 177 | { 178 | $this->expectException(ParseException::class); 179 | $this->expectExceptionMessage('Cannot have non-digit values for String length'); 180 | 181 | Bencode::decode('-6:String'); 182 | } 183 | 184 | /** 185 | * @group string 186 | */ 187 | public function testDecodeStringUnexpectedEof() 188 | { 189 | $this->expectException(ParseException::class); 190 | $this->expectExceptionMessage('String length is not match'); 191 | 192 | Bencode::decode('10:String'); 193 | } 194 | 195 | /** 196 | * @group list 197 | */ 198 | public function testDecodeList() 199 | { 200 | // of integers 201 | $this->assertEquals([2, 3, 5, 7, 11, 13], Bencode::decode('li2ei3ei5ei7ei11ei13ee')); 202 | // of strings 203 | $this->assertEquals(['s1', 's2'], Bencode::decode('l2:s12:s2e')); 204 | // mixed 205 | $this->assertEquals([2, 's1', 3, 's2', 5], Bencode::decode('li2e2:s1i3e2:s2i5ee')); 206 | // empty 207 | $this->assertEquals([], Bencode::decode('le')); 208 | } 209 | 210 | /** 211 | * @group dictionary 212 | */ 213 | public function testDecodeDictionary() 214 | { 215 | // simple 216 | $this->assertEquals(['a' => 'b', 'c' => 'd'], Bencode::decode('d1:a1:b1:c1:de')); 217 | 218 | // numeric keys 219 | // php converts numeric array keys to integers 220 | $this->assertEquals([1 => 2, 3 => 4], Bencode::decode('d1:1i2e1:3i4ee')); 221 | 222 | // empty 223 | $this->assertEquals([], Bencode::decode('de')); 224 | } 225 | 226 | /** 227 | * **Notice:** 228 | * None Exception will throw when decode a bencode dict string with `Invalid order of dictionary keys`, 229 | * and a sorted PHP Array will return 230 | * 231 | * @group dictionary 232 | */ 233 | public function testDecodeDictionarySorted() 234 | { 235 | // d3:aaa1:b3:ccc1:de 236 | $this->assertEquals(['aaa' => 'b', 'ccc' => 'd'], Bencode::decode('d3:ccc1:d3:aaa1:be')); 237 | 238 | // d1:11:a2:111:b1:21:c2:221:de 239 | $this->assertEquals([1 => 'a', 11 => 'b', 2 => 'c', 22 => 'd'], Bencode::decode('d1:11:a1:21:c2:111:b2:221:de')); 240 | } 241 | 242 | /** 243 | * @group dictionary 244 | */ 245 | public function testDecodeDictionaryKeyNotString() 246 | { 247 | $this->expectException(ParseException::class); 248 | $this->expectExceptionMessage('Non string key found in the dictionary'); 249 | 250 | Bencode::decode('di123ei321ee'); 251 | } 252 | 253 | /** 254 | * @group dictionary 255 | */ 256 | public function testDecodeDictionaryWithKeyButWithoutValue() 257 | { 258 | $this->expectException(ParseException::class); 259 | $this->expectExceptionMessage('Invalid integer format or integer overflow'); // FIXME 260 | Bencode::decode('d1:ai1e1:be'); 261 | } 262 | 263 | /** 264 | * @group dictionary 265 | */ 266 | public function testDecodeDictionaryDuplicateKey() 267 | { 268 | $this->expectException(ParseException::class); 269 | $this->expectExceptionMessage('Duplicate Dictionary key exist before'); 270 | 271 | Bencode::decode('d1:a1:b1:a1:de'); 272 | } 273 | 274 | public function testDecodeTorrent() 275 | { 276 | $bencode = 'd8:announce39:http://torrent.foobar.baz:9374/announce13:announce-listll39:http://torrent.foobar.baz:9374/announceel44:http://ipv6.torrent.foobar.baz:9374/announceee7:comment31:My torrent comment goes here :)13:creation datei1382003607e4:infod6:lengthi925892608e4:name13:some-file.boo12:piece lengthi524288e6:pieces0:ee'; 277 | 278 | $torrent = array( 279 | 'announce' => 'http://torrent.foobar.baz:9374/announce', 280 | 'announce-list' => array( 281 | array('http://torrent.foobar.baz:9374/announce'), 282 | array('http://ipv6.torrent.foobar.baz:9374/announce'), 283 | ), 284 | 'comment' => 'My torrent comment goes here :)', 285 | 'creation date' => 1382003607, 286 | 'info' => array( 287 | 'length' => 925892608, 288 | 'name' => 'some-file.boo', 289 | 'piece length' => 524288, 290 | 'pieces' => '', 291 | ), 292 | ); 293 | 294 | $this->assertEquals($torrent, Bencode::decode($bencode)); 295 | } 296 | 297 | /** 298 | * @group all 299 | */ 300 | public function testDecodeNothing() 301 | { 302 | $this->expectException(ParseException::class); 303 | $this->expectExceptionMessage('Decode Input is not valid String'); 304 | 305 | Bencode::decode(''); 306 | } 307 | 308 | /** 309 | * @group all 310 | */ 311 | public function testDecodeNull() 312 | { 313 | $this->expectException(ParseException::class); 314 | $this->expectExceptionMessage('Decode Input is not valid String'); 315 | 316 | Bencode::decode(null); 317 | } 318 | 319 | /** 320 | * @group all 321 | */ 322 | public function testDecodeWithExtraJunk() 323 | { 324 | $this->expectException(ParseException::class); 325 | $this->expectExceptionMessage('Could not fully decode bencode string'); 326 | Bencode::decode('i0ejunk'); 327 | } 328 | } 329 | -------------------------------------------------------------------------------- /tests/EncodeTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('i314e', Bencode::encode(314)); 15 | 16 | // negative 17 | $this->assertEquals('i-512e', Bencode::encode(-512)); 18 | 19 | // zero 20 | $this->assertEquals('i0e', Bencode::encode(0)); 21 | $this->assertEquals('i0e', Bencode::encode(-0)); 22 | } 23 | 24 | /** 25 | * @group string 26 | */ 27 | public function testEncodeString() 28 | { 29 | // arbitrary 30 | $this->assertEquals('11:test string', Bencode::encode('test string')); 31 | 32 | // special characters 33 | $this->assertEquals("25:zero\0newline\nsymblol05\x05ok", Bencode::encode("zero\0newline\nsymblol05\x05ok")); 34 | 35 | // empty 36 | $this->assertEquals('0:', Bencode::encode('')); 37 | 38 | // unicode. prefix number reflects the number if bytes 39 | $this->assertEquals('9:日本語', Bencode::encode('日本語')); 40 | 41 | // scalars converted to string 42 | $this->assertEquals('6:3.1416', Bencode::encode(3.1416)); 43 | 44 | // number in string type 45 | $this->assertEquals('12:123456789012', Bencode::encode('123456789012')); 46 | } 47 | 48 | /** 49 | * Boolean will convert silently to string since boolean and NULL is not valid Bencode type, 50 | * mapped as : 51 | * - true -> "1" 52 | * - false -> "" (like empty string) 53 | * - null -> "" (like empty string) 54 | * 55 | * @group string 56 | */ 57 | public function testEncodeBooleanAndNull() 58 | { 59 | $this->assertEquals('1:1', Bencode::encode(true)); 60 | $this->assertEquals('0:', Bencode::encode(false)); 61 | $this->assertEquals('0:', Bencode::encode(null)); 62 | } 63 | 64 | /** 65 | * @group list 66 | */ 67 | public function testEncodeList() 68 | { 69 | // sequential array should become list 70 | $this->assertEquals('li1ei2e1:34:testi5ee', Bencode::encode([1, 2, '3', 'test', 5])); 71 | 72 | // empty list 73 | $this->assertEquals('le', Bencode::encode([])); 74 | } 75 | 76 | /** 77 | * @group dictionary 78 | */ 79 | public function testEncodeDictionary() 80 | { 81 | // array with string keys 82 | $this->assertEquals('d3:key5:value4:test8:whatevere', Bencode::encode(['key' => 'value', 'test' => 'whatever'])); 83 | 84 | // any non-sequential array 85 | $this->assertEquals('d1:0i1e1:1i2e1:21:31:3i5e1:44:teste', Bencode::encode([1, 2, '3', 4 => 'test', 3 => 5])); 86 | 87 | // keys should be sorted by binary comparison of the strings 88 | $stringKeys = [ 89 | 'a' => '', 90 | 'b' => '', 91 | 'c' => '', 92 | 'A' => '', 93 | 'B' => '', 94 | 'C' => '', 95 | 'key' => '', 96 | '本' => '', 97 | 'ы' => '', 98 | 'Ы' => '', 99 | 'š' => '', 100 | 'Š' => '', 101 | ]; 102 | $expectedWithStringKeys = 'd' . 103 | '1:A0:' . 104 | '1:B0:' . 105 | '1:C0:' . 106 | '1:a0:' . 107 | '1:b0:' . 108 | '1:c0:' . 109 | '3:key0:' . 110 | '2:Š0:' . 111 | '2:š0:' . 112 | '2:Ы0:' . 113 | '2:ы0:' . 114 | '3:本0:' . 115 | 'e'; 116 | 117 | $this->assertEquals($expectedWithStringKeys, Bencode::encode($stringKeys)); 118 | 119 | // also check that php doesn't silently convert numeric keys to integer 120 | $numericKeys = [ 121 | 1 => '', 122 | 5 => '', 123 | 9 => '', 124 | 11 => '', 125 | 55 => '', 126 | 99 => '', 127 | 111 => '', 128 | 555 => '', 129 | 999 => '', 130 | ]; 131 | 132 | $expectedWithNumericKeys = 'd' . 133 | '1:10:' . 134 | '2:110:' . 135 | '3:1110:' . 136 | '1:50:' . 137 | '2:550:' . 138 | '3:5550:' . 139 | '1:90:' . 140 | '2:990:' . 141 | '3:9990:' . 142 | 'e'; 143 | 144 | $this->assertEquals($expectedWithNumericKeys, Bencode::encode($numericKeys)); 145 | } 146 | 147 | /** 148 | * @group all 149 | */ 150 | public function testEncodeAllTypes() 151 | { 152 | // just so some data in combinations 153 | $data1 = [ 154 | 'integer' => 1, // 7:integeri1e 155 | 'list' => [ 156 | 1, 2, 3, 'test', 157 | ['list', 'in', 'list'], // l4:list2:in4:liste 158 | ['dict' => 'in list'], // d4:dict7:in liste 159 | ], // 4:listli1ei2ei3e4:testl4:list2:in4:listed4:dict7:in listee 160 | 'dict' => [ 161 | 'int' => 123, 'list' => [] 162 | ], // 4:dictd3:inti123e4:listlee 163 | 'string' => 'str', // 6:string3:str 164 | ]; 165 | $data2 = [ 166 | 'integer' => 1, 167 | 'string' => 'str', 168 | 'dict' => ['list' => [], 'int' => 123], 169 | 'list' => [1, 2, 3, 'test', ['list', 'in', 'list'], ['dict' => 'in list']], 170 | ]; 171 | 172 | $expected = 'd4:dictd3:inti123e4:listlee7:integeri1e4:listli1ei2ei3e4:testl4:list2:in4:listed4:dict7:in listee6:string3:stre'; 173 | 174 | $result1 = Bencode::encode($data1); 175 | $result2 = Bencode::encode($data2); 176 | 177 | $this->assertEquals($expected, $result1); 178 | $this->assertEquals($result1, $result2); // different order of dict keys should not change the result 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /tests/TorrentFileTreeSortTest.php: -------------------------------------------------------------------------------- 1 | torrent = TorrentFile::load("tests/asserts/test-tree-sort.torrent"); 13 | $this->torrent->parse(); 14 | } 15 | 16 | public function testGetFileTreeByParse() 17 | { 18 | $this->assertEquals( 19 | json_encode([ 20 | 'test_tree_sort' => [ 21 | 'file.txt' => 1048576, 22 | 'z' => [ 23 | 'c.txt' => 1048576, 24 | ], 25 | 'a' => [ 26 | 'd.txt' => 1048576, 27 | 'c.txt' => 1048576 28 | ] 29 | ] 30 | ]), 31 | json_encode($this->torrent->getFileTree()) 32 | ); 33 | } 34 | 35 | public function testGetFileTreeByString() 36 | { 37 | $this->assertEquals( 38 | json_encode([ 39 | 'test_tree_sort' => [ 40 | 'a' => [ 41 | 'c.txt' => 1048576, 42 | 'd.txt' => 1048576 43 | ], 44 | 'file.txt' => 1048576, 45 | 'z' => [ 46 | 'c.txt' => 1048576, 47 | ], 48 | ] 49 | ]), 50 | json_encode($this->torrent->getFileTree(TorrentFile::FILETREE_SORT_STRING)) 51 | ); 52 | } 53 | 54 | public function testGetFileTreeByFolder() 55 | { 56 | $this->assertEquals( 57 | json_encode([ 58 | 'test_tree_sort' => [ 59 | 'z' => [ 60 | 'c.txt' => 1048576, 61 | ], 62 | 'a' => [ 63 | 'd.txt' => 1048576, 64 | 'c.txt' => 1048576 65 | ], 66 | 'file.txt' => 1048576 67 | ] 68 | ]), 69 | json_encode($this->torrent->getFileTree(TorrentFile::FILETREE_SORT_FOLDER)) 70 | ); 71 | } 72 | 73 | public function testGetFileTreeByNatural() 74 | { 75 | $this->assertEquals( 76 | json_encode([ 77 | 'test_tree_sort' => [ 78 | 'a' => [ 79 | 'c.txt' => 1048576, 80 | 'd.txt' => 1048576 81 | ], 82 | 'z' => [ 83 | 'c.txt' => 1048576, 84 | ], 85 | 'file.txt' => 1048576 86 | ] 87 | ]), 88 | json_encode($this->torrent->getFileTree(TorrentFile::FILETREE_SORT_NATURAL)) 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /tests/TorrentHybridMultiTest.php: -------------------------------------------------------------------------------- 1 | '1e2dbc73590ba3bea3cee6e3053d98da86e6c842', 17 | TorrentFile::PROTOCOL_V2 => '3f6fb45188917a8aed604ba7f399843f7891f68748bef89b7692465656ca6076' 18 | ]; 19 | } 20 | -------------------------------------------------------------------------------- /tests/TorrentHybridSingleTest.php: -------------------------------------------------------------------------------- 1 | 'be2a86eff99608a56c506157dd5c9bc8779aa81d', 17 | TorrentFile::PROTOCOL_V2 => 'fd0e265c50a080759b61e7a66cf9c9a00af0256815e96a4c3564f733127dda46' 18 | ]; 19 | } 20 | -------------------------------------------------------------------------------- /tests/TorrentV1MultiTest.php: -------------------------------------------------------------------------------- 1 | '344f85b35113783a34bb22ba7661fa26f1046bd1', 20 | TorrentFile::PROTOCOL_V2 => null 21 | ]; 22 | 23 | public function testFilesNotExist() { 24 | $this->expectException(ParseException::class); 25 | $this->expectExceptionMessage('Checking Dictionary missing key: '); 26 | 27 | $this->torrent->unsetInfoField('files'); 28 | $this->torrent->parse(); 29 | } 30 | 31 | public function testFilesNotArray() { 32 | $this->expectException(ParseException::class); 33 | $this->expectExceptionMessage('Invalid entry type in dictionary, '); 34 | 35 | $this->torrent->setInfoField('files', 'somestring'); 36 | $this->torrent->parse(); 37 | } 38 | 39 | public function testFilesLengthNotInt() { 40 | $this->expectException(ParseException::class); 41 | $this->expectExceptionMessage('Invalid entry type in dictionary, '); 42 | 43 | $files = $this->torrent->getInfoField('files'); 44 | $files[0]['length'] = '12345667'; 45 | 46 | $this->torrent->setInfoField('files', $files); 47 | $this->torrent->parse(); 48 | } 49 | 50 | public function testFilesPathNotArray() { 51 | $this->expectException(ParseException::class); 52 | $this->expectExceptionMessage('Invalid entry type in dictionary, '); 53 | 54 | $files = $this->torrent->getInfoField('files'); 55 | $files[0]['path'] = '12345667'; 56 | 57 | $this->torrent->setInfoField('files', $files); 58 | $this->torrent->parse(); 59 | } 60 | 61 | public function testFilesPathEntityWithNotString() { 62 | $this->expectException(ParseException::class); 63 | $this->expectExceptionMessage('Invalid path with non-string or empty-string value'); 64 | 65 | $files = $this->torrent->getInfoField('files'); 66 | $files[0]['path'][0] = 123; 67 | $this->torrent->setInfoField('files', $files); 68 | 69 | $this->torrent->parse(); 70 | } 71 | 72 | public function testFilesPathEntityWithEmptyString() { 73 | $this->expectException(ParseException::class); 74 | $this->expectExceptionMessage('Invalid path with non-string or empty-string value'); 75 | 76 | $files = $this->torrent->getInfoField('files'); 77 | $files[0]['path'][0] = ''; 78 | $this->torrent->setInfoField('files', $files); 79 | 80 | $this->torrent->parse(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/TorrentV1SingleTest.php: -------------------------------------------------------------------------------- 1 | 'd0e710431bed8cb4b1860b9a7a40a20df8de8266', 19 | TorrentFile::PROTOCOL_V2 => null 20 | ]; 21 | } 22 | -------------------------------------------------------------------------------- /tests/TorrentV2MultiTest.php: -------------------------------------------------------------------------------- 1 | null, 19 | TorrentFile::PROTOCOL_V2 => '832d96b4f8b422aa75f8d40975b1a408154bc1a2bdffccf7b689386cde125a30' 20 | ]; 21 | } 22 | -------------------------------------------------------------------------------- /tests/TorrentV2SingleTest.php: -------------------------------------------------------------------------------- 1 | null, 19 | TorrentFile::PROTOCOL_V2 => 'a58e747f0ce2c2073c6fd635d4afdd5c6162574d6c9184318f884f553c3ed65b' 20 | ]; 21 | } 22 | -------------------------------------------------------------------------------- /tests/asserts/hybrid-multi.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhilip/Bencode/67a580fbc50ddec5fc01df67202619461b0fd643/tests/asserts/hybrid-multi.torrent -------------------------------------------------------------------------------- /tests/asserts/hybrid-single.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhilip/Bencode/67a580fbc50ddec5fc01df67202619461b0fd643/tests/asserts/hybrid-single.torrent -------------------------------------------------------------------------------- /tests/asserts/test-tree-sort.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhilip/Bencode/67a580fbc50ddec5fc01df67202619461b0fd643/tests/asserts/test-tree-sort.torrent -------------------------------------------------------------------------------- /tests/asserts/v1-multi.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhilip/Bencode/67a580fbc50ddec5fc01df67202619461b0fd643/tests/asserts/v1-multi.torrent -------------------------------------------------------------------------------- /tests/asserts/v1-single.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhilip/Bencode/67a580fbc50ddec5fc01df67202619461b0fd643/tests/asserts/v1-single.torrent -------------------------------------------------------------------------------- /tests/asserts/v2-multi.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhilip/Bencode/67a580fbc50ddec5fc01df67202619461b0fd643/tests/asserts/v2-multi.torrent -------------------------------------------------------------------------------- /tests/asserts/v2-single.torrent: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Rhilip/Bencode/67a580fbc50ddec5fc01df67202619461b0fd643/tests/asserts/v2-single.torrent -------------------------------------------------------------------------------- /tests/traits/TorrentFileCommonTrait.php: -------------------------------------------------------------------------------- 1 | torrent = TorrentFile::load("tests/asserts/{$this->protocol}-{$this->fileMode}.torrent"); 27 | } 28 | 29 | public function testAnnounce() 30 | { 31 | $this->assertEquals($this->announce, $this->torrent->getAnnounce()); 32 | 33 | $announce = 'https://example2.com/announce'; 34 | $this->torrent->setAnnounce($announce); 35 | $this->assertEquals($announce, $this->torrent->getAnnounce()); 36 | } 37 | 38 | public function testAnnounceList() 39 | { 40 | $this->assertEquals($this->announceList, $this->torrent->getAnnounceList()); 41 | 42 | $announceList = [["https://example1.com/announce"], ["https://example2.com/announce"]]; 43 | $this->torrent->setAnnounceList($announceList); 44 | $this->assertEquals($announceList, $this->torrent->getAnnounceList()); 45 | } 46 | 47 | public function testComment() 48 | { 49 | $this->assertEquals($this->comment, $this->torrent->getComment()); 50 | 51 | $comment = 'new comment'; 52 | $this->torrent->setComment($comment); 53 | $this->assertEquals($comment, $this->torrent->getComment()); 54 | } 55 | 56 | public function testCreatBy() 57 | { 58 | $this->assertEquals($this->createdBy, $this->torrent->getCreatedBy()); 59 | 60 | $createdBy = 'new createdBy'; 61 | $this->torrent->setCreatedBy($createdBy); 62 | $this->assertEquals($createdBy, $this->torrent->getCreatedBy()); 63 | } 64 | 65 | public function testCreationDate() 66 | { 67 | $this->assertEquals($this->creationDate, $this->torrent->getCreationDate()); 68 | 69 | $creationDate = time(); 70 | $this->torrent->setCreationDate($creationDate); 71 | $this->assertEquals($creationDate, $this->torrent->getCreationDate()); 72 | } 73 | 74 | public function testHttpSeeds() 75 | { 76 | $this->assertNull($this->torrent->getHttpSeeds()); 77 | 78 | $httpSeeds = ['udp://example.com/seed']; 79 | $this->torrent->setHttpSeeds($httpSeeds); 80 | $this->assertEquals($httpSeeds, $this->torrent->getHttpSeeds()); 81 | } 82 | 83 | public function testNodes() 84 | { 85 | $this->assertNull($this->torrent->getNodes()); 86 | 87 | $nodes = ['udp://example.com/seed']; 88 | $this->torrent->setNodes($nodes); 89 | $this->assertEquals($nodes, $this->torrent->getNodes()); 90 | } 91 | 92 | public function testUrlList() 93 | { 94 | $this->assertEquals($this->urlList, $this->torrent->getUrlList()); 95 | 96 | $urlList = "https://example1.com/webseed"; 97 | $this->torrent->setUrlList($urlList); 98 | $this->assertEquals($urlList, $this->torrent->getUrlList()); 99 | } 100 | 101 | public function testGetProtocol() 102 | { 103 | $this->assertEquals($this->protocol, $this->torrent->getProtocol()); 104 | } 105 | 106 | public function testgetFileMode() 107 | { 108 | $this->assertEquals($this->fileMode, $this->torrent->getFileMode()); 109 | } 110 | 111 | public function testInfoHash() 112 | { 113 | $this->assertEquals($this->infoHashs, $this->torrent->getInfoHashs()); 114 | 115 | if ($this->protocol === TorrentFile::PROTOCOL_V1) { 116 | $this->assertEquals($this->infoHashs[TorrentFile::PROTOCOL_V1], $this->torrent->getInfoHashV1()); 117 | $this->assertEquals($this->infoHashs[TorrentFile::PROTOCOL_V1], $this->torrent->getInfoHash()); 118 | } 119 | 120 | if ($this->protocol === TorrentFile::PROTOCOL_V2) { 121 | $this->assertEquals($this->infoHashs[TorrentFile::PROTOCOL_V2], $this->torrent->getInfoHashV2()); 122 | $this->assertEquals($this->infoHashs[TorrentFile::PROTOCOL_V2], $this->torrent->getInfoHash()); 123 | $this->assertEquals(substr(pack("H*", $this->infoHashs[TorrentFile::PROTOCOL_V2]), 0, 20), $this->torrent->getInfoHashV2ForAnnounce()); 124 | } 125 | 126 | if ($this->protocol === TorrentFile::PROTOCOL_HYBRID) { 127 | $this->assertEquals($this->infoHashs[TorrentFile::PROTOCOL_V1], $this->torrent->getInfoHashV1()); 128 | $this->assertEquals($this->infoHashs[TorrentFile::PROTOCOL_V2], $this->torrent->getInfoHashV2()); 129 | $this->assertEquals($this->infoHashs[TorrentFile::PROTOCOL_V2], $this->torrent->getInfoHash()); 130 | $this->assertEquals(substr(pack("H*", $this->infoHashs[TorrentFile::PROTOCOL_V2]), 0, 20), $this->torrent->getInfoHashV2ForAnnounce()); 131 | } 132 | } 133 | 134 | public function testInfoHashNotChangeAfterParse() 135 | { 136 | $this->torrent->parse(); 137 | $this->testInfoHash(); 138 | } 139 | 140 | public function testGetPieceLength() 141 | { 142 | $this->assertEquals($this->pieceLength, $this->torrent->getPieceLength()); 143 | } 144 | 145 | public function testGetName() 146 | { 147 | $name = $this->fileMode === TorrentFile::FILEMODE_MULTI ? 'tname' : 'file1.dat'; 148 | 149 | $this->assertEquals($name, $this->torrent->getName()); 150 | } 151 | 152 | public function testSetNameEmpty() 153 | { 154 | $this->expectException(\InvalidArgumentException::class); 155 | $this->expectExceptionMessage('$name must not be empty'); 156 | 157 | $this->torrent->setName(''); 158 | } 159 | 160 | public function testSetNameWithSlash() 161 | { 162 | $this->expectException(\InvalidArgumentException::class); 163 | $this->expectExceptionMessage('$name must not contain slashes and zero bytes'); 164 | 165 | $this->torrent->setName('prefix/suffix'); 166 | } 167 | 168 | public function testSetNameWithZeroBytes() 169 | { 170 | $this->expectException(\InvalidArgumentException::class); 171 | $this->expectExceptionMessage('$name must not contain slashes and zero bytes'); 172 | 173 | $this->torrent->setName("test\0"); 174 | } 175 | 176 | public function testSource() 177 | { 178 | $this->assertEquals($this->source, $this->torrent->getSource()); 179 | 180 | $source = "new source"; 181 | $this->torrent->setSource($source); 182 | $this->assertEquals($source, $this->torrent->getSource()); 183 | } 184 | 185 | public function testInfoChangeByEdit(){ 186 | $infoHashs = $this->torrent->getInfoHashs(); 187 | 188 | $this->torrent->setInfoField('rhilip','bencode'); 189 | 190 | // infohash should change since we edit info field 191 | $this->assertNotEqualsCanonicalizing($infoHashs, $this->torrent->getInfoHashs()); 192 | } 193 | 194 | public function testPrivate() 195 | { 196 | $this->assertFalse($this->torrent->isPrivate()); 197 | 198 | $this->torrent->setPrivate(true); 199 | $this->assertTrue($this->torrent->isPrivate()); 200 | 201 | $this->torrent->setPrivate(false); 202 | $this->assertFalse($this->torrent->isPrivate()); 203 | } 204 | 205 | public function testGetMagnetLink() 206 | { 207 | $xtComponent = ''; 208 | if ($this->protocol === TorrentFile::PROTOCOL_V1) { 209 | $xtComponent = 'xt=urn:btih:' . $this->infoHashs[TorrentFile::PROTOCOL_V1]; 210 | } 211 | 212 | if ($this->protocol === TorrentFile::PROTOCOL_V2) { 213 | $xtComponent = 'xt=urn:btmh:1220' . $this->infoHashs[TorrentFile::PROTOCOL_V2]; 214 | } 215 | 216 | if ($this->protocol === TorrentFile::PROTOCOL_HYBRID) { 217 | $xtComponent = 'xt=urn:btih:' . $this->infoHashs[TorrentFile::PROTOCOL_V1] . 218 | '&xt=urn:btmh:1220' . $this->infoHashs[TorrentFile::PROTOCOL_V2]; 219 | } 220 | 221 | $name = $this->fileMode === TorrentFile::FILEMODE_MULTI ? 'tname' : 'file1.dat'; 222 | $dnComponent = 'dn=' . rawurlencode($name); 223 | 224 | $trComponent = 'tr=https%3A%2F%2Fexample.com%2Fannounce&tr=https%3A%2F%2Fexample1.com%2Fannounce'; 225 | 226 | $this->assertEquals('magnet:?' . implode('&', [$xtComponent, $dnComponent, $trComponent]), $this->torrent->getMagnetLink()); 227 | $this->assertEquals('magnet:?' . implode('&', [$xtComponent, $dnComponent]), $this->torrent->getMagnetLink(true, false)); 228 | $this->assertEquals('magnet:?' . implode('&', [$xtComponent, $trComponent]), $this->torrent->getMagnetLink(false, true)); 229 | $this->assertEquals('magnet:?' . $xtComponent, $this->torrent->getMagnetLink(false, false)); 230 | 231 | // torrent without `announce-list` field should use announce field as `&tr=` component in uri. 232 | $torrentWithoutAnnounceList = clone $this->torrent; 233 | $torrentWithoutAnnounceList->unsetRootField('announce-list'); 234 | $this->assertEquals('magnet:?' . implode('&', [$xtComponent, $dnComponent, 'tr=https%3A%2F%2Fexample.com%2Fannounce']), $torrentWithoutAnnounceList->getMagnetLink()); 235 | 236 | // torrent without `announce` and `announce-list` fields should no `&tr=` component in uri. 237 | $torrentWithoutAnnounces = clone $this->torrent; 238 | $torrentWithoutAnnounces->unsetRootField('announce')->unsetRootField('announce-list'); 239 | $this->assertEquals('magnet:?' . implode('&', [$xtComponent, $dnComponent]), $torrentWithoutAnnounces->getMagnetLink()); 240 | 241 | /** 242 | * Extra Test with Torrent with Multitracker Metadata Extension (BEP0012) 243 | * Note: 244 | * 1. if the "announce-list" key is present, the client will ignore the "announce" key 245 | * and only use the URLs in "announce-list". ( From BEP ) 246 | * So the "announce" field will not exist in magnet link if "announce-list" exists. 247 | * 2. dupe tracker will be ignored. 248 | */ 249 | $torrentWithEditAnnounce = clone $this->torrent; 250 | $torrentWithEditAnnounce 251 | ->setAnnounce('https://127.0.0.1:8888/announce') 252 | ->setAnnounceList([ 253 | ['https://127.0.0.2:8890/announce'], 254 | ['https://127.0.0.3:8891/announce', 'https://127.0.0.4:8892/announce'], 255 | ['https://127.0.0.2:8890/announce'] 256 | ]); 257 | $trComponent = 'tr=https%3A%2F%2F127.0.0.2%3A8890%2Fannounce&tr=https%3A%2F%2F127.0.0.3%3A8891%2Fannounce&tr=https%3A%2F%2F127.0.0.4%3A8892%2Fannounce'; 258 | $this->assertEquals('magnet:?' . implode('&', [$xtComponent, $dnComponent, $trComponent]), $torrentWithEditAnnounce->getMagnetLink()); 259 | } 260 | 261 | public function testGetSize() 262 | { 263 | $size = $this->fileMode === TorrentFile::FILEMODE_MULTI ? 33554432 /* 32MiB */ : 16777216 /* 16MiB */; 264 | $this->assertEquals($size, $this->torrent->getSize()); 265 | } 266 | 267 | public function testGetFileCount() 268 | { 269 | $fileCount = $this->fileMode === TorrentFile::FILEMODE_MULTI ? 2 : 1; 270 | $this->assertEquals($fileCount, $this->torrent->getFileCount()); 271 | } 272 | 273 | public function testGetFileList() 274 | { 275 | $fileCount = $this->fileMode === TorrentFile::FILEMODE_MULTI 276 | ? [['path' => 'dict/file2.dat', 'size' => 16777216], ['path' => 'file1.dat', 'size' => 16777216]] 277 | : [['path' => 'file1.dat', 'size' => 16777216]]; 278 | $this->assertEqualsCanonicalizing($fileCount, $this->torrent->getFileList()); 279 | } 280 | 281 | public function testGetFileTree() 282 | { 283 | $fileCount = $this->fileMode === TorrentFile::FILEMODE_MULTI 284 | ? ['tname' => ['dict' => ['file2.dat' => 16777216], 'file1.dat' => 16777216]] 285 | : ['file1.dat' => 16777216]; 286 | $this->assertEqualsCanonicalizing($fileCount, $this->torrent->getFileTree()); 287 | } 288 | 289 | public function testConstructWithoutInfo() 290 | { 291 | $this->expectException(ParseException::class); 292 | $this->expectExceptionMessage('Checking Dictionary missing key: '); 293 | 294 | $torrentString = $this->torrent->unsetRootField('info')->dumpToString(); 295 | TorrentFile::loadFromString($torrentString); 296 | } 297 | 298 | public function testConstructWithoutPieceLength() 299 | { 300 | $this->expectException(ParseException::class); 301 | $this->expectExceptionMessage('Checking Dictionary missing key: '); 302 | 303 | $torrentString = $this->torrent->unsetInfoField('piece length')->dumpToString(); 304 | TorrentFile::loadFromString($torrentString); 305 | } 306 | 307 | public function testConstructWithoutName() 308 | { 309 | $this->expectException(ParseException::class); 310 | $this->expectExceptionMessage('Checking Dictionary missing key: '); 311 | 312 | $torrentString = $this->torrent->unsetInfoField('name')->dumpToString(); 313 | TorrentFile::loadFromString($torrentString); 314 | } 315 | 316 | public function testCleanRootFields() 317 | { 318 | $this->torrent->setRootField('rhilip', 'bencode'); 319 | $this->assertEquals('bencode', $this->torrent->getRootField('rhilip')); 320 | 321 | $this->torrent->cleanRootFields(); 322 | $this->assertNull($this->torrent->getRootField('rhilip')); 323 | } 324 | 325 | public function testCleanInfoFields() 326 | { 327 | $this->torrent->setInfoField('rhilip', 'bencode'); 328 | $this->assertEquals('bencode', $this->torrent->getInfoField('rhilip')); 329 | 330 | $this->torrent->cleanInfoFields(); 331 | $this->assertNull($this->torrent->getInfoField('rhilip')); 332 | } 333 | 334 | public function testCustomParseValidator() 335 | { 336 | $this->expectException(ParseException::class); 337 | $this->expectExceptionMessage('file1.dat found'); 338 | 339 | $this->torrent->setParseValidator(function ($filename, $path) { 340 | if (strpos($filename, 'file1.dat') !== false) { 341 | throw new ParseException('file1.dat found'); 342 | } 343 | }); 344 | $this->torrent->parse(); 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /tests/traits/TorrentFileV1Trait.php: -------------------------------------------------------------------------------- 1 | expectException(ParseException::class); 13 | $this->expectExceptionMessage('Invalid pieces length'); 14 | 15 | $this->torrent->setInfoField('pieces', $this->torrent->getRootField('pieces') . 'somestring'); 16 | $this->torrent->parse(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/traits/TorrentFileV2Trait.php: -------------------------------------------------------------------------------- 1 | expectException(ParseException::class); 9 | $this->expectExceptionMessage('Checking Dictionary missing key: '); 10 | 11 | $this->torrent->unsetInfoField('file tree'); 12 | $this->torrent->parse(); 13 | } 14 | 15 | public function testFileTreeNotArray() { 16 | $this->expectException(ParseException::class); 17 | $this->expectExceptionMessage('Invalid entry type in dictionary, '); 18 | 19 | $this->torrent->setInfoField('file tree', 'someString'); 20 | $this->torrent->parse(); 21 | } 22 | 23 | public function testPieceLayersNotExist() { 24 | $this->expectException(ParseException::class); 25 | $this->expectExceptionMessage('Checking Dictionary missing key: '); 26 | 27 | $this->torrent->unsetRootField('piece layers'); 28 | $this->torrent->parse(); 29 | } 30 | 31 | public function testPieceLayersNotArray() { 32 | $this->expectException(ParseException::class); 33 | $this->expectExceptionMessage('Invalid entry type in dictionary, '); 34 | 35 | $this->torrent->setRootField('piece layers', 'someString'); 36 | $this->torrent->parse(); 37 | } 38 | 39 | public function testInvalidNodePiecesRootLength() { 40 | $this->expectException(ParseException::class); 41 | $this->expectExceptionMessage('Invalid pieces_root length.'); 42 | 43 | $fileTree = $this->torrent->getInfoField('file tree'); 44 | $fileTree['file1.dat']['']['pieces root'] .= 'a'; 45 | 46 | $this->torrent->setInfoField('file tree', $fileTree); 47 | $this->torrent->parse(); 48 | } 49 | 50 | public function testInvalidNodeLength() { 51 | $this->expectException(ParseException::class); 52 | $this->expectExceptionMessage('Invalid entry type in dictionary,'); 53 | 54 | $fileTree = $this->torrent->getInfoField('file tree'); 55 | $fileTree['file1.dat']['']['length'] = '1234566'; 56 | 57 | $this->torrent->setInfoField('file tree', $fileTree); 58 | $this->torrent->parse(); 59 | } 60 | 61 | public function testNodePiecesRootNotExistInPieceLayer() { 62 | $this->expectException(ParseException::class); 63 | $this->expectExceptionMessage('Pieces not exist in piece layers'); 64 | 65 | $fileTree = $this->torrent->getInfoField('file tree'); 66 | $fileTree['file1.dat']['']['pieces root'] = hash('sha256', 'adfadsfasd',true); 67 | 68 | $this->torrent->setInfoField('file tree', $fileTree); 69 | $this->torrent->parse(); 70 | } 71 | } 72 | --------------------------------------------------------------------------------