use std::fmt::{self, Write};
use std::str::FromStr;
use crate::error::{Error, ErrorKind};
use crate::metainfo::{Metainfo, Mode};
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct MagnetUri {
pub info_hashes: Vec<InfoHash>,
pub display_name: Option<String>,
pub trackers: Vec<String>,
pub web_seeds: Vec<String>,
pub exact_source: Option<String>,
pub acceptable_source: Option<String>,
pub keyword_topic: Option<String>,
pub manifest_topic: Option<String>,
pub exact_length: Option<u64>,
pub peers: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct InfoHash {
pub bytes: [u8; 20],
pub raw: String,
}
impl FromStr for MagnetUri {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
tracing::debug!("parsing magnet URI: {}", s);
let s = s.trim();
let body = s
.strip_prefix("magnet:")
.or_else(|| {
if s.len() > 7 && s[..7].eq_ignore_ascii_case("magnet:") {
Some(&s[7..])
} else {
None
}
})
.ok_or(Error::new(ErrorKind::InvalidInput))?
.strip_prefix('?')
.ok_or(Error::new(ErrorKind::InvalidInput))?;
let mut info_hashes = Vec::new();
let mut display_name = None;
let mut trackers = Vec::new();
let mut web_seeds = Vec::new();
let mut exact_source = None;
let mut acceptable_source = None;
let mut keyword_topic = None;
let mut manifest_topic = None;
let mut exact_length = None;
let mut peers = Vec::new();
for param in body.split('&') {
if param.is_empty() {
continue;
}
let (key, value) = match param.split_once('=') {
Some((k, v)) => (k, v),
None => continue,
};
match key {
"xt" => {
if let Some(hash) = parse_xt(value) {
info_hashes.push(hash);
}
}
"dn" => {
display_name = Some(url_decode(value));
}
"tr" => {
trackers.push(url_decode(value));
}
"ws" => {
web_seeds.push(url_decode(value));
}
"xs" => {
exact_source = Some(url_decode(value));
}
"as" => {
acceptable_source = Some(url_decode(value));
}
"kt" => {
keyword_topic = Some(url_decode(value));
}
"mt" => {
manifest_topic = Some(url_decode(value));
}
"x.pe" => {
peers.push(url_decode(value));
}
"xl" => {
exact_length = value.parse::<u64>().ok();
}
_ => {
}
}
}
if info_hashes.is_empty() {
return Err(Error::new(ErrorKind::InvalidInput));
}
Ok(MagnetUri {
info_hashes,
display_name,
trackers,
web_seeds,
exact_source,
acceptable_source,
keyword_topic,
manifest_topic,
exact_length,
peers,
})
}
}
impl From<&Metainfo> for MagnetUri {
fn from(meta: &Metainfo) -> Self {
let ih = meta.info_hash();
MagnetUri {
info_hashes: vec![InfoHash {
bytes: ih,
raw: hex_encode(ih),
}],
display_name: Some(match &meta.info.mode {
Mode::Single { name, .. } | Mode::Multiple { name, .. } => name.clone(),
}),
exact_length: Some(meta.info.total_size()),
trackers: std::iter::once(meta.announce.clone())
.chain(meta.announce_list.iter().flatten().cloned())
.collect(),
web_seeds: Vec::new(),
exact_source: None,
acceptable_source: None,
keyword_topic: None,
manifest_topic: None,
peers: Vec::new(),
}
}
}
impl fmt::Display for MagnetUri {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "magnet:?")?;
let mut first = true;
for xt in &self.info_hashes {
if !first {
write!(f, "&")?;
}
write!(f, "xt=urn:btih:{}", xt.raw)?;
first = false;
}
if let Some(ref dn) = self.display_name {
if !first {
write!(f, "&")?;
}
write!(f, "dn={}", url_encode(dn))?;
first = false;
}
for tr in &self.trackers {
if !first {
write!(f, "&")?;
}
write!(f, "tr={}", url_encode(tr))?;
first = false;
}
for ws in &self.web_seeds {
write!(f, "&ws={}", url_encode(ws))?;
}
if let Some(ref xs) = self.exact_source {
write!(f, "&xs={}", url_encode(xs))?;
}
if let Some(ref a) = self.acceptable_source {
write!(f, "&as={}", url_encode(a))?;
}
if let Some(ref kt) = self.keyword_topic {
write!(f, "&kt={}", url_encode(kt))?;
}
if let Some(ref mt) = self.manifest_topic {
write!(f, "&mt={}", url_encode(mt))?;
}
for peer in &self.peers {
write!(f, "&x.pe={}", url_encode(peer))?;
}
if let Some(xl) = self.exact_length {
write!(f, "&xl={}", xl)?;
}
Ok(())
}
}
impl MagnetUri {
pub fn primary_info_hash(&self) -> &[u8; 20] {
&self.info_hashes[0].bytes
}
}
fn parse_xt(value: &str) -> Option<InfoHash> {
let hash_str = value.strip_prefix("urn:btih:")?;
let raw = hash_str.to_string();
let bytes = if hash_str.len() == 40 {
hex_decode(hash_str).ok()
} else if hash_str.len() == 32 {
base32_decode(hash_str).ok()
} else {
return None;
}?;
Some(InfoHash { bytes, raw })
}
#[doc(hidden)]
pub fn hex_encode(bytes: [u8; 20]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
fn hex_decode(s: &str) -> Result<[u8; 20], Error> {
if s.len() != 40 {
return Err(Error::new(ErrorKind::InvalidInput));
}
let mut out = [0u8; 20];
for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
let hi = hex_val(chunk[0])?;
let lo = hex_val(chunk[1])?;
out[i] = (hi << 4) | lo;
}
Ok(out)
}
fn hex_val(b: u8) -> Result<u8, Error> {
match b {
b'0'..=b'9' => Ok(b - b'0'),
b'a'..=b'f' => Ok(b - b'a' + 10),
b'A'..=b'F' => Ok(b - b'A' + 10),
_ => Err(Error::new(ErrorKind::InvalidInput)),
}
}
fn base32_decode(s: &str) -> Result<[u8; 20], Error> {
if s.len() != 32 {
return Err(Error::new(ErrorKind::InvalidInput));
}
let mut out = [0u8; 20];
let bytes = s.as_bytes();
for chunk_idx in 0..4 {
let offset = chunk_idx * 8;
let mut buf: u64 = 0;
for j in 0..8 {
let c = bytes[offset + j];
let val = base32_val(c)?;
buf = (buf << 5) | val as u64;
}
let dst = chunk_idx * 5;
out[dst] = ((buf >> 32) & 0xFF) as u8;
out[dst + 1] = ((buf >> 24) & 0xFF) as u8;
out[dst + 2] = ((buf >> 16) & 0xFF) as u8;
out[dst + 3] = ((buf >> 8) & 0xFF) as u8;
out[dst + 4] = (buf & 0xFF) as u8;
}
Ok(out)
}
fn base32_val(c: u8) -> Result<u8, Error> {
match c {
b'A'..=b'Z' => Ok(c - b'A'),
b'a'..=b'z' => Ok(c - b'a'),
b'2'..=b'7' => Ok(c - b'2' + 26),
_ => Err(Error::new(ErrorKind::InvalidInput)),
}
}
fn url_decode(s: &str) -> String {
let mut buf = Vec::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& i + 2 < bytes.len()
&& let (Ok(hi), Ok(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
{
buf.push((hi << 4) | lo);
i += 3;
continue;
}
buf.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&buf).into_owned()
}
fn url_encode(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
result.push(*b as char);
}
_ => {
write!(result, "%{:02X}", b).unwrap();
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_decode_valid() {
let result = hex_decode("0123456789abcdef0123456789abcdef01234567").unwrap();
assert_eq!(result[0], 0x01);
assert_eq!(result[1], 0x23);
assert_eq!(result[19], 0x67);
}
#[test]
fn hex_decode_invalid_length() {
assert!(hex_decode("abc").is_err());
}
#[test]
fn base32_decode_valid() {
let result = base32_decode("64wsmv3zsbx5fve2sn5zxdq5w22lfpxy").unwrap();
assert_eq!(result.len(), 20);
}
#[test]
fn base32_decode_invalid_length() {
assert!(base32_decode("abc").is_err());
}
#[test]
fn url_decode_percent() {
assert_eq!(url_decode("hello%20world"), "hello world");
}
#[test]
fn url_decode_no_encoding() {
assert_eq!(url_decode("hello world"), "hello world");
}
#[test]
fn parse_xl_parameter() {
use std::str::FromStr;
let uri = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&xl=1024";
let magnet = MagnetUri::from_str(uri).unwrap();
assert_eq!(magnet.exact_length, Some(1024));
}
#[test]
fn metainfo_to_magnet() {
use crate::metainfo::{Bytes, Info, Metainfo, Mode, RawInfo};
let info = Info {
piece_length: 262144,
pieces: vec![[0u8; 20]],
mode: Mode::Single {
name: "test.txt".into(),
length: 1024,
},
raw_info: RawInfo::Bytes(Bytes::from_static(b"d4:infod...e")),
};
let meta = Metainfo {
announce: "http://tracker.example.com/announce".into(),
announce_list: vec![],
info,
creation_date: None,
comment: None,
created_by: None,
encoding: None,
};
let magnet = MagnetUri::from(&meta);
assert_eq!(magnet.info_hashes.len(), 1);
assert_eq!(magnet.display_name.as_deref(), Some("test.txt"));
assert_eq!(magnet.exact_length, Some(1024));
assert_eq!(magnet.trackers.len(), 1);
assert_eq!(magnet.trackers[0], "http://tracker.example.com/announce");
}
#[test]
fn parse_magnet_ws() {
let uri = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567\
&ws=http://example.com/file";
let magnet = MagnetUri::from_str(uri).unwrap();
assert_eq!(magnet.web_seeds, vec!["http://example.com/file"]);
}
#[test]
fn parse_magnet_xs() {
let uri = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567\
&xs=urn:sha1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let magnet = MagnetUri::from_str(uri).unwrap();
assert_eq!(
magnet.exact_source.as_deref(),
Some("urn:sha1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
);
}
#[test]
fn parse_magnet_as() {
let uri = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567\
&as=http://alt.example.com/file";
let magnet = MagnetUri::from_str(uri).unwrap();
assert_eq!(
magnet.acceptable_source.as_deref(),
Some("http://alt.example.com/file")
);
}
#[test]
fn parse_magnet_kt_mt() {
let uri = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567\
&kt=keyword1+keyword2&mt=http://manifest.example.com";
let magnet = MagnetUri::from_str(uri).unwrap();
assert_eq!(magnet.keyword_topic.as_deref(), Some("keyword1+keyword2"));
assert_eq!(
magnet.manifest_topic.as_deref(),
Some("http://manifest.example.com")
);
}
#[test]
fn parse_x_pe_single() {
let uri = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567\
&x.pe=192.168.1.1:6881";
let magnet = MagnetUri::from_str(uri).unwrap();
assert_eq!(magnet.peers, vec!["192.168.1.1:6881"]);
}
#[test]
fn parse_x_pe_multiple() {
let uri = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567\
&x.pe=192.168.1.1:6881&x.pe=10.0.0.1:6882";
let magnet = MagnetUri::from_str(uri).unwrap();
assert_eq!(magnet.peers, vec!["192.168.1.1:6881", "10.0.0.1:6882"]);
}
#[test]
fn parse_x_pe_ipv6() {
let uri = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567\
&x.pe=%5B%3A%3A1%5D%3A6881"; let magnet = MagnetUri::from_str(uri).unwrap();
assert_eq!(magnet.peers, vec!["[::1]:6881"]);
}
#[test]
fn roundtrip_x_pe() {
let uri = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567\
&x.pe=192.168.1.1%3A6881";
let magnet = MagnetUri::from_str(uri).unwrap();
assert_eq!(magnet.peers, vec!["192.168.1.1:6881"]);
let magnet2 = magnet.to_string().parse::<MagnetUri>().unwrap();
assert_eq!(magnet, magnet2);
}
#[test]
fn parse_magnet_all_params() {
let uri = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567\
&dn=Test+File\
&tr=http://t1.com/ann\
&tr=http://t2.com/ann\
&ws=http://webseed.example.com/data\
&xs=urn:sha1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\
&as=http://alt.example.com/data\
&kt=test+keyword\
&mt=http://manifest.example.com\
&xl=4096\
&x.pe=1.2.3.4%3A6881";
let magnet = MagnetUri::from_str(uri).unwrap();
assert_eq!(magnet.info_hashes.len(), 1);
assert_eq!(magnet.display_name.as_deref(), Some("Test+File"));
assert_eq!(magnet.trackers.len(), 2);
assert_eq!(magnet.web_seeds.len(), 1);
assert!(magnet.exact_source.is_some());
assert!(magnet.acceptable_source.is_some());
assert!(magnet.keyword_topic.is_some());
assert!(magnet.manifest_topic.is_some());
assert_eq!(magnet.exact_length, Some(4096));
assert_eq!(magnet.peers, vec!["1.2.3.4:6881"]);
}
#[test]
fn display_all_params() {
let uri = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567\
&dn=Test%20File\
&tr=http%3A%2F%2Ft.com%2Fann\
&ws=http%3A%2F%2Fweb.example.com\
&xs=urn%3Asha1%3AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\
&as=http%3A%2F%2Falt.example.com\
&kt=k\
&mt=http%3A%2F%2Fm.example.com\
&xl=2048";
let magnet = MagnetUri::from_str(uri).unwrap();
let displayed = magnet.to_string();
assert!(displayed.contains("dn=Test%20File"));
assert!(displayed.contains("tr=http%3A%2F%2Ft.com%2Fann"));
assert!(displayed.contains("ws=http%3A%2F%2Fweb.example.com"));
assert!(displayed.contains("xt=urn:btih:0123456789abcdef0123456789abcdef01234567"));
assert!(displayed.contains("xl=2048"));
}
#[test]
fn roundtrip_percent_encoded() {
let uri = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567\
&dn=Test%20File%21\
&tr=http%3A%2F%2Ft.com%3A8080%2Fann%3Fkey%3Dval";
let magnet = MagnetUri::from_str(uri).unwrap();
let magnet2 = magnet.to_string().parse::<MagnetUri>().unwrap();
assert_eq!(magnet, magnet2);
}
#[test]
fn roundtrip_unicode_dn() {
let uri = "magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc\
&dn=%E2%98%83%20snowman"; let magnet = MagnetUri::from_str(uri).unwrap();
let encoded = magnet.to_string();
assert!(encoded.is_ascii());
assert!(encoded.contains("dn=%E2%98%83%20snowman"));
let magnet2 = encoded.parse::<MagnetUri>().unwrap();
assert_eq!(magnet, magnet2);
}
#[test]
fn reject_xt_wrong_prefix() {
let uri = "magnet:?xt=urn:sha1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
assert!(MagnetUri::from_str(uri).is_err());
}
#[test]
fn reject_xt_hex_wrong_length() {
let uri = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef0123456";
assert!(MagnetUri::from_str(uri).is_err());
}
#[test]
fn reject_xt_base32_wrong_length() {
let uri = "magnet:?xt=urn:btih:64wsmv3zsbx5fve2sn5zxdq5w22lfpx";
assert!(MagnetUri::from_str(uri).is_err());
}
#[test]
fn reject_xt_invalid_length() {
let uri = "magnet:?xt=urn:btih:short";
assert!(MagnetUri::from_str(uri).is_err());
}
#[test]
fn url_decode_multiple_percents() {
assert_eq!(url_decode("hello%20world%21"), "hello world!");
}
#[test]
fn url_decode_incomplete_percent() {
assert_eq!(url_decode("hello%"), "hello%");
}
#[test]
fn url_decode_truncated_percent() {
assert_eq!(url_decode("hello%2"), "hello%2");
}
#[test]
fn url_decode_invalid_hex() {
assert_eq!(url_decode("hello%ZZworld"), "hello%ZZworld");
}
#[test]
fn url_decode_partial_hex() {
assert_eq!(url_decode("hello%2gworld"), "hello%2gworld");
}
#[test]
fn primary_info_hash_returns_first() {
let uri = "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\
&xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
let magnet = MagnetUri::from_str(uri).unwrap();
let primary = magnet.primary_info_hash();
assert_eq!(
primary,
&[
0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa,
0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa,
]
);
}
#[test]
fn magnet_with_percent_encoded_dn() {
let uri = "magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc\
&dn=%E2%98%83%20snowman"; let magnet = MagnetUri::from_str(uri).unwrap();
let name = magnet.display_name.unwrap();
assert_eq!(name, "\u{2603} snowman");
}
}
#[cfg(all(test, feature = "serde"))]
mod serde_tests {
use super::*;
#[test]
fn magnet_uri_roundtrip_minimal() {
let uri = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567";
let magnet = MagnetUri::from_str(uri).unwrap();
let json = serde_json::to_string(&magnet).unwrap();
let back: MagnetUri = serde_json::from_str(&json).unwrap();
assert_eq!(back, magnet);
}
#[test]
fn magnet_uri_roundtrip_full() {
let uri = "magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\
&dn=Test%20File\
&tr=http%3A%2F%2Ft.com%2Fann\
&tr=http%3A%2F%2Ft2.com%2Fann\
&ws=http%3A%2F%2Fweb.example.com\
&xs=urn%3Asha1%3AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\
&as=http%3A%2F%2Falt.example.com\
&kt=test+keyword\
&mt=http%3A%2F%2Fm.example.com\
&xl=4096\
&x.pe=1.2.3.4%3A6881";
let magnet = MagnetUri::from_str(uri).unwrap();
let json = serde_json::to_string(&magnet).unwrap();
let back: MagnetUri = serde_json::from_str(&json).unwrap();
assert_eq!(back, magnet);
}
#[test]
fn info_hash_roundtrip() {
let ih = InfoHash {
bytes: [0x42; 20],
raw: "4242424242424242424242424242424242424242".into(),
};
let json = serde_json::to_string(&ih).unwrap();
let back: InfoHash = serde_json::from_str(&json).unwrap();
assert_eq!(back.bytes, ih.bytes);
assert_eq!(back.raw, ih.raw);
}
}