use std::collections::HashMap;
use serde::{Deserialize, Serialize};
fn default_neg_one() -> i64 {
-1
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UnfinishedPiece {
pub piece: i64,
#[serde(with = "serde_bytes")]
pub bitmask: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FastResumeData {
#[serde(rename = "file-format")]
pub file_format: String,
#[serde(rename = "file-version")]
pub file_version: i64,
#[serde(rename = "info-hash")]
#[serde(with = "serde_bytes")]
pub info_hash: Vec<u8>,
#[serde(rename = "name")]
pub name: String,
#[serde(rename = "save_path")]
pub save_path: String,
#[serde(rename = "pieces")]
#[serde(with = "serde_bytes")]
pub pieces: Vec<u8>,
#[serde(rename = "unfinished")]
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub unfinished: Vec<UnfinishedPiece>,
#[serde(rename = "total_uploaded")]
pub total_uploaded: i64,
#[serde(rename = "total_downloaded")]
pub total_downloaded: i64,
#[serde(rename = "active_time")]
pub active_time: i64,
#[serde(rename = "seeding_time")]
pub seeding_time: i64,
#[serde(rename = "finished_time")]
pub finished_time: i64,
#[serde(rename = "added_time")]
pub added_time: i64,
#[serde(rename = "completed_time")]
#[serde(default)]
pub completed_time: i64,
#[serde(rename = "last_download")]
#[serde(default)]
pub last_download: i64,
#[serde(rename = "last_upload")]
#[serde(default)]
pub last_upload: i64,
#[serde(rename = "paused")]
#[serde(default)]
pub paused: i64,
#[serde(rename = "auto_managed")]
#[serde(default)]
pub auto_managed: i64,
#[serde(rename = "queue_position")]
#[serde(default = "default_neg_one")]
pub queue_position: i64,
#[serde(rename = "sequential_download")]
#[serde(default)]
pub sequential_download: i64,
#[serde(rename = "seed_mode")]
#[serde(default)]
pub seed_mode: i64,
#[serde(rename = "trackers")]
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub trackers: Vec<Vec<String>>,
#[serde(rename = "peers")]
#[serde(with = "serde_bytes")]
#[serde(default)]
pub peers: Vec<u8>,
#[serde(rename = "peers6")]
#[serde(with = "serde_bytes")]
#[serde(default)]
pub peers6: Vec<u8>,
#[serde(rename = "file_priority")]
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub file_priority: Vec<i64>,
#[serde(rename = "piece_priority")]
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub piece_priority: Vec<i64>,
#[serde(rename = "upload_rate_limit")]
#[serde(default)]
pub upload_rate_limit: i64,
#[serde(rename = "download_rate_limit")]
#[serde(default)]
pub download_rate_limit: i64,
#[serde(rename = "max_connections")]
#[serde(default)]
pub max_connections: i64,
#[serde(rename = "max_uploads")]
#[serde(default)]
pub max_uploads: i64,
#[serde(rename = "info")]
#[serde(with = "serde_bytes")]
#[serde(skip_serializing_if = "Option::is_none", default)]
pub info: Option<Vec<u8>>,
#[serde(rename = "super_seeding")]
#[serde(default)]
pub super_seeding: i64,
#[serde(rename = "url_seeds")]
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub url_seeds: Vec<String>,
#[serde(rename = "http_seeds")]
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub http_seeds: Vec<String>,
#[serde(rename = "info-hash2")]
#[serde(with = "serde_bytes")]
#[serde(skip_serializing_if = "Option::is_none", default)]
pub info_hash2: Option<Vec<u8>>,
#[serde(rename = "trees")]
#[serde(skip_serializing_if = "HashMap::is_empty", default)]
pub trees: HashMap<String, Vec<u8>>,
}
impl FastResumeData {
pub fn new(info_hash: Vec<u8>, name: String, save_path: String) -> Self {
Self {
file_format: "libtorrent resume file".into(),
file_version: 1,
info_hash,
name,
save_path,
pieces: Vec::new(),
unfinished: Vec::new(),
total_uploaded: 0,
total_downloaded: 0,
active_time: 0,
seeding_time: 0,
finished_time: 0,
added_time: 0,
completed_time: 0,
last_download: 0,
last_upload: 0,
paused: 0,
auto_managed: 0,
queue_position: -1,
sequential_download: 0,
seed_mode: 0,
trackers: Vec::new(),
peers: Vec::new(),
peers6: Vec::new(),
file_priority: Vec::new(),
piece_priority: Vec::new(),
upload_rate_limit: -1,
download_rate_limit: -1,
max_connections: -1,
max_uploads: -1,
super_seeding: 0,
info: None,
url_seeds: Vec::new(),
http_seeds: Vec::new(),
info_hash2: None,
trees: HashMap::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn fast_resume_data_bencode_round_trip() {
let mut resume =
FastResumeData::new(vec![0xAA; 20], "test-torrent".into(), "/downloads".into());
resume.total_uploaded = 1024 * 1024;
resume.total_downloaded = 2048 * 1024;
resume.active_time = 3600;
resume.added_time = 1700000000;
resume.trackers = vec![
vec!["http://tracker1.example.com/announce".into()],
vec![
"http://tracker2.example.com/announce".into(),
"http://tracker3.example.com/announce".into(),
],
];
resume.pieces = vec![0xFF; 10];
let encoded = irontide_bencode::to_bytes(&resume).unwrap();
let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
assert_eq!(resume, decoded);
}
#[test]
fn unfinished_piece_bencode_round_trip() {
let piece = UnfinishedPiece {
piece: 42,
bitmask: vec![0b1010_1010, 0b0101_0101],
};
let encoded = irontide_bencode::to_bytes(&piece).unwrap();
let decoded: UnfinishedPiece = irontide_bencode::from_bytes(&encoded).unwrap();
assert_eq!(piece, decoded);
}
#[test]
fn resume_data_with_unfinished_pieces() {
let mut resume = FastResumeData::new(
vec![0xBB; 20],
"partial-torrent".into(),
"/downloads".into(),
);
resume.unfinished = vec![
UnfinishedPiece {
piece: 5,
bitmask: vec![0xFF, 0x0F],
},
UnfinishedPiece {
piece: 12,
bitmask: vec![0xF0],
},
];
let encoded = irontide_bencode::to_bytes(&resume).unwrap();
let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
assert_eq!(resume, decoded);
}
#[test]
fn default_fields_serialize_correctly() {
let resume = FastResumeData::new(vec![0x00; 20], "minimal".into(), "/tmp".into());
let encoded = irontide_bencode::to_bytes(&resume).unwrap();
let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
assert_eq!(resume, decoded);
assert_eq!(decoded.total_uploaded, 0);
assert_eq!(decoded.total_downloaded, 0);
assert_eq!(decoded.paused, 0);
assert_eq!(decoded.upload_rate_limit, -1);
assert_eq!(decoded.download_rate_limit, -1);
assert_eq!(decoded.max_connections, -1);
assert_eq!(decoded.max_uploads, -1);
assert!(decoded.trackers.is_empty());
assert!(decoded.unfinished.is_empty());
assert!(decoded.file_priority.is_empty());
assert!(decoded.info.is_none());
}
#[test]
fn info_dict_embedding_round_trip() {
let mut resume =
FastResumeData::new(vec![0xCC; 20], "with-info".into(), "/downloads".into());
resume.info = Some(
b"d4:name10:test-torte12:piece lengthi262144e6:pieces20:AAAAAAAAAAAAAAAAAAAAe".to_vec(),
);
let encoded = irontide_bencode::to_bytes(&resume).unwrap();
let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
assert_eq!(resume, decoded);
assert!(decoded.info.is_some());
assert_eq!(decoded.info.unwrap().len(), resume.info.unwrap().len());
}
#[test]
fn resume_data_queue_position_default() {
let rd = FastResumeData::new(vec![0; 20], "test".into(), "/tmp".into());
assert_eq!(rd.queue_position, -1);
}
#[test]
fn format_markers_correct() {
let resume = FastResumeData::new(vec![0x00; 20], "test".into(), "/tmp".into());
assert_eq!(resume.file_format, "libtorrent resume file");
assert_eq!(resume.file_version, 1);
}
#[test]
fn resume_data_url_seeds_round_trip() {
let mut resume =
FastResumeData::new(vec![0xDD; 20], "web-seed-test".into(), "/downloads".into());
resume.url_seeds = vec![
"http://example.com/files".into(),
"http://mirror.example.com/".into(),
];
let encoded = irontide_bencode::to_bytes(&resume).unwrap();
let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
assert_eq!(decoded.url_seeds, resume.url_seeds);
}
#[test]
fn resume_data_http_seeds_round_trip() {
let mut resume =
FastResumeData::new(vec![0xEE; 20], "http-seed-test".into(), "/downloads".into());
resume.http_seeds = vec!["http://seed.example.com/seed".into()];
let encoded = irontide_bencode::to_bytes(&resume).unwrap();
let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
assert_eq!(decoded.http_seeds, resume.http_seeds);
}
#[test]
fn resume_data_super_seeding_round_trip() {
let mut resume = FastResumeData::new(
vec![0xFF; 20],
"super-seed-test".into(),
"/downloads".into(),
);
resume.super_seeding = 1;
let encoded = irontide_bencode::to_bytes(&resume).unwrap();
let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
assert_eq!(decoded.super_seeding, 1);
let default_resume = FastResumeData::new(vec![0; 20], "test".into(), "/tmp".into());
assert_eq!(default_resume.super_seeding, 0);
}
#[test]
fn resume_data_v2_fields_round_trip() {
let mut resume =
FastResumeData::new(vec![0xAA; 20], "v2-torrent".into(), "/downloads".into());
resume.info_hash2 = Some(vec![0xBB; 32]);
resume.trees.insert(
hex::encode([0xCC; 32]),
vec![0xDD; 64], );
let encoded = irontide_bencode::to_bytes(&resume).unwrap();
let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
assert_eq!(decoded.info_hash2, Some(vec![0xBB; 32]));
assert_eq!(decoded.trees.len(), 1);
}
#[test]
fn resume_data_v1_backward_compat() {
let resume = FastResumeData::new(vec![0x00; 20], "v1-torrent".into(), "/tmp".into());
assert!(resume.info_hash2.is_none());
assert!(resume.trees.is_empty());
let encoded = irontide_bencode::to_bytes(&resume).unwrap();
let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
assert!(decoded.info_hash2.is_none());
assert!(decoded.trees.is_empty());
}
#[test]
fn resume_data_v2_empty_trees_not_serialized() {
let resume = FastResumeData::new(vec![0x00; 20], "minimal".into(), "/tmp".into());
let encoded = irontide_bencode::to_bytes(&resume).unwrap();
let encoded_str = String::from_utf8_lossy(&encoded);
assert!(!encoded_str.contains("5:trees"));
}
#[test]
fn resume_data_empty_seeds_not_serialized() {
let resume = FastResumeData::new(vec![0x00; 20], "no-seeds".into(), "/tmp".into());
assert!(resume.url_seeds.is_empty());
assert!(resume.http_seeds.is_empty());
let encoded = irontide_bencode::to_bytes(&resume).unwrap();
let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
assert!(decoded.url_seeds.is_empty());
assert!(decoded.http_seeds.is_empty());
}
#[test]
fn resume_data_hybrid_both_hashes() {
let mut resume =
FastResumeData::new(vec![0x11; 20], "hybrid-torrent".into(), "/downloads".into());
resume.info_hash2 = Some(vec![0x22; 32]);
resume.trees.insert(
hex::encode([0x33; 32]),
vec![0x44; 96], );
let encoded = irontide_bencode::to_bytes(&resume).unwrap();
let decoded: FastResumeData = irontide_bencode::from_bytes(&encoded).unwrap();
assert_eq!(decoded.info_hash, vec![0x11; 20]);
assert_eq!(decoded.info_hash2.as_deref(), Some([0x22; 32].as_ref()));
assert_eq!(decoded.trees.len(), 1);
let layer = decoded.trees.values().next().unwrap();
assert_eq!(layer.len(), 96);
}
}