all repos — rastro @ main

BitTorrent tracker!

app/probes/bencode.py (view raw)

 1
 2
 3
 4
 5
 6
 7
 8
 9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
"""Bencode decoder for tracker announce responses.

Adapted from newTrackon (MIT). Rewritten for this architecture.
Peers are validated structurally and discarded by callers; this module never
persists peer addresses.
"""

from __future__ import annotations

from collections import OrderedDict
from socket import AF_INET, AF_INET6, inet_ntop
from struct import unpack_from
from typing import Any

TOK_DICT = b"d"
TOK_LIST = b"l"
TOK_INT = b"i"
TOK_END = b"e"
TOK_STR_SEP = b":"

BDecoded = OrderedDict[bytes, Any] | list[Any] | int | bytes | None


class BencodeError(ValueError):
    pass


def bdecode(data: bytes) -> dict[str, Any]:
    """Decode a bencoded tracker response into a string-keyed dict.

    Binary `peers` / `peers6` are converted to length-checked peer lists for
    validation, then callers must discard addresses immediately.
    """
    if not data:
        raise BencodeError("empty bencode payload")
    decoded = Decoder(data).decode()
    if not isinstance(decoded, OrderedDict):
        raise BencodeError("tracker response must be a dict")
    response: dict[str, Any] = {}
    for key, value in decoded.items():
        response[key.decode("utf-8", errors="replace")] = value

    if "peers" in response and isinstance(response["peers"], bytes):
        response["peers"] = decode_binary_peers_list(response["peers"], 0, AF_INET)
    if "peers6" in response and isinstance(response["peers6"], bytes):
        response["peers6"] = decode_binary_peers_list(response["peers6"], 0, AF_INET6)

    for key, value in list(response.items()):
        if isinstance(value, bytes):
            response[key] = value.decode("utf-8", errors="replace")
    return response


def decode_binary_peers_list(buf: bytes, offset: int, ip_family: int) -> list[dict[str, Any]]:
    """Validate compact peer binary layout; return ephemeral peer dicts."""
    peers: list[dict[str, Any]] = []
    peer_length = 6 if ip_family == AF_INET else 18
    view = memoryview(buf)
    while offset < len(buf):
        if len(buf) < offset + peer_length:
            break
        ip_bytes = bytes(view[offset : offset + peer_length - 2])
        try:
            ip_str = inet_ntop(ip_family, ip_bytes)
        except OSError as exc:
            raise BencodeError("invalid compact peer address") from exc
        offset += peer_length - 2
        port = unpack_from("!H", buf, offset)[0]
        offset += 2
        peers.append({"ip": ip_str, "port": port})
    return peers


def validate_peers_then_discard(response: dict[str, Any]) -> bool:
    """Return True if peers/peers6 field exists and is structurally valid, then clear it."""
    has_peers = False
    for key in ("peers", "peers6"):
        if key not in response:
            continue
        value = response[key]
        if isinstance(value, list):
            has_peers = True
        elif isinstance(value, (bytes, str)):
            # dictionary-model peers — accept presence without storing
            has_peers = True
        response[key] = None
    return has_peers


class Decoder:
    def __init__(self, data: bytes) -> None:
        self.index = 0
        self.data = data

    def decode(self) -> BDecoded:
        c = self.peek()
        if c is None:
            raise BencodeError("unexpected EOF")
        if c == TOK_DICT:
            self.read(1)
            return self.decode_dict()
        if c == TOK_LIST:
            self.read(1)
            return self.decode_list()
        if c == TOK_INT:
            self.read(1)
            return self.decode_int()
        if c in b"0123456789":
            return self.decode_str()
        raise BencodeError("invalid bencode token")

    def peek(self) -> bytes | None:
        if self.index >= len(self.data):
            return None
        return self.data[self.index : self.index + 1]

    def read(self, length: int) -> bytes:
        if self.index + length > len(self.data):
            raise BencodeError("unexpected EOF while reading")
        result = self.data[self.index : self.index + length]
        self.index += length
        return result

    def read_until(self, token: bytes) -> bytes:
        loc = self.data.find(token, self.index)
        if loc == -1:
            raise BencodeError("token not found")
        result = self.data[self.index : loc]
        self.index = loc + 1
        return result

    def decode_dict(self) -> OrderedDict[bytes, BDecoded]:
        result: OrderedDict[bytes, BDecoded] = OrderedDict()
        while self.data[self.index : self.index + 1] != TOK_END:
            key = self.decode()
            if not isinstance(key, bytes):
                raise BencodeError("dict key must be bytes")
            result[key] = self.decode()
        self.read(1)
        return result

    def decode_list(self) -> list[BDecoded]:
        result: list[BDecoded] = []
        while self.data[self.index : self.index + 1] != TOK_END:
            result.append(self.decode())
        self.read(1)
        return result

    def decode_int(self) -> int:
        return int(self.read_until(TOK_END))

    def decode_str(self) -> bytes:
        length = int(self.read_until(TOK_STR_SEP))
        if length < 0:
            raise BencodeError("negative string length")
        return self.read(length)