Files
2026-10-15 14:15:10 +02:00

182 lines
6.9 KiB
Python

"""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: # i<number>e
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: # <len>:<bytes>
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)