"""Safe, small bencode parser and ephemeral magnet metadata inspection state.""" import json import os import re import shutil import time from pathlib import Path from urllib.parse import parse_qs, urlsplit from . import store ID = re.compile(r'^[a-f0-9]{32}$') MAX_METADATA = 32 * 1024 * 1024 class MetadataError(ValueError): pass def decode_bencode(raw): if len(raw) > MAX_METADATA: raise MetadataError('Metadane torrenta są zbyt duże') position = 0 count = 0 def decode(depth=0): nonlocal position, count if depth > 24: raise MetadataError('Metadane mają zbyt duże zagnieżdżenie') count += 1 if count > 600000 or position >= len(raw): raise MetadataError('Uszkodzone metadane torrenta') start = raw[position] if start == 105: # ie end = raw.find(b'e', position + 1) if end < 0 or end - position > 25: raise MetadataError('Błędna liczba bencode') try: number = int(raw[position + 1:end]) except ValueError as exc: raise MetadataError('Błędna liczba bencode') from exc position = end + 1 return number if start in (108, 100): # l, d position += 1 output = [] if start == 108 else {} while position < len(raw) and raw[position] != 101: if start == 108: output.append(decode(depth + 1)) else: key = decode(depth + 1) if not isinstance(key, bytes): raise MetadataError('Niepoprawny klucz bencode') output[key] = decode(depth + 1) if position >= len(raw): raise MetadataError('Nieukończone metadane') position += 1 return output if 48 <= start <= 57: # : colon = raw.find(b':', position, min(len(raw), position + 22)) if colon < 0: raise MetadataError('Błędny ciąg bencode') try: length = int(raw[position:colon]) except ValueError as exc: raise MetadataError('Błędna długość bencode') from exc position = colon + 1 if length < 0 or length > len(raw) - position: raise MetadataError('Niepoprawna długość pola') result = raw[position:position + length] position += length return result raise MetadataError('Uszkodzone metadane torrenta') result = decode() if position != len(raw) or not isinstance(result, dict): raise MetadataError('Niepoprawny format torrenta') return result def _segment(raw): if not isinstance(raw, bytes) or not raw or len(raw) > 255 or raw in (b'.', b'..'): raise MetadataError('Niedozwolona nazwa w torrencie') if any(char in raw for char in (b'/', b'\\', b'\0')): raise MetadataError('Niebezpieczna ścieżka w torrencie') return raw.decode('utf-8', errors='replace') def read_torrent(path): try: data = decode_bencode(Path(path).read_bytes()) info = data[b'info'] name = _segment(info.get(b'name.utf-8') or info[b'name']) result = [] if b'files' in info: files = info[b'files'] if not isinstance(files, list) or len(files) > 50000: raise MetadataError('Nieprawidłowa lista plików torrenta') for index, file in enumerate(files, start=1): parts = file.get(b'path.utf-8') or file[b'path'] if not isinstance(parts, list) or not parts: raise MetadataError('Błędna ścieżka pliku') length = file[b'length'] if not isinstance(length, int) or length < 0: raise MetadataError('Nieprawidłowy rozmiar pliku') result.append({'index': index, 'name': '/'.join([name] + [_segment(x) for x in parts]), 'size': length, 'padding': b'p' in file.get(b'attr', b'')}) else: length = info[b'length'] if not isinstance(length, int) or length < 0: raise MetadataError('Nieprawidłowy rozmiar pliku') result.append({'index': 1, 'name': name, 'size': length, 'padding': False}) if not result: raise MetadataError('Torrent nie zawiera plików') return {'name': name, 'files': result} except (KeyError, TypeError, OSError, ValueError) as exc: if isinstance(exc, MetadataError): raise raise MetadataError('Nie można odczytać plików z torrenta') from exc def suggested_indices(magnet, max_index): """magnet so indexes are zero-based; aria2 select-file indexes are one-based.""" so = parse_qs(urlsplit(magnet).query).get('so', [''])[0] suggested = set() for chunk in so.split(',')[:1000]: try: if '-' in chunk: a, b = (int(x) for x in chunk.split('-', 1)) if b < a or b - a > 1000: continue values = range(a, b + 1) else: values = [int(chunk)] suggested.update(n + 1 for n in values if 0 <= n < max_index) except ValueError: continue return sorted(suggested) def inspect_dir(preview_id): if not ID.fullmatch(preview_id): raise MetadataError('Nieprawidłowy identyfikator metadanych') return store.DATA / 'metadata' / preview_id def write_request(preview_id, data): path = inspect_dir(preview_id) path.mkdir(mode=0o700, parents=True, exist_ok=True) filename = path / 'request.json' flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY with os.fdopen(os.open(filename, flags, 0o600), 'w', encoding='utf-8') as f: json.dump({'created': time.time(), **data}, f) def load_request(preview_id): try: info = json.loads((inspect_dir(preview_id) / 'request.json').read_text(encoding='utf-8')) if not isinstance(info['gid'], str) or not isinstance(info['magnet'], str): raise MetadataError('Błędne dane podglądu') return info except FileNotFoundError as exc: raise MetadataError('Nie znaleziono podglądu magneta') from exc except (ValueError, KeyError, TypeError) as exc: raise MetadataError('Uszkodzone dane podglądu') from exc def torrent_file(preview_id): matches = list(inspect_dir(preview_id).glob('*.torrent')) return matches[0] if len(matches) == 1 and matches[0].stat().st_size <= MAX_METADATA else None def cleanup_old(max_age=86400): root = store.DATA / 'metadata' if not root.exists(): return now = time.time() for child in root.iterdir(): if (ID.fullmatch(child.name) and child.is_dir() and not child.is_symlink() and now - child.stat().st_mtime > max_age): shutil.rmtree(child)