use std::collections::BTreeMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use bytes::Bytes;
use sha1::{Digest, Sha1};
use super::error::MetainfoError;
use super::merkle::{hash_block, MerkleTree, MERKLE_BLOCK_SIZE};
use super::torrent::TorrentVersion;
use crate::bencode::{encode, Value};
type PieceLayersMap = BTreeMap<[u8; 32], Vec<[u8; 32]>>;
pub const MIN_V2_PIECE_LENGTH: u64 = 16384;
pub const DEFAULT_PIECE_LENGTH: u64 = 262144;
#[derive(Debug, Clone)]
struct BuilderFile {
path: Vec<String>,
data: Vec<u8>,
}
#[derive(Debug)]
pub struct TorrentBuilder {
name: String,
version: TorrentVersion,
files: Vec<BuilderFile>,
piece_length: u64,
announce: Option<String>,
announce_list: Vec<Vec<String>>,
private: bool,
comment: Option<String>,
created_by: Option<String>,
creation_date: Option<i64>,
url_list: Vec<String>,
}
impl TorrentBuilder {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
version: TorrentVersion::V1,
files: Vec::new(),
piece_length: DEFAULT_PIECE_LENGTH,
announce: None,
announce_list: Vec::new(),
private: false,
comment: None,
created_by: Some(format!("rbit/{}", env!("CARGO_PKG_VERSION"))),
creation_date: None,
url_list: Vec::new(),
}
}
pub fn version(mut self, version: TorrentVersion) -> Self {
self.version = version;
self
}
pub fn piece_length(mut self, length: u64) -> Self {
self.piece_length = length;
self
}
pub fn add_file(mut self, path: impl AsRef<Path>, data: Vec<u8>) -> Self {
let path_components: Vec<String> = path
.as_ref()
.components()
.filter_map(|c| match c {
std::path::Component::Normal(s) => s.to_str().map(String::from),
_ => None,
})
.collect();
self.files.push(BuilderFile {
path: path_components,
data,
});
self
}
pub fn add_file_from_reader<R: Read>(
self,
path: impl AsRef<Path>,
mut reader: R,
) -> Result<Self, MetainfoError> {
let mut data = Vec::new();
reader
.read_to_end(&mut data)
.map_err(|e| MetainfoError::InvalidField(Box::leak(e.to_string().into_boxed_str())))?;
Ok(self.add_file(path, data))
}
pub fn add_file_from_path(self, path: impl AsRef<Path>) -> Result<Self, MetainfoError> {
let path = path.as_ref();
let data = std::fs::read(path)
.map_err(|e| MetainfoError::InvalidField(Box::leak(e.to_string().into_boxed_str())))?;
let filename = path
.file_name()
.and_then(|s| s.to_str())
.ok_or(MetainfoError::InvalidField("invalid filename"))?;
Ok(self.add_file(filename, data))
}
pub fn add_file_from_path_as(
self,
disk_path: impl AsRef<Path>,
torrent_path: impl AsRef<Path>,
) -> Result<Self, MetainfoError> {
let data = std::fs::read(disk_path.as_ref())
.map_err(|e| MetainfoError::InvalidField(Box::leak(e.to_string().into_boxed_str())))?;
Ok(self.add_file(torrent_path, data))
}
pub fn add_directory(mut self, dir_path: impl AsRef<Path>) -> Result<Self, MetainfoError> {
let dir_path = dir_path.as_ref();
self = self.add_directory_recursive(dir_path, PathBuf::new())?;
Ok(self)
}
fn add_directory_recursive(
mut self,
base_path: &Path,
relative_path: PathBuf,
) -> Result<Self, MetainfoError> {
let current_path = base_path.join(&relative_path);
let entries = std::fs::read_dir(¤t_path)
.map_err(|e| MetainfoError::InvalidField(Box::leak(e.to_string().into_boxed_str())))?;
for entry in entries {
let entry = entry.map_err(|e| {
MetainfoError::InvalidField(Box::leak(e.to_string().into_boxed_str()))
})?;
let path = entry.path();
let file_name = entry.file_name();
let new_relative = relative_path.join(&file_name);
if path.is_dir() {
self = self.add_directory_recursive(base_path, new_relative)?;
} else if path.is_file() {
let data = std::fs::read(&path).map_err(|e| {
MetainfoError::InvalidField(Box::leak(e.to_string().into_boxed_str()))
})?;
self = self.add_file(&new_relative, data);
}
}
Ok(self)
}
pub fn add_tracker(mut self, url: impl Into<String>) -> Self {
let url = url.into();
if self.announce.is_none() {
self.announce = Some(url);
} else {
self.announce_list.push(vec![url]);
}
self
}
pub fn add_tracker_tier(mut self, urls: Vec<String>) -> Self {
self.announce_list.push(urls);
self
}
pub fn private(mut self, private: bool) -> Self {
self.private = private;
self
}
pub fn comment(mut self, comment: impl Into<String>) -> Self {
self.comment = Some(comment.into());
self
}
pub fn created_by(mut self, created_by: impl Into<String>) -> Self {
self.created_by = Some(created_by.into());
self
}
pub fn creation_date(mut self, timestamp: i64) -> Self {
self.creation_date = Some(timestamp);
self
}
pub fn add_web_seed(mut self, url: impl Into<String>) -> Self {
self.url_list.push(url.into());
self
}
pub fn build(self) -> Result<Vec<u8>, MetainfoError> {
self.validate()?;
match self.version {
TorrentVersion::V1 => self.build_v1(),
TorrentVersion::V2 => self.build_v2(),
TorrentVersion::Hybrid => self.build_hybrid(),
}
}
fn validate(&self) -> Result<(), MetainfoError> {
if self.name.is_empty() {
return Err(MetainfoError::MissingField("name"));
}
if self.files.is_empty() {
return Err(MetainfoError::MissingField("files"));
}
if self.version.supports_v2() {
if self.piece_length < MIN_V2_PIECE_LENGTH {
return Err(MetainfoError::InvalidField("piece length too small for v2"));
}
if !self.piece_length.is_power_of_two() {
return Err(MetainfoError::InvalidField(
"piece length must be power of 2 for v2",
));
}
}
for file in &self.files {
for component in &file.path {
if component == "." || component == ".." || component.is_empty() {
return Err(MetainfoError::InvalidField("invalid path component"));
}
}
}
Ok(())
}
fn build_v1(self) -> Result<Vec<u8>, MetainfoError> {
let mut root = BTreeMap::new();
let info = self.build_info_v1()?;
root.insert(Bytes::from_static(b"info"), info);
self.add_common_fields(&mut root);
encode(&Value::Dict(root)).map_err(|_| MetainfoError::InvalidField("encoding failed"))
}
fn build_v2(self) -> Result<Vec<u8>, MetainfoError> {
let mut root = BTreeMap::new();
let (info, piece_layers) = self.build_info_v2()?;
root.insert(Bytes::from_static(b"info"), info);
if !piece_layers.is_empty() {
root.insert(
Bytes::from_static(b"piece layers"),
Self::encode_piece_layers(&piece_layers),
);
}
self.add_common_fields(&mut root);
encode(&Value::Dict(root)).map_err(|_| MetainfoError::InvalidField("encoding failed"))
}
fn build_hybrid(self) -> Result<Vec<u8>, MetainfoError> {
let mut root = BTreeMap::new();
let (info, piece_layers) = self.build_info_hybrid()?;
root.insert(Bytes::from_static(b"info"), info);
if !piece_layers.is_empty() {
root.insert(
Bytes::from_static(b"piece layers"),
Self::encode_piece_layers(&piece_layers),
);
}
self.add_common_fields(&mut root);
encode(&Value::Dict(root)).map_err(|_| MetainfoError::InvalidField("encoding failed"))
}
fn build_info_v1(&self) -> Result<Value, MetainfoError> {
let mut info = BTreeMap::new();
info.insert(
Bytes::from_static(b"name"),
Value::Bytes(Bytes::from(self.name.clone())),
);
info.insert(
Bytes::from_static(b"piece length"),
Value::Integer(self.piece_length as i64),
);
if self.private {
info.insert(Bytes::from_static(b"private"), Value::Integer(1));
}
let (total_data, pieces) = self.compute_v1_pieces();
let pieces_bytes: Vec<u8> = pieces.iter().flat_map(|h| h.iter().copied()).collect();
info.insert(
Bytes::from_static(b"pieces"),
Value::Bytes(Bytes::from(pieces_bytes)),
);
if self.files.len() == 1 && self.files[0].path.len() == 1 {
info.insert(
Bytes::from_static(b"length"),
Value::Integer(total_data.len() as i64),
);
} else {
let files_list = self.build_files_list_v1();
info.insert(Bytes::from_static(b"files"), Value::List(files_list));
}
Ok(Value::Dict(info))
}
fn build_info_v2(&self) -> Result<(Value, PieceLayersMap), MetainfoError> {
let mut info = BTreeMap::new();
let mut piece_layers = BTreeMap::new();
info.insert(
Bytes::from_static(b"name"),
Value::Bytes(Bytes::from(self.name.clone())),
);
info.insert(
Bytes::from_static(b"piece length"),
Value::Integer(self.piece_length as i64),
);
info.insert(Bytes::from_static(b"meta version"), Value::Integer(2));
if self.private {
info.insert(Bytes::from_static(b"private"), Value::Integer(1));
}
let file_tree = self.build_file_tree_v2(&mut piece_layers)?;
info.insert(Bytes::from_static(b"file tree"), file_tree);
Ok((Value::Dict(info), piece_layers))
}
fn build_info_hybrid(&self) -> Result<(Value, PieceLayersMap), MetainfoError> {
let mut info = BTreeMap::new();
let mut piece_layers = BTreeMap::new();
info.insert(
Bytes::from_static(b"name"),
Value::Bytes(Bytes::from(self.name.clone())),
);
info.insert(
Bytes::from_static(b"piece length"),
Value::Integer(self.piece_length as i64),
);
info.insert(Bytes::from_static(b"meta version"), Value::Integer(2));
if self.private {
info.insert(Bytes::from_static(b"private"), Value::Integer(1));
}
let (_total_data, v1_pieces) = self.compute_v1_pieces_with_padding();
let pieces_bytes: Vec<u8> = v1_pieces.iter().flat_map(|h| h.iter().copied()).collect();
info.insert(
Bytes::from_static(b"pieces"),
Value::Bytes(Bytes::from(pieces_bytes)),
);
let file_tree = self.build_file_tree_v2(&mut piece_layers)?;
info.insert(Bytes::from_static(b"file tree"), file_tree);
if self.files.len() == 1 && self.files[0].path.len() == 1 {
info.insert(
Bytes::from_static(b"length"),
Value::Integer(self.files[0].data.len() as i64),
);
} else {
let files_list = self.build_files_list_hybrid();
info.insert(Bytes::from_static(b"files"), Value::List(files_list));
}
Ok((Value::Dict(info), piece_layers))
}
fn compute_v1_pieces(&self) -> (Vec<u8>, Vec<[u8; 20]>) {
let total_data: Vec<u8> = self
.files
.iter()
.flat_map(|f| f.data.iter().copied())
.collect();
let pieces: Vec<[u8; 20]> = total_data
.chunks(self.piece_length as usize)
.map(|chunk| {
let mut hasher = Sha1::new();
hasher.update(chunk);
hasher.finalize().into()
})
.collect();
(total_data, pieces)
}
fn compute_v1_pieces_with_padding(&self) -> (Vec<u8>, Vec<[u8; 20]>) {
let mut total_data = Vec::new();
for file in &self.files {
total_data.extend_from_slice(&file.data);
if !file.data.is_empty() {
let remainder = file.data.len() % self.piece_length as usize;
if remainder != 0 {
let padding = self.piece_length as usize - remainder;
total_data.extend(std::iter::repeat_n(0u8, padding));
}
}
}
let pieces: Vec<[u8; 20]> = total_data
.chunks(self.piece_length as usize)
.map(|chunk| {
let mut hasher = Sha1::new();
hasher.update(chunk);
hasher.finalize().into()
})
.collect();
(total_data, pieces)
}
fn build_files_list_v1(&self) -> Vec<Value> {
self.files
.iter()
.map(|file| {
let mut file_dict = BTreeMap::new();
file_dict.insert(
Bytes::from_static(b"length"),
Value::Integer(file.data.len() as i64),
);
let path_list: Vec<Value> = file
.path
.iter()
.map(|p| Value::Bytes(Bytes::from(p.clone())))
.collect();
file_dict.insert(Bytes::from_static(b"path"), Value::List(path_list));
Value::Dict(file_dict)
})
.collect()
}
fn build_files_list_hybrid(&self) -> Vec<Value> {
let mut files_list = Vec::new();
for (i, file) in self.files.iter().enumerate() {
let mut file_dict = BTreeMap::new();
file_dict.insert(
Bytes::from_static(b"length"),
Value::Integer(file.data.len() as i64),
);
let path_list: Vec<Value> = file
.path
.iter()
.map(|p| Value::Bytes(Bytes::from(p.clone())))
.collect();
file_dict.insert(Bytes::from_static(b"path"), Value::List(path_list));
files_list.push(Value::Dict(file_dict));
if i < self.files.len() - 1 && !file.data.is_empty() {
let remainder = file.data.len() % self.piece_length as usize;
if remainder != 0 {
let padding_size = self.piece_length as usize - remainder;
let mut padding_dict = BTreeMap::new();
padding_dict.insert(
Bytes::from_static(b"length"),
Value::Integer(padding_size as i64),
);
padding_dict.insert(
Bytes::from_static(b"attr"),
Value::Bytes(Bytes::from_static(b"p")),
);
padding_dict.insert(
Bytes::from_static(b"path"),
Value::List(vec![Value::Bytes(Bytes::from(format!(
".pad/{}",
padding_size
)))]),
);
files_list.push(Value::Dict(padding_dict));
}
}
}
files_list
}
fn build_file_tree_v2(
&self,
piece_layers: &mut BTreeMap<[u8; 32], Vec<[u8; 32]>>,
) -> Result<Value, MetainfoError> {
let mut root_tree: BTreeMap<Bytes, Value> = BTreeMap::new();
for file in &self.files {
let (pieces_root, layer_hashes) = self.compute_file_merkle(&file.data);
let file_piece_count = file.data.len().div_ceil(self.piece_length as usize);
if file_piece_count > 1 {
piece_layers.insert(pieces_root, layer_hashes);
}
let mut current = &mut root_tree;
for (i, component) in file.path.iter().enumerate() {
let key = Bytes::from(component.clone());
if i == file.path.len() - 1 {
let file_entry = self.build_file_entry(file.data.len() as u64, pieces_root);
current.insert(key, file_entry);
} else {
let entry = current
.entry(key)
.or_insert_with(|| Value::Dict(BTreeMap::new()));
if let Value::Dict(ref mut dict) = entry {
current = dict;
} else {
return Err(MetainfoError::InvalidField("path conflict"));
}
}
}
}
Ok(Value::Dict(root_tree))
}
fn compute_file_merkle(&self, data: &[u8]) -> ([u8; 32], Vec<[u8; 32]>) {
if data.is_empty() {
return ([0u8; 32], Vec::new());
}
let block_hashes: Vec<[u8; 32]> = data.chunks(MERKLE_BLOCK_SIZE).map(hash_block).collect();
let tree = MerkleTree::from_piece_hashes(block_hashes.clone());
let file_root = tree.root().unwrap_or([0u8; 32]);
let blocks_per_piece = (self.piece_length as usize) / MERKLE_BLOCK_SIZE;
let mut layer_hashes = Vec::new();
for piece_blocks in block_hashes.chunks(blocks_per_piece) {
let mut padded = piece_blocks.to_vec();
while padded.len() < blocks_per_piece {
padded.push([0u8; 32]);
}
let piece_tree = MerkleTree::from_piece_hashes(padded);
if let Some(root) = piece_tree.root() {
layer_hashes.push(root);
}
}
(file_root, layer_hashes)
}
fn build_file_entry(&self, length: u64, pieces_root: [u8; 32]) -> Value {
let mut entry = BTreeMap::new();
let mut file_props = BTreeMap::new();
file_props.insert(Bytes::from_static(b"length"), Value::Integer(length as i64));
if length > 0 {
file_props.insert(
Bytes::from_static(b"pieces root"),
Value::Bytes(Bytes::from(pieces_root.to_vec())),
);
}
entry.insert(Bytes::from_static(b""), Value::Dict(file_props));
Value::Dict(entry)
}
fn encode_piece_layers(layers: &BTreeMap<[u8; 32], Vec<[u8; 32]>>) -> Value {
let mut dict = BTreeMap::new();
for (root, hashes) in layers {
let concat: Vec<u8> = hashes.iter().flat_map(|h| h.iter().copied()).collect();
dict.insert(
Bytes::from(root.to_vec()),
Value::Bytes(Bytes::from(concat)),
);
}
Value::Dict(dict)
}
fn add_common_fields(&self, root: &mut BTreeMap<Bytes, Value>) {
if let Some(ref announce) = self.announce {
root.insert(
Bytes::from_static(b"announce"),
Value::Bytes(Bytes::from(announce.clone())),
);
}
if !self.announce_list.is_empty() {
let list: Vec<Value> = self
.announce_list
.iter()
.map(|tier| {
Value::List(
tier.iter()
.map(|url| Value::Bytes(Bytes::from(url.clone())))
.collect(),
)
})
.collect();
root.insert(Bytes::from_static(b"announce-list"), Value::List(list));
}
if let Some(ref comment) = self.comment {
root.insert(
Bytes::from_static(b"comment"),
Value::Bytes(Bytes::from(comment.clone())),
);
}
if let Some(ref created_by) = self.created_by {
root.insert(
Bytes::from_static(b"created by"),
Value::Bytes(Bytes::from(created_by.clone())),
);
}
let timestamp = self.creation_date.unwrap_or_else(|| {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
});
root.insert(
Bytes::from_static(b"creation date"),
Value::Integer(timestamp),
);
if !self.url_list.is_empty() {
if self.url_list.len() == 1 {
root.insert(
Bytes::from_static(b"url-list"),
Value::Bytes(Bytes::from(self.url_list[0].clone())),
);
} else {
let list: Vec<Value> = self
.url_list
.iter()
.map(|url| Value::Bytes(Bytes::from(url.clone())))
.collect();
root.insert(Bytes::from_static(b"url-list"), Value::List(list));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::metainfo::Metainfo;
#[test]
fn test_builder_v1_single_file() {
let data = b"Hello, BitTorrent v1!";
let torrent_bytes = TorrentBuilder::new("test")
.version(TorrentVersion::V1)
.add_file("test.txt", data.to_vec())
.piece_length(16384)
.build()
.unwrap();
let metainfo = Metainfo::from_bytes(&torrent_bytes).unwrap();
assert_eq!(metainfo.info.name, "test");
assert_eq!(metainfo.info.piece_length, 16384);
assert_eq!(metainfo.info.total_length, data.len() as u64);
assert!(metainfo.info.is_v1());
}
#[test]
fn test_builder_v1_multi_file() {
let torrent_bytes = TorrentBuilder::new("myfiles")
.version(TorrentVersion::V1)
.add_file("file1.txt", b"First file content".to_vec())
.add_file("subdir/file2.txt", b"Second file content".to_vec())
.piece_length(16384)
.build()
.unwrap();
let metainfo = Metainfo::from_bytes(&torrent_bytes).unwrap();
assert_eq!(metainfo.info.name, "myfiles");
assert_eq!(metainfo.info.files.len(), 2);
assert!(metainfo.info.is_v1());
}
#[test]
fn test_builder_v2_single_file() {
let data = b"Hello, BitTorrent v2!";
let torrent_bytes = TorrentBuilder::new("test")
.version(TorrentVersion::V2)
.add_file("test.txt", data.to_vec())
.piece_length(16384)
.build()
.unwrap();
let metainfo = Metainfo::from_bytes(&torrent_bytes).unwrap();
assert_eq!(metainfo.info.name, "test");
assert!(metainfo.info.is_v2());
assert_eq!(metainfo.info.meta_version, Some(2));
}
#[test]
fn test_builder_v2_requires_power_of_two() {
let result = TorrentBuilder::new("test")
.version(TorrentVersion::V2)
.add_file("test.txt", b"data".to_vec())
.piece_length(30000) .build();
assert!(result.is_err());
}
#[test]
fn test_builder_v2_requires_min_piece_length() {
let result = TorrentBuilder::new("test")
.version(TorrentVersion::V2)
.add_file("test.txt", b"data".to_vec())
.piece_length(8192) .build();
assert!(result.is_err());
}
#[test]
fn test_builder_hybrid() {
let data = vec![0u8; 32768]; let torrent_bytes = TorrentBuilder::new("hybrid_test")
.version(TorrentVersion::Hybrid)
.add_file("data.bin", data)
.piece_length(16384)
.build()
.unwrap();
let metainfo = Metainfo::from_bytes(&torrent_bytes).unwrap();
assert!(metainfo.info.is_hybrid());
assert!(metainfo.info.pieces.has_v1());
assert!(metainfo.info.pieces.has_v2());
}
#[test]
fn test_builder_with_trackers() {
let torrent_bytes = TorrentBuilder::new("test")
.add_file("test.txt", b"data".to_vec())
.add_tracker("http://tracker1.example.com/announce")
.add_tracker("http://tracker2.example.com/announce")
.build()
.unwrap();
let metainfo = Metainfo::from_bytes(&torrent_bytes).unwrap();
assert!(metainfo.announce.is_some());
assert!(!metainfo.announce_list.is_empty());
}
#[test]
fn test_builder_private_torrent() {
let torrent_bytes = TorrentBuilder::new("test")
.add_file("test.txt", b"data".to_vec())
.private(true)
.build()
.unwrap();
let metainfo = Metainfo::from_bytes(&torrent_bytes).unwrap();
assert!(metainfo.info.private);
}
#[test]
fn test_builder_with_comment() {
let torrent_bytes = TorrentBuilder::new("test")
.add_file("test.txt", b"data".to_vec())
.comment("Test comment")
.build()
.unwrap();
let metainfo = Metainfo::from_bytes(&torrent_bytes).unwrap();
assert_eq!(metainfo.comment, Some("Test comment".to_string()));
}
#[test]
fn test_builder_empty_name_fails() {
let result = TorrentBuilder::new("")
.add_file("test.txt", b"data".to_vec())
.build();
assert!(result.is_err());
}
#[test]
fn test_builder_no_files_fails() {
let result = TorrentBuilder::new("test").build();
assert!(result.is_err());
}
#[test]
fn test_builder_roundtrip_v1() {
let original_data = vec![0xAB; 50000]; let torrent_bytes = TorrentBuilder::new("roundtrip")
.version(TorrentVersion::V1)
.add_file("data.bin", original_data.clone())
.piece_length(16384)
.add_tracker("http://example.com/announce")
.comment("Roundtrip test")
.build()
.unwrap();
let metainfo = Metainfo::from_bytes(&torrent_bytes).unwrap();
assert_eq!(metainfo.info.name, "roundtrip");
assert_eq!(metainfo.info.total_length, 50000);
assert_eq!(metainfo.info.piece_length, 16384);
assert_eq!(metainfo.info.piece_count(), 4); assert_eq!(metainfo.comment, Some("Roundtrip test".to_string()));
}
#[test]
fn test_builder_roundtrip_v2() {
let original_data = vec![0xCD; 50000];
let torrent_bytes = TorrentBuilder::new("roundtrip_v2")
.version(TorrentVersion::V2)
.add_file("data.bin", original_data)
.piece_length(16384)
.build()
.unwrap();
let metainfo = Metainfo::from_bytes(&torrent_bytes).unwrap();
assert_eq!(metainfo.info.name, "roundtrip_v2");
assert!(metainfo.info.is_v2());
assert_eq!(metainfo.info.meta_version, Some(2));
}
}