chore: initial sanitized public snapshot
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user