Files
aria2-rust-pro/crates/aria2-rust-pro-protocol/src/magnet.rs
T

585 lines
21 KiB
Rust

//! Magnet URI parsing and serialization helpers.
#![forbid(unsafe_code)]
use crate::bt_metalink::BtMetalinkError;
use crate::{
torrent::{PeerWireExtensionHandshakeModel, TorrentPeerModel, TorrentTrackerModel},
tracker::DhtNodeModel,
};
/// Parsed representation of a magnet URI.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MagnetUriModel {
/// `BitTorrent` info-hash extracted from `xt=urn:btih:...`.
pub info_hash: String,
/// Optional display name from `dn=`.
pub display_name: Option<String>,
/// Tracker URLs from `tr=`.
pub trackers: Vec<String>,
/// Web-seed URLs from `ws=`.
pub web_seeds: Vec<String>,
/// Optional exact-topic or keyword field from `kt=`/`x.pe=`.
pub exact_topic: Option<String>,
}
/// Higher-level magnet metadata used by callers that already know payload size.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MagnetMetadataModel {
/// Canonical parsed URI.
pub uri: MagnetUriModel,
/// Known payload length when available.
pub known_length: Option<u64>,
/// Additional origin or source descriptors.
pub sources: Vec<String>,
}
/// Fully-shaped magnet bootstrap data ready for CLI or dispatcher orchestration.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MagnetBootstrapModel {
/// Canonical parsed magnet URI model.
pub uri: MagnetUriModel,
/// Lowercase hexadecimal 20-byte `BitTorrent` info-hash.
pub info_hash_hex: String,
/// Raw 20-byte `BitTorrent` info-hash.
pub info_hash_bytes: [u8; 20],
/// Tracker rows shaped with stable tier indices.
pub trackers: Vec<TorrentTrackerModel>,
/// Parsed peer endpoints from repeated `x.pe=` hints.
pub peer_hints: Vec<TorrentPeerModel>,
/// The same `x.pe=` hints re-shaped as DHT/bootstrap nodes.
pub peer_hint_nodes: Vec<DhtNodeModel>,
}
impl MagnetUriModel {
/// Parses a magnet URI into the protocol model.
///
/// # Errors
///
/// Returns an error when the input is not a valid magnet URI.
pub fn from_uri(input: &str) -> Result<Self, BtMetalinkError> {
parse_magnet_uri(input)
}
/// Returns the number of embedded tracker URLs.
#[must_use]
pub fn tracker_count(&self) -> usize {
self.trackers.len()
}
/// Decodes the magnet `btih` token into its raw 20-byte info-hash.
///
/// # Errors
///
/// Returns an error when the magnet does not contain a valid hexadecimal or base32 BTIH.
pub fn info_hash_bytes(&self) -> Result<[u8; 20], BtMetalinkError> {
decode_btih_token(&self.info_hash)
}
/// Returns the info-hash normalized to lowercase hexadecimal.
///
/// # Errors
///
/// Returns an error when the stored BTIH token is not a valid `BitTorrent` info-hash.
pub fn canonical_info_hash_hex(&self) -> Result<String, BtMetalinkError> {
self.info_hash_bytes().map(|hash| hex_encode_lower(&hash))
}
/// Shapes tracker URLs into stable tier-indexed tracker rows.
#[must_use]
pub fn tracker_models(&self) -> Vec<TorrentTrackerModel> {
self.trackers
.iter()
.enumerate()
.map(|(index, tracker)| TorrentTrackerModel {
url: tracker.clone(),
tier: Some(u32::try_from(index).unwrap_or(u32::MAX)),
id: None,
seeders: None,
leechers: None,
})
.collect()
}
/// Builds orchestration-oriented bootstrap data from the parsed magnet model.
///
/// This variant only uses data preserved by [`MagnetUriModel`], so peer hints are empty.
///
/// # Errors
///
/// Returns an error when the stored BTIH token is invalid.
pub fn bootstrap(&self) -> Result<MagnetBootstrapModel, BtMetalinkError> {
Ok(MagnetBootstrapModel {
uri: self.clone(),
info_hash_hex: self.canonical_info_hash_hex()?,
info_hash_bytes: self.info_hash_bytes()?,
trackers: self.tracker_models(),
peer_hints: Vec::new(),
peer_hint_nodes: Vec::new(),
})
}
/// Serializes the model back into a magnet URI string.
#[must_use]
pub fn to_uri(&self) -> String {
let mut query = Vec::new();
query.push(format!(
"xt={}",
percent_encode_query_value(&format!("urn:btih:{}", self.info_hash))
));
if let Some(display_name) = &self.display_name {
query.push(format!("dn={}", percent_encode_query_value(display_name)));
}
for tracker in &self.trackers {
query.push(format!("tr={}", percent_encode_query_value(tracker)));
}
for web_seed in &self.web_seeds {
query.push(format!("ws={}", percent_encode_query_value(web_seed)));
}
if let Some(exact_topic) = &self.exact_topic {
query.push(format!("kt={}", percent_encode_query_value(exact_topic)));
}
format!("magnet:?{}", query.join("&"))
}
}
impl MagnetMetadataModel {
/// Wraps a parsed URI together with an optional known payload length.
#[must_use]
pub fn from_uri(uri: MagnetUriModel, known_length: Option<u64>) -> Self {
Self {
uri,
known_length,
sources: Vec::new(),
}
}
/// Updates the known metadata length from an extended handshake when advertised.
pub fn apply_extension_handshake(&mut self, handshake: &PeerWireExtensionHandshakeModel) {
if let Some(metadata_size) = handshake.metadata_size {
self.known_length = Some(u64::from(metadata_size));
}
}
/// Serializes the wrapped URI back into a magnet string.
#[must_use]
pub fn to_uri(&self) -> String {
self.uri.to_uri()
}
}
/// Parses a magnet URI string into a [`MagnetUriModel`].
///
/// # Errors
///
/// Returns an error when the URI is missing the `magnet:?` prefix or a valid
/// `xt=urn:btih:<hash>` entry.
pub fn parse_magnet_uri(input: &str) -> Result<MagnetUriModel, BtMetalinkError> {
parse_magnet_fields(input).map(|fields| MagnetUriModel {
info_hash: fields.info_hash,
display_name: fields.display_name,
trackers: fields.trackers,
web_seeds: fields.web_seeds,
exact_topic: fields
.keyword_topic
.or_else(|| fields.peer_hints.last().cloned()),
})
}
/// Parses a magnet URI into a richer bootstrap model for live BT orchestration.
///
/// # Errors
///
/// Returns an error when the magnet URI is malformed or the BTIH / peer hints are invalid.
pub fn parse_magnet_bootstrap(input: &str) -> Result<MagnetBootstrapModel, BtMetalinkError> {
let fields = parse_magnet_fields(input)?;
let uri = MagnetUriModel {
info_hash: fields.info_hash,
display_name: fields.display_name,
trackers: fields.trackers,
web_seeds: fields.web_seeds,
exact_topic: fields
.keyword_topic
.or_else(|| fields.peer_hints.last().cloned()),
};
let mut bootstrap = uri.bootstrap()?;
let peer_hints = fields
.peer_hints
.iter()
.map(|raw| {
TorrentPeerModel::from_endpoint(raw).map_err(|reason| BtMetalinkError::InvalidMagnet {
reason: format!("invalid x.pe peer hint {raw:?}: {reason}"),
})
})
.collect::<Result<Vec<_>, _>>()?;
let peer_hint_nodes = peer_hints
.iter()
.map(TorrentPeerModel::to_dht_node)
.collect::<Vec<_>>();
bootstrap.peer_hints = peer_hints;
bootstrap.peer_hint_nodes = peer_hint_nodes;
Ok(bootstrap)
}
/// Decodes a magnet query fragment using percent-decoding plus `+` as space.
fn percent_decode(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let bytes = input.as_bytes();
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' && index + 2 < bytes.len() {
let hi = bytes[index + 1];
let lo = bytes[index + 2];
if let (Some(hi), Some(lo)) = (hex_value(hi), hex_value(lo)) {
output.push(char::from((hi << 4) | lo));
index += 3;
continue;
}
}
if bytes[index] == b'+' {
output.push(' ');
index += 1;
continue;
}
output.push(char::from(bytes[index]));
index += 1;
}
output
}
/// Percent-encodes one magnet query value while preserving URL-safe delimiters.
fn percent_encode_query_value(input: &str) -> String {
let mut output = String::with_capacity(input.len());
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b':' | b'/' => {
output.push(char::from(byte));
}
b' ' => output.push_str("%20"),
_ => {
output.push('%');
output.push(char::from(HEX[usize::from(byte >> 4)]));
output.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
}
}
output
}
/// Uppercase hexadecimal digits used by the percent encoder.
const HEX: &[u8; 16] = b"0123456789ABCDEF";
/// Converts one ASCII hex digit into its numeric nibble value.
const fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
/// Parsed magnet query fields before they are shaped into public models.
struct MagnetParseFields {
/// Raw `btih` token captured from the `xt=` field.
info_hash: String,
/// Optional display name decoded from `dn=`.
display_name: Option<String>,
/// Tracker URLs collected from repeated `tr=` fields.
trackers: Vec<String>,
/// Web-seed URLs collected from repeated `ws=` fields.
web_seeds: Vec<String>,
/// Optional keyword topic decoded from `kt=`.
keyword_topic: Option<String>,
/// Peer bootstrap hints collected from repeated `x.pe=` fields.
peer_hints: Vec<String>,
}
/// Parses raw magnet query fields while preserving repeated `x.pe=` entries.
fn parse_magnet_fields(input: &str) -> Result<MagnetParseFields, BtMetalinkError> {
let payload = input
.strip_prefix("magnet:?")
.ok_or_else(|| BtMetalinkError::InvalidMagnet {
reason: "missing magnet:? prefix".to_owned(),
})?;
let mut info_hash = None;
let mut display_name = None;
let mut trackers = Vec::new();
let mut web_seeds = Vec::new();
let mut keyword_topic = None;
let mut peer_hints = Vec::new();
for pair in payload.split('&') {
let Some((key, value)) = pair.split_once('=') else {
continue;
};
match key {
"xt" if value.starts_with("urn:btih:") => {
info_hash = Some(value.trim_start_matches("urn:btih:").to_owned());
}
"dn" => display_name = Some(percent_decode(value)),
"tr" => trackers.push(percent_decode(value)),
"ws" => web_seeds.push(percent_decode(value)),
"kt" => keyword_topic = Some(percent_decode(value)),
"x.pe" => peer_hints.push(percent_decode(value)),
_ => {}
}
}
let info_hash = info_hash.ok_or_else(|| BtMetalinkError::InvalidMagnet {
reason: "missing xt=urn:btih:<hash>".to_owned(),
})?;
Ok(MagnetParseFields {
info_hash,
display_name,
trackers,
web_seeds,
keyword_topic,
peer_hints,
})
}
/// Decodes one BTIH token into its 20-byte info-hash form.
fn decode_btih_token(input: &str) -> Result<[u8; 20], BtMetalinkError> {
let normalized = input
.chars()
.filter(char::is_ascii_alphanumeric)
.collect::<String>();
if normalized.len() == 40 && normalized.chars().all(|ch| ch.is_ascii_hexdigit()) {
let mut out = [0_u8; 20];
for (index, chunk) in normalized.as_bytes().chunks_exact(2).enumerate() {
let hi = hex_value(chunk[0]).ok_or_else(|| BtMetalinkError::InvalidMagnet {
reason: format!("invalid hex digit in btih token: {input}"),
})?;
let lo = hex_value(chunk[1]).ok_or_else(|| BtMetalinkError::InvalidMagnet {
reason: format!("invalid hex digit in btih token: {input}"),
})?;
out[index] = (hi << 4) | lo;
}
return Ok(out);
}
if normalized.len() == 32 {
return decode_base32_btih(&normalized);
}
Err(BtMetalinkError::InvalidMagnet {
reason: format!("btih token must be 40 hex or 32 base32 characters, got {input}"),
})
}
/// Decodes an RFC 4648 base32 BTIH token into raw bytes.
fn decode_base32_btih(input: &str) -> Result<[u8; 20], BtMetalinkError> {
let mut out = [0_u8; 20];
let mut accumulator = 0_u64;
let mut bits = 0_u32;
let mut written = 0_usize;
for byte in input.bytes() {
let value = match byte {
b'A'..=b'Z' => byte - b'A',
b'a'..=b'z' => byte - b'a',
b'2'..=b'7' => byte - b'2' + 26,
_ => {
return Err(BtMetalinkError::InvalidMagnet {
reason: format!("invalid base32 digit in btih token: {input}"),
});
}
};
accumulator = (accumulator << 5) | u64::from(value);
bits += 5;
while bits >= 8 {
bits -= 8;
if written >= out.len() {
return Err(BtMetalinkError::InvalidMagnet {
reason: format!("base32 btih token decoded longer than 20 bytes: {input}"),
});
}
out[written] = u8::try_from((accumulator >> bits) & 0xff)
.expect("masked base32 byte must fit into u8");
written += 1;
}
}
if written != out.len() {
return Err(BtMetalinkError::InvalidMagnet {
reason: format!("base32 btih token decoded to {written} bytes instead of 20"),
});
}
Ok(out)
}
/// Encodes bytes as lowercase hexadecimal text.
fn hex_encode_lower(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(nibble_to_hex(byte >> 4));
out.push(nibble_to_hex(byte & 0x0f));
}
out
}
/// Formats one nibble as a lowercase hexadecimal digit.
fn nibble_to_hex(nibble: u8) -> char {
match nibble {
0..=9 => char::from(b'0' + nibble),
10..=15 => char::from(b'a' + (nibble - 10)),
_ => '?',
}
}
#[cfg(test)]
mod tests {
use super::{MagnetMetadataModel, MagnetUriModel, parse_magnet_bootstrap, parse_magnet_uri};
#[test]
fn parses_magnet_uri_into_model() {
let uri = parse_magnet_uri(
"magnet:?xt=urn:btih:0123456789abcdef&dn=Ubuntu%2024.04&tr=http%3A%2F%2Ftracker.example.org%2Fannounce&ws=https%3A%2F%2Fcdn.example.org%2Fubuntu.iso",
)
.expect("magnet uri should parse");
assert_eq!(uri.info_hash, "0123456789abcdef");
assert_eq!(uri.display_name.as_deref(), Some("Ubuntu 24.04"));
assert_eq!(uri.trackers.len(), 1);
assert_eq!(uri.web_seeds.len(), 1);
}
#[test]
fn model_constructor_tracks_torrent_count() {
let model = MagnetUriModel {
info_hash: "deadbeef".to_owned(),
display_name: None,
trackers: vec!["http://tracker.example.org/announce".to_owned()],
web_seeds: Vec::new(),
exact_topic: Some("urn:btih:deadbeef".to_owned()),
};
assert_eq!(model.tracker_count(), 1);
assert_eq!(
model.to_uri(),
"magnet:?xt=urn:btih:deadbeef&tr=http://tracker.example.org/announce&kt=urn:btih:deadbeef"
);
}
#[test]
fn magnet_metadata_wraps_uri() {
let uri = MagnetUriModel {
info_hash: "0123456789abcdef".to_owned(),
display_name: Some("Ubuntu".to_owned()),
trackers: Vec::new(),
web_seeds: Vec::new(),
exact_topic: None,
};
let metadata = MagnetMetadataModel::from_uri(uri.clone(), Some(123));
assert_eq!(metadata.uri, uri);
assert_eq!(metadata.known_length, Some(123));
assert_eq!(
metadata.to_uri(),
"magnet:?xt=urn:btih:0123456789abcdef&dn=Ubuntu"
);
}
#[test]
fn magnet_metadata_can_apply_known_length_from_extended_handshake() {
let uri = MagnetUriModel {
info_hash: "0123456789abcdef0123456789abcdef01234567".to_owned(),
display_name: Some("Ubuntu".to_owned()),
trackers: vec!["udp://tracker.example.org:6969".to_owned()],
web_seeds: Vec::new(),
exact_topic: None,
};
let mut metadata = MagnetMetadataModel::from_uri(uri, None);
let handshake = crate::torrent::PeerWireExtensionHandshakeModel {
extensions: std::collections::BTreeMap::from([("ut_metadata".to_owned(), 3_u8)]),
client_name: Some("aria2-rust-pro".to_owned()),
metadata_size: Some(48_321),
request_queue: Some(64),
};
metadata.apply_extension_handshake(&handshake);
assert_eq!(metadata.known_length, Some(48_321));
}
#[test]
fn parses_realistic_uri_with_multiple_trackers_and_keyword_topic() {
let text = "magnet:?xt=urn:btih:0123456789ABCDEF0123456789ABCDEF01234567&dn=Arch+Linux+ISO&tr=udp%3A%2F%2Ftracker.one.example%3A1337%2Fannounce&tr=https%3A%2F%2Ftracker.two.example%2Fannounce&ws=https%3A%2F%2Fcdn.example.org%2Farch.iso&kt=linux+iso";
let model = parse_magnet_uri(text).expect("realistic magnet should parse");
assert_eq!(model.info_hash, "0123456789ABCDEF0123456789ABCDEF01234567");
assert_eq!(model.display_name.as_deref(), Some("Arch Linux ISO"));
assert_eq!(
model.trackers,
vec![
"udp://tracker.one.example:1337/announce".to_owned(),
"https://tracker.two.example/announce".to_owned(),
]
);
assert_eq!(model.web_seeds, vec!["https://cdn.example.org/arch.iso"]);
assert_eq!(model.exact_topic.as_deref(), Some("linux iso"));
}
#[test]
fn parser_accepts_x_pe_and_roundtrips_as_kt() {
let parsed = parse_magnet_uri(
"magnet:?xt=urn:btih:89abcdef0123456789abcdef0123456789abcdef&dn=Ubuntu%2026.04&tr=http%3A%2F%2Ft1.example%2Fa&tr=http%3A%2F%2Ft2.example%2Fa&ws=https%3A%2F%2Fseed.example%2Fubuntu.iso&x.pe=ubuntu%20lts",
)
.expect("x.pe should parse");
assert_eq!(parsed.exact_topic.as_deref(), Some("ubuntu lts"));
assert_eq!(parsed.trackers.len(), 2);
let roundtrip = parse_magnet_uri(&parsed.to_uri()).expect("roundtrip should parse");
assert_eq!(roundtrip, parsed);
}
#[test]
fn magnet_bootstrap_normalizes_info_hash_and_extracts_peer_hints() {
let bootstrap = parse_magnet_bootstrap(
"magnet:?xt=urn:btih:00112233445566778899AABBCCDDEEFF00112233&dn=magnet-bootstrap.iso&tr=http%3A%2F%2Ftracker-a.example.org%2Fannounce&tr=udp%3A%2F%2Ftracker-b.example.org%3A6969&x.pe=198.51.100.9%3A51413&x.pe=%5B2001%3Adb8%3A%3A9%5D%3A51413",
)
.expect("bootstrap magnet should parse");
assert_eq!(
bootstrap.info_hash_hex,
"00112233445566778899aabbccddeeff00112233"
);
assert_eq!(bootstrap.info_hash_bytes[0], 0x00);
assert_eq!(bootstrap.info_hash_bytes[19], 0x33);
assert_eq!(bootstrap.trackers.len(), 2);
assert_eq!(bootstrap.trackers[0].tier, Some(0));
assert_eq!(bootstrap.trackers[1].tier, Some(1));
assert_eq!(bootstrap.peer_hints.len(), 2);
assert_eq!(bootstrap.peer_hints[0].ip, "198.51.100.9");
assert_eq!(bootstrap.peer_hints[0].port, 51413);
assert_eq!(bootstrap.peer_hints[1].ip, "2001:db8::9");
assert_eq!(bootstrap.peer_hints[1].port, 51413);
assert_eq!(bootstrap.peer_hint_nodes[0].to_spec(), "198.51.100.9:51413");
assert_eq!(
bootstrap.peer_hint_nodes[1].to_spec(),
"[2001:db8::9]:51413"
);
}
#[test]
fn magnet_info_hash_helper_decodes_base32_btih() {
let parsed =
parse_magnet_uri("magnet:?xt=urn:btih:AERUKZ4JVPG66AJDIVTYTK6N54ASGRLH&dn=base32.iso")
.expect("base32 magnet should parse");
assert_eq!(
parsed
.canonical_info_hash_hex()
.expect("base32 btih should normalize"),
"0123456789abcdef0123456789abcdef01234567"
);
assert_eq!(
parsed.info_hash_bytes().expect("base32 btih should decode"),
[
0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab,
0xcd, 0xef, 0x01, 0x23, 0x45, 0x67,
]
);
}
}