chore: initial sanitized public snapshot

This commit is contained in:
Aria2 Rust Pro Contributors
2026-07-18 14:51:59 +08:00
commit 14dcf8c9bf
321 changed files with 76893 additions and 0 deletions
@@ -0,0 +1,214 @@
use std::collections::BTreeMap;
/// Internal torrent bencode dictionary keyed by normalized string keys.
pub(super) type TorrentBencodeDict = BTreeMap<String, BencodeValue>;
/// Internal bencode value representation used while parsing torrent metadata.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) enum BencodeValue {
/// Signed integer literal.
Int(i64),
/// Raw byte string payload.
Bytes(Vec<u8>),
/// Ordered list of nested bencode values.
List(Vec<Self>),
/// Dictionary keyed by normalized torrent strings.
Dict(TorrentBencodeDict),
}
/// Encodes a torrent-style bencode value into raw bytes.
pub(super) fn encode_bencode_value(value: &BencodeValue, out: &mut Vec<u8>) {
match value {
BencodeValue::Int(number) => {
out.push(b'i');
out.extend_from_slice(number.to_string().as_bytes());
out.push(b'e');
}
BencodeValue::Bytes(bytes) => {
out.extend_from_slice(bytes.len().to_string().as_bytes());
out.push(b':');
out.extend_from_slice(bytes);
}
BencodeValue::List(values) => {
out.push(b'l');
for value in values {
encode_bencode_value(value, out);
}
out.push(b'e');
}
BencodeValue::Dict(dict) => encode_bencode_dict(dict, out),
}
}
/// Encodes a torrent-style bencode dictionary into raw bytes.
pub(super) fn encode_bencode_dict(dict: &TorrentBencodeDict, out: &mut Vec<u8>) {
out.push(b'd');
for (key, value) in dict {
out.extend_from_slice(key.len().to_string().as_bytes());
out.push(b':');
out.extend_from_slice(key.as_bytes());
encode_bencode_value(value, out);
}
out.push(b'e');
}
/// Encodes a torrent-style bencode dictionary as a root value.
pub(super) fn encode_bencode_root(dict: &TorrentBencodeDict) -> Vec<u8> {
let mut out = Vec::new();
encode_bencode_dict(dict, &mut out);
out
}
/// Parses the torrent root dictionary and captures the raw `info` dictionary bytes.
pub(super) fn parse_root_dict(input: &[u8]) -> Result<(TorrentBencodeDict, Option<&[u8]>), String> {
if input.first().copied() != Some(b'd') {
return Err("torrent root must be dictionary".to_owned());
}
let mut cursor = 1;
let mut map = BTreeMap::new();
let mut info_raw = None;
while cursor < input.len() {
if input[cursor] == b'e' {
cursor += 1;
if cursor != input.len() {
return Err("trailing bytes after root dictionary".to_owned());
}
return Ok((map, info_raw));
}
let (key_bytes, next) = parse_bytes(input, cursor)?;
cursor = next;
let key = String::from_utf8(key_bytes).map_err(|_| "invalid dictionary key".to_owned())?;
let value_start = cursor;
let (value, end) = parse_value(input, cursor)?;
if key == "info" && matches!(value, BencodeValue::Dict(_)) {
info_raw = input.get(value_start..end);
}
cursor = end;
map.insert(key, value);
}
Err("unterminated dictionary".to_owned())
}
/// Parses one root bencode dictionary and allows trailing bytes after the dictionary.
pub(super) fn parse_bencode_root_prefix(
input: &[u8],
) -> Result<(TorrentBencodeDict, usize), String> {
let (value, consumed) = parse_value(input, 0)?;
match value {
BencodeValue::Dict(dict) => Ok((dict, consumed)),
_ => Err("bencode root must be a dictionary".to_owned()),
}
}
/// Parses one complete root bencode dictionary.
pub(super) fn parse_bencode_root_exact(input: &[u8]) -> Result<TorrentBencodeDict, String> {
let (dict, consumed) = parse_bencode_root_prefix(input)?;
if consumed != input.len() {
return Err("trailing bytes after bencode dictionary".to_owned());
}
Ok(dict)
}
/// Parses one torrent bencode value and returns the decoded value plus next index.
fn parse_value(input: &[u8], index: usize) -> Result<(BencodeValue, usize), String> {
match input.get(index).copied() {
Some(b'i') => parse_int(input, index),
Some(b'l') => parse_list(input, index),
Some(b'd') => parse_dict(input, index).map(|(map, end)| (BencodeValue::Dict(map), end)),
Some(b'0'..=b'9') => {
parse_bytes(input, index).map(|(bytes, end)| (BencodeValue::Bytes(bytes), end))
}
_ => Err("invalid bencode value".to_owned()),
}
}
/// Parses one torrent bencode integer starting at `index`.
fn parse_int(input: &[u8], index: usize) -> Result<(BencodeValue, usize), String> {
let mut cursor = index + 1;
while cursor < input.len() && input[cursor] != b'e' {
cursor += 1;
}
if cursor >= input.len() {
return Err("unterminated integer".to_owned());
}
let number = std::str::from_utf8(&input[index + 1..cursor])
.map_err(|_| "invalid integer".to_owned())?
.parse::<i64>()
.map_err(|_| "invalid integer".to_owned())?;
Ok((BencodeValue::Int(number), cursor + 1))
}
/// Parses one torrent bencode list starting at `index`.
fn parse_list(input: &[u8], index: usize) -> Result<(BencodeValue, usize), String> {
let mut cursor = index + 1;
let mut values = Vec::new();
while cursor < input.len() {
if input[cursor] == b'e' {
return Ok((BencodeValue::List(values), cursor + 1));
}
let (value, end) = parse_value(input, cursor)?;
values.push(value);
cursor = end;
}
Err("unterminated list".to_owned())
}
/// Parses one torrent bencode dictionary starting at `index`.
fn parse_dict(input: &[u8], index: usize) -> Result<(TorrentBencodeDict, usize), String> {
let mut cursor = index + 1;
let mut map = BTreeMap::new();
while cursor < input.len() {
if input[cursor] == b'e' {
return Ok((map, cursor + 1));
}
let (key_bytes, next) = parse_bytes(input, cursor)?;
cursor = next;
let key = String::from_utf8(key_bytes).map_err(|_| "invalid dictionary key".to_owned())?;
let (value, end) = parse_value(input, cursor)?;
cursor = end;
map.insert(key, value);
}
Err("unterminated dictionary".to_owned())
}
/// Parses one torrent bencode byte string starting at `index`.
fn parse_bytes(input: &[u8], index: usize) -> Result<(Vec<u8>, usize), String> {
let mut cursor = index;
while cursor < input.len() && input[cursor].is_ascii_digit() {
cursor += 1;
}
if cursor == index || cursor >= input.len() || input[cursor] != b':' {
return Err("invalid bencode byte string".to_owned());
}
let len = std::str::from_utf8(&input[index..cursor])
.map_err(|_| "invalid byte string length".to_owned())?
.parse::<usize>()
.map_err(|_| "invalid byte string length".to_owned())?;
let start = cursor + 1;
let end = start.saturating_add(len);
if end > input.len() {
return Err("truncated byte string".to_owned());
}
Ok((input[start..end].to_vec(), end))
}
/// Looks up a byte-string field inside a torrent bencode dictionary.
pub(super) fn dict_bytes<'a>(dict: &'a TorrentBencodeDict, key: &str) -> Option<&'a [u8]> {
match dict.get(key) {
Some(BencodeValue::Bytes(bytes)) => Some(bytes.as_slice()),
_ => None,
}
}
/// Looks up an integer field inside a torrent bencode dictionary.
pub(super) fn dict_int(dict: &TorrentBencodeDict, key: &str) -> Option<i64> {
match dict.get(key) {
Some(BencodeValue::Int(value)) => Some(*value),
_ => None,
}
}
@@ -0,0 +1,157 @@
use std::collections::BTreeMap;
use crate::tracker::DhtNodeModel;
use super::utils::hex_encode;
/// DHT bencode codec helpers.
mod codec;
/// Compact-node and compact-peer conversion helpers.
pub(super) mod compact;
/// DHT message builders plus wire-format parsing helpers.
mod message;
/// One DHT message with a transaction id and typed body.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DhtMessageModel {
/// Opaque DHT transaction id.
pub transaction_id: Vec<u8>,
/// Typed message body.
pub body: DhtMessageBody,
}
/// Supported DHT message body variants.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DhtMessageBody {
/// Outbound or inbound query.
Query(DhtQueryModel),
/// Successful response payload.
Response(DhtResponseModel),
/// Error response payload.
Error(DhtErrorModel),
}
/// Supported DHT query variants.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DhtQueryModel {
/// `ping` query.
Ping(DhtPingQueryModel),
/// `find_node` query.
FindNode(DhtFindNodeQueryModel),
/// `get_peers` query.
GetPeers(DhtGetPeersQueryModel),
/// `announce_peer` query.
AnnouncePeer(DhtAnnouncePeerQueryModel),
}
/// DHT `ping` query payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DhtPingQueryModel {
/// Querying node id.
pub node_id: Vec<u8>,
}
/// DHT `find_node` query payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DhtFindNodeQueryModel {
/// Querying node id.
pub node_id: Vec<u8>,
/// Target node id being searched.
pub target: Vec<u8>,
}
/// DHT `get_peers` query payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DhtGetPeersQueryModel {
/// Querying node id.
pub node_id: Vec<u8>,
/// Torrent info-hash being searched.
pub info_hash: Vec<u8>,
}
/// DHT `announce_peer` query payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DhtAnnouncePeerQueryModel {
/// Querying node id.
pub node_id: Vec<u8>,
/// Torrent info-hash being announced.
pub info_hash: Vec<u8>,
/// Advertised listening port.
pub port: u16,
/// Tracker-issued or routing token.
pub token: Vec<u8>,
/// Whether the sender requested implied-port semantics.
pub implied_port: bool,
}
/// Supported DHT response variants.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DhtResponseModel {
/// `ping` or `announce_peer` response payload.
Ping(DhtPingResponseModel),
/// `find_node` response payload.
FindNode(DhtFindNodeResponseModel),
/// `get_peers` response payload.
GetPeers(DhtGetPeersResponseModel),
}
/// DHT `ping` response payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DhtPingResponseModel {
/// Responding node id.
pub node_id: Vec<u8>,
}
/// One compact DHT node entry.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DhtCompactNodeModel {
/// Remote node id.
pub node_id: [u8; 20],
/// IPv4 address bytes.
pub address: [u8; 4],
/// UDP port in host byte order.
pub port: u16,
}
/// DHT `find_node` response payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DhtFindNodeResponseModel {
/// Responding node id.
pub node_id: Vec<u8>,
/// Returned compact nodes.
pub nodes: Vec<DhtCompactNodeModel>,
}
/// DHT `get_peers` response payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DhtGetPeersResponseModel {
/// Responding node id.
pub node_id: Vec<u8>,
/// Optional token to reuse in `announce_peer`.
pub token: Option<Vec<u8>>,
/// Optional compact-node blob.
pub nodes: Option<Vec<u8>>,
/// Optional compact-peer values.
pub values: Vec<Vec<u8>>,
}
/// DHT error payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DhtErrorModel {
/// Numeric error code.
pub code: i64,
/// Human-readable error message.
pub message: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// Internal bencode value representation used while parsing DHT payloads.
enum DhtBencodeValue {
/// Signed integer literal.
Int(i64),
/// Raw byte string payload.
Bytes(Vec<u8>),
/// Ordered list of nested bencode values.
List(Vec<Self>),
/// Dictionary keyed by raw byte strings.
Dict(BTreeMap<Vec<u8>, Self>),
}
@@ -0,0 +1,177 @@
use std::collections::BTreeMap;
use super::DhtBencodeValue;
/// Encodes one DHT bencode value into a byte buffer.
fn dht_encode_value(value: &DhtBencodeValue, out: &mut Vec<u8>) {
match value {
DhtBencodeValue::Int(number) => {
out.push(b'i');
out.extend_from_slice(number.to_string().as_bytes());
out.push(b'e');
}
DhtBencodeValue::Bytes(bytes) => {
out.extend_from_slice(bytes.len().to_string().as_bytes());
out.push(b':');
out.extend_from_slice(bytes);
}
DhtBencodeValue::List(values) => {
out.push(b'l');
for item in values {
dht_encode_value(item, out);
}
out.push(b'e');
}
DhtBencodeValue::Dict(values) => {
out.push(b'd');
for (key, value) in values {
out.extend_from_slice(key.len().to_string().as_bytes());
out.push(b':');
out.extend_from_slice(key);
dht_encode_value(value, out);
}
out.push(b'e');
}
}
}
/// Encodes a DHT dictionary into canonical bencode bytes.
pub(super) fn dht_encode_dict(dict: &BTreeMap<Vec<u8>, DhtBencodeValue>) -> Vec<u8> {
let mut out = Vec::new();
dht_encode_value(&DhtBencodeValue::Dict(dict.clone()), &mut out);
out
}
/// Parses one DHT bencode value and returns the decoded value plus next index.
pub(super) fn dht_parse_value(
input: &[u8],
index: usize,
) -> Result<(DhtBencodeValue, usize), String> {
match input.get(index).copied() {
Some(b'i') => dht_parse_int(input, index + 1),
Some(b'l') => dht_parse_list(input, index + 1),
Some(b'd') => dht_parse_dict(input, index + 1),
Some(byte) if byte.is_ascii_digit() => dht_parse_bytes(input, index),
Some(_) => Err("invalid dht bencode value".to_owned()),
None => Err("unexpected end of dht bencode input".to_owned()),
}
}
/// Parses one DHT bencode integer starting at `index`.
fn dht_parse_int(input: &[u8], index: usize) -> Result<(DhtBencodeValue, usize), String> {
let mut cursor = index;
while cursor < input.len() && input[cursor] != b'e' {
cursor += 1;
}
if cursor >= input.len() {
return Err("unterminated dht integer".to_owned());
}
let text = std::str::from_utf8(&input[index..cursor])
.map_err(|_| "invalid dht integer bytes".to_owned())?;
let value = text
.parse::<i64>()
.map_err(|_| "invalid dht integer value".to_owned())?;
Ok((DhtBencodeValue::Int(value), cursor + 1))
}
/// Parses one DHT bencode list starting at `index`.
fn dht_parse_list(input: &[u8], index: usize) -> Result<(DhtBencodeValue, usize), String> {
let mut values = Vec::new();
let mut cursor = index;
while cursor < input.len() {
if input[cursor] == b'e' {
return Ok((DhtBencodeValue::List(values), cursor + 1));
}
let (value, next) = dht_parse_value(input, cursor)?;
values.push(value);
cursor = next;
}
Err("unterminated dht list".to_owned())
}
/// Parses one DHT bencode dictionary starting at `index`.
fn dht_parse_dict(input: &[u8], index: usize) -> Result<(DhtBencodeValue, usize), String> {
let mut map = BTreeMap::new();
let mut cursor = index;
while cursor < input.len() {
if input[cursor] == b'e' {
return Ok((DhtBencodeValue::Dict(map), cursor + 1));
}
let (key, key_end) = dht_parse_bytes_raw(input, cursor)?;
let (value, value_end) = dht_parse_value(input, key_end)?;
map.insert(key, value);
cursor = value_end;
}
Err("unterminated dht dictionary".to_owned())
}
/// Parses one DHT bencode byte string starting at `index`.
fn dht_parse_bytes(input: &[u8], index: usize) -> Result<(DhtBencodeValue, usize), String> {
let (bytes, next) = dht_parse_bytes_raw(input, index)?;
Ok((DhtBencodeValue::Bytes(bytes), next))
}
/// Parses one raw DHT bencode byte string and returns its bytes plus next index.
fn dht_parse_bytes_raw(input: &[u8], index: usize) -> Result<(Vec<u8>, usize), String> {
let mut cursor = index;
while cursor < input.len() && input[cursor].is_ascii_digit() {
cursor += 1;
}
if cursor == index || cursor >= input.len() || input[cursor] != b':' {
return Err("invalid dht byte string".to_owned());
}
let length = std::str::from_utf8(&input[index..cursor])
.map_err(|_| "invalid dht byte string length".to_owned())?
.parse::<usize>()
.map_err(|_| "invalid dht byte string length".to_owned())?;
let start = cursor + 1;
let end = start.saturating_add(length);
if end > input.len() {
return Err("truncated dht byte string".to_owned());
}
Ok((input[start..end].to_vec(), end))
}
/// Looks up a raw byte-string field inside a DHT dictionary.
pub(super) fn dht_dict_get_bytes(
dict: &BTreeMap<Vec<u8>, DhtBencodeValue>,
key: &[u8],
) -> Option<Vec<u8>> {
match dict.get(key) {
Some(DhtBencodeValue::Bytes(bytes)) => Some(bytes.clone()),
_ => None,
}
}
/// Looks up an integer field inside a DHT dictionary.
pub(super) fn dht_dict_get_int(
dict: &BTreeMap<Vec<u8>, DhtBencodeValue>,
key: &[u8],
) -> Option<i64> {
match dict.get(key) {
Some(DhtBencodeValue::Int(value)) => Some(*value),
_ => None,
}
}
/// Looks up a nested dictionary field inside a DHT dictionary.
pub(super) fn dht_dict_get_dict<'a>(
dict: &'a BTreeMap<Vec<u8>, DhtBencodeValue>,
key: &[u8],
) -> Option<&'a BTreeMap<Vec<u8>, DhtBencodeValue>> {
match dict.get(key) {
Some(DhtBencodeValue::Dict(value)) => Some(value),
_ => None,
}
}
/// Looks up a list field inside a DHT dictionary.
pub(super) fn dht_dict_get_list(
dict: &BTreeMap<Vec<u8>, DhtBencodeValue>,
key: &[u8],
) -> Option<Vec<DhtBencodeValue>> {
match dict.get(key) {
Some(DhtBencodeValue::List(values)) => Some(values.clone()),
_ => None,
}
}
@@ -0,0 +1,114 @@
use super::hex_encode;
use super::{
DhtCompactNodeModel, DhtFindNodeResponseModel, DhtGetPeersResponseModel, DhtNodeModel,
};
use crate::torrent::TorrentPeerModel;
impl DhtCompactNodeModel {
/// Converts the compact node entry into a higher-level DHT node model.
#[must_use]
pub fn to_dht_node(self) -> DhtNodeModel {
DhtNodeModel {
node_id: hex_encode(&self.node_id),
address: format!(
"{}.{}.{}.{}",
self.address[0], self.address[1], self.address[2], self.address[3]
),
port: self.port,
}
}
}
impl DhtFindNodeResponseModel {
/// Shapes compact DHT node entries into higher-level node models.
#[must_use]
pub fn dht_nodes(&self) -> Vec<DhtNodeModel> {
self.nodes
.iter()
.copied()
.map(DhtCompactNodeModel::to_dht_node)
.collect()
}
}
impl DhtGetPeersResponseModel {
/// Decodes compact peer-contact payloads into higher-level peer rows.
///
/// # Errors
///
/// Returns an error when any compact peer payload is malformed.
pub fn peer_contacts(&self) -> Result<Vec<TorrentPeerModel>, String> {
parse_compact_peer_contacts(&self.values)
}
/// Decodes the optional compact-node blob into higher-level DHT node rows.
///
/// # Errors
///
/// Returns an error when the compact-node blob is malformed.
pub fn dht_nodes(&self) -> Result<Vec<DhtNodeModel>, String> {
let Some(nodes) = &self.nodes else {
return Ok(Vec::new());
};
decode_compact_dht_nodes(nodes).map(|nodes| {
nodes
.into_iter()
.map(DhtCompactNodeModel::to_dht_node)
.collect()
})
}
}
/// Encodes compact DHT nodes into the BEP 5 26-byte-per-node representation.
pub(in super::super) fn encode_compact_dht_nodes(nodes: &[DhtCompactNodeModel]) -> Vec<u8> {
let mut bytes = Vec::with_capacity(nodes.len() * 26);
for node in nodes {
bytes.extend_from_slice(&node.node_id);
bytes.extend_from_slice(&node.address);
bytes.extend_from_slice(&node.port.to_be_bytes());
}
bytes
}
/// Decodes compact DHT nodes from the BEP 5 26-byte-per-node representation.
pub(in super::super) fn decode_compact_dht_nodes(
input: &[u8],
) -> Result<Vec<DhtCompactNodeModel>, String> {
if !input.len().is_multiple_of(26) {
return Err("compact dht node list length must be a multiple of 26".to_owned());
}
let mut nodes = Vec::with_capacity(input.len() / 26);
for chunk in input.chunks_exact(26) {
let mut node_id = [0_u8; 20];
node_id.copy_from_slice(&chunk[..20]);
let mut address = [0_u8; 4];
address.copy_from_slice(&chunk[20..24]);
nodes.push(DhtCompactNodeModel {
node_id,
address,
port: u16::from_be_bytes([chunk[24], chunk[25]]),
});
}
Ok(nodes)
}
/// Decodes compact BEP 5 peer-contact payloads into higher-level peer models.
fn parse_compact_peer_contacts(values: &[Vec<u8>]) -> Result<Vec<TorrentPeerModel>, String> {
let mut peers = Vec::new();
for value in values {
if value.len() % 6 != 0 {
return Err("compact peer list length must be a multiple of 6".to_owned());
}
for chunk in value.chunks_exact(6) {
peers.push(TorrentPeerModel {
peer_id: None,
ip: format!("{}.{}.{}.{}", chunk[0], chunk[1], chunk[2], chunk[3]),
port: u16::from_be_bytes([chunk[4], chunk[5]]),
client_name: None,
interested: false,
choked: false,
});
}
}
Ok(peers)
}
@@ -0,0 +1,488 @@
use super::codec::{
dht_dict_get_bytes, dht_dict_get_dict, dht_dict_get_int, dht_dict_get_list, dht_encode_dict,
dht_parse_value,
};
use super::compact::{decode_compact_dht_nodes, encode_compact_dht_nodes};
use std::collections::BTreeMap;
use super::{
DhtAnnouncePeerQueryModel, DhtBencodeValue, DhtCompactNodeModel, DhtErrorModel,
DhtFindNodeQueryModel, DhtFindNodeResponseModel, DhtGetPeersQueryModel,
DhtGetPeersResponseModel, DhtMessageBody, DhtMessageModel, DhtPingQueryModel,
DhtPingResponseModel, DhtQueryModel, DhtResponseModel,
};
impl DhtMessageModel {
#[must_use]
/// Builds a DHT `ping` query.
pub fn ping_query(transaction_id: impl Into<Vec<u8>>, node_id: impl Into<Vec<u8>>) -> Self {
Self {
transaction_id: transaction_id.into(),
body: DhtMessageBody::Query(DhtQueryModel::Ping(DhtPingQueryModel {
node_id: node_id.into(),
})),
}
}
#[must_use]
/// Builds a DHT `find_node` query.
pub fn find_node_query(
transaction_id: impl Into<Vec<u8>>,
node_id: impl Into<Vec<u8>>,
target: impl Into<Vec<u8>>,
) -> Self {
Self {
transaction_id: transaction_id.into(),
body: DhtMessageBody::Query(DhtQueryModel::FindNode(DhtFindNodeQueryModel {
node_id: node_id.into(),
target: target.into(),
})),
}
}
#[must_use]
/// Builds a DHT `get_peers` query.
pub fn get_peers_query(
transaction_id: impl Into<Vec<u8>>,
node_id: impl Into<Vec<u8>>,
info_hash: impl Into<Vec<u8>>,
) -> Self {
Self {
transaction_id: transaction_id.into(),
body: DhtMessageBody::Query(DhtQueryModel::GetPeers(DhtGetPeersQueryModel {
node_id: node_id.into(),
info_hash: info_hash.into(),
})),
}
}
#[must_use]
/// Builds a DHT `announce_peer` query.
pub fn announce_peer_query(
transaction_id: impl Into<Vec<u8>>,
node_id: impl Into<Vec<u8>>,
info_hash: impl Into<Vec<u8>>,
port: u16,
token: impl Into<Vec<u8>>,
implied_port: bool,
) -> Self {
Self {
transaction_id: transaction_id.into(),
body: DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(DhtAnnouncePeerQueryModel {
node_id: node_id.into(),
info_hash: info_hash.into(),
port,
token: token.into(),
implied_port,
})),
}
}
#[must_use]
/// Builds a DHT `ping` response.
pub fn ping_response(transaction_id: impl Into<Vec<u8>>, node_id: impl Into<Vec<u8>>) -> Self {
Self {
transaction_id: transaction_id.into(),
body: DhtMessageBody::Response(DhtResponseModel::Ping(DhtPingResponseModel {
node_id: node_id.into(),
})),
}
}
#[must_use]
/// Builds a DHT `find_node` response.
pub fn find_node_response(
transaction_id: impl Into<Vec<u8>>,
node_id: impl Into<Vec<u8>>,
nodes: Vec<DhtCompactNodeModel>,
) -> Self {
Self {
transaction_id: transaction_id.into(),
body: DhtMessageBody::Response(DhtResponseModel::FindNode(DhtFindNodeResponseModel {
node_id: node_id.into(),
nodes,
})),
}
}
#[must_use]
/// Builds a DHT `get_peers` response.
pub fn get_peers_response(
transaction_id: impl Into<Vec<u8>>,
node_id: impl Into<Vec<u8>>,
token: Option<Vec<u8>>,
nodes: Option<Vec<u8>>,
values: Vec<Vec<u8>>,
) -> Self {
Self {
transaction_id: transaction_id.into(),
body: DhtMessageBody::Response(DhtResponseModel::GetPeers(DhtGetPeersResponseModel {
node_id: node_id.into(),
token,
nodes,
values,
})),
}
}
#[must_use]
/// Builds a DHT `announce_peer` response.
pub fn announce_peer_response(
transaction_id: impl Into<Vec<u8>>,
node_id: impl Into<Vec<u8>>,
) -> Self {
Self {
transaction_id: transaction_id.into(),
body: DhtMessageBody::Response(DhtResponseModel::Ping(DhtPingResponseModel {
node_id: node_id.into(),
})),
}
}
#[must_use]
/// Builds a DHT error response.
pub fn error_response(
transaction_id: impl Into<Vec<u8>>,
code: i64,
message: impl Into<String>,
) -> Self {
Self {
transaction_id: transaction_id.into(),
body: DhtMessageBody::Error(DhtErrorModel {
code,
message: message.into(),
}),
}
}
#[must_use]
/// Returns the raw DHT transaction id bytes.
pub fn transaction_id(&self) -> &[u8] {
&self.transaction_id
}
#[must_use]
/// Returns the DHT query method name when the message body is a query.
pub fn method(&self) -> Option<&'static str> {
match &self.body {
DhtMessageBody::Query(DhtQueryModel::Ping(_)) => Some("ping"),
DhtMessageBody::Query(DhtQueryModel::FindNode(_)) => Some("find_node"),
DhtMessageBody::Query(DhtQueryModel::GetPeers(_)) => Some("get_peers"),
DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(_)) => Some("announce_peer"),
_ => None,
}
}
#[must_use]
/// Returns whether this message body is a DHT query.
pub fn is_query(&self) -> bool {
matches!(self.body, DhtMessageBody::Query(_))
}
#[must_use]
/// Serializes the DHT message as a bencoded root dictionary.
pub fn to_bencode_bytes(&self) -> Vec<u8> {
dht_encode_dict(&self.as_bencode_root())
}
/// Parses a DHT message from a bencoded payload.
///
/// # Errors
///
/// Returns an error when the payload is not a supported DHT message dictionary.
pub fn from_bencode_bytes(input: &[u8]) -> Result<Self, String> {
let (value, next) = dht_parse_value(input, 0)?;
if next != input.len() {
return Err("trailing bytes after dht message".to_owned());
}
let DhtBencodeValue::Dict(root) = value else {
return Err("dht message must be a bencoded dictionary".to_owned());
};
let transaction_id = dht_dict_get_bytes(&root, b"t")
.ok_or_else(|| "missing dht transaction id".to_owned())?;
let message_type =
dht_dict_get_bytes(&root, b"y").ok_or_else(|| "missing dht message type".to_owned())?;
match message_type.as_slice() {
b"q" => parse_dht_query_message(transaction_id, &root),
b"r" => parse_dht_response_message(transaction_id, &root),
b"e" => parse_dht_error_message(transaction_id, &root),
_ => Err("unsupported dht message type".to_owned()),
}
}
#[expect(
clippy::too_many_lines,
reason = "torrent roundtrip test keeps the end-to-end fixture in one place for auditability"
)]
/// Rebuilds the DHT message as the bencode root dictionary used on the wire.
fn as_bencode_root(&self) -> BTreeMap<Vec<u8>, DhtBencodeValue> {
let mut root = BTreeMap::new();
root.insert(
b"t".to_vec(),
DhtBencodeValue::Bytes(self.transaction_id.clone()),
);
match &self.body {
DhtMessageBody::Query(query) => {
root.insert(b"y".to_vec(), DhtBencodeValue::Bytes(b"q".to_vec()));
match query {
DhtQueryModel::Ping(query) => {
root.insert(b"q".to_vec(), DhtBencodeValue::Bytes(b"ping".to_vec()));
let mut args = BTreeMap::new();
args.insert(
b"id".to_vec(),
DhtBencodeValue::Bytes(query.node_id.clone()),
);
root.insert(b"a".to_vec(), DhtBencodeValue::Dict(args));
}
DhtQueryModel::FindNode(query) => {
root.insert(b"q".to_vec(), DhtBencodeValue::Bytes(b"find_node".to_vec()));
let mut args = BTreeMap::new();
args.insert(
b"id".to_vec(),
DhtBencodeValue::Bytes(query.node_id.clone()),
);
args.insert(
b"target".to_vec(),
DhtBencodeValue::Bytes(query.target.clone()),
);
root.insert(b"a".to_vec(), DhtBencodeValue::Dict(args));
}
DhtQueryModel::GetPeers(query) => {
root.insert(b"q".to_vec(), DhtBencodeValue::Bytes(b"get_peers".to_vec()));
let mut args = BTreeMap::new();
args.insert(
b"id".to_vec(),
DhtBencodeValue::Bytes(query.node_id.clone()),
);
args.insert(
b"info_hash".to_vec(),
DhtBencodeValue::Bytes(query.info_hash.clone()),
);
root.insert(b"a".to_vec(), DhtBencodeValue::Dict(args));
}
DhtQueryModel::AnnouncePeer(query) => {
root.insert(
b"q".to_vec(),
DhtBencodeValue::Bytes(b"announce_peer".to_vec()),
);
let mut args = BTreeMap::new();
args.insert(
b"id".to_vec(),
DhtBencodeValue::Bytes(query.node_id.clone()),
);
args.insert(
b"info_hash".to_vec(),
DhtBencodeValue::Bytes(query.info_hash.clone()),
);
args.insert(
b"port".to_vec(),
DhtBencodeValue::Int(i64::from(query.port)),
);
args.insert(
b"token".to_vec(),
DhtBencodeValue::Bytes(query.token.clone()),
);
if query.implied_port {
args.insert(b"implied_port".to_vec(), DhtBencodeValue::Int(1));
}
root.insert(b"a".to_vec(), DhtBencodeValue::Dict(args));
}
}
}
DhtMessageBody::Response(response) => {
root.insert(b"y".to_vec(), DhtBencodeValue::Bytes(b"r".to_vec()));
let mut payload = BTreeMap::new();
match response {
DhtResponseModel::Ping(response) => {
payload.insert(
b"id".to_vec(),
DhtBencodeValue::Bytes(response.node_id.clone()),
);
}
DhtResponseModel::FindNode(response) => {
payload.insert(
b"id".to_vec(),
DhtBencodeValue::Bytes(response.node_id.clone()),
);
if !response.nodes.is_empty() {
payload.insert(
b"nodes".to_vec(),
DhtBencodeValue::Bytes(encode_compact_dht_nodes(&response.nodes)),
);
}
}
DhtResponseModel::GetPeers(response) => {
payload.insert(
b"id".to_vec(),
DhtBencodeValue::Bytes(response.node_id.clone()),
);
if let Some(token) = &response.token {
payload
.insert(b"token".to_vec(), DhtBencodeValue::Bytes(token.clone()));
}
if let Some(nodes) = &response.nodes {
payload
.insert(b"nodes".to_vec(), DhtBencodeValue::Bytes(nodes.clone()));
}
if !response.values.is_empty() {
payload.insert(
b"values".to_vec(),
DhtBencodeValue::List(
response
.values
.iter()
.cloned()
.map(DhtBencodeValue::Bytes)
.collect(),
),
);
}
}
}
root.insert(b"r".to_vec(), DhtBencodeValue::Dict(payload));
}
DhtMessageBody::Error(error) => {
root.insert(b"y".to_vec(), DhtBencodeValue::Bytes(b"e".to_vec()));
root.insert(
b"e".to_vec(),
DhtBencodeValue::List(vec![
DhtBencodeValue::Int(error.code),
DhtBencodeValue::Bytes(error.message.as_bytes().to_vec()),
]),
);
}
}
root
}
}
/// Parses a DHT query message body from the decoded root dictionary.
fn parse_dht_query_message(
transaction_id: Vec<u8>,
root: &BTreeMap<Vec<u8>, DhtBencodeValue>,
) -> Result<DhtMessageModel, String> {
let method =
dht_dict_get_bytes(root, b"q").ok_or_else(|| "missing dht query method".to_owned())?;
let arguments =
dht_dict_get_dict(root, b"a").ok_or_else(|| "missing dht query arguments".to_owned())?;
let body = match method.as_slice() {
b"ping" => {
let node_id = dht_dict_get_bytes(arguments, b"id")
.ok_or_else(|| "missing dht ping id".to_owned())?;
DhtMessageBody::Query(DhtQueryModel::Ping(DhtPingQueryModel { node_id }))
}
b"find_node" => {
let node_id = dht_dict_get_bytes(arguments, b"id")
.ok_or_else(|| "missing dht find_node id".to_owned())?;
let target = dht_dict_get_bytes(arguments, b"target")
.ok_or_else(|| "missing dht find_node target".to_owned())?;
DhtMessageBody::Query(DhtQueryModel::FindNode(DhtFindNodeQueryModel {
node_id,
target,
}))
}
b"get_peers" => {
let node_id = dht_dict_get_bytes(arguments, b"id")
.ok_or_else(|| "missing dht get_peers id".to_owned())?;
let info_hash = dht_dict_get_bytes(arguments, b"info_hash")
.ok_or_else(|| "missing dht get_peers info_hash".to_owned())?;
DhtMessageBody::Query(DhtQueryModel::GetPeers(DhtGetPeersQueryModel {
node_id,
info_hash,
}))
}
b"announce_peer" => {
let node_id = dht_dict_get_bytes(arguments, b"id")
.ok_or_else(|| "missing dht announce_peer id".to_owned())?;
let info_hash = dht_dict_get_bytes(arguments, b"info_hash")
.ok_or_else(|| "missing dht announce_peer info_hash".to_owned())?;
let port = dht_dict_get_int(arguments, b"port")
.ok_or_else(|| "missing dht announce_peer port".to_owned())?;
let token = dht_dict_get_bytes(arguments, b"token")
.ok_or_else(|| "missing dht announce_peer token".to_owned())?;
let implied_port =
dht_dict_get_int(arguments, b"implied_port").is_some_and(|value| value != 0);
DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(DhtAnnouncePeerQueryModel {
node_id,
info_hash,
port: u16::try_from(port)
.map_err(|_| "dht announce_peer port out of range".to_owned())?,
token,
implied_port,
}))
}
_ => return Err("unsupported dht query method".to_owned()),
};
Ok(DhtMessageModel {
transaction_id,
body,
})
}
/// Parses a DHT response message body from the decoded root dictionary.
fn parse_dht_response_message(
transaction_id: Vec<u8>,
root: &BTreeMap<Vec<u8>, DhtBencodeValue>,
) -> Result<DhtMessageModel, String> {
let payload =
dht_dict_get_dict(root, b"r").ok_or_else(|| "missing dht response body".to_owned())?;
let node_id =
dht_dict_get_bytes(payload, b"id").ok_or_else(|| "missing dht response id".to_owned())?;
let token = dht_dict_get_bytes(payload, b"token");
let nodes = dht_dict_get_bytes(payload, b"nodes");
let values = dht_dict_get_list(payload, b"values")
.unwrap_or_default()
.into_iter()
.map(|value| match value {
DhtBencodeValue::Bytes(bytes) => Ok(bytes),
_ => Err("dht response values entries must be byte strings".to_owned()),
})
.collect::<Result<Vec<_>, _>>()?;
let response = if token.is_some() || !values.is_empty() {
DhtResponseModel::GetPeers(DhtGetPeersResponseModel {
node_id,
token,
nodes,
values,
})
} else if let Some(nodes) = nodes {
DhtResponseModel::FindNode(DhtFindNodeResponseModel {
node_id,
nodes: decode_compact_dht_nodes(&nodes)?,
})
} else {
DhtResponseModel::Ping(DhtPingResponseModel { node_id })
};
Ok(DhtMessageModel {
transaction_id,
body: DhtMessageBody::Response(response),
})
}
/// Parses a DHT error message body from the decoded root dictionary.
fn parse_dht_error_message(
transaction_id: Vec<u8>,
root: &BTreeMap<Vec<u8>, DhtBencodeValue>,
) -> Result<DhtMessageModel, String> {
let errors =
dht_dict_get_list(root, b"e").ok_or_else(|| "missing dht error payload".to_owned())?;
if errors.len() != 2 {
return Err("dht error payload must have [code, message]".to_owned());
}
let code = match errors.first() {
Some(DhtBencodeValue::Int(value)) => *value,
_ => return Err("dht error code must be an integer".to_owned()),
};
let message = match errors.get(1) {
Some(DhtBencodeValue::Bytes(bytes)) => String::from_utf8_lossy(bytes).into_owned(),
_ => return Err("dht error message must be bytes".to_owned()),
};
Ok(DhtMessageModel {
transaction_id,
body: DhtMessageBody::Error(DhtErrorModel { code, message }),
})
}
@@ -0,0 +1,185 @@
use sha1::{Digest, Sha1};
use super::{
bencode::{BencodeValue, TorrentBencodeDict, dict_bytes, dict_int, parse_root_dict},
model::{
TorrentBootstrapModel, TorrentFileEntryModel, TorrentHashModel, TorrentInfoModel,
TorrentMetadataModel, TorrentPieceModel, TorrentTrackerModel,
},
utils::{bytes_to_string, hex_encode, i64_to_u64, value_to_string},
};
/// Parses a `.torrent` payload into structured metadata.
///
/// # Errors
///
/// Returns an error when the bencoded payload is malformed or lacks the required info dictionary.
pub fn parse_torrent_metadata(input: &[u8]) -> Result<TorrentMetadataModel, String> {
let (root, info_raw) = parse_root_dict(input)?;
let info_map = match root.get("info") {
Some(BencodeValue::Dict(map)) => map,
Some(_) => return Err("torrent info must be a dictionary".to_owned()),
None => return Err("missing torrent info dictionary".to_owned()),
};
let info_hash_hex = hex_encode(&Sha1::digest(
info_raw.ok_or_else(|| "missing info bytes".to_owned())?,
));
let info = parse_info_model(info_map, info_hash_hex);
let announce = dict_bytes(&root, "announce").map(bytes_to_string);
let creation_date = dict_bytes(&root, "creation date").map(bytes_to_string);
let comment = dict_bytes(&root, "comment").map(bytes_to_string);
Ok(TorrentMetadataModel {
pieces: build_piece_models(&info),
info,
announce,
trackers: parse_trackers(&root),
peers: Vec::new(),
dht_nodes: parse_dht_nodes(&root),
creation_date,
comment,
})
}
/// Parses a `.torrent` payload and immediately shapes it into bootstrap-ready metadata.
///
/// # Errors
///
/// Returns an error when the `.torrent` payload is malformed or the derived bootstrap fields
/// cannot be shaped.
pub fn parse_torrent_bootstrap(input: &[u8]) -> Result<TorrentBootstrapModel, String> {
parse_torrent_metadata(input)?.bootstrap()
}
/// Builds the higher-level torrent info model from the parsed `info` dictionary.
fn parse_info_model(info: &TorrentBencodeDict, info_hash_hex: String) -> TorrentInfoModel {
let name = dict_bytes(info, "name")
.map(bytes_to_string)
.unwrap_or_default();
let piece_length = i64_to_u64(dict_int(info, "piece length").unwrap_or_default());
let pieces = dict_bytes(info, "pieces")
.map(|bytes| {
bytes
.chunks_exact(20)
.map(|chunk| {
let mut hash = [0_u8; 20];
hash.copy_from_slice(chunk);
hash
})
.collect()
})
.unwrap_or_default();
let private = dict_int(info, "private").is_some_and(|value| value != 0);
let hash = Some(TorrentHashModel {
info_hash_hex,
info_hash_base32: None,
});
let files = if let Some(BencodeValue::List(entries)) = info.get("files") {
let mut offset = 0_u64;
entries
.iter()
.filter_map(|entry| match entry {
BencodeValue::Dict(file) => {
let length = i64_to_u64(dict_int(file, "length").unwrap_or_default());
let path = match file.get("path") {
Some(BencodeValue::List(parts)) => parts
.iter()
.map(value_to_string)
.collect::<Vec<_>>()
.join("/"),
_ => String::new(),
};
let item = TorrentFileEntryModel {
path,
length,
piece_offset: Some(offset),
selected: true,
};
offset = offset.saturating_add(length);
Some(item)
}
_ => None,
})
.collect()
} else {
vec![TorrentFileEntryModel {
path: name.clone(),
length: i64_to_u64(dict_int(info, "length").unwrap_or_default()),
piece_offset: Some(0),
selected: true,
}]
};
TorrentInfoModel {
name,
piece_length,
pieces,
files,
hash,
private,
}
}
/// Derives piece descriptors with offsets and effective lengths from torrent metadata.
fn build_piece_models(info: &TorrentInfoModel) -> Vec<TorrentPieceModel> {
info.pieces
.iter()
.enumerate()
.filter_map(|(index, hash)| {
u32::try_from(index).ok().map(|index| TorrentPieceModel {
index,
hash: *hash,
length: info.piece_length,
})
})
.collect()
}
/// Parses the primary announce URL plus announce-list tiers into stable tracker entries.
fn parse_trackers(root: &TorrentBencodeDict) -> Vec<TorrentTrackerModel> {
let mut trackers = Vec::new();
if let Some(url) = dict_bytes(root, "announce").map(bytes_to_string) {
trackers.push(TorrentTrackerModel {
url,
tier: Some(0),
id: None,
seeders: None,
leechers: None,
});
}
if let Some(BencodeValue::List(tiers)) = root.get("announce-list") {
for (tier_index, tier) in tiers.iter().enumerate() {
if let BencodeValue::List(urls) = tier {
let tier = u32::try_from(tier_index)
.ok()
.and_then(|value| value.checked_add(1));
for url in urls {
trackers.push(TorrentTrackerModel {
url: value_to_string(url),
tier,
id: None,
seeders: None,
leechers: None,
});
}
}
}
}
trackers
}
/// Parses DHT bootstrap nodes from the optional `nodes` field.
fn parse_dht_nodes(root: &TorrentBencodeDict) -> Vec<String> {
match root.get("nodes") {
Some(BencodeValue::List(nodes)) => nodes
.iter()
.filter_map(|node| match node {
BencodeValue::List(parts) => parts.first().zip(parts.get(1)).map(|(host, port)| {
format!("{}:{}", value_to_string(host), value_to_string(port))
}),
_ => None,
})
.collect(),
_ => Vec::new(),
}
}
@@ -0,0 +1,281 @@
use crate::{
magnet::MagnetUriModel,
tracker::{DhtNodeModel, TrackerRequestModel},
};
use super::utils::decode_hex_20_array;
/// Derived info-hash encodings for one parsed torrent info dictionary.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TorrentHashModel {
/// Lowercase hexadecimal SHA-1 info-hash.
pub info_hash_hex: String,
/// Optional base32-encoded SHA-1 info-hash.
pub info_hash_base32: Option<String>,
}
/// One torrent piece with its index, SHA-1 hash, and visible byte length.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TorrentPieceModel {
/// Zero-based piece index.
pub index: u32,
/// Raw 20-byte SHA-1 piece hash.
pub hash: [u8; 20],
/// Declared byte length of the piece.
pub length: u64,
}
/// One file entry from the torrent info dictionary.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TorrentFileEntryModel {
/// Normalized relative path of the file inside the torrent payload.
pub path: String,
/// Declared byte length of the file.
pub length: u64,
/// Byte offset where this file begins within the concatenated torrent payload.
pub piece_offset: Option<u64>,
/// Whether the file is currently selected for download.
pub selected: bool,
}
/// Parsed contents of the torrent info dictionary.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TorrentInfoModel {
/// Display name of the torrent or root directory.
pub name: String,
/// Declared piece length in bytes.
pub piece_length: u64,
/// Raw SHA-1 piece hashes in info-dictionary order.
pub pieces: Vec<[u8; 20]>,
/// File list represented by the torrent.
pub files: Vec<TorrentFileEntryModel>,
/// Precomputed info-hash encodings, when the raw info dictionary was available.
pub hash: Option<TorrentHashModel>,
/// Whether the torrent declares the private flag.
pub private: bool,
}
/// One announce or scrape tracker entry associated with the torrent.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TorrentTrackerModel {
/// Tracker URL.
pub url: String,
/// Optional announce-list tier index.
pub tier: Option<u32>,
/// Optional tracker id returned by the tracker.
pub id: Option<String>,
/// Optional reported seeder count.
pub seeders: Option<u32>,
/// Optional reported leecher count.
pub leechers: Option<u32>,
}
/// One peer surfaced through torrent runtime or tracker responses.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TorrentPeerModel {
/// Optional 20-byte peer id.
pub peer_id: Option<[u8; 20]>,
/// Peer IP address in string form.
pub ip: String,
/// Peer port.
pub port: u16,
/// Optional peer client name.
pub client_name: Option<String>,
/// Whether the peer is interested in local pieces.
pub interested: bool,
/// Whether the peer is currently choking the local side.
pub choked: bool,
}
/// Full parsed torrent metadata plus runtime-adjacent tracker and peer surfaces.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TorrentMetadataModel {
/// Parsed info dictionary.
pub info: TorrentInfoModel,
/// Primary announce URL, if present.
pub announce: Option<String>,
/// Flattened tracker list with stable tier annotations.
pub trackers: Vec<TorrentTrackerModel>,
/// Known peers currently associated with the torrent.
pub peers: Vec<TorrentPeerModel>,
/// DHT bootstrap nodes from the `nodes` list, normalized as `host:port` strings.
pub dht_nodes: Vec<String>,
/// Derived piece models with offsets and lengths.
pub pieces: Vec<TorrentPieceModel>,
/// Optional creation date text carried by the torrent.
pub creation_date: Option<String>,
/// Optional comment text carried by the torrent.
pub comment: Option<String>,
}
/// Higher-level `.torrent` bootstrap data suitable for dispatcher / CLI handoff.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TorrentBootstrapModel {
/// Fully parsed torrent metadata.
pub metadata: TorrentMetadataModel,
/// Lowercase hexadecimal SHA-1 info-hash.
pub info_hash_hex: String,
/// Raw 20-byte SHA-1 info-hash.
pub info_hash_bytes: [u8; 20],
/// Magnet projection derived from the torrent metadata.
pub magnet: MagnetUriModel,
/// Parsed DHT node models ready for DHT/bootstrap orchestration.
pub dht_nodes: Vec<DhtNodeModel>,
}
impl TorrentPeerModel {
/// Parses a peer endpoint from `host:port` or `[ipv6]:port` syntax.
///
/// # Errors
///
/// Returns an error when the endpoint is malformed.
pub fn from_endpoint(raw: &str) -> Result<Self, String> {
let node = DhtNodeModel::from_spec(raw).map_err(|error| error.to_string())?;
Ok(Self {
peer_id: None,
ip: node.address,
port: node.port,
client_name: None,
interested: false,
choked: false,
})
}
/// Formats the peer as a stable endpoint string.
#[must_use]
pub fn endpoint(&self) -> String {
self.to_dht_node().to_spec()
}
/// Shapes the peer endpoint into a DHT/bootstrap node model.
#[must_use]
pub fn to_dht_node(&self) -> DhtNodeModel {
DhtNodeModel {
node_id: String::new(),
address: self.ip.clone(),
port: self.port,
}
}
}
impl TorrentMetadataModel {
#[must_use]
/// Returns the total payload length across all torrent files.
pub fn total_length(&self) -> u64 {
self.info.files.iter().map(|file| file.length).sum()
}
/// Returns the torrent info-hash as lowercase hexadecimal text.
#[must_use]
pub fn info_hash_hex(&self) -> Option<&str> {
self.info
.hash
.as_ref()
.map(|hash| hash.info_hash_hex.as_str())
}
/// Decodes the torrent info-hash into its raw 20-byte SHA-1 representation.
///
/// # Errors
///
/// Returns an error when the torrent metadata does not carry an info-hash.
pub fn info_hash_bytes(&self) -> Result<[u8; 20], String> {
decode_hex_20_array(
self.info_hash_hex()
.ok_or_else(|| "torrent metadata is missing info-hash".to_owned())?,
)
}
/// Returns the tracker URLs in stable announce order.
#[must_use]
pub fn tracker_urls(&self) -> Vec<String> {
self.trackers
.iter()
.map(|tracker| tracker.url.clone())
.collect()
}
/// Returns the first tracker URL when present.
#[must_use]
pub fn primary_tracker_url(&self) -> Option<&str> {
self.trackers.first().map(|tracker| tracker.url.as_str())
}
/// Projects the torrent metadata into a canonical magnet URI model.
///
/// # Errors
///
/// Returns an error when the torrent metadata does not carry an info-hash.
pub fn magnet_uri_model(&self) -> Result<MagnetUriModel, String> {
Ok(MagnetUriModel {
info_hash: self
.info_hash_hex()
.ok_or_else(|| "torrent metadata is missing info-hash".to_owned())?
.to_owned(),
display_name: Some(self.info.name.clone()),
trackers: self.tracker_urls(),
web_seeds: Vec::new(),
exact_topic: None,
})
}
/// Parses stored DHT node specs into higher-level node models.
///
/// # Errors
///
/// Returns an error when any stored node spec is malformed.
pub fn dht_node_models(&self) -> Result<Vec<DhtNodeModel>, String> {
self.dht_nodes
.iter()
.map(|node| DhtNodeModel::from_spec(node).map_err(|error| error.to_string()))
.collect()
}
/// Shapes the torrent metadata into a bootstrap model suitable for `.torrent` handoff.
///
/// # Errors
///
/// Returns an error when the torrent metadata does not carry a usable info-hash or contains
/// malformed DHT node specs.
pub fn bootstrap(&self) -> Result<TorrentBootstrapModel, String> {
Ok(TorrentBootstrapModel {
metadata: self.clone(),
info_hash_hex: self
.info_hash_hex()
.ok_or_else(|| "torrent metadata is missing info-hash".to_owned())?
.to_owned(),
info_hash_bytes: self.info_hash_bytes()?,
magnet: self.magnet_uri_model()?,
dht_nodes: self.dht_node_models()?,
})
}
#[must_use]
/// Builds a tracker announce request from the parsed torrent metadata.
pub fn tracker_request(
&self,
announce_url: impl Into<String>,
peer_id: impl Into<String>,
port: u16,
uploaded: u64,
downloaded: u64,
) -> TrackerRequestModel {
TrackerRequestModel {
announce_url: announce_url.into(),
info_hash: self
.info
.hash
.as_ref()
.map(|hash| hash.info_hash_hex.clone())
.unwrap_or_default(),
peer_id: peer_id.into(),
port,
uploaded,
downloaded,
left: self.total_length().saturating_sub(downloaded),
event: None,
compact: true,
numwant: Some(50),
}
}
}
@@ -0,0 +1,223 @@
use std::collections::BTreeMap;
use super::{
bencode::{
BencodeValue, dict_bytes, dict_int, encode_bencode_root, parse_bencode_root_exact,
parse_bencode_root_prefix,
},
utils::{bytes_to_string, i64_to_u64},
};
/// BEP 10 extension-protocol handshake and metadata helpers.
mod extension;
/// Peer-wire frame parsing and serialization helpers.
mod framing;
/// `BitTorrent` handshake parsing and serialization helpers.
mod handshake;
/// Generic torrent message wrapper reused by higher-level peer-wire helpers.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TorrentMessageModel {
/// Canonical internal message type name.
pub message_type: String,
/// Raw payload bytes excluding transport framing.
pub payload: Vec<u8>,
}
/// `BitTorrent` peer-wire handshake header.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PeerWireHandshakeModel {
/// Reserved extension bits.
pub reserved: [u8; 8],
/// 20-byte torrent info-hash.
pub info_hash: [u8; 20],
/// 20-byte local peer id.
pub peer_id: [u8; 20],
}
/// One parsed peer-wire message, optionally associated with a peer id.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PeerWireMessageModel {
/// Optional peer id attached by higher-level wrappers.
pub peer_id: Option<[u8; 20]>,
/// Parsed message payload.
pub message: TorrentMessageModel,
}
/// Lightweight inspection result for a framed peer-wire message.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PeerWireFrameHeaderModel {
/// Optional peer-wire message id. `None` represents keepalive.
pub message_id: Option<u8>,
/// Payload length excluding the length prefix and optional message id byte.
pub payload_len: usize,
}
/// Packed peer-wire bitfield bytes.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PeerWireBitfieldModel {
/// Raw bitfield bytes in network order.
pub bytes: Vec<u8>,
}
/// Piece request or cancel coordinates for the peer-wire protocol.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PeerWireBlockRequestModel {
/// Zero-based piece index.
pub piece_index: u32,
/// Byte offset within the piece.
pub block_offset: u32,
/// Requested block length in bytes.
pub block_length: u32,
}
/// Piece payload delivered through the peer-wire protocol.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PeerWirePieceBlockModel {
/// Zero-based piece index.
pub piece_index: u32,
/// Byte offset within the piece.
pub block_offset: u32,
/// Raw block bytes.
pub block: Vec<u8>,
}
/// Extension-protocol message payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PeerWireExtensionMessageModel {
/// Extension message id.
pub extension_message_id: u8,
/// Extension payload bytes after the extension id.
pub payload: Vec<u8>,
}
/// Parsed extended-handshake payload from BEP 10.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PeerWireExtensionHandshakeModel {
/// Named extension ids announced under the `m` dictionary.
pub extensions: BTreeMap<String, u8>,
/// Optional peer/client version string from `v`.
pub client_name: Option<String>,
/// Optional BEP 9 metadata byte length.
pub metadata_size: Option<u32>,
/// Optional request queue depth from `reqq`.
pub request_queue: Option<u32>,
}
/// BEP 9 `ut_metadata` message type.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PeerWireMetadataMessageType {
/// Requests one metadata piece.
Request,
/// Carries one metadata piece payload.
Data,
/// Rejects one metadata piece request.
Reject,
}
impl PeerWireMetadataMessageType {
/// Returns the BEP 9 wire value for the metadata message type.
#[must_use]
pub const fn wire_value(self) -> u8 {
match self {
Self::Request => 0,
Self::Data => 1,
Self::Reject => 2,
}
}
/// Decodes one BEP 9 wire value into the typed metadata message kind.
fn from_wire_value(value: i64) -> Result<Self, String> {
match value {
0 => Ok(Self::Request),
1 => Ok(Self::Data),
2 => Ok(Self::Reject),
_ => Err(format!("unsupported ut_metadata msg_type: {value}")),
}
}
}
/// Default BEP 9 metadata piece size in bytes.
pub const PEER_WIRE_METADATA_PIECE_SIZE: u32 = 16 * 1024;
/// Parsed BEP 9 `ut_metadata` message with header and optional payload bytes.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PeerWireMetadataMessageModel {
/// Metadata message subtype.
pub message_type: PeerWireMetadataMessageType,
/// Metadata piece index addressed by the message.
pub piece: u32,
/// Total metadata byte length when included in `data` messages.
pub total_size: Option<u32>,
/// Metadata payload bytes for `data` messages.
pub payload: Vec<u8>,
}
/// Unknown peer-wire message payload retained losslessly.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PeerWireUnknownMessageModel {
/// Raw peer-wire message id.
pub message_id: u8,
/// Unparsed message payload bytes.
pub payload: Vec<u8>,
}
/// Supported peer-wire message variants.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PeerWireMessageKind {
/// Zero-length keepalive frame.
KeepAlive,
/// Choke control message.
Choke,
/// Unchoke control message.
Unchoke,
/// Interested control message.
Interested,
/// Not-interested control message.
NotInterested,
/// `have` message naming one completed piece.
Have(u32),
/// `bitfield` message.
Bitfield(PeerWireBitfieldModel),
/// `request` message.
Request(PeerWireBlockRequestModel),
/// `piece` message.
Piece(PeerWirePieceBlockModel),
/// `cancel` message.
Cancel(PeerWireBlockRequestModel),
/// `port` DHT advertisement message.
Port(u16),
/// Extension-protocol message.
Extension(PeerWireExtensionMessageModel),
/// Unknown message preserved losslessly.
Unknown(PeerWireUnknownMessageModel),
}
/// Canonical protocol string embedded in peer-wire handshakes.
const PEER_WIRE_PROTOCOL_NAME: &str = "BitTorrent protocol";
/// Byte length of [`PEER_WIRE_PROTOCOL_NAME`].
const PEER_WIRE_PROTOCOL_LEN: u8 = 19;
/// Handshake bytes following the protocol-length octet and protocol string.
const PEER_WIRE_HANDSHAKE_PREFIX_LEN: usize = 49;
/// Peer-wire message id for `choke`.
const PEER_WIRE_CHOKE_ID: u8 = 0;
/// Peer-wire message id for `unchoke`.
const PEER_WIRE_UNCHOKE_ID: u8 = 1;
/// Peer-wire message id for `interested`.
const PEER_WIRE_INTERESTED_ID: u8 = 2;
/// Peer-wire message id for `not interested`.
const PEER_WIRE_NOT_INTERESTED_ID: u8 = 3;
/// Peer-wire message id for `have`.
const PEER_WIRE_HAVE_ID: u8 = 4;
/// Peer-wire message id for `bitfield`.
const PEER_WIRE_BITFIELD_ID: u8 = 5;
/// Peer-wire message id for `request`.
const PEER_WIRE_REQUEST_ID: u8 = 6;
/// Peer-wire message id for `piece`.
const PEER_WIRE_PIECE_ID: u8 = 7;
/// Peer-wire message id for `cancel`.
const PEER_WIRE_CANCEL_ID: u8 = 8;
/// Peer-wire message id for `port`.
const PEER_WIRE_PORT_ID: u8 = 9;
/// Peer-wire message id for extension-protocol payloads.
const PEER_WIRE_EXTENSION_ID: u8 = 20;
@@ -0,0 +1,291 @@
use std::collections::BTreeMap;
use super::{
BencodeValue, PEER_WIRE_METADATA_PIECE_SIZE, PeerWireExtensionHandshakeModel,
PeerWireExtensionMessageModel, PeerWireMetadataMessageModel, PeerWireMetadataMessageType,
bytes_to_string, dict_bytes, dict_int, encode_bencode_root, i64_to_u64,
parse_bencode_root_exact, parse_bencode_root_prefix,
};
impl PeerWireExtensionHandshakeModel {
/// Returns the announced `ut_metadata` extension id when present.
#[must_use]
pub fn ut_metadata_id(&self) -> Option<u8> {
self.extensions
.get("ut_metadata")
.copied()
.filter(|id| *id != 0)
}
/// Returns the advertised metadata payload size as piece count when present.
#[must_use]
pub fn metadata_piece_count(&self) -> Option<u32> {
self.metadata_size.map(metadata_piece_count)
}
/// Serializes the handshake into a BEP 10 bencoded dictionary.
#[must_use]
pub fn to_bencode_bytes(&self) -> Vec<u8> {
let mut root = BTreeMap::new();
let mut extensions = BTreeMap::new();
for (name, id) in &self.extensions {
extensions.insert(name.clone(), BencodeValue::Int(i64::from(*id)));
}
root.insert("m".to_owned(), BencodeValue::Dict(extensions));
if let Some(client_name) = &self.client_name {
root.insert(
"v".to_owned(),
BencodeValue::Bytes(client_name.as_bytes().to_vec()),
);
}
if let Some(metadata_size) = self.metadata_size {
root.insert(
"metadata_size".to_owned(),
BencodeValue::Int(i64::from(metadata_size)),
);
}
if let Some(request_queue) = self.request_queue {
root.insert(
"reqq".to_owned(),
BencodeValue::Int(i64::from(request_queue)),
);
}
encode_bencode_root(&root)
}
/// Parses a BEP 10 extended-handshake dictionary.
///
/// # Errors
///
/// Returns an error when the payload is not a valid handshake dictionary.
pub fn from_bencode_bytes(input: &[u8]) -> Result<Self, String> {
let root = parse_bencode_root_exact(input)?;
let mut extensions = BTreeMap::new();
if let Some(BencodeValue::Dict(values)) = root.get("m") {
for (name, value) in values {
if let BencodeValue::Int(id) = value {
let id_u8 = u8::try_from(*id)
.map_err(|_| format!("extension id for {name} does not fit u8"))?;
extensions.insert(name.clone(), id_u8);
}
}
}
Ok(Self {
extensions,
client_name: dict_bytes(&root, "v").map(bytes_to_string),
metadata_size: dict_int(&root, "metadata_size")
.map(i64_to_u64)
.map(u32::try_from)
.transpose()
.map_err(|_| "metadata_size does not fit u32".to_owned())?,
request_queue: dict_int(&root, "reqq")
.map(i64_to_u64)
.map(u32::try_from)
.transpose()
.map_err(|_| "reqq does not fit u32".to_owned())?,
})
}
/// Wraps the handshake as a peer-wire extension message with extended-message id `0`.
#[must_use]
pub fn to_peer_wire_message(&self) -> PeerWireExtensionMessageModel {
PeerWireExtensionMessageModel {
extension_message_id: 0,
payload: self.to_bencode_bytes(),
}
}
/// Parses a peer-wire extended handshake message.
///
/// # Errors
///
/// Returns an error when the message is not the extension handshake or the payload is invalid.
pub fn from_peer_wire_message(message: &PeerWireExtensionMessageModel) -> Result<Self, String> {
if message.extension_message_id != 0 {
return Err(format!(
"extended handshake must use extension message id 0, got {}",
message.extension_message_id
));
}
Self::from_bencode_bytes(&message.payload)
}
}
impl PeerWireMetadataMessageModel {
/// Builds a BEP 9 metadata request for one piece index.
#[must_use]
pub fn request(piece: u32) -> Self {
Self {
message_type: PeerWireMetadataMessageType::Request,
piece,
total_size: None,
payload: Vec::new(),
}
}
/// Builds a BEP 9 metadata data message for one piece index.
#[must_use]
pub fn data(piece: u32, total_size: u32, payload: Vec<u8>) -> Self {
Self {
message_type: PeerWireMetadataMessageType::Data,
piece,
total_size: Some(total_size),
payload,
}
}
/// Builds a BEP 9 metadata reject message for one piece index.
#[must_use]
pub fn reject(piece: u32) -> Self {
Self {
message_type: PeerWireMetadataMessageType::Reject,
piece,
total_size: None,
payload: Vec::new(),
}
}
/// Wraps the metadata message into a peer-wire extension payload using the supplied id.
#[must_use]
pub fn to_peer_wire_message(&self, extension_message_id: u8) -> PeerWireExtensionMessageModel {
PeerWireExtensionMessageModel {
extension_message_id,
payload: self.to_bencode_bytes(),
}
}
/// Serializes the BEP 9 header plus any trailing metadata payload.
#[must_use]
pub fn to_bencode_bytes(&self) -> Vec<u8> {
let mut root = BTreeMap::new();
root.insert(
"msg_type".to_owned(),
BencodeValue::Int(i64::from(self.message_type.wire_value())),
);
root.insert("piece".to_owned(), BencodeValue::Int(i64::from(self.piece)));
if self.message_type == PeerWireMetadataMessageType::Data
&& let Some(total_size) = self.total_size
{
root.insert(
"total_size".to_owned(),
BencodeValue::Int(i64::from(total_size)),
);
}
let mut out = encode_bencode_root(&root);
if self.message_type == PeerWireMetadataMessageType::Data {
out.extend_from_slice(&self.payload);
}
out
}
/// Parses a BEP 9 message from raw extension payload bytes.
///
/// # Errors
///
/// Returns an error when the message header is malformed or contains unsupported values.
pub fn from_bencode_bytes(input: &[u8]) -> Result<Self, String> {
let (root, consumed) = parse_bencode_root_prefix(input)?;
let message_type_raw =
dict_int(&root, "msg_type").ok_or_else(|| "missing ut_metadata msg_type".to_owned())?;
let message_type = PeerWireMetadataMessageType::from_wire_value(message_type_raw)?;
let piece = dict_int(&root, "piece")
.ok_or_else(|| "missing ut_metadata piece".to_owned())
.map(i64_to_u64)
.and_then(|value| {
u32::try_from(value).map_err(|_| "ut_metadata piece does not fit u32".to_owned())
})?;
let total_size = dict_int(&root, "total_size")
.map(i64_to_u64)
.map(u32::try_from)
.transpose()
.map_err(|_| "ut_metadata total_size does not fit u32".to_owned())?;
let payload = input[consumed..].to_vec();
if message_type != PeerWireMetadataMessageType::Data && total_size.is_some() {
return Err(
"ut_metadata request/reject messages must not include total_size".to_owned(),
);
}
if message_type != PeerWireMetadataMessageType::Data && !payload.is_empty() {
return Err(
"ut_metadata request/reject messages must not carry trailing payload".to_owned(),
);
}
if message_type == PeerWireMetadataMessageType::Data {
let total_size = total_size
.ok_or_else(|| "ut_metadata data messages must include total_size".to_owned())?;
let expected_payload_len = metadata_piece_len(piece, total_size)?;
if payload.len() != expected_payload_len {
return Err(format!(
"ut_metadata data payload length {} does not match expected {} bytes for piece {}",
payload.len(),
expected_payload_len,
piece
));
}
}
Ok(Self {
message_type,
piece,
total_size,
payload,
})
}
/// Parses a peer-wire extension message as a BEP 9 metadata message.
///
/// # Errors
///
/// Returns an error when the extension id mismatches or the payload is malformed.
pub fn from_peer_wire_message(
message: &PeerWireExtensionMessageModel,
expected_extension_message_id: u8,
) -> Result<Self, String> {
if expected_extension_message_id == 0 {
return Err("ut_metadata cannot use peer-wire extension message id 0".to_owned());
}
if message.extension_message_id != expected_extension_message_id {
return Err(format!(
"ut_metadata message expected extension id {expected_extension_message_id}, got {}",
message.extension_message_id
));
}
Self::from_bencode_bytes(&message.payload)
}
}
/// Returns the number of metadata pieces required to carry `total_size` bytes.
#[must_use]
fn metadata_piece_count(total_size: u32) -> u32 {
if total_size == 0 {
return 0;
}
(total_size - 1) / PEER_WIRE_METADATA_PIECE_SIZE + 1
}
/// Returns the expected payload length for one metadata piece.
///
/// # Errors
///
/// Returns an error when the total size is zero or the requested piece is out of range.
fn metadata_piece_len(piece: u32, total_size: u32) -> Result<usize, String> {
if total_size == 0 {
return Err("ut_metadata total_size must be positive".to_owned());
}
let piece_count = metadata_piece_count(total_size);
if piece >= piece_count {
return Err(format!(
"ut_metadata piece {piece} is out of range for total_size {total_size}"
));
}
let piece_size = PEER_WIRE_METADATA_PIECE_SIZE;
let base_offset = piece
.checked_mul(piece_size)
.ok_or_else(|| "ut_metadata piece offset overflow".to_owned())?;
let remaining = total_size - base_offset;
let expected_len = remaining.min(piece_size);
usize::try_from(expected_len)
.map_err(|_| "ut_metadata payload length does not fit usize".to_owned())
}
@@ -0,0 +1,506 @@
use super::{
PEER_WIRE_BITFIELD_ID, PEER_WIRE_CANCEL_ID, PEER_WIRE_CHOKE_ID, PEER_WIRE_EXTENSION_ID,
PEER_WIRE_HAVE_ID, PEER_WIRE_INTERESTED_ID, PEER_WIRE_NOT_INTERESTED_ID, PEER_WIRE_PIECE_ID,
PEER_WIRE_PORT_ID, PEER_WIRE_REQUEST_ID, PEER_WIRE_UNCHOKE_ID, PeerWireBitfieldModel,
PeerWireBlockRequestModel, PeerWireExtensionMessageModel, PeerWireFrameHeaderModel,
PeerWireMessageKind, PeerWireMessageModel, PeerWirePieceBlockModel,
PeerWireUnknownMessageModel, TorrentMessageModel,
};
impl PeerWireMessageModel {
#[must_use]
/// Wraps one parsed torrent message with an optional peer id.
pub fn new(peer_id: Option<[u8; 20]>, message: TorrentMessageModel) -> Self {
Self { peer_id, message }
}
/// Parses one framed peer-wire message and reports the number of bytes consumed.
///
/// # Errors
///
/// Returns an error when the frame header or payload is malformed.
pub fn parse_frame(input: &[u8]) -> Result<(Self, usize), String> {
let (message, consumed) = TorrentMessageModel::parse_peer_wire_frame(input)?;
Ok((
Self {
peer_id: None,
message,
},
consumed,
))
}
/// Parses one complete framed peer-wire message.
///
/// # Errors
///
/// Returns an error when the frame is malformed or contains trailing bytes.
pub fn parse_frame_exact(input: &[u8]) -> Result<Self, String> {
let (message, consumed) = Self::parse_frame(input)?;
if consumed != input.len() {
return Err("trailing bytes after peer-wire frame".to_owned());
}
Ok(message)
}
/// Serializes the wrapped message as a framed peer-wire payload.
///
/// # Errors
///
/// Returns an error when the inner message cannot be represented as peer-wire bytes.
pub fn serialize_frame(&self) -> Result<Vec<u8>, String> {
self.message.serialize_peer_wire_frame()
}
}
impl PeerWireBitfieldModel {
#[must_use]
/// Builds a bitfield from per-piece completion flags.
pub fn from_piece_flags(flags: &[bool]) -> Self {
let mut bytes = vec![0_u8; flags.len().div_ceil(8)];
for (index, &present) in flags.iter().enumerate() {
if present {
bytes[index / 8] |= 1 << (7 - (index % 8));
}
}
Self { bytes }
}
#[must_use]
/// Returns the maximum number of pieces represented by this bitfield.
pub fn piece_capacity(&self) -> usize {
self.bytes.len() * 8
}
#[must_use]
/// Returns whether the bitfield marks `piece_index` as present.
pub fn has_piece(&self, piece_index: usize) -> bool {
let byte = piece_index / 8;
let bit = piece_index % 8;
self.bytes
.get(byte)
.is_some_and(|value| value & (1 << (7 - bit)) != 0)
}
#[must_use]
/// Expands the bitfield into per-piece completion flags.
pub fn to_piece_flags(&self, piece_count: usize) -> Vec<bool> {
(0..piece_count)
.map(|index| self.has_piece(index))
.collect()
}
}
impl TorrentMessageModel {
#[must_use]
/// Builds an internal torrent message from a peer-wire message variant.
pub fn from_peer_wire_kind(kind: PeerWireMessageKind) -> Self {
match kind {
PeerWireMessageKind::KeepAlive => Self {
message_type: "keepalive".to_owned(),
payload: Vec::new(),
},
PeerWireMessageKind::Choke => Self {
message_type: "choke".to_owned(),
payload: Vec::new(),
},
PeerWireMessageKind::Unchoke => Self {
message_type: "unchoke".to_owned(),
payload: Vec::new(),
},
PeerWireMessageKind::Interested => Self {
message_type: "interested".to_owned(),
payload: Vec::new(),
},
PeerWireMessageKind::NotInterested => Self {
message_type: "not_interested".to_owned(),
payload: Vec::new(),
},
PeerWireMessageKind::Have(piece_index) => Self {
message_type: "have".to_owned(),
payload: piece_index.to_be_bytes().to_vec(),
},
PeerWireMessageKind::Bitfield(bitfield) => Self {
message_type: "bitfield".to_owned(),
payload: bitfield.bytes,
},
PeerWireMessageKind::Request(request) => Self {
message_type: "request".to_owned(),
payload: encode_block_request_payload(&request),
},
PeerWireMessageKind::Piece(piece) => Self {
message_type: "piece".to_owned(),
payload: encode_piece_payload(&piece),
},
PeerWireMessageKind::Cancel(request) => Self {
message_type: "cancel".to_owned(),
payload: encode_block_request_payload(&request),
},
PeerWireMessageKind::Port(port) => Self {
message_type: "port".to_owned(),
payload: port.to_be_bytes().to_vec(),
},
PeerWireMessageKind::Extension(extension) => {
let mut payload = Vec::with_capacity(1 + extension.payload.len());
payload.push(extension.extension_message_id);
payload.extend_from_slice(&extension.payload);
Self {
message_type: "extension".to_owned(),
payload,
}
}
PeerWireMessageKind::Unknown(message) => Self {
message_type: format!("unknown:{}", message.message_id),
payload: message.payload,
},
}
}
/// Reconstructs the typed peer-wire message kind from the internal message payload.
///
/// # Errors
///
/// Returns an error when the message type or payload shape is unsupported.
pub fn peer_wire_kind(&self) -> Result<PeerWireMessageKind, String> {
peer_wire_kind_from_raw(&self.message_type, &self.payload)
}
/// Inspects a framed peer-wire message header without fully decoding the payload.
///
/// # Errors
///
/// Returns an error when the frame is truncated or malformed.
pub fn inspect_peer_wire_frame(
input: &[u8],
) -> Result<(PeerWireFrameHeaderModel, usize), String> {
if input.len() < 4 {
return Err("truncated peer-wire frame: missing length prefix".to_owned());
}
let frame_len =
usize::try_from(u32::from_be_bytes([input[0], input[1], input[2], input[3]]))
.map_err(|_| "peer-wire frame length does not fit usize".to_owned())?;
let total_len = 4_usize
.checked_add(frame_len)
.ok_or_else(|| "peer-wire frame length overflow".to_owned())?;
if input.len() < total_len {
return Err(format!(
"truncated peer-wire frame: expected {total_len} bytes, got {}",
input.len()
));
}
if frame_len == 0 {
return Ok((
PeerWireFrameHeaderModel {
message_id: None,
payload_len: 0,
},
total_len,
));
}
let message_id = input[4];
Ok((
PeerWireFrameHeaderModel {
message_id: Some(message_id),
payload_len: frame_len - 1,
},
total_len,
))
}
/// Parses a framed peer-wire message and reports the number of consumed bytes.
///
/// # Errors
///
/// Returns an error when the frame is truncated or malformed.
pub fn parse_peer_wire_frame(input: &[u8]) -> Result<(Self, usize), String> {
let (header, consumed) = Self::inspect_peer_wire_frame(input)?;
let Some(message_id) = header.message_id else {
return Ok((
Self::from_peer_wire_kind(PeerWireMessageKind::KeepAlive),
consumed,
));
};
let payload = &input[5..consumed];
let kind = peer_wire_kind_from_message_id(message_id, payload)?;
Ok((Self::from_peer_wire_kind(kind), consumed))
}
/// Parses one complete framed peer-wire message.
///
/// # Errors
///
/// Returns an error when the frame is truncated, malformed, or has trailing bytes.
pub fn parse_peer_wire_frame_exact(input: &[u8]) -> Result<Self, String> {
let (message, consumed) = Self::parse_peer_wire_frame(input)?;
if consumed != input.len() {
return Err("trailing bytes after peer-wire frame".to_owned());
}
Ok(message)
}
/// Serializes the message as a framed peer-wire payload.
///
/// # Errors
///
/// Returns an error when the message cannot be represented as a supported peer-wire frame.
pub fn serialize_peer_wire_frame(&self) -> Result<Vec<u8>, String> {
let kind = self.peer_wire_kind()?;
serialize_peer_wire_kind(&kind)
}
}
/// Serializes one peer-wire message kind into a framed peer-wire payload.
fn serialize_peer_wire_kind(kind: &PeerWireMessageKind) -> Result<Vec<u8>, String> {
let mut payload = Vec::new();
let message_id = match kind {
PeerWireMessageKind::KeepAlive => None,
PeerWireMessageKind::Choke => Some(PEER_WIRE_CHOKE_ID),
PeerWireMessageKind::Unchoke => Some(PEER_WIRE_UNCHOKE_ID),
PeerWireMessageKind::Interested => Some(PEER_WIRE_INTERESTED_ID),
PeerWireMessageKind::NotInterested => Some(PEER_WIRE_NOT_INTERESTED_ID),
PeerWireMessageKind::Have(piece_index) => {
payload.extend_from_slice(&piece_index.to_be_bytes());
Some(PEER_WIRE_HAVE_ID)
}
PeerWireMessageKind::Bitfield(bitfield) => {
payload.extend_from_slice(&bitfield.bytes);
Some(PEER_WIRE_BITFIELD_ID)
}
PeerWireMessageKind::Request(request) => {
payload.extend_from_slice(&encode_block_request_payload(request));
Some(PEER_WIRE_REQUEST_ID)
}
PeerWireMessageKind::Piece(piece) => {
payload.extend_from_slice(&encode_piece_payload(piece));
Some(PEER_WIRE_PIECE_ID)
}
PeerWireMessageKind::Cancel(request) => {
payload.extend_from_slice(&encode_block_request_payload(request));
Some(PEER_WIRE_CANCEL_ID)
}
PeerWireMessageKind::Port(port) => {
payload.extend_from_slice(&port.to_be_bytes());
Some(PEER_WIRE_PORT_ID)
}
PeerWireMessageKind::Extension(extension) => {
payload.push(extension.extension_message_id);
payload.extend_from_slice(&extension.payload);
Some(PEER_WIRE_EXTENSION_ID)
}
PeerWireMessageKind::Unknown(message) => {
payload.extend_from_slice(&message.payload);
Some(message.message_id)
}
};
let Some(message_id) = message_id else {
return Ok(vec![0, 0, 0, 0]);
};
let frame_len = 1 + payload.len();
let frame_len_u32 = u32::try_from(frame_len)
.map_err(|_| "peer-wire frame exceeds u32 length prefix".to_owned())?;
let mut bytes = Vec::with_capacity(4 + frame_len);
bytes.extend_from_slice(&frame_len_u32.to_be_bytes());
bytes.push(message_id);
bytes.extend_from_slice(&payload);
Ok(bytes)
}
/// Interprets one internal message-type label and payload as a peer-wire message kind.
fn peer_wire_kind_from_raw(
message_type: &str,
payload: &[u8],
) -> Result<PeerWireMessageKind, String> {
match message_type {
"keepalive" => {
expect_empty_payload("keepalive", payload).map(|()| PeerWireMessageKind::KeepAlive)
}
"choke" => expect_empty_payload("choke", payload).map(|()| PeerWireMessageKind::Choke),
"unchoke" => {
expect_empty_payload("unchoke", payload).map(|()| PeerWireMessageKind::Unchoke)
}
"interested" => {
expect_empty_payload("interested", payload).map(|()| PeerWireMessageKind::Interested)
}
"not_interested" | "not-interested" => expect_empty_payload("not_interested", payload)
.map(|()| PeerWireMessageKind::NotInterested),
"have" => parse_have_payload(payload),
"bitfield" => Ok(PeerWireMessageKind::Bitfield(PeerWireBitfieldModel {
bytes: payload.to_vec(),
})),
"request" => {
parse_block_request_payload("request", payload).map(PeerWireMessageKind::Request)
}
"piece" => parse_piece_payload(payload).map(PeerWireMessageKind::Piece),
"cancel" => parse_block_request_payload("cancel", payload).map(PeerWireMessageKind::Cancel),
"port" => parse_port_payload(payload),
"extension" => parse_extension_payload(payload),
_ => parse_unknown_message_id(message_type).map_or_else(
|| {
Err(format!(
"unsupported peer-wire message type: {message_type}"
))
},
|message_id| {
Ok(PeerWireMessageKind::Unknown(PeerWireUnknownMessageModel {
message_id,
payload: payload.to_vec(),
}))
},
),
}
}
/// Interprets a peer-wire message id and payload as a typed peer-wire message kind.
fn peer_wire_kind_from_message_id(
message_id: u8,
payload: &[u8],
) -> Result<PeerWireMessageKind, String> {
match message_id {
PEER_WIRE_CHOKE_ID => {
expect_empty_payload("choke", payload).map(|()| PeerWireMessageKind::Choke)
}
PEER_WIRE_UNCHOKE_ID => {
expect_empty_payload("unchoke", payload).map(|()| PeerWireMessageKind::Unchoke)
}
PEER_WIRE_INTERESTED_ID => {
expect_empty_payload("interested", payload).map(|()| PeerWireMessageKind::Interested)
}
PEER_WIRE_NOT_INTERESTED_ID => expect_empty_payload("not_interested", payload)
.map(|()| PeerWireMessageKind::NotInterested),
PEER_WIRE_HAVE_ID => parse_have_payload(payload),
PEER_WIRE_BITFIELD_ID => Ok(PeerWireMessageKind::Bitfield(PeerWireBitfieldModel {
bytes: payload.to_vec(),
})),
PEER_WIRE_REQUEST_ID => {
parse_block_request_payload("request", payload).map(PeerWireMessageKind::Request)
}
PEER_WIRE_PIECE_ID => parse_piece_payload(payload).map(PeerWireMessageKind::Piece),
PEER_WIRE_CANCEL_ID => {
parse_block_request_payload("cancel", payload).map(PeerWireMessageKind::Cancel)
}
PEER_WIRE_PORT_ID => parse_port_payload(payload),
PEER_WIRE_EXTENSION_ID => parse_extension_payload(payload),
_ => Ok(PeerWireMessageKind::Unknown(PeerWireUnknownMessageModel {
message_id,
payload: payload.to_vec(),
})),
}
}
/// Verifies that a peer-wire control payload is empty.
fn expect_empty_payload(name: &str, payload: &[u8]) -> Result<(), String> {
if payload.is_empty() {
Ok(())
} else {
Err(format!(
"peer-wire {name} payload must be empty, got {} bytes",
payload.len()
))
}
}
/// Parses a `have` payload into its piece index variant.
fn parse_have_payload(payload: &[u8]) -> Result<PeerWireMessageKind, String> {
let piece_index = read_u32(payload, "have", 0)?;
Ok(PeerWireMessageKind::Have(piece_index))
}
/// Parses a `request` or `cancel` payload into block coordinates.
fn parse_block_request_payload(
name: &str,
payload: &[u8],
) -> Result<PeerWireBlockRequestModel, String> {
if payload.len() != 12 {
return Err(format!(
"peer-wire {name} payload must be 12 bytes, got {}",
payload.len()
));
}
Ok(PeerWireBlockRequestModel {
piece_index: read_u32(payload, name, 0)?,
block_offset: read_u32(payload, name, 4)?,
block_length: read_u32(payload, name, 8)?,
})
}
/// Parses a `piece` payload into block coordinates plus data.
fn parse_piece_payload(payload: &[u8]) -> Result<PeerWirePieceBlockModel, String> {
if payload.len() < 8 {
return Err(format!(
"peer-wire piece payload must be at least 8 bytes, got {}",
payload.len()
));
}
Ok(PeerWirePieceBlockModel {
piece_index: read_u32(payload, "piece", 0)?,
block_offset: read_u32(payload, "piece", 4)?,
block: payload[8..].to_vec(),
})
}
/// Parses a `port` payload into the corresponding peer-wire message variant.
fn parse_port_payload(payload: &[u8]) -> Result<PeerWireMessageKind, String> {
if payload.len() != 2 {
return Err(format!(
"peer-wire port payload must be 2 bytes, got {}",
payload.len()
));
}
Ok(PeerWireMessageKind::Port(u16::from_be_bytes([
payload[0], payload[1],
])))
}
/// Parses an extension-protocol payload into the typed extension message variant.
fn parse_extension_payload(payload: &[u8]) -> Result<PeerWireMessageKind, String> {
let Some((&extension_message_id, rest)) = payload.split_first() else {
return Err("peer-wire extension payload must include extension message id".to_owned());
};
Ok(PeerWireMessageKind::Extension(
PeerWireExtensionMessageModel {
extension_message_id,
payload: rest.to_vec(),
},
))
}
/// Parses a synthetic `unknown:<id>` message-type label into a raw peer-wire id.
fn parse_unknown_message_id(message_type: &str) -> Option<u8> {
message_type
.strip_prefix("unknown:")
.and_then(|value| value.parse::<u8>().ok())
}
/// Encodes request or cancel block coordinates into peer-wire payload bytes.
fn encode_block_request_payload(request: &PeerWireBlockRequestModel) -> Vec<u8> {
let mut payload = Vec::with_capacity(12);
payload.extend_from_slice(&request.piece_index.to_be_bytes());
payload.extend_from_slice(&request.block_offset.to_be_bytes());
payload.extend_from_slice(&request.block_length.to_be_bytes());
payload
}
/// Encodes a piece block into peer-wire payload bytes.
fn encode_piece_payload(piece: &PeerWirePieceBlockModel) -> Vec<u8> {
let mut payload = Vec::with_capacity(8 + piece.block.len());
payload.extend_from_slice(&piece.piece_index.to_be_bytes());
payload.extend_from_slice(&piece.block_offset.to_be_bytes());
payload.extend_from_slice(&piece.block);
payload
}
/// Reads one big-endian `u32` from a peer-wire payload.
fn read_u32(payload: &[u8], name: &str, start: usize) -> Result<u32, String> {
let end = start + 4;
let bytes = payload
.get(start..end)
.ok_or_else(|| format!("peer-wire {name} payload truncated at byte offset {start}"))?;
Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
}
@@ -0,0 +1,122 @@
use super::{
PEER_WIRE_HANDSHAKE_PREFIX_LEN, PEER_WIRE_PROTOCOL_LEN, PEER_WIRE_PROTOCOL_NAME,
PeerWireHandshakeModel,
};
impl PeerWireHandshakeModel {
#[must_use]
/// Builds a handshake with all reserved bits cleared.
pub fn new(info_hash: [u8; 20], peer_id: [u8; 20]) -> Self {
Self {
reserved: [0; 8],
info_hash,
peer_id,
}
}
/// Returns a handshake with the extension-protocol bit enabled.
#[must_use]
pub fn with_extension_protocol_enabled(mut self) -> Self {
self.reserved[5] |= 0x10;
self
}
/// Returns a handshake with the DHT bit enabled.
#[must_use]
pub fn with_dht_enabled(mut self) -> Self {
self.reserved[7] |= 0x01;
self
}
/// Serializes the handshake to its peer-wire byte representation.
///
/// # Panics
///
/// Panics if the fixed peer-wire protocol name no longer fits into a single-byte
/// length prefix.
#[must_use]
pub fn serialize(&self) -> Vec<u8> {
let mut bytes = Vec::with_capacity(PEER_WIRE_HANDSHAKE_PREFIX_LEN + 19);
bytes.push(PEER_WIRE_PROTOCOL_LEN);
bytes.extend_from_slice(PEER_WIRE_PROTOCOL_NAME.as_bytes());
bytes.extend_from_slice(&self.reserved);
bytes.extend_from_slice(&self.info_hash);
bytes.extend_from_slice(&self.peer_id);
bytes
}
/// Parses one complete peer-wire handshake from `input`.
///
/// # Errors
///
/// Returns an error when the frame is truncated, malformed, or contains trailing bytes.
pub fn parse(input: &[u8]) -> Result<Self, String> {
let (handshake, consumed) = Self::parse_prefix(input)?;
if consumed != input.len() {
return Err("trailing bytes after peer-wire handshake".to_owned());
}
Ok(handshake)
}
/// Parses a peer-wire handshake prefix and returns the consumed byte count.
///
/// # Errors
///
/// Returns an error when the frame is truncated or malformed.
pub fn parse_prefix(input: &[u8]) -> Result<(Self, usize), String> {
let Some(&protocol_len_byte) = input.first() else {
return Err("truncated peer-wire handshake: missing protocol length".to_owned());
};
let protocol_len = usize::from(protocol_len_byte);
let total_len = PEER_WIRE_HANDSHAKE_PREFIX_LEN + protocol_len;
if input.len() < total_len {
return Err(format!(
"truncated peer-wire handshake: expected {total_len} bytes, got {}",
input.len()
));
}
let protocol = &input[1..=protocol_len];
if protocol_len != PEER_WIRE_PROTOCOL_NAME.len() {
return Err(format!(
"invalid peer-wire protocol length: expected {}, got {protocol_len}",
PEER_WIRE_PROTOCOL_NAME.len()
));
}
if protocol != PEER_WIRE_PROTOCOL_NAME.as_bytes() {
return Err("invalid peer-wire protocol header".to_owned());
}
let reserved_start = 1 + protocol_len;
let mut reserved = [0_u8; 8];
reserved.copy_from_slice(&input[reserved_start..reserved_start + 8]);
let info_hash_start = reserved_start + 8;
let mut info_hash = [0_u8; 20];
info_hash.copy_from_slice(&input[info_hash_start..info_hash_start + 20]);
let peer_id_start = info_hash_start + 20;
let mut peer_id = [0_u8; 20];
peer_id.copy_from_slice(&input[peer_id_start..peer_id_start + 20]);
Ok((
Self {
reserved,
info_hash,
peer_id,
},
total_len,
))
}
#[must_use]
/// Returns whether the extension-protocol reserved bit is enabled.
pub fn extension_protocol_enabled(&self) -> bool {
self.reserved[5] & 0x10 != 0
}
#[must_use]
/// Returns whether the DHT reserved bit is enabled.
pub fn dht_enabled(&self) -> bool {
self.reserved[7] & 0x01 != 0
}
}
@@ -0,0 +1,721 @@
use std::collections::BTreeMap;
use super::{
DhtAnnouncePeerQueryModel, DhtCompactNodeModel, DhtFindNodeResponseModel,
DhtGetPeersResponseModel, DhtMessageBody, DhtMessageModel, DhtQueryModel, DhtResponseModel,
PeerWireBitfieldModel, PeerWireBlockRequestModel, PeerWireExtensionHandshakeModel,
PeerWireExtensionMessageModel, PeerWireFrameHeaderModel, PeerWireHandshakeModel,
PeerWireMessageKind, PeerWireMessageModel, PeerWireMetadataMessageModel,
PeerWireMetadataMessageType, PeerWirePieceBlockModel, PeerWireUnknownMessageModel,
TorrentMessageModel, decode_compact_dht_nodes, encode_compact_dht_nodes,
parse_torrent_bootstrap, parse_torrent_metadata,
};
#[test]
fn dht_ping_query_roundtrip_serializes_and_parses() {
let message = DhtMessageModel::ping_query(b"aa".to_vec(), vec![0x11; 20]);
assert_eq!(message.method(), Some("ping"));
assert!(message.is_query());
let encoded = message.to_bencode_bytes();
let decoded = DhtMessageModel::from_bencode_bytes(&encoded).expect("encoded ping should parse");
assert_eq!(decoded, message);
assert_eq!(decoded.transaction_id(), b"aa");
}
#[test]
fn dht_ping_response_roundtrip_serializes_and_parses() {
let message = DhtMessageModel::ping_response(b"pr".to_vec(), vec![0x44; 20]);
let encoded = message.to_bencode_bytes();
let decoded = DhtMessageModel::from_bencode_bytes(&encoded).expect("encoded ping should parse");
assert_eq!(decoded, message);
assert!(matches!(
decoded.body,
DhtMessageBody::Response(DhtResponseModel::Ping(_))
));
}
#[test]
fn dht_find_node_query_roundtrip_serializes_and_parses() {
let message = DhtMessageModel::find_node_query(b"fn".to_vec(), vec![0x22; 20], vec![0x33; 20]);
assert_eq!(message.method(), Some("find_node"));
let encoded = message.to_bencode_bytes();
let decoded =
DhtMessageModel::from_bencode_bytes(&encoded).expect("encoded find_node should parse");
assert_eq!(decoded, message);
}
#[test]
fn dht_announce_peer_query_roundtrip_serializes_and_parses() {
let message = DhtMessageModel::announce_peer_query(
b"ap".to_vec(),
vec![0x11; 20],
vec![0x22; 20],
6881,
b"tok".to_vec(),
true,
);
assert_eq!(message.method(), Some("announce_peer"));
let encoded = message.to_bencode_bytes();
let decoded =
DhtMessageModel::from_bencode_bytes(&encoded).expect("encoded announce_peer should parse");
assert_eq!(decoded, message);
assert!(matches!(
decoded.body,
DhtMessageBody::Query(DhtQueryModel::AnnouncePeer(DhtAnnouncePeerQueryModel {
implied_port: true,
port: 6881,
..
}))
));
}
#[test]
fn dht_get_peers_query_roundtrip_serializes_and_parses() {
let message = DhtMessageModel::get_peers_query(b"gp".to_vec(), vec![0x22; 20], vec![0x33; 20]);
assert_eq!(message.method(), Some("get_peers"));
assert!(matches!(
&message.body,
DhtMessageBody::Query(DhtQueryModel::GetPeers(_))
));
let encoded = message.to_bencode_bytes();
let decoded =
DhtMessageModel::from_bencode_bytes(&encoded).expect("encoded get_peers should parse");
assert_eq!(decoded, message);
}
#[test]
fn dht_get_peers_response_roundtrip_preserves_nodes_values_and_token() {
let message = DhtMessageModel::get_peers_response(
b"r1".to_vec(),
vec![0x44; 20],
Some(b"tok".to_vec()),
Some(vec![0xaa, 0xbb, 0xcc, 0xdd]),
vec![vec![127, 0, 0, 1, 0x1a, 0xe1]],
);
let encoded = message.to_bencode_bytes();
let decoded =
DhtMessageModel::from_bencode_bytes(&encoded).expect("encoded response should parse");
assert_eq!(decoded, message);
assert!(matches!(
decoded.body,
DhtMessageBody::Response(DhtResponseModel::GetPeers(DhtGetPeersResponseModel {
token: Some(_),
nodes: Some(_),
..
}))
));
}
#[test]
fn dht_find_node_response_roundtrip_preserves_compact_nodes() {
let nodes = vec![
DhtCompactNodeModel {
node_id: [0x11; 20],
address: [127, 0, 0, 1],
port: 6881,
},
DhtCompactNodeModel {
node_id: [0x22; 20],
address: [192, 0, 2, 1],
port: 51413,
},
];
let message = DhtMessageModel::find_node_response(b"fnr".to_vec(), vec![0x33; 20], nodes);
let encoded = message.to_bencode_bytes();
let decoded = DhtMessageModel::from_bencode_bytes(&encoded)
.expect("encoded find_node response should parse");
assert_eq!(decoded, message);
assert!(matches!(
decoded.body,
DhtMessageBody::Response(DhtResponseModel::FindNode(DhtFindNodeResponseModel {
nodes: ref parsed_nodes,
..
})) if parsed_nodes.len() == 2
));
}
#[test]
fn compact_dht_node_codec_roundtrip_serializes_bytes_in_network_order() {
let node = DhtCompactNodeModel {
node_id: [0x7f; 20],
address: [198, 51, 100, 7],
port: 51413,
};
let bytes = encode_compact_dht_nodes(std::slice::from_ref(&node));
assert_eq!(bytes.len(), 26);
let decoded = decode_compact_dht_nodes(&bytes).expect("compact node codec should parse");
assert_eq!(decoded, vec![node]);
}
#[test]
fn dht_error_roundtrip_serializes_and_parses() {
let message = DhtMessageModel::error_response(b"e1".to_vec(), 203, "protocol error");
let encoded = message.to_bencode_bytes();
let decoded =
DhtMessageModel::from_bencode_bytes(&encoded).expect("encoded error should parse");
assert_eq!(decoded, message);
assert_eq!(decoded.method(), None);
}
#[test]
fn dht_parse_rejects_unsupported_query_method() {
let payload = b"d1:ad2:id20:aaaaaaaaaaaaaaaaaaaae1:q4:find1:t2:aa1:y1:qe";
let error =
DhtMessageModel::from_bencode_bytes(payload).expect_err("unsupported method should fail");
assert!(error.contains("unsupported dht query method"));
}
#[test]
fn parses_single_file_torrent_metadata() {
let torrent = br"d8:announce35:http://tracker.example.org/announce4:infod4:name10:ubuntu.iso12:piece lengthi16384e6:lengthi32768e6:pieces40:aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbee";
let metadata = parse_torrent_metadata(torrent).expect("torrent metadata should parse");
assert_eq!(metadata.info.name, "ubuntu.iso");
assert_eq!(metadata.info.piece_length, 16384);
assert_eq!(metadata.info.files.len(), 1);
assert_eq!(
metadata.announce.as_deref(),
Some("http://tracker.example.org/announce")
);
assert_eq!(metadata.trackers.len(), 1);
assert_eq!(metadata.pieces.len(), 2);
assert_eq!(
metadata
.info
.hash
.as_ref()
.map(|hash| hash.info_hash_hex.len()),
Some(40)
);
}
#[test]
fn parses_announce_list_tiers_with_stable_tier_indices() {
let torrent = br"d8:announce35:http://tracker.example.org/announce13:announce-listll30:udp://tier1-a.example.org:696935:http://tier1-b.example.org/announceel30:udp://tier2-a.example.org:6969ee4:infod6:lengthi4096e4:name8:mini.iso12:piece lengthi1024e6:pieces20:aaaaaaaaaaaaaaaaaaaaee";
let metadata = parse_torrent_metadata(torrent).expect("torrent metadata should parse");
assert_eq!(metadata.trackers.len(), 4);
assert_eq!(
metadata.trackers[0].url,
"http://tracker.example.org/announce"
);
assert_eq!(metadata.trackers[0].tier, Some(0));
assert_eq!(metadata.trackers[1].url, "udp://tier1-a.example.org:6969");
assert_eq!(metadata.trackers[1].tier, Some(1));
assert_eq!(
metadata.trackers[2].url,
"http://tier1-b.example.org/announce"
);
assert_eq!(metadata.trackers[2].tier, Some(1));
assert_eq!(metadata.trackers[3].url, "udp://tier2-a.example.org:6969");
assert_eq!(metadata.trackers[3].tier, Some(2));
}
#[test]
fn parses_multi_file_paths_and_piece_offsets() {
let torrent = br"d8:announce35:http://tracker.example.org/announce4:infod5:filesld6:lengthi123e4:pathl4:dir110:file-a.bineed6:lengthi200e4:pathl4:dir24:subd10:file-b.bineed6:lengthi45e4:pathl10:readme.txteee4:name6:bundle12:piece lengthi128e6:pieces60:aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbccccccccccccccccccccee";
let metadata = parse_torrent_metadata(torrent).expect("multi-file torrent should parse");
assert_eq!(metadata.info.files.len(), 3);
assert_eq!(metadata.info.files[0].path, "dir1/file-a.bin");
assert_eq!(metadata.info.files[0].length, 123);
assert_eq!(metadata.info.files[0].piece_offset, Some(0));
assert_eq!(metadata.info.files[1].path, "dir2/subd/file-b.bin");
assert_eq!(metadata.info.files[1].length, 200);
assert_eq!(metadata.info.files[1].piece_offset, Some(123));
assert_eq!(metadata.info.files[2].path, "readme.txt");
assert_eq!(metadata.info.files[2].length, 45);
assert_eq!(metadata.info.files[2].piece_offset, Some(323));
assert_eq!(metadata.total_length(), 368);
}
#[test]
fn parses_dht_nodes_from_nodes_list_when_present() {
let torrent = br"d8:announce35:http://tracker.example.org/announce5:nodesll17:router.bittorrenti6881eel14:node.local.lani51413eee4:infod6:lengthi2048e4:name8:node.iso12:piece lengthi1024e6:pieces20:aaaaaaaaaaaaaaaaaaaaee";
let metadata = parse_torrent_metadata(torrent).expect("torrent with nodes should parse");
assert_eq!(metadata.dht_nodes.len(), 2);
assert_eq!(metadata.dht_nodes[0], "router.bittorrent:6881");
assert_eq!(metadata.dht_nodes[1], "node.local.lan:51413");
}
#[test]
fn torrent_bootstrap_exposes_magnet_info_hash_and_dht_models() {
let torrent = br"d8:announce35:http://tracker.example.org/announce13:announce-listll30:udp://tier1-a.example.org:696935:http://tier1-b.example.org/announceee5:nodesll17:router.bittorrenti6881eel14:node.local.lani51413eee4:infod6:lengthi2048e4:name8:node.iso12:piece lengthi1024e6:pieces40:aaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbee";
let bootstrap = parse_torrent_bootstrap(torrent).expect("torrent bootstrap should parse");
assert_eq!(bootstrap.metadata.info.name, "node.iso");
assert_eq!(
bootstrap.info_hash_hex,
bootstrap
.metadata
.info
.hash
.as_ref()
.expect("torrent hash should exist")
.info_hash_hex
);
assert_eq!(bootstrap.info_hash_bytes.len(), 20);
assert_eq!(
bootstrap.magnet.trackers,
vec![
"http://tracker.example.org/announce".to_owned(),
"udp://tier1-a.example.org:6969".to_owned(),
"http://tier1-b.example.org/announce".to_owned(),
]
);
assert_eq!(bootstrap.dht_nodes[0].to_spec(), "router.bittorrent:6881");
assert_eq!(bootstrap.dht_nodes[1].to_spec(), "node.local.lan:51413");
}
#[test]
fn dht_get_peers_response_helpers_decode_peer_values_and_nodes() {
let response = DhtMessageModel::get_peers_response(
b"gp".to_vec(),
vec![0x11; 20],
Some(b"tok".to_vec()),
Some(vec![
0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80, 0x90, 0xa0, 0xb0, 0xc0, 0xd0, 0xe0,
0xf0, 0x00, 0x01, 0x02, 0x03, 0x04, 192, 0, 2, 10, 0x1a, 0xe1,
]),
vec![vec![198, 51, 100, 9, 0xc8, 0xd5]],
);
let DhtMessageBody::Response(DhtResponseModel::GetPeers(payload)) = response.body else {
panic!("expected get_peers response");
};
let peers = payload
.peer_contacts()
.expect("compact peers should decode");
assert_eq!(peers.len(), 1);
assert_eq!(peers[0].ip, "198.51.100.9");
assert_eq!(peers[0].port, 51413);
let nodes = payload.dht_nodes().expect("compact nodes should decode");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].node_id, "102030405060708090a0b0c0d0e0f00001020304");
assert_eq!(nodes[0].to_spec(), "192.0.2.10:6881");
}
#[test]
fn peer_wire_handshake_roundtrip_preserves_reserved_bits_and_ids() {
let handshake = PeerWireHandshakeModel {
reserved: [0, 0, 0, 0, 0, 0x10, 0, 0x01],
info_hash: [0x11; 20],
peer_id: [0x22; 20],
};
let bytes = handshake.serialize();
let parsed =
PeerWireHandshakeModel::parse(&bytes).expect("handshake bytes should parse cleanly");
assert_eq!(parsed, handshake);
assert!(parsed.extension_protocol_enabled());
assert!(parsed.dht_enabled());
}
#[test]
fn peer_wire_handshake_rejects_truncated_and_invalid_protocol_bytes() {
let truncated = vec![19, b'B', b'i'];
assert!(
PeerWireHandshakeModel::parse(&truncated)
.expect_err("truncated handshake should fail")
.contains("truncated")
);
let mut invalid = PeerWireHandshakeModel {
reserved: [0; 8],
info_hash: [1; 20],
peer_id: [2; 20],
}
.serialize();
invalid[1] = b'X';
assert!(
PeerWireHandshakeModel::parse(&invalid)
.expect_err("invalid protocol name should fail")
.contains("protocol")
);
}
#[test]
fn peer_wire_keepalive_roundtrip_uses_zero_length_frame() {
let message = TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::KeepAlive);
let bytes = message
.serialize_peer_wire_frame()
.expect("keepalive should serialize");
assert_eq!(bytes, vec![0, 0, 0, 0]);
let parsed =
TorrentMessageModel::parse_peer_wire_frame_exact(&bytes).expect("keepalive should parse");
assert_eq!(parsed, message);
assert_eq!(parsed.peer_wire_kind(), Ok(PeerWireMessageKind::KeepAlive));
}
#[test]
fn peer_wire_inspect_frame_reports_message_id_payload_len_and_consumed_bytes() {
let keepalive_header = TorrentMessageModel::inspect_peer_wire_frame(&[0, 0, 0, 0])
.expect("keepalive frame header should parse");
assert_eq!(
keepalive_header,
(
PeerWireFrameHeaderModel {
message_id: None,
payload_len: 0,
},
4
)
);
let request = TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Request(
PeerWireBlockRequestModel {
piece_index: 1,
block_offset: 2,
block_length: 16_384,
},
));
let mut request_frame = request
.serialize_peer_wire_frame()
.expect("request frame should serialize");
request_frame.extend_from_slice(&[0x99, 0x88, 0x77]);
let request_header = TorrentMessageModel::inspect_peer_wire_frame(&request_frame)
.expect("request frame header should parse");
assert_eq!(
request_header,
(
PeerWireFrameHeaderModel {
message_id: Some(6),
payload_len: 12,
},
17
)
);
}
#[test]
fn peer_wire_inspect_frame_rejects_truncated_prefix_or_payload() {
assert!(
TorrentMessageModel::inspect_peer_wire_frame(&[0, 0, 0])
.expect_err("missing length prefix should fail")
.contains("missing length prefix")
);
assert!(
TorrentMessageModel::inspect_peer_wire_frame(&[0, 0, 0, 1])
.expect_err("missing message id should fail")
.contains("truncated")
);
}
#[test]
fn peer_wire_control_messages_roundtrip_through_wrapper_surface() {
let cases = [
PeerWireMessageKind::Choke,
PeerWireMessageKind::Unchoke,
PeerWireMessageKind::Interested,
PeerWireMessageKind::NotInterested,
];
for kind in cases {
let wire = PeerWireMessageModel::new(
Some([0x44; 20]),
TorrentMessageModel::from_peer_wire_kind(kind.clone()),
);
let bytes = wire
.serialize_frame()
.expect("control peer-wire frame should serialize");
let parsed = PeerWireMessageModel::parse_frame_exact(&bytes)
.expect("control peer-wire frame should parse");
assert_eq!(parsed.peer_id, None);
assert_eq!(parsed.message.peer_wire_kind(), Ok(kind));
}
}
#[test]
fn peer_wire_bitfield_helpers_pack_bits_msb_first() {
let flags = [
true, false, true, true, false, false, false, true, true, false,
];
let bitfield = PeerWireBitfieldModel::from_piece_flags(&flags);
assert_eq!(bitfield.bytes, vec![0b1011_0001, 0b1000_0000]);
assert_eq!(bitfield.piece_capacity(), 16);
assert!(bitfield.has_piece(0));
assert!(bitfield.has_piece(8));
assert!(!bitfield.has_piece(9));
assert_eq!(bitfield.to_piece_flags(flags.len()), flags);
let message =
TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Bitfield(bitfield.clone()));
let roundtrip = TorrentMessageModel::parse_peer_wire_frame_exact(
&message
.serialize_peer_wire_frame()
.expect("bitfield should serialize"),
)
.expect("bitfield should roundtrip");
assert_eq!(
roundtrip.peer_wire_kind(),
Ok(PeerWireMessageKind::Bitfield(bitfield))
);
}
#[test]
fn peer_wire_have_request_and_cancel_roundtrip() {
let have = TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Have(77));
let request = PeerWireBlockRequestModel {
piece_index: 7,
block_offset: 16_384,
block_length: 4_096,
};
let request_message =
TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Request(request));
let cancel_message =
TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Cancel(request));
assert_eq!(
TorrentMessageModel::parse_peer_wire_frame_exact(
&have
.serialize_peer_wire_frame()
.expect("have should serialize"),
)
.expect("have should parse")
.peer_wire_kind(),
Ok(PeerWireMessageKind::Have(77))
);
assert_eq!(
TorrentMessageModel::parse_peer_wire_frame_exact(
&request_message
.serialize_peer_wire_frame()
.expect("request should serialize"),
)
.expect("request should parse")
.peer_wire_kind(),
Ok(PeerWireMessageKind::Request(request))
);
assert_eq!(
TorrentMessageModel::parse_peer_wire_frame_exact(
&cancel_message
.serialize_peer_wire_frame()
.expect("cancel should serialize"),
)
.expect("cancel should parse")
.peer_wire_kind(),
Ok(PeerWireMessageKind::Cancel(request))
);
}
#[test]
fn peer_wire_piece_port_extension_and_unknown_roundtrip() {
let piece = PeerWirePieceBlockModel {
piece_index: 5,
block_offset: 32_768,
block: b"block-data".to_vec(),
};
let extension = PeerWireExtensionMessageModel {
extension_message_id: 3,
payload: b"ut_metadata".to_vec(),
};
let unknown = PeerWireUnknownMessageModel {
message_id: 99,
payload: vec![9, 8, 7, 6],
};
let piece_message =
TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Piece(piece.clone()));
let port_message = TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Port(51413));
let extension_message =
TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Extension(extension.clone()));
let unknown_message =
TorrentMessageModel::from_peer_wire_kind(PeerWireMessageKind::Unknown(unknown.clone()));
assert_eq!(
TorrentMessageModel::parse_peer_wire_frame_exact(
&piece_message
.serialize_peer_wire_frame()
.expect("piece should serialize"),
)
.expect("piece should parse")
.peer_wire_kind(),
Ok(PeerWireMessageKind::Piece(piece))
);
assert_eq!(
TorrentMessageModel::parse_peer_wire_frame_exact(
&port_message
.serialize_peer_wire_frame()
.expect("port should serialize"),
)
.expect("port should parse")
.peer_wire_kind(),
Ok(PeerWireMessageKind::Port(51413))
);
assert_eq!(
TorrentMessageModel::parse_peer_wire_frame_exact(
&extension_message
.serialize_peer_wire_frame()
.expect("extension should serialize"),
)
.expect("extension should parse")
.peer_wire_kind(),
Ok(PeerWireMessageKind::Extension(extension))
);
assert_eq!(
TorrentMessageModel::parse_peer_wire_frame_exact(
&unknown_message
.serialize_peer_wire_frame()
.expect("unknown should serialize"),
)
.expect("unknown should parse")
.peer_wire_kind(),
Ok(PeerWireMessageKind::Unknown(unknown))
);
}
#[test]
fn extension_handshake_and_ut_metadata_messages_roundtrip() {
let handshake = PeerWireExtensionHandshakeModel {
extensions: BTreeMap::from([
("ut_metadata".to_owned(), 3_u8),
("ut_pex".to_owned(), 1_u8),
]),
client_name: Some("aria2-rust-pro".to_owned()),
metadata_size: Some(32_768),
request_queue: Some(32),
};
let handshake_message = handshake.to_peer_wire_message();
assert_eq!(handshake_message.extension_message_id, 0);
let parsed_handshake =
PeerWireExtensionHandshakeModel::from_peer_wire_message(&handshake_message)
.expect("extended handshake should parse");
assert_eq!(parsed_handshake, handshake);
assert_eq!(parsed_handshake.ut_metadata_id(), Some(3));
assert_eq!(parsed_handshake.metadata_piece_count(), Some(2));
let request = PeerWireMetadataMessageModel::request(7);
let parsed_request =
PeerWireMetadataMessageModel::from_peer_wire_message(&request.to_peer_wire_message(3), 3)
.expect("metadata request should parse");
assert_eq!(
parsed_request.message_type,
PeerWireMetadataMessageType::Request
);
assert_eq!(parsed_request.piece, 7);
assert!(parsed_request.payload.is_empty());
let data = PeerWireMetadataMessageModel::data(0, 11, b"piece-bytes".to_vec());
let parsed_data =
PeerWireMetadataMessageModel::from_peer_wire_message(&data.to_peer_wire_message(3), 3)
.expect("metadata data should parse");
assert_eq!(parsed_data.message_type, PeerWireMetadataMessageType::Data);
assert_eq!(parsed_data.piece, 0);
assert_eq!(parsed_data.total_size, Some(11));
assert_eq!(parsed_data.payload, b"piece-bytes");
let reject = PeerWireMetadataMessageModel::reject(4);
let parsed_reject =
PeerWireMetadataMessageModel::from_peer_wire_message(&reject.to_peer_wire_message(3), 3)
.expect("metadata reject should parse");
assert_eq!(
parsed_reject.message_type,
PeerWireMetadataMessageType::Reject
);
assert_eq!(parsed_reject.piece, 4);
}
#[test]
fn extended_handshake_treats_zero_ut_metadata_id_as_disabled() {
let handshake = PeerWireExtensionHandshakeModel {
extensions: BTreeMap::from([
("ut_metadata".to_owned(), 0_u8),
("ut_pex".to_owned(), 1_u8),
]),
client_name: Some("aria2-rust-pro".to_owned()),
metadata_size: Some(16_384),
request_queue: Some(8),
};
let parsed = PeerWireExtensionHandshakeModel::from_bencode_bytes(&handshake.to_bencode_bytes())
.expect("extended handshake should parse");
assert_eq!(parsed.extensions.get("ut_metadata"), Some(&0));
assert_eq!(parsed.ut_metadata_id(), None);
assert_eq!(parsed.metadata_piece_count(), Some(1));
}
#[test]
fn ut_metadata_data_messages_reject_out_of_range_piece_indexes() {
let invalid = b"d8:msg_typei1e5:piecei2e10:total_sizei16384eepayload".to_vec();
assert!(
PeerWireMetadataMessageModel::from_bencode_bytes(&invalid)
.expect_err("piece index beyond metadata size should fail")
.contains("piece")
);
}
#[test]
fn ut_metadata_data_messages_reject_payload_lengths_that_do_not_match_piece_geometry() {
let invalid = b"d8:msg_typei1e5:piecei0e10:total_sizei16385ee".to_vec();
assert!(
PeerWireMetadataMessageModel::from_bencode_bytes(&invalid)
.expect_err("empty payload for non-empty piece should fail")
.contains("payload")
);
}
#[test]
fn ut_metadata_request_and_reject_messages_reject_total_size_fields() {
let request_with_total_size = b"d8:msg_typei0e5:piecei0e10:total_sizei16384ee".to_vec();
assert!(
PeerWireMetadataMessageModel::from_bencode_bytes(&request_with_total_size)
.expect_err("request total_size should fail")
.contains("total_size")
);
let reject_with_total_size = b"d8:msg_typei2e5:piecei1e10:total_sizei16384ee".to_vec();
assert!(
PeerWireMetadataMessageModel::from_bencode_bytes(&reject_with_total_size)
.expect_err("reject total_size should fail")
.contains("total_size")
);
}
#[test]
fn peer_wire_parse_rejects_truncated_and_invalid_payload_shapes() {
let truncated = [0, 0, 0, 13, 6, 0, 0, 0, 1, 0, 0];
assert!(
TorrentMessageModel::parse_peer_wire_frame_exact(&truncated)
.expect_err("truncated request frame should fail")
.contains("truncated")
);
let invalid_have = [0, 0, 0, 4, 4, 0, 0, 0];
assert!(
TorrentMessageModel::parse_peer_wire_frame_exact(&invalid_have)
.expect_err("invalid have frame should fail")
.contains("have")
);
let invalid_extension = [0, 0, 0, 1, 20];
assert!(
TorrentMessageModel::parse_peer_wire_frame_exact(&invalid_extension)
.expect_err("extension frame missing ext id should fail")
.contains("extension")
);
}
@@ -0,0 +1,69 @@
use super::bencode::BencodeValue;
/// Decodes a 40-character hexadecimal string into a fixed-width 20-byte array.
pub(super) fn decode_hex_20_array(input: &str) -> Result<[u8; 20], String> {
if input.len() != 40 {
return Err(format!(
"expected 40 hex characters for 20-byte info-hash, got {}",
input.len()
));
}
let mut out = [0_u8; 20];
for (index, chunk) in input.as_bytes().chunks_exact(2).enumerate() {
let hi = decode_hex_nibble(chunk[0])
.ok_or_else(|| "info-hash contains non-hex characters".to_owned())?;
let lo = decode_hex_nibble(chunk[1])
.ok_or_else(|| "info-hash contains non-hex characters".to_owned())?;
out[index] = (hi << 4) | lo;
}
Ok(out)
}
/// Decodes one ASCII hexadecimal nibble.
const fn decode_hex_nibble(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
/// Decodes torrent byte strings into owned lossy UTF-8 text.
pub(super) fn bytes_to_string(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
/// Converts one torrent bencode value into human-readable string form.
pub(super) fn value_to_string(value: &BencodeValue) -> String {
match value {
BencodeValue::Bytes(bytes) => bytes_to_string(bytes),
BencodeValue::Int(value) => value.to_string(),
BencodeValue::List(values) => values
.iter()
.map(value_to_string)
.collect::<Vec<_>>()
.join(","),
BencodeValue::Dict(map) => map
.iter()
.map(|(key, value)| format!("{key}={}", value_to_string(value)))
.collect::<Vec<_>>()
.join("&"),
}
}
/// Hex-encodes raw torrent bytes using lowercase hexadecimal.
pub(super) fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &byte in bytes {
out.push(char::from(HEX[usize::from(byte >> 4)]));
out.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
out
}
/// Saturates an integer-like torrent length field into `u64`.
pub(super) fn i64_to_u64(value: i64) -> u64 {
u64::try_from(value.max(0)).unwrap_or(u64::MAX)
}