use std::fmt;
pub const INITIAL_CONNECTION_ID: u64 = 0x41727101980;
pub const DEFAULT_ANNOUNCE_INTERVAL: u32 = 300;
pub const CONNECTION_TIMEOUT_SECS: u64 = 120;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum UdpAction {
Connect = 0,
Announce = 1,
Scrape = 2,
Error = 3,
}
impl UdpAction {
pub fn from_i32(v: i32) -> Option<UdpAction> {
match v {
0 => Some(UdpAction::Connect),
1 => Some(UdpAction::Announce),
2 => Some(UdpAction::Scrape),
3 => Some(UdpAction::Error),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(i32)]
pub enum UdpEvent {
None = 0,
Completed = 1,
Started = 2,
Stopped = 3,
}
impl fmt::Display for UdpEvent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
UdpEvent::None => write!(f, "none"),
UdpEvent::Completed => write!(f, "completed"),
UdpEvent::Started => write!(f, "started"),
UdpEvent::Stopped => write!(f, "stopped"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UdpState {
Pending,
Complete,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UdpError {
Success,
TrackerError,
Timeout,
Network,
Shutdown,
}
impl fmt::Display for UdpError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
UdpError::Success => write!(f, "success"),
UdpError::TrackerError => write!(f, "tracker_error"),
UdpError::Timeout => write!(f, "timeout"),
UdpError::Network => write!(f, "network"),
UdpError::Shutdown => write!(f, "shutdown"),
}
}
}
pub fn build_connect_request(txn_id: u32) -> Vec<u8> {
let mut buf = Vec::with_capacity(16);
buf.extend_from_slice(&INITIAL_CONNECTION_ID.to_be_bytes());
buf.extend_from_slice(&(UdpAction::Connect as i32).to_be_bytes());
buf.extend_from_slice(&txn_id.to_be_bytes());
assert_eq!(buf.len(), 16, "CONNECT request must be exactly 16 bytes");
buf
}
#[allow(clippy::too_many_arguments)]
pub fn build_announce_request(
conn_id: u64,
txn_id: u32,
info_hash: &[u8; 20],
peer_id: &[u8; 20],
downloaded: i64,
left: i64,
uploaded: i64,
event: UdpEvent,
ip: u32,
key: u32,
num_want: i32,
port: u16,
) -> Vec<u8> {
let mut buf = Vec::with_capacity(100);
buf.extend_from_slice(&conn_id.to_be_bytes());
buf.extend_from_slice(&(UdpAction::Announce as i32).to_be_bytes());
buf.extend_from_slice(&txn_id.to_be_bytes());
buf.extend_from_slice(info_hash);
buf.extend_from_slice(peer_id);
buf.extend_from_slice(&downloaded.to_be_bytes());
buf.extend_from_slice(&uploaded.to_be_bytes());
buf.extend_from_slice(&left.to_be_bytes());
buf.extend_from_slice(&(event as i32).to_be_bytes());
buf.extend_from_slice(&ip.to_be_bytes());
buf.extend_from_slice(&key.to_be_bytes());
buf.extend_from_slice(&num_want.to_be_bytes());
buf.extend_from_slice(&port.to_be_bytes());
buf
}
pub struct ConnectResponse {
pub transaction_id: u32,
pub connection_id: u64,
}
pub fn parse_connect_response(data: &[u8]) -> Result<ConnectResponse, String> {
if data.len() < 16 {
return Err(format!(
"CONNECT response too short: {} bytes (min 16)",
data.len()
));
}
let action = i32::from_be_bytes([data[0], data[1], data[2], data[3]]);
if action != UdpAction::Connect as i32 {
return Err(format!("Unexpected action in CONNECT response: {}", action));
}
let txn_id = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
let conn_id = u64::from_be_bytes([
data[8], data[9], data[10], data[11], data[12], data[13], data[14], data[15],
]);
Ok(ConnectResponse {
transaction_id: txn_id,
connection_id: conn_id,
})
}
#[derive(Clone)]
pub struct AnnounceResponse {
pub transaction_id: u32,
pub interval: u32,
pub leechers: u32,
pub seeders: u32,
pub peers: Vec<(String, u16)>,
}
impl fmt::Debug for AnnounceResponse {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AnnounceResponse")
.field("transaction_id", &self.transaction_id)
.field("interval", &self.interval)
.field("leechers", &self.leechers)
.field("seeders", &self.seeders)
.field("peers", &self.peers)
.finish()
}
}
fn parse_compact_peers(data: &[u8]) -> Vec<(String, u16)> {
if data.is_empty() || !data.len().is_multiple_of(6) {
return Vec::new();
}
let mut peers = Vec::with_capacity(data.len() / 6);
let (chunks, _remainder) = data.as_chunks::<6>();
for chunk in chunks {
let ip = u32::from_be_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
let port = u16::from_be_bytes([chunk[4], chunk[5]]);
let ip_str = format!(
"{}.{}.{}.{}",
(ip >> 24) & 0xFF,
(ip >> 16) & 0xFF,
(ip >> 8) & 0xFF,
ip & 0xFF
);
peers.push((ip_str, port));
}
peers
}
pub fn parse_announce_response(data: &[u8]) -> Result<AnnounceResponse, String> {
if data.len() < 20 {
return Err(format!(
"ANNOUNCE response too short: {} bytes (min 20)",
data.len()
));
}
let action = i32::from_be_bytes([data[0], data[1], data[2], data[3]]);
match UdpAction::from_i32(action) {
None | Some(UdpAction::Error) => {
let msg_len = (data.len() - 8).min(256);
let msg = String::from_utf8_lossy(&data[8..8 + msg_len]);
return Err(format!("Tracker error: {}", msg));
}
_ => {}
}
let txn_id = u32::from_be_bytes([data[4], data[5], data[6], data[7]]);
let interval = u32::from_be_bytes([data[8], data[9], data[10], data[11]]);
let leechers = u32::from_be_bytes([data[12], data[13], data[14], data[15]]);
let seeders = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
let peers = if data.len() > 20 {
parse_compact_peers(&data[20..])
} else {
Vec::new()
};
Ok(AnnounceResponse {
transaction_id: txn_id,
interval,
leechers,
seeders,
peers,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScrapeResult {
pub seeders: u32,
pub leechers: u32,
pub completed: u32,
}
pub fn build_scrape_request(conn_id: u64, txn_id: u32, info_hashes: &[[u8; 20]]) -> Vec<u8> {
let mut buf = Vec::with_capacity(16 + info_hashes.len() * 20);
buf.extend_from_slice(&conn_id.to_be_bytes());
buf.extend_from_slice(&(UdpAction::Scrape as i32).to_be_bytes());
buf.extend_from_slice(&txn_id.to_be_bytes());
for ih in info_hashes {
buf.extend_from_slice(ih);
}
buf
}
pub fn parse_scrape_response(data: &[u8]) -> Result<Vec<ScrapeResult>, String> {
if data.len() < 8 {
return Err(format!(
"SCRAPE response too short: {} bytes (min 8)",
data.len()
));
}
let action = i32::from_be_bytes([data[0], data[1], data[2], data[3]]);
if action != UdpAction::Scrape as i32 {
return Err(format!("Unexpected action in SCRAPE response: {}", action));
}
let body = &data[8..];
if !body.len().is_multiple_of(12) {
return Err(format!(
"SCRAPE response body length {} is not a multiple of 12",
body.len()
));
}
let count = body.len() / 12;
let mut results = Vec::with_capacity(count);
for i in 0..count {
let offset = i * 12;
if offset + 12 > body.len() {
return Err("SCRAPE response body truncated".into());
}
results.push(ScrapeResult {
seeders: u32::from_be_bytes(
body[offset..offset + 4]
.try_into()
.map_err(|e| format!("Failed to read seeders: {}", e))?,
),
leechers: u32::from_be_bytes(
body[offset + 4..offset + 8]
.try_into()
.map_err(|e| format!("Failed to read leechers: {}", e))?,
),
completed: u32::from_be_bytes(
body[offset + 8..offset + 12]
.try_into()
.map_err(|e| format!("Failed to read completed: {}", e))?,
),
});
}
Ok(results)
}
use std::collections::HashMap;
use std::net::{SocketAddr, UdpSocket};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tracing::{debug, info};
#[derive(Debug, Clone)]
struct ConnectionCache {
connection_id: u64,
expires_at: Instant,
}
impl ConnectionCache {
fn new(connection_id: u64) -> Self {
Self {
connection_id,
expires_at: Instant::now() + Duration::from_secs(CONNECTION_TIMEOUT_SECS),
}
}
fn is_expired(&self) -> bool {
Instant::now() >= self.expires_at
}
}
pub struct UdpTrackerClient {
socket: UdpSocket,
tracker_addr: SocketAddr,
tracker_url: String,
connection_cache: Arc<Mutex<HashMap<SocketAddr, ConnectionCache>>>,
}
#[derive(Debug, Clone)]
pub struct AnnounceParams<'a> {
pub info_hash: &'a [u8; 20],
pub peer_id: &'a [u8; 20],
pub port: u16,
pub uploaded: u64,
pub downloaded: u64,
pub left: u64,
pub event: UdpEvent,
pub num_want: i32,
}
impl UdpTrackerClient {
pub fn new(tracker_url: &str) -> Result<Self, String> {
let addr_str = tracker_url
.strip_prefix("udp://")
.ok_or_else(|| format!("Invalid UDP tracker URL: {}", tracker_url))?;
let addr_str = addr_str.split('/').next().unwrap_or(addr_str);
let addr: SocketAddr = addr_str
.parse()
.map_err(|e| format!("Failed to parse tracker address '{}': {}", addr_str, e))?;
let socket = UdpSocket::bind("0.0.0.0:0")
.map_err(|e| format!("Failed to create UDP socket: {}", e))?;
socket
.set_read_timeout(Some(Duration::from_secs(15)))
.map_err(|e| format!("Failed to set socket timeout: {}", e))?;
Ok(Self {
socket,
tracker_addr: addr,
tracker_url: tracker_url.to_string(),
connection_cache: Arc::new(Mutex::new(HashMap::new())),
})
}
fn generate_transaction_id() -> u32 {
use rand::Rng;
rand::thread_rng().r#gen()
}
fn get_cached_connection_id(&self) -> Option<u64> {
let cache = self.connection_cache.lock().unwrap();
cache.get(&self.tracker_addr).and_then(|c| {
if c.is_expired() {
None
} else {
Some(c.connection_id)
}
})
}
fn cache_connection_id(&self, connection_id: u64) {
let mut cache = self.connection_cache.lock().unwrap();
cache.insert(self.tracker_addr, ConnectionCache::new(connection_id));
debug!(
"Cached connection ID {:#016x} for {}",
connection_id, self.tracker_url
);
}
fn send_with_retry(&self, request: &[u8], max_retries: u8) -> Result<Vec<u8>, String> {
let mut timeout = Duration::from_secs(15);
for attempt in 0..max_retries {
if attempt > 0 {
info!(
"Retrying UDP request to {} (attempt {}/{})",
self.tracker_url,
attempt + 1,
max_retries
);
}
self.socket
.send_to(request, self.tracker_addr)
.map_err(|e| format!("Failed to send UDP packet: {}", e))?;
let mut buf = vec![0u8; 2048];
match self.socket.recv_from(&mut buf) {
Ok((len, _addr)) => {
buf.truncate(len);
return Ok(buf);
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
timeout = std::cmp::min(timeout * 2, Duration::from_secs(120));
std::thread::sleep(timeout);
continue;
}
Err(e) => {
return Err(format!("Failed to receive UDP packet: {}", e));
}
}
}
Err(format!(
"UDP request to {} failed after {} retries",
self.tracker_url, max_retries
))
}
pub fn connect(&self) -> Result<u64, String> {
if let Some(conn_id) = self.get_cached_connection_id() {
debug!("Using cached connection ID for {}", self.tracker_url);
return Ok(conn_id);
}
let txn_id = Self::generate_transaction_id();
let request = build_connect_request(txn_id);
info!(
"Sending CONNECT to {} (txn_id={:#08x})",
self.tracker_url, txn_id
);
let response = self.send_with_retry(&request, 8)?;
let parsed = parse_connect_response(&response)?;
if parsed.transaction_id != txn_id {
return Err(format!(
"Transaction ID mismatch: expected {:#08x}, got {:#08x}",
txn_id, parsed.transaction_id
));
}
self.cache_connection_id(parsed.connection_id);
info!(
"Received CONNECT response from {} (connection_id={:#016x})",
self.tracker_url, parsed.connection_id
);
Ok(parsed.connection_id)
}
pub fn announce(&self, params: &AnnounceParams<'_>) -> Result<AnnounceResponse, String> {
let conn_id = self.connect()?;
let txn_id = Self::generate_transaction_id();
use rand::Rng;
let key = rand::thread_rng().r#gen::<u32>();
let request = build_announce_request(
conn_id,
txn_id,
params.info_hash,
params.peer_id,
params.downloaded as i64,
params.left as i64,
params.uploaded as i64,
params.event,
0, key,
params.num_want,
params.port,
);
info!(
"Sending ANNOUNCE to {} (txn_id={:#08x}, event={})",
self.tracker_url, txn_id, params.event
);
let response = self.send_with_retry(&request, 8)?;
let parsed = parse_announce_response(&response)?;
if parsed.transaction_id != txn_id {
return Err(format!(
"Transaction ID mismatch: expected {:#08x}, got {:#08x}",
txn_id, parsed.transaction_id
));
}
info!(
"Received ANNOUNCE from {}: interval={}s, peers={}, seeders={}, leechers={}",
self.tracker_url,
parsed.interval,
parsed.peers.len(),
parsed.seeders,
parsed.leechers
);
Ok(parsed)
}
pub fn scrape(&self, info_hashes: &[[u8; 20]]) -> Result<Vec<ScrapeResult>, String> {
if info_hashes.is_empty() {
return Err("No info hashes provided for scrape".to_string());
}
let conn_id = self.connect()?;
let txn_id = Self::generate_transaction_id();
let request = build_scrape_request(conn_id, txn_id, info_hashes);
info!(
"Sending SCRAPE to {} (txn_id={:#08x}, {} hashes)",
self.tracker_url,
txn_id,
info_hashes.len()
);
let response = self.send_with_retry(&request, 8)?;
let parsed = parse_scrape_response(&response)?;
info!(
"Received SCRAPE from {}: {} results",
self.tracker_url,
parsed.len()
);
Ok(parsed)
}
pub fn tracker_url(&self) -> &str {
&self.tracker_url
}
pub fn tracker_addr(&self) -> SocketAddr {
self.tracker_addr
}
}
pub struct AsyncUdpTrackerClient {
inner: Arc<UdpTrackerClient>,
}
impl AsyncUdpTrackerClient {
pub fn new(tracker_url: &str) -> Result<Self, String> {
Ok(Self {
inner: Arc::new(UdpTrackerClient::new(tracker_url)?),
})
}
pub async fn announce(&self, params: &AnnounceParams<'_>) -> Result<AnnounceResponse, String> {
let inner = self.inner.clone();
let info_hash = *params.info_hash;
let peer_id = *params.peer_id;
let port = params.port;
let uploaded = params.uploaded;
let downloaded = params.downloaded;
let left = params.left;
let event = params.event;
let num_want = params.num_want;
tokio::task::spawn_blocking(move || {
inner.announce(&AnnounceParams {
info_hash: &info_hash,
peer_id: &peer_id,
port,
uploaded,
downloaded,
left,
event,
num_want,
})
})
.await
.map_err(|e| format!("Task join error: {}", e))?
}
pub async fn scrape(&self, info_hashes: &[[u8; 20]]) -> Result<Vec<ScrapeResult>, String> {
let inner = self.inner.clone();
let info_hashes = info_hashes.to_vec();
tokio::task::spawn_blocking(move || inner.scrape(&info_hashes))
.await
.map_err(|e| format!("Task join error: {}", e))?
}
pub fn tracker_url(&self) -> &str {
self.inner.tracker_url()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_connect_request_length_and_magic() {
let req = build_connect_request(0x12345678);
assert_eq!(req.len(), 16);
let conn_id = u64::from_be_bytes([
req[0], req[1], req[2], req[3], req[4], req[5], req[6], req[7],
]);
assert_eq!(conn_id, INITIAL_CONNECTION_ID);
let action = i32::from_be_bytes([req[8], req[9], req[10], req[11]]);
assert_eq!(action, 0);
let txn = u32::from_be_bytes([req[12], req[13], req[14], req[15]]);
assert_eq!(txn, 0x12345678);
}
#[test]
fn test_build_announce_request_format() {
let info_hash = [0xABu8; 20];
let peer_id = [0xCDu8; 20];
let req = build_announce_request(
0x123456789ABCDEF0,
0xDEADBEEF,
&info_hash,
&peer_id,
1024,
2048,
4096,
UdpEvent::Started,
0,
0x12345678,
50,
6881,
);
assert_eq!(req.len(), 98);
let conn_id = u64::from_be_bytes(req[..8].try_into().unwrap());
assert_eq!(conn_id, 0x123456789ABCDEF0);
let event = i32::from_be_bytes(req[80..84].try_into().unwrap());
assert_eq!(event, UdpEvent::Started as i32);
let port = u16::from_be_bytes(req[96..98].try_into().unwrap());
assert_eq!(port, 6881);
}
#[test]
fn test_parse_connect_response_valid() {
let mut data = vec![0u8; 16];
data[0..4].copy_from_slice(&(0i32).to_be_bytes()); data[4..8].copy_from_slice(&0xABCDEF01u32.to_be_bytes()); data[8..16].copy_from_slice(&0x123456789ABCDEF0u64.to_be_bytes());
let resp = parse_connect_response(&data).unwrap();
assert_eq!(resp.transaction_id, 0xABCDEF01);
assert_eq!(resp.connection_id, 0x123456789ABCDEF0);
}
#[test]
fn test_parse_connect_response_too_short() {
assert!(parse_connect_response(&[0u8; 15]).is_err());
assert!(parse_connect_response(&[]).is_err());
}
#[test]
fn test_parse_connect_response_wrong_action() {
let mut data = vec![0u8; 16];
data[0..4].copy_from_slice(&3i32.to_be_bytes()); assert!(parse_connect_response(&data).is_err());
}
#[test]
fn test_parse_announce_with_peers() {
let mut data = vec![0u8; 20];
data[0..4].copy_from_slice(&1i32.to_be_bytes()); data[4..8].copy_from_slice(&0x12345678u32.to_be_bytes()); data[8..12].copy_from_slice(&900u32.to_be_bytes()); data[12..16].copy_from_slice(&5u32.to_be_bytes()); data[16..20].copy_from_slice(&3u32.to_be_bytes());
data.extend_from_slice(&[
192, 168, 1, 1, 0x19, 0xFA, 10, 0, 0, 1, 0x17, 0x70, ]);
let resp = parse_announce_response(&data).unwrap();
assert_eq!(resp.transaction_id, 0x12345678);
assert_eq!(resp.interval, 900);
assert_eq!(resp.leechers, 5);
assert_eq!(resp.seeders, 3);
assert_eq!(resp.peers.len(), 2);
assert_eq!(resp.peers[0], ("192.168.1.1".into(), 6650));
assert_eq!(resp.peers[1], ("10.0.0.1".into(), 6000));
}
#[test]
fn test_parse_announce_empty_peers() {
let mut data = vec![0u8; 20];
data[0..4].copy_from_slice(&1i32.to_be_bytes());
data[4..8].copy_from_slice(&0u32.to_be_bytes());
data[8..12].copy_from_slice(&1800u32.to_be_bytes());
let resp = parse_announce_response(&data).unwrap();
assert_eq!(resp.interval, 1800);
assert!(resp.peers.is_empty());
}
#[test]
fn test_parse_announce_too_short() {
assert!(parse_announce_response(&[0u8; 19]).is_err());
}
#[test]
fn test_parse_announce_error_action() {
let mut data = vec![0u8; 23];
data[0..4].copy_from_slice(&3i32.to_be_bytes()); data[8..23].copy_from_slice(b"tracker offline");
let result = parse_announce_response(&data);
assert!(result.is_err());
assert!(result.unwrap_err().contains("tracker offline"));
}
#[test]
fn test_parse_compact_peer_format() {
let data = [
127, 0, 0, 1, 0x1F, 0x90, 255, 255, 255, 255, 0x00, 0x50, 10, 0, 0, 1, 0xBB, 0x82, ];
let peers = parse_compact_peers(&data);
assert_eq!(peers.len(), 3);
assert_eq!(peers[0], ("127.0.0.1".into(), 8080));
assert_eq!(peers[1], ("255.255.255.255".into(), 80));
assert_eq!(peers[2], ("10.0.0.1".into(), 48002));
}
#[test]
fn test_event_enum_values() {
assert_eq!(UdpEvent::None as i32, 0);
assert_eq!(UdpEvent::Completed as i32, 1);
assert_eq!(UdpEvent::Started as i32, 2);
assert_eq!(UdpEvent::Stopped as i32, 3);
}
#[test]
fn test_big_endian_encoding_consistency() {
let val: u32 = 0xAABBCCDD;
let bytes = val.to_be_bytes();
assert_eq!(bytes, [0xAA, 0xBB, 0xCC, 0xDD]);
assert_eq!(u32::from_be_bytes(bytes), val);
}
#[test]
fn test_udp_action_roundtrip() {
for a in &[
UdpAction::Connect,
UdpAction::Announce,
UdpAction::Scrape,
UdpAction::Error,
] {
assert_eq!(UdpAction::from_i32(*a as i32), Some(*a));
}
assert_eq!(UdpAction::from_i32(99), None);
}
#[test]
fn test_scrape_request_encoding() {
let conn_id = 0x123456789ABCDEF0u64;
let txn_id = 0xDEADBEEFu32;
let info_hashes = [[0xABu8; 20], [0xCDu8; 20]];
let req = build_scrape_request(conn_id, txn_id, &info_hashes);
assert_eq!(req.len(), 56);
let parsed_conn_id = u64::from_be_bytes(req[0..8].try_into().unwrap());
assert_eq!(parsed_conn_id, conn_id);
let action = i32::from_be_bytes(req[8..12].try_into().unwrap());
assert_eq!(action, UdpAction::Scrape as i32);
let parsed_txn = u32::from_be_bytes(req[12..16].try_into().unwrap());
assert_eq!(parsed_txn, txn_id);
assert_eq!(&req[16..36], &[0xABu8; 20]);
assert_eq!(&req[36..56], &[0xCDu8; 20]);
}
#[test]
fn test_scrape_response_parsing_single_hash() {
let mut data = vec![0u8; 20]; data[0..4].copy_from_slice(&(UdpAction::Scrape as i32).to_be_bytes()); data[4..8].copy_from_slice(&0x12345678u32.to_be_bytes()); data[8..12].copy_from_slice(&100u32.to_be_bytes()); data[12..16].copy_from_slice(&50u32.to_be_bytes()); data[16..20].copy_from_slice(&200u32.to_be_bytes());
let results = parse_scrape_response(&data).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(
results[0],
ScrapeResult {
seeders: 100,
leechers: 50,
completed: 200,
}
);
}
#[test]
fn test_scrape_response_parsing_multi_hash() {
let mut data = vec![0u8; 44]; data[0..4].copy_from_slice(&(UdpAction::Scrape as i32).to_be_bytes());
data[4..8].copy_from_slice(&0xAABBCCDDu32.to_be_bytes());
data[8..12].copy_from_slice(&10u32.to_be_bytes());
data[12..16].copy_from_slice(&5u32.to_be_bytes());
data[16..20].copy_from_slice(&100u32.to_be_bytes());
data[20..24].copy_from_slice(&999u32.to_be_bytes());
data[24..28].copy_from_slice(&888u32.to_be_bytes());
data[28..32].copy_from_slice(&777u32.to_be_bytes());
data[32..36].copy_from_slice(&1u32.to_be_bytes());
data[36..40].copy_from_slice(&2u32.to_be_bytes());
data[40..44].copy_from_slice(&3u32.to_be_bytes());
let results = parse_scrape_response(&data).unwrap();
assert_eq!(results.len(), 3);
assert_eq!(results[0].seeders, 10);
assert_eq!(results[0].leechers, 5);
assert_eq!(results[0].completed, 100);
assert_eq!(results[1].seeders, 999);
assert_eq!(results[1].leechers, 888);
assert_eq!(results[1].completed, 777);
assert_eq!(results[2].seeders, 1);
assert_eq!(results[2].leechers, 2);
assert_eq!(results[2].completed, 3);
}
#[test]
fn test_scrape_error_action() {
let mut data = vec![0u8; 20];
data[0..4].copy_from_slice(&3i32.to_be_bytes()); data[4..8].copy_from_slice(&0x12345678u32.to_be_bytes());
let result = parse_scrape_response(&data);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Unexpected action"));
}
#[test]
fn test_scrape_response_too_short() {
assert!(parse_scrape_response(&[0u8; 7]).is_err());
assert!(parse_scrape_response(&[]).is_err());
}
}