use crate::common::*;
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct MagnetLink {
pub(crate) indices: BTreeSet<u64>,
pub(crate) infohash: Infohash,
pub(crate) name: Option<String>,
pub(crate) peers: Vec<HostPort>,
pub(crate) trackers: Vec<Url>,
}
impl MagnetLink {
pub(crate) fn from_metainfo_lossy(metainfo: &Metainfo) -> Result<MagnetLink> {
let mut link = Self::with_infohash(metainfo.infohash_lossy()?);
link.set_name(metainfo.info.name.clone());
for tracker in metainfo.trackers() {
link.add_tracker(tracker?);
}
Ok(link)
}
pub(crate) fn with_infohash(infohash: Infohash) -> Self {
MagnetLink {
infohash,
name: None,
peers: Vec::new(),
trackers: Vec::new(),
indices: BTreeSet::new(),
}
}
pub(crate) fn set_name(&mut self, name: impl Into<String>) {
self.name = Some(name.into());
}
pub(crate) fn add_peer(&mut self, peer: HostPort) {
self.peers.push(peer);
}
pub(crate) fn add_tracker(&mut self, tracker: Url) {
self.trackers.push(tracker);
}
pub(crate) fn add_index(&mut self, index: u64) {
self.indices.insert(index);
}
pub(crate) fn to_url(&self) -> Url {
let mut url = Url::parse("magnet:").invariant_unwrap("`magnet:` is valid URL");
let mut query = format!("xt=urn:btih:{}", self.infohash);
let mut append = |key: &str, value: &str| {
query.push('&');
query.push_str(key);
query.push('=');
query.push_str(&Self::percent_encode_query_param(value));
};
if let Some(name) = &self.name {
append("dn", name);
}
for tracker in &self.trackers {
append("tr", tracker.as_str());
}
for peer in &self.peers {
append("x.pe", &peer.to_string());
}
if !self.indices.is_empty() {
let indices = self
.indices
.iter()
.map(ToString::to_string)
.collect::<Vec<String>>()
.join(",");
append("so", &indices);
}
url.set_query(Some(&query));
url
}
fn parse(text: &str) -> Result<Self, MagnetLinkParseError> {
let url = Url::parse(text).context(magnet_link_parse_error::Url)?;
if url.scheme() != "magnet" {
return Err(MagnetLinkParseError::Scheme {
scheme: url.scheme().into(),
});
}
let mut link = None;
for (k, v) in url.query_pairs() {
if k.as_ref() == "xt" {
if let Some(infohash) = v.strip_prefix("urn:btih:") {
if infohash.len() != 40 {
return Err(MagnetLinkParseError::InfohashLength {
text: infohash.into(),
});
}
let buf = hex::decode(infohash).context(magnet_link_parse_error::HexParse {
text: infohash.to_owned(),
})?;
link = Some(MagnetLink::with_infohash(
Sha1Digest::from_bytes(
buf
.as_slice()
.try_into()
.invariant_unwrap("bounds are checked above"),
)
.into(),
));
break;
}
}
}
let mut link = link.ok_or(MagnetLinkParseError::TopicMissing)?;
for (k, v) in url.query_pairs() {
match k.as_ref() {
"tr" => link.add_tracker(Url::parse(&v).context(
magnet_link_parse_error::TrackerAddress {
text: v.to_string(),
},
)?),
"dn" => link.set_name(v),
"x.pe" => link.add_peer(HostPort::from_str(&v).context(
magnet_link_parse_error::PeerAddress {
text: v.to_string(),
},
)?),
_ => {}
}
}
Ok(link)
}
fn percent_encode_query_param(s: &str) -> String {
const ENCODE: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'&')
.add(b'<')
.add(b'=')
.add(b'>')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
percent_encoding::utf8_percent_encode(s, ENCODE).to_string()
}
}
impl FromStr for MagnetLink {
type Err = Error;
fn from_str(text: &str) -> Result<Self, Self::Err> {
Self::parse(text).context(error::MagnetLinkParse { text })
}
}
impl Display for MagnetLink {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
write!(f, "{}", self.to_url())
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn display() {
let link = MagnetLink::with_infohash(Infohash::from_bencoded_info_dict("".as_bytes()));
assert_eq!(
link.to_string(),
"magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709"
);
}
#[test]
fn basic() {
let link = MagnetLink::with_infohash(Infohash::from_bencoded_info_dict("".as_bytes()));
assert_eq!(
link.to_url().as_str(),
"magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709"
);
}
#[test]
fn with_name() {
let mut link = MagnetLink::with_infohash(Infohash::from_bencoded_info_dict("".as_bytes()));
link.set_name("foo");
assert_eq!(
link.to_url().as_str(),
"magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709&dn=foo"
);
}
#[test]
fn with_peer() {
let mut link = MagnetLink::with_infohash(Infohash::from_bencoded_info_dict("".as_bytes()));
link.add_peer("foo.com:1337".parse().unwrap());
assert_eq!(
link.to_url().as_str(),
"magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709&x.pe=foo.com:1337"
);
}
#[test]
fn with_tracker() {
let mut link = MagnetLink::with_infohash(Infohash::from_bencoded_info_dict("".as_bytes()));
link.add_tracker(Url::parse("http://foo.com/announce").unwrap());
assert_eq!(
link.to_url().as_str(),
"magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709&tr=http://foo.com/announce"
);
}
#[test]
fn with_indices() {
let mut link = MagnetLink::with_infohash(Infohash::from_bencoded_info_dict("".as_bytes()));
link.add_index(4);
link.add_index(6);
link.add_index(6);
link.add_index(2);
assert_eq!(
link.to_url().as_str(),
"magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709&so=2,4,6"
);
}
#[test]
fn complex() {
let mut link = MagnetLink::with_infohash(Infohash::from_bencoded_info_dict("".as_bytes()));
link.set_name("foo");
link.add_tracker(Url::parse("http://foo.com/announce").unwrap());
link.add_tracker(Url::parse("http://bar.net/announce").unwrap());
link.add_peer("foo.com:1337".parse().unwrap());
link.add_peer("bar.net:666".parse().unwrap());
assert_eq!(
link.to_url().as_str(),
concat!(
"magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709",
"&dn=foo",
"&tr=http://foo.com/announce",
"&tr=http://bar.net/announce",
"&x.pe=foo.com:1337",
"&x.pe=bar.net:666",
),
);
}
#[test]
fn link_from_str_round_trip() {
let mut link_to = MagnetLink::with_infohash(Infohash::from_bencoded_info_dict("".as_bytes()));
link_to.set_name("foo");
link_to.add_tracker(Url::parse("http://foo.com/announce").unwrap());
link_to.add_tracker(Url::parse("http://bar.net/announce").unwrap());
link_to.add_peer("foo.com:1337".parse().unwrap());
link_to.add_peer("bar.net:666".parse().unwrap());
let link_from = MagnetLink::from_str(link_to.to_url().as_ref()).unwrap();
assert_eq!(link_to, link_from);
}
#[test]
fn link_from_str_tracker_round_trip() {
let magnet_str = concat!(
"magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709",
"&dn=foo",
"&tr=http://foo.com/announce",
"&tr=http://bar.net/announce"
);
let link_from = MagnetLink::from_str(magnet_str).unwrap();
let link_roundtripped = MagnetLink::from_str(&link_from.to_string()).unwrap();
assert_eq!(link_from, link_roundtripped,);
}
#[test]
fn link_from_str_tracker_urlencoding() {
let magnet_str = concat!(
"magnet:?xt=urn:btih:da39a3ee5e6b4b0d3255bfef95601890afd80709",
"&dn=foo",
"&tr=http://foo.com/announce",
);
let link_from = MagnetLink::from_str(magnet_str).unwrap();
let tracker_url = link_from.trackers.first().unwrap();
assert_eq!(
tracker_url,
&"http://foo.com/announce".parse::<Url>().unwrap(),
);
}
#[test]
fn link_from_str_url_error() {
let link = "%imdl.io";
let e = MagnetLink::from_str(link).unwrap_err();
assert_matches!(e, Error::MagnetLinkParse {
text,
source: MagnetLinkParseError::Url { .. },
} if text == link);
}
#[test]
fn link_from_str_scheme_error() {
let link = "mailto:?alice@imdl.io";
let e = MagnetLink::from_str(link).unwrap_err();
assert_matches!(e, Error::MagnetLinkParse {
text,
source: MagnetLinkParseError::Scheme { scheme },
} if text == link && scheme == "mailto");
}
#[test]
fn link_from_str_infohash_length_error() {
let infohash = "123456789abcedf";
let link = format!("magnet:?xt=urn:btih:{infohash}");
let e = MagnetLink::from_str(&link).unwrap_err();
assert_matches!(e, Error::MagnetLinkParse {
text,
source: MagnetLinkParseError::InfohashLength { text: ih },
} if text == link && infohash == ih);
}
#[test]
fn link_from_str_infohash_bad_hex() {
let infohash = "laaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let link = format!("magnet:?xt=urn:btih:{infohash}");
let e = MagnetLink::from_str(&link).unwrap_err();
assert_matches!(e, Error::MagnetLinkParse {
text,
source: MagnetLinkParseError::HexParse {
text: ih,
..
}} if text == link && infohash == ih);
}
#[test]
fn link_from_str_topic_missing() {
let link = "magnet:?";
let e = MagnetLink::from_str(link).unwrap_err();
assert_matches!(e,
Error::MagnetLinkParse {
text,
source: MagnetLinkParseError::TopicMissing,
} if text == link);
}
#[test]
fn link_from_str_tracker_address() {
let infohash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let bad_addr = "%imdl.io/announce";
let link = format!("magnet:?xt=urn:btih:{infohash}&tr={bad_addr}");
let e = MagnetLink::from_str(&link).unwrap_err();
assert_matches!(e,
Error::MagnetLinkParse {
text,
source: MagnetLinkParseError::TrackerAddress {
text: addr,
..
},
} if text == link && addr == bad_addr);
}
#[test]
fn link_from_str_peer_address() {
let infohash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let bad_addr = "%imdl.io:13337";
let link = format!("magnet:?xt=urn:btih:{infohash}&x.pe={bad_addr}");
let e = MagnetLink::from_str(&link).unwrap_err();
assert_matches!(e,
Error::MagnetLinkParse {
text,
source: MagnetLinkParseError::PeerAddress {
text: addr,
..
}
} if text == link && addr == bad_addr
);
}
#[test]
fn magnet_link_query_params_are_percent_encoded() {
let mut e = "magnet:?xt=urn:btih:0000000000000000000000000000000000000000"
.parse::<MagnetLink>()
.unwrap();
e.set_name("foo bar");
e.add_tracker("http://[::]".parse().unwrap());
e.add_peer("[::]:0".parse().unwrap());
assert_eq!(
e.to_url().as_str(),
concat!(
"magnet:",
"?xt=urn:btih:0000000000000000000000000000000000000000",
"&dn=foo%20bar",
"&tr=http://%5B::%5D/",
"&x.pe=%5B::%5D:0",
),
);
}
#[test]
fn percent_encode() {
let mut safe = "/?".to_string();
safe.push_str(":@");
for c in 'a'..='z' {
safe.push(c);
}
for c in 'A'..='Z' {
safe.push(c);
}
for c in '0'..='9' {
safe.push(c);
}
safe.push_str("-._~");
safe.push_str("!$'()*+,;");
for c in '\u{0}'..='\u{80}' {
let s = c.to_string();
if safe.contains(c) {
assert_eq!(MagnetLink::percent_encode_query_param(&s), s);
} else {
assert_eq!(
MagnetLink::percent_encode_query_param(&s),
s.bytes()
.map(|byte| format!("%{byte:02X}"))
.collect::<String>(),
);
}
}
}
}