//! 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, /// Tracker URLs from `tr=`. pub trackers: Vec, /// Web-seed URLs from `ws=`. pub web_seeds: Vec, /// Optional exact-topic or keyword field from `kt=`/`x.pe=`. pub exact_topic: Option, } /// 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, /// Additional origin or source descriptors. pub sources: Vec, } /// 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, /// Parsed peer endpoints from repeated `x.pe=` hints. pub peer_hints: Vec, /// The same `x.pe=` hints re-shaped as DHT/bootstrap nodes. pub peer_hint_nodes: Vec, } 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 { 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 { 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 { 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 { 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) -> 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:` entry. pub fn parse_magnet_uri(input: &str) -> Result { 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 { 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::, _>>()?; let peer_hint_nodes = peer_hints .iter() .map(TorrentPeerModel::to_dht_node) .collect::>(); 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 { 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, /// Tracker URLs collected from repeated `tr=` fields. trackers: Vec, /// Web-seed URLs collected from repeated `ws=` fields. web_seeds: Vec, /// Optional keyword topic decoded from `kt=`. keyword_topic: Option, /// Peer bootstrap hints collected from repeated `x.pe=` fields. peer_hints: Vec, } /// Parses raw magnet query fields while preserving repeated `x.pe=` entries. fn parse_magnet_fields(input: &str) -> Result { 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:".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::(); 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, ] ); } }