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 { 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 { 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::>() .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 { 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 { 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 { 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(), } }