| 1 | #!/usr/bin/env python3 |
| 2 | |
| 3 | # Midnight Commander - extFS extension to explore BitTorrent files |
| 4 | # |
| 5 | # Copyright (C) 2019 |
| 6 | # The Free Software Foundation, Inc. |
| 7 | # |
| 8 | # Written by: |
| 9 | # Artem Senichev <artemsen@gmail.com>, 2019 |
| 10 | # |
| 11 | # This file is part of the Midnight Commander. |
| 12 | # |
| 13 | # The Midnight Commander is free software: you can redistribute it |
| 14 | # and/or modify it under the terms of the GNU General Public License as |
| 15 | # published by the Free Software Foundation, either version 3 of the License, |
| 16 | # or (at your option) any later version. |
| 17 | # |
| 18 | # The Midnight Commander is distributed in the hope that it will be useful, |
| 19 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 20 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 21 | # GNU General Public License for more details. |
| 22 | # |
| 23 | # You should have received a copy of the GNU General Public License |
| 24 | # along with this program. If not, see <http://www.gnu.org/licenses/>. |
| 25 | |
| 26 | import os |
| 27 | import sys |
| 28 | import time |
| 29 | import json |
| 30 | |
| 31 | |
| 32 | class BtDecoder(object): |
| 33 | """ |
| 34 | Decoder for BitTorrent files. |
| 35 | """ |
| 36 | |
| 37 | # Predefined node names |
| 38 | INFO_DICT = 'info' |
| 39 | FILES_DICT = 'files' |
| 40 | FILE_PATH = 'path' |
| 41 | FILE_SIZE = 'length' |
| 42 | TORR_NAME = 'name' |
| 43 | PIECES_NODE = 'pieces' |
| 44 | CREATION_DATE = 'creation date' |
| 45 | |
| 46 | class FormatError(Exception): |
| 47 | """ |
| 48 | BitTorrent file format exception. |
| 49 | """ |
| 50 | def __init__(self, msg, offset=-1, dump=None): |
| 51 | self.msg = msg |
| 52 | self.offset = offset |
| 53 | self.dump = dump |
| 54 | |
| 55 | def __init__(self, file): |
| 56 | """ |
| 57 | Constructor. |
| 58 | :param file: BitTorrent file to open (*.torrent) |
| 59 | """ |
| 60 | with open(file, 'rb') as f: |
| 61 | self._data = f.read() |
| 62 | self._offset = 0 |
| 63 | self._root = self._decode() |
| 64 | |
| 65 | def file_list(self): |
| 66 | """ |
| 67 | Print file list in format compatible with Midnight Commander. |
| 68 | """ |
| 69 | if BtDecoder.INFO_DICT not in self._root: |
| 70 | raise BtDecoder.FormatError('Info block not found') |
| 71 | info_dict = self._root[BtDecoder.INFO_DICT] |
| 72 | # Build the file list |
| 73 | file_list = [] |
| 74 | if BtDecoder.FILES_DICT in info_dict: |
| 75 | files_dict = info_dict[BtDecoder.FILES_DICT] |
| 76 | for file in files_dict: |
| 77 | if BtDecoder.FILE_PATH in file: |
| 78 | path = '/'.join(file[BtDecoder.FILE_PATH]) |
| 79 | file_list.append((path, file.get(BtDecoder.FILE_SIZE, 0))) |
| 80 | elif BtDecoder.TORR_NAME in info_dict: |
| 81 | file_list.append((info_dict[BtDecoder.TORR_NAME], info_dict.get(BtDecoder.FILE_SIZE, 0))) |
| 82 | |
| 83 | # Use torrent creation date as file time |
| 84 | if BtDecoder.CREATION_DATE in self._root: |
| 85 | ts = time.localtime(self._root[BtDecoder.CREATION_DATE]) |
| 86 | else: |
| 87 | ts = time.localtime() |
| 88 | ft = '{:02}-{:02}-{:04} {:02}:{:02}:{:02}'.format(ts.tm_mon, ts.tm_mday, ts.tm_year, |
| 89 | ts.tm_hour, ts.tm_min, ts.tm_sec) |
| 90 | # Use current UID/GID as file owner |
| 91 | uid = os.getuid() |
| 92 | gid = os.getgid() |
| 93 | |
| 94 | for path, size in file_list: |
| 95 | print('-rw-r--r-- 1 {:3} {:3} {} {} {}'.format(uid, gid, size, ft, path)) |
| 96 | |
| 97 | def dump(self): |
| 98 | """ |
| 99 | Print json dump of torrent file content. |
| 100 | """ |
| 101 | print(json.dumps(self._root, indent=2)) |
| 102 | |
| 103 | def _decode(self): |
| 104 | """ |
| 105 | Decode next node from buffer. |
| 106 | :return: node instance |
| 107 | """ |
| 108 | type_id = self._data[self._offset] |
| 109 | if ord('0') <= type_id <= ord('9'): |
| 110 | node = self._decode_string() |
| 111 | elif type_id == ord('i'): |
| 112 | node = self._decode_integer() |
| 113 | elif type_id == ord('l'): |
| 114 | node = self._decode_list() |
| 115 | elif type_id == ord('d'): |
| 116 | node = self._decode_dict() |
| 117 | else: |
| 118 | raise BtDecoder.FormatError('Invalid node type', self._offset, |
| 119 | self._data[self._offset:self._offset + 8]) |
| 120 | return node |
| 121 | |
| 122 | def _decode_string(self): |
| 123 | """ |
| 124 | Node decoder - text string. |
| 125 | """ |
| 126 | try: |
| 127 | delimiter = self._data.index(ord(':'), self._offset) |
| 128 | length = int(self._data[self._offset:delimiter].decode('utf8', 'ignore')) |
| 129 | node = self._data[delimiter + 1:delimiter + 1 + length].decode('utf8', 'ignore') |
| 130 | self._offset = delimiter + length + 1 |
| 131 | return node |
| 132 | except Exception: |
| 133 | raise BtDecoder.FormatError('Unable to decode string node', self._offset, |
| 134 | self._data[self._offset:self._offset + 8]) |
| 135 | |
| 136 | def _decode_integer(self): |
| 137 | """ |
| 138 | Node decoder - integer value. |
| 139 | """ |
| 140 | try: |
| 141 | self._offset += 1 |
| 142 | delimiter = self._data.index(ord('e'), self._offset) |
| 143 | node = int(self._data[self._offset:delimiter].decode('utf8', 'ignore')) |
| 144 | self._offset = delimiter + 1 |
| 145 | return node |
| 146 | except Exception: |
| 147 | raise BtDecoder.FormatError('Unable to decode integer node', self._offset, |
| 148 | self._data[self._offset:self._offset + 8]) |
| 149 | |
| 150 | def _decode_list(self): |
| 151 | """ |
| 152 | Node decoder - list. |
| 153 | """ |
| 154 | self._offset += 1 |
| 155 | node = [] |
| 156 | try: |
| 157 | while self._offset < len(self._data) and self._data[self._offset] != ord('e'): |
| 158 | node.append(self._decode()) |
| 159 | self._offset += 1 |
| 160 | except Exception: |
| 161 | # Stop further processing |
| 162 | self._offset = len(self._data) |
| 163 | return node |
| 164 | |
| 165 | def _decode_dict(self): |
| 166 | """ |
| 167 | Node decoder - dictionary. |
| 168 | """ |
| 169 | self._offset += 1 |
| 170 | node = {} |
| 171 | try: |
| 172 | while self._offset < len(self._data) and self._data[self._offset] != ord('e'): |
| 173 | key = self._decode_string() |
| 174 | val = self._decode() |
| 175 | if key == BtDecoder.PIECES_NODE: |
| 176 | val = '<Binary data>' |
| 177 | node[key] = val |
| 178 | self._offset += 1 |
| 179 | except Exception: |
| 180 | # Stop further processing |
| 181 | self._offset = len(self._data) |
| 182 | return node |
| 183 | |
| 184 | |
| 185 | def main(): |
| 186 | """ |
| 187 | Main entry point. |
| 188 | :return: exit code, 0 if operation was completed successfully |
| 189 | """ |
| 190 | if len(sys.argv) < 3 or '-h' in sys.argv or '--help' in sys.argv: |
| 191 | print('Use: {} list|view FILE'.format(sys.argv[0])) |
| 192 | return 1 |
| 193 | try: |
| 194 | if sys.argv[1] == 'list': |
| 195 | BtDecoder(sys.argv[2]).file_list() |
| 196 | elif sys.argv[1] == 'view': |
| 197 | BtDecoder(sys.argv[2]).dump() |
| 198 | else: |
| 199 | return 1 |
| 200 | return 0 |
| 201 | except BtDecoder.FormatError as e: |
| 202 | print('Invalid torrent file format!', file=sys.stderr) |
| 203 | print(e.msg, file=sys.stderr) |
| 204 | if e.offset >= 0: |
| 205 | msg = 'Offset: 0x{:02x}'.format(e.offset) |
| 206 | if e.dump: |
| 207 | msg += ' ' + ''.join(' {:02x}'.format(x) for x in e.dump) |
| 208 | print(msg, file=sys.stderr) |
| 209 | return 1 |
| 210 | except Exception as e: |
| 211 | print(str(e), file=sys.stderr) |
| 212 | return 1 |
| 213 | |
| 214 | |
| 215 | if __name__ == '__main__': |
| 216 | exit(main()) |