186 lines
6.4 KiB
Rust
186 lines
6.4 KiB
Rust
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(),
|
|
}
|
|
}
|