Files
archive-clients/src/archive_clients/bencode.py
T

236 lines
8.4 KiB
Python

"""Strict BitTorrent metainfo decoding with exact info-dictionary hashing."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from typing import Any
class BencodeError(ValueError):
pass
@dataclass(frozen=True)
class MetaFile:
path: str
logical_bytes: int
padding: bool
@dataclass(frozen=True)
class Metainfo:
info_hash_v1_hex: str
info_hash_v2_hex: str
files: tuple[MetaFile, ...]
class _Parser:
def __init__(self, data: bytes):
self.data = data
self.position = 0
self.info_bytes: bytes | None = None
def parse(self, depth: int = 0) -> Any:
if depth > 100 or self.position >= len(self.data):
raise BencodeError("invalid or excessively nested bencode")
marker = self.data[self.position]
if marker == ord("i"):
return self._integer()
if marker == ord("l"):
return self._list(depth)
if marker == ord("d"):
return self._dictionary(depth)
if ord("0") <= marker <= ord("9"):
return self._bytes()
raise BencodeError("invalid bencode marker")
def _integer(self) -> int:
end = self.data.find(b"e", self.position + 1)
if end < 0:
raise BencodeError("unterminated integer")
raw = self.data[self.position + 1:end]
if (
not raw
or raw == b"-0"
or raw.startswith(b"+")
or (raw.startswith(b"0") and len(raw) > 1)
or (raw.startswith(b"-0"))
):
raise BencodeError("non-canonical integer")
try:
value = int(raw)
except ValueError as exc:
raise BencodeError("invalid integer") from exc
self.position = end + 1
return value
def _bytes(self) -> bytes:
separator = self.data.find(b":", self.position)
if separator < 0:
raise BencodeError("invalid byte string")
raw_length = self.data[self.position:separator]
if not raw_length or (raw_length.startswith(b"0") and len(raw_length) > 1):
raise BencodeError("non-canonical byte-string length")
try:
length = int(raw_length)
except ValueError as exc:
raise BencodeError("invalid byte-string length") from exc
start = separator + 1
end = start + length
if end > len(self.data):
raise BencodeError("truncated byte string")
self.position = end
return self.data[start:end]
def _list(self, depth: int) -> list[Any]:
self.position += 1
result = []
while self.position < len(self.data) and self.data[self.position] != ord("e"):
result.append(self.parse(depth + 1))
if self.position >= len(self.data):
raise BencodeError("unterminated list")
self.position += 1
return result
def _dictionary(self, depth: int) -> dict[bytes, Any]:
self.position += 1
result: dict[bytes, Any] = {}
previous: bytes | None = None
while self.position < len(self.data) and self.data[self.position] != ord("e"):
key = self._bytes()
if previous is not None and key <= previous:
raise BencodeError("dictionary keys are duplicate or unsorted")
previous = key
value_start = self.position
value = self.parse(depth + 1)
if depth == 0 and key == b"info":
self.info_bytes = self.data[value_start:self.position]
result[key] = value
if self.position >= len(self.data):
raise BencodeError("unterminated dictionary")
self.position += 1
return result
def decode_metainfo(data: bytes) -> Metainfo:
parser = _Parser(data)
root = parser.parse()
if parser.position != len(data) or not isinstance(root, dict):
raise BencodeError("metainfo must be one top-level dictionary")
info = root.get(b"info")
if not isinstance(info, dict) or parser.info_bytes is None:
raise BencodeError("metainfo has no info dictionary")
is_v1 = isinstance(info.get(b"pieces"), bytes)
is_v2 = info.get(b"meta version") == 2
if not is_v1 and not is_v2:
raise BencodeError("unsupported torrent metainfo version")
files = _v1_files(info) if is_v1 else _v2_files(info)
return Metainfo(
hashlib.sha1(parser.info_bytes).hexdigest() if is_v1 else "",
hashlib.sha256(parser.info_bytes).hexdigest() if is_v2 else "",
tuple(files),
)
def encode(value: Any) -> bytes:
if isinstance(value, bytes):
return str(len(value)).encode("ascii") + b":" + value
if isinstance(value, str):
return encode(value.encode("utf-8"))
if isinstance(value, bool) or not isinstance(value, (int, list, dict)):
raise BencodeError("unsupported bencode value")
if isinstance(value, int):
return f"i{value}e".encode("ascii")
if isinstance(value, list):
return b"l" + b"".join(encode(item) for item in value) + b"e"
keys = sorted(value)
if any(not isinstance(key, bytes) for key in keys):
raise BencodeError("dictionary keys must be bytes")
return b"d" + b"".join(encode(key) + encode(value[key]) for key in keys) + b"e"
def _v1_files(info: dict[bytes, Any]) -> list[MetaFile]:
name = _component(info.get(b"name.utf-8", info.get(b"name")))
raw_files = info.get(b"files")
if raw_files is None:
length = _length(info.get(b"length"))
return [MetaFile(name, length, _padding(info))]
if not isinstance(raw_files, list):
raise BencodeError("v1 files must be a list")
result = []
for item in raw_files:
if not isinstance(item, dict) or not isinstance(item.get(b"path"), list):
raise BencodeError("invalid v1 file record")
raw_path = item.get(b"path.utf-8", item[b"path"])
if not isinstance(raw_path, list):
raise BencodeError("invalid v1 UTF-8 file path")
components = [name] + [_component(part) for part in raw_path]
result.append(MetaFile(
"/".join(components), _length(item.get(b"length")),
_padding(item) or _bitcomet_padding_name(components[-1]),
))
return result
def _v2_files(info: dict[bytes, Any]) -> list[MetaFile]:
_component(info.get(b"name.utf-8", info.get(b"name")))
tree = info.get(b"file tree")
if not isinstance(tree, dict):
raise BencodeError("v2 metainfo has no file tree")
result: list[MetaFile] = []
def visit(node: dict[bytes, Any], components: list[str]) -> None:
leaf = node.get(b"")
if leaf is not None:
if not isinstance(leaf, dict):
raise BencodeError("invalid v2 file leaf")
result.append(MetaFile(
"/".join(components),
_length(leaf.get(b"length")),
_padding(leaf),
))
for key in sorted(item for item in node if item != b""):
child = node[key]
if not isinstance(child, dict):
raise BencodeError("invalid v2 file tree node")
visit(child, components + [_component(key)])
visit(tree, [])
if not result:
raise BencodeError("v2 file tree is empty")
return result
def _component(value: Any) -> str:
if not isinstance(value, bytes):
raise BencodeError("torrent path component must be bytes")
try:
decoded = value.decode("utf-8")
except UnicodeDecodeError as exc:
raise BencodeError("torrent path component is not UTF-8") from exc
if not decoded or decoded in {".", ".."} or "/" in decoded or "\x00" in decoded:
raise BencodeError("unsafe torrent path component")
return decoded
def _length(value: Any) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise BencodeError("invalid torrent file length")
return value
def _padding(value: dict[bytes, Any]) -> bool:
attributes = value.get(b"attr", b"")
return isinstance(attributes, bytes) and b"p" in attributes
def _bitcomet_padding_name(component: str) -> bool:
"""Recognize BitComet's legacy padding-file convention.
Such v1 torrents often omit the standard ``attr=p`` flag, but
qBittorrent/libtorrent still hides these synthetic entries from its file
API. The exact reserved prefix is the interoperable marker.
"""
return component.startswith("_____padding_file_")