use url::Url;
use crate::error::Error;
use crate::file_selection::FileSelection;
use crate::hash::{Id20, Id32};
use crate::info_hashes::InfoHashes;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Magnet {
pub info_hashes: InfoHashes,
pub display_name: Option<String>,
pub trackers: Vec<String>,
pub peers: Vec<String>,
pub selected_files: Option<Vec<FileSelection>>,
}
impl Magnet {
pub fn info_hash(&self) -> Id20 {
self.info_hashes.best_v1()
}
pub fn is_v2(&self) -> bool {
self.info_hashes.has_v2()
}
pub fn is_hybrid(&self) -> bool {
self.info_hashes.is_hybrid()
}
pub fn parse(uri: &str) -> Result<Self, Error> {
let normalized = if let Some(rest) = uri.strip_prefix("magnet:?") {
format!("magnet://dummy?{rest}")
} else {
return Err(Error::InvalidMagnet("must start with 'magnet:?'".into()));
};
let url = Url::parse(&normalized)
.map_err(|e| Error::InvalidMagnet(format!("URL parse error: {e}")))?;
let mut v1_hash: Option<Id20> = None;
let mut v2_hash: Option<Id32> = None;
let mut display_name = None;
let mut trackers = Vec::new();
let mut peers = Vec::new();
let mut selected_files = None;
for (key, value) in url.query_pairs() {
match key.as_ref() {
"xt" => {
if let Some(hash_str) = value.strip_prefix("urn:btih:") {
v1_hash = Some(parse_v1_hash(hash_str)?);
} else if let Some(hash_str) = value.strip_prefix("urn:btmh:") {
v2_hash = Some(parse_v2_multihash(hash_str)?);
}
}
"dn" => {
display_name = Some(value.into_owned());
}
"tr" => {
trackers.push(value.into_owned());
}
"x.pe" => {
peers.push(value.into_owned());
}
"so" => {
if let Ok(sels) = FileSelection::parse(&value) {
selected_files = Some(sels);
}
}
_ => {} }
}
if v1_hash.is_none() && v2_hash.is_none() {
return Err(Error::InvalidMagnet(
"missing xt=urn:btih: or xt=urn:btmh:".into(),
));
}
let info_hashes = InfoHashes {
v1: v1_hash,
v2: v2_hash,
};
Ok(Magnet {
info_hashes,
display_name,
trackers,
peers,
selected_files,
})
}
pub fn to_uri(&self) -> String {
let mut parts = Vec::new();
if let Some(v1) = self.info_hashes.v1 {
parts.push(format!("magnet:?xt=urn:btih:{}", v1.to_hex()));
}
if let Some(v2) = self.info_hashes.v2 {
let prefix = if parts.is_empty() { "magnet:?" } else { "" };
parts.push(format!("{}xt=urn:btmh:{}", prefix, v2.to_multihash_hex()));
}
if let Some(ref name) = self.display_name {
parts.push(format!(
"dn={}",
url::form_urlencoded::byte_serialize(name.as_bytes()).collect::<String>()
));
}
for tracker in &self.trackers {
parts.push(format!(
"tr={}",
url::form_urlencoded::byte_serialize(tracker.as_bytes()).collect::<String>()
));
}
if let Some(ref sels) = self.selected_files {
parts.push(format!("so={}", FileSelection::to_so_value(sels)));
}
parts.join("&")
}
}
fn parse_v1_hash(s: &str) -> Result<Id20, Error> {
match s.len() {
40 => Id20::from_hex(s),
32 => Id20::from_base32(&s.to_ascii_uppercase()),
_ => Err(Error::InvalidMagnet(format!(
"v1 info hash must be 40 hex or 32 base32 chars, got {} chars",
s.len()
))),
}
}
fn parse_v2_multihash(s: &str) -> Result<Id32, Error> {
Id32::from_multihash_hex(s).map_err(|e| Error::InvalidMagnet(format!("v2 multihash: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_hex_magnet() {
let uri = "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d&dn=test&tr=http://tracker.example.com/announce";
let m = Magnet::parse(uri).unwrap();
assert_eq!(
m.info_hash(),
Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap()
);
assert_eq!(m.display_name.as_deref(), Some("test"));
assert_eq!(m.trackers, vec!["http://tracker.example.com/announce"]);
}
#[test]
fn parse_base32_magnet() {
let id = Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap();
let b32 = id.to_base32();
let uri = format!("magnet:?xt=urn:btih:{b32}");
let m = Magnet::parse(&uri).unwrap();
assert_eq!(m.info_hash(), id);
}
#[test]
fn parse_multiple_trackers() {
let uri = "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d&tr=http://a.com&tr=http://b.com";
let m = Magnet::parse(uri).unwrap();
assert_eq!(m.trackers.len(), 2);
}
#[test]
fn round_trip_uri() {
let uri = "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d&dn=test%20file&tr=http%3A%2F%2Ftracker.example.com%2Fannounce";
let m = Magnet::parse(uri).unwrap();
let rebuilt = m.to_uri();
let m2 = Magnet::parse(&rebuilt).unwrap();
assert_eq!(m.info_hash(), m2.info_hash());
assert_eq!(m.display_name, m2.display_name);
assert_eq!(m.trackers, m2.trackers);
}
#[test]
fn reject_invalid_magnet() {
assert!(Magnet::parse("http://example.com").is_err());
assert!(Magnet::parse("magnet:?dn=test").is_err()); }
#[test]
fn parse_v2_only_magnet() {
let hash =
Id32::from_hex("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
.unwrap();
let mh = hash.to_multihash_hex();
let uri = format!("magnet:?xt=urn:btmh:{mh}&dn=v2test");
let m = Magnet::parse(&uri).unwrap();
assert!(m.is_v2());
assert!(!m.is_hybrid());
assert_eq!(m.info_hashes.v2, Some(hash));
assert_eq!(m.display_name.as_deref(), Some("v2test"));
}
#[test]
fn parse_hybrid_magnet() {
let v1 = Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap();
let v2 = Id32::from_hex("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
.unwrap();
let uri = format!(
"magnet:?xt=urn:btih:{}&xt=urn:btmh:{}",
v1.to_hex(),
v2.to_multihash_hex()
);
let m = Magnet::parse(&uri).unwrap();
assert!(m.is_hybrid());
assert_eq!(m.info_hashes.v1, Some(v1));
assert_eq!(m.info_hashes.v2, Some(v2));
assert_eq!(m.info_hash(), v1);
}
#[test]
fn v2_round_trip() {
let hash =
Id32::from_hex("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
.unwrap();
let mh = hash.to_multihash_hex();
let uri = format!("magnet:?xt=urn:btmh:{mh}");
let m = Magnet::parse(&uri).unwrap();
let rebuilt = m.to_uri();
let m2 = Magnet::parse(&rebuilt).unwrap();
assert_eq!(m.info_hashes, m2.info_hashes);
}
#[test]
fn hybrid_round_trip() {
let v1 = Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap();
let v2 = Id32::from_hex("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
.unwrap();
let uri = format!(
"magnet:?xt=urn:btih:{}&xt=urn:btmh:{}",
v1.to_hex(),
v2.to_multihash_hex()
);
let m = Magnet::parse(&uri).unwrap();
let rebuilt = m.to_uri();
let m2 = Magnet::parse(&rebuilt).unwrap();
assert_eq!(m.info_hashes, m2.info_hashes);
}
#[test]
fn reject_no_hash() {
assert!(Magnet::parse("magnet:?dn=test").is_err());
}
#[test]
fn parse_so_parameter() {
let uri = "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d&so=0,2,4-6";
let m = Magnet::parse(uri).unwrap();
let sels = m.selected_files.unwrap();
assert_eq!(
sels,
vec![
crate::file_selection::FileSelection::Single(0),
crate::file_selection::FileSelection::Single(2),
crate::file_selection::FileSelection::Range(4, 6),
]
);
}
#[test]
fn so_parameter_round_trip() {
let uri = "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d&so=0,2,4-6";
let m = Magnet::parse(uri).unwrap();
let rebuilt = m.to_uri();
let m2 = Magnet::parse(&rebuilt).unwrap();
assert_eq!(m.selected_files, m2.selected_files);
}
#[test]
fn no_so_parameter_is_none() {
let uri = "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d";
let m = Magnet::parse(uri).unwrap();
assert!(m.selected_files.is_none());
}
#[test]
fn invalid_so_parameter_ignored() {
let uri = "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d&so=abc";
let m = Magnet::parse(uri).unwrap();
assert!(m.selected_files.is_none());
}
#[test]
fn v1_only_backward_compat() {
let uri = "magnet:?xt=urn:btih:aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d";
let m = Magnet::parse(uri).unwrap();
assert!(!m.is_v2());
assert!(!m.is_hybrid());
assert_eq!(
m.info_hash(),
Id20::from_hex("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d").unwrap()
);
}
}