use fluent_uri::pct_enc::{encoder::Query, EStr};
use fluent_uri::{ParseError as UriParseError, Uri};
use crate::{InfoHash, InfoHashError, TorrentID, Tracker, TrackerError};
use std::str::FromStr;
#[derive(Clone, Debug, PartialEq)]
pub enum MagnetLinkError {
InvalidURI { source: UriParseError },
InvalidURINoQuery,
InvalidURIQueryUnicode,
InvalidURIQueryEmptyValue { key: String },
InvalidURIQueryInterrogation,
InvalidURINewLine,
InvalidScheme { scheme: String },
NoHashFound,
InvalidHash { source: InfoHashError },
TooManyHashes { number: usize },
DuplicateName,
#[cfg(feature = "magnet_force_name")]
NoNameFound,
InvalidTracker {
tracker: String,
source: TrackerError,
},
}
impl std::fmt::Display for MagnetLinkError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MagnetLinkError::InvalidURI { source } => {
write!(f, "Invalid URI: {source}")
}
MagnetLinkError::InvalidURINoQuery => {
write!(f, "Invalid URI: no query string")
}
MagnetLinkError::InvalidURIQueryEmptyValue { key } => {
write!(f, "Invalid URI: query has key {key} with no value")
}
MagnetLinkError::InvalidURIQueryUnicode => {
write!(f, "Invalid URI: the query part contains non-utf8 chars")
}
MagnetLinkError::InvalidURIQueryInterrogation => {
write!(f, "Invalid URI: the query part should only contain one `?`")
}
MagnetLinkError::InvalidURINewLine => {
write!(f, "Invalid URI: newlines are not allowed in magnet links")
}
MagnetLinkError::InvalidScheme { scheme } => {
write!(f, "Invalid URI scheme: {scheme}")
}
MagnetLinkError::NoHashFound => {
write!(f, "No hash found (only btih/btmh hashes are supported)")
}
MagnetLinkError::InvalidHash { source } => {
write!(f, "Invalid hash: {source}")
}
MagnetLinkError::TooManyHashes { number } => {
write!(f, "Too many hashes ({number})")
}
MagnetLinkError::DuplicateName => {
write!(
f,
"Too many name declarations for the magnet, only expecting one."
)
}
#[cfg(feature = "magnet_force_name")]
MagnetLinkError::NoNameFound => {
write!(f, "No name found")
}
MagnetLinkError::InvalidTracker { tracker, .. } => {
write!(f, "Invalid tracker: {tracker}")
}
}
}
}
impl From<InfoHashError> for MagnetLinkError {
fn from(e: InfoHashError) -> MagnetLinkError {
MagnetLinkError::InvalidHash { source: e }
}
}
impl<Input> From<(UriParseError, Input)> for MagnetLinkError {
fn from(e: (UriParseError, Input)) -> MagnetLinkError {
MagnetLinkError::InvalidURI { source: e.0 }
}
}
impl std::error::Error for MagnetLinkError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
MagnetLinkError::InvalidURI { source } => Some(source),
MagnetLinkError::InvalidHash { source } => Some(source),
MagnetLinkError::InvalidTracker { source, .. } => Some(source),
_ => None,
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg_attr(feature = "sea_orm", derive(sea_orm::DeriveValueType))]
#[cfg_attr(feature = "sea_orm", sea_orm(value_type = "String"))]
#[serde(try_from = "String")]
#[serde(into = "String")]
pub struct MagnetLink {
hash: InfoHash,
query: String,
name: String,
trackers: Vec<Tracker>,
}
impl MagnetLink {
pub fn new(s: &str) -> Result<MagnetLink, MagnetLinkError> {
if s.contains('\n') {
return Err(MagnetLinkError::InvalidURINewLine);
}
let u = Uri::parse(s.to_string())?;
MagnetLink::from_url(&u)
}
pub fn from_url(u: &Uri<String>) -> Result<MagnetLink, MagnetLinkError> {
if u.scheme().as_str() != "magnet" {
return Err(MagnetLinkError::InvalidScheme {
scheme: u.scheme().to_string(),
});
}
let mut name = String::new();
let mut hashes: Vec<String> = Vec::new();
let mut trackers: Vec<Tracker> = Vec::new();
let query = u.query().ok_or(MagnetLinkError::InvalidURINoQuery)?;
for (key, val) in Self::unsafe_parse_query(query)? {
if val.as_str().contains('?') {
return Err(MagnetLinkError::InvalidURIQueryInterrogation);
}
if val.is_empty() {
return Err(MagnetLinkError::InvalidURIQueryEmptyValue {
key: key.as_str().to_string(),
});
}
match key.as_str() {
"xt" => {
let val = val.as_str();
if val.starts_with("urn:btih:") {
hashes.push(val.strip_prefix("urn:btih:").unwrap().to_string());
} else if val.starts_with("urn:btmh:1220") {
hashes.push(val.strip_prefix("urn:btmh:1220").unwrap().to_string());
}
}
"dn" => {
if !name.is_empty() {
return Err(MagnetLinkError::DuplicateName);
}
name = val
.decode()
.to_string()
.map_err(|_| MagnetLinkError::InvalidURIQueryUnicode)?
.replace('+', " ")
.to_owned();
}
"tr" => {
let tracker_uri = val
.decode()
.to_string()
.map_err(|_| MagnetLinkError::InvalidURIQueryUnicode)?;
trackers.push(Tracker::new(&tracker_uri).map_err(|e| {
MagnetLinkError::InvalidTracker {
source: e,
tracker: tracker_uri.to_string(),
}
})?);
}
_ => {
continue;
}
}
}
#[cfg(feature = "magnet_force_name")]
if name.is_empty() {
return Err(MagnetLinkError::NoNameFound);
}
let hashes_len = hashes.len();
if hashes_len == 0 {
return Err(MagnetLinkError::NoHashFound);
}
if hashes_len > 2 {
return Err(MagnetLinkError::TooManyHashes { number: hashes_len });
}
let mut valid_hashes: Vec<InfoHash> = Vec::new();
for hash in hashes {
let valid_hash = InfoHash::new(&hash)?;
valid_hashes.push(valid_hash);
}
let final_hash = if valid_hashes.len() == 1 {
valid_hashes.first().unwrap().clone()
} else {
let (hash1, hash2) = (valid_hashes.first().unwrap(), valid_hashes.get(1).unwrap());
hash1.hybrid(hash2)?
};
trackers.sort_unstable();
trackers.dedup();
Ok(MagnetLink {
hash: final_hash,
name: name.to_string(),
query: query.as_str().to_string(),
trackers,
})
}
#[allow(clippy::type_complexity)]
pub fn unsafe_parse_query(
query: &EStr<Query>,
) -> Result<Vec<(&EStr<Query>, &EStr<Query>)>, MagnetLinkError> {
let pairs: Vec<(&EStr<Query>, &EStr<Query>)> = query
.split('&')
.map(|s| s.split_once('=').unwrap_or((s, EStr::EMPTY)))
.collect();
Ok(pairs)
}
pub fn hash(&self) -> &InfoHash {
&self.hash
}
pub fn name(&self) -> &str {
&self.name
}
pub fn id(&self) -> TorrentID {
self.hash.id()
}
pub fn trackers(&self) -> &[Tracker] {
&self.trackers
}
}
impl std::fmt::Display for MagnetLink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "magnet:?{}", self.query)
}
}
impl PartialEq for MagnetLink {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
&& self.hash == other.hash
&& self.trackers == other.trackers
}
}
impl FromStr for MagnetLink {
type Err = MagnetLinkError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
impl TryFrom<String> for MagnetLink {
type Error = MagnetLinkError;
fn try_from(s: String) -> Result<Self, Self::Error> {
Self::new(&s)
}
}
impl From<MagnetLink> for String {
fn from(m: MagnetLink) -> Self {
m.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn can_load_v1() {
let magnet_source =
std::fs::read_to_string("tests/bittorrent-v1-emma-goldman.magnet").unwrap();
let magnet = MagnetLink::new(&magnet_source).unwrap();
assert_eq!(
magnet.name,
"Goldman, Emma - Essential Works of Anarchism".to_string()
);
assert_eq!(
magnet.hash,
InfoHash::V1("c811b41641a09d192b8ed81b14064fff55d85ce3".to_string())
);
}
#[test]
fn can_load_hybrid() {
let magnet_source =
std::fs::read_to_string("tests/bittorrent-v2-hybrid-test.magnet").unwrap();
let magnet = MagnetLink::new(&magnet_source).unwrap();
assert_eq!(magnet.name, "bittorrent-v1-v2-hybrid-test");
assert_eq!(
magnet.hash,
InfoHash::Hybrid((
"631a31dd0a46257d5078c0dee4e66e26f73e42ac".to_string(),
"d8dd32ac93357c368556af3ac1d95c9d76bd0dff6fa9833ecdac3d53134efabb".to_string()
))
);
}
#[test]
fn can_load_v2() {
let magnet_source = std::fs::read_to_string("tests/bittorrent-v2-test.magnet").unwrap();
let magnet = MagnetLink::new(&magnet_source).unwrap();
assert_eq!(magnet.name, "bittorrent-v2-test".to_string());
assert_eq!(
magnet.hash,
InfoHash::V2(
"caf1e1c30e81cb361b9ee167c4aa64228a7fa4fa9f6105232b28ad099f3a302e".to_string()
)
);
}
#[test]
#[cfg(not(feature = "magnet_force_name"))]
fn can_load_without_name() {
let magnet =
MagnetLink::new("magnet:?xt=urn:btih:c811b41641a09d192b8ed81b14064fff55d85ce3")
.unwrap();
assert_eq!(magnet.name, "".to_string());
assert_eq!(
magnet.hash,
InfoHash::V1("c811b41641a09d192b8ed81b14064fff55d85ce3".to_string())
);
}
#[test]
fn fails_load_no_hash() {
let res = MagnetLink::new(
"magnet:?dn=Goldman%2c%20Emma%20-%20Essential%20Works%20of%20Anarchism",
);
assert!(res.is_err());
let err = res.unwrap_err();
assert_eq!(err, MagnetLinkError::NoHashFound);
}
#[test]
fn fails_load_too_many_hashes() {
let res = MagnetLink::new("magnet:?xt=urn:btih:c811b41641a09d192b8ed81b14064fff55d85ce3&dn=Goldman%2c%20Emma%20-%20Essential%20Works%20of%20Anarchism&xt=urn:btih:c811b41641a09d192b8ed81b14064fff55d85ce4&xt=urn:btih:c811b41641a09d192b8ed81b14064fff55d85ce5");
assert!(res.is_err());
let err = res.unwrap_err();
assert_eq!(err, MagnetLinkError::TooManyHashes { number: 3 });
}
#[test]
fn fails_load_conflicting_hash() {
let res = MagnetLink::new("magnet:?xt=urn:btih:c811b41641a09d192b8ed81b14064fff55d85ce3&dn=Goldman%2c%20Emma%20-%20Essential%20Works%20of%20Anarchism&xt=urn:btih:c811b41641a09d192b8ed81b14064fff55d85ce4");
assert!(res.is_err());
let err = res.unwrap_err();
assert_eq!(
err,
MagnetLinkError::InvalidHash {
source: InfoHashError::FailedHybrid {
hashtype: "V1".to_string()
}
}
);
}
#[test]
fn fails_load_illegal_uri_chars() {
let res = MagnetLink::new("magnet:?xt=urn:btih:c811b41641a09d192b8ed81b14064fff55d85ce3&dn=Goldman%2c%20Emma%20-%20Essential%20Works%20of%20Anarchism&xt=urn:btih:c811b41641a09d192b8ed81b14064fff55d85ce4&xt=urn:btih:c811b41641a09d192b8ed81b14064fff55d85ce5");
assert!(res.is_err());
let err = res.unwrap_err();
assert_eq!(err, MagnetLinkError::TooManyHashes { number: 3 });
}
#[test]
fn fails_load_invalid_hash_chars() {
let res = MagnetLink::new("magnet:?xt=urn:btih:c811b41641a09d192b8ed81b14064fff55d85WWW&dn=Goldman%2c%20Emma%20-%20Essential%20Works%20of%20Anarchism");
assert!(res.is_err());
let err = res.unwrap_err();
assert_eq!(
err,
MagnetLinkError::InvalidHash {
source: InfoHashError::InvalidChars {
hash: "c811b41641a09d192b8ed81b14064fff55d85WWW".to_string()
}
}
);
}
#[test]
fn fails_load_invalid_hash_length() {
let res = MagnetLink::new("magnet:?xt=urn:btih:c811b41641a09d192b8ed81b14064fff55d85ce311&dn=Goldman%2c%20Emma%20-%20Essential%20Works%20of%20Anarchism");
assert!(res.is_err());
let err = res.unwrap_err();
assert_eq!(
err,
MagnetLinkError::InvalidHash {
source: InfoHashError::InvalidLength {
len: 42,
hash: "c811b41641a09d192b8ed81b14064fff55d85ce311".to_string()
}
}
);
}
#[test]
fn fails_load_not_magnet() {
let res = MagnetLink::new("https://fr.wikipedia.org");
assert!(res.is_err());
let err = res.unwrap_err();
assert_eq!(
err,
MagnetLinkError::InvalidScheme {
scheme: "https".to_string()
}
);
}
#[test]
fn fails_newline_in_magnet() {
let mut magnet_url = std::fs::read_to_string("tests/bittorrent-v2-test.magnet").unwrap();
magnet_url.push('\n');
let res = MagnetLink::new(&magnet_url);
assert!(res.is_err());
assert_eq!(res.unwrap_err(), MagnetLinkError::InvalidURINewLine,);
}
#[test]
fn survives_roundtrip() {
let magnet_url =
Uri::parse(std::fs::read_to_string("tests/bittorrent-v2-test.magnet").unwrap())
.unwrap();
let magnet = MagnetLink::from_url(&magnet_url).unwrap();
let magnet_str = magnet.to_string();
assert_eq!(&magnet_url.to_string(), &magnet_str);
}
#[test]
fn survives_roundtrip_tracker_urlencoding() {
let magnet_str =
std::fs::read_to_string("tests/bittorrent-v1-emma-goldman.magnet").unwrap();
let magnet = MagnetLink::new(&magnet_str).unwrap();
assert_eq!(&magnet.to_string(), &magnet_str);
}
#[test]
fn can_parse_magnet_trackers() {
let expected = &[
"http://tracker.tfile.co:80/announce",
"udp://9.rarbg.me:2730/announce",
"udp://9.rarbg.me:2740/announce",
"udp://9.rarbg.me:2770/announce",
"udp://9.rarbg.to:2710/announce",
"udp://9.rarbg.to:2720/announce",
"udp://9.rarbg.to:2730/announce",
"udp://9.rarbg.to:2740/announce",
"udp://9.rarbg.to:2770/announce",
"udp://bt.xxx-tracker.com:2710/announce",
"udp://denis.stalker.upeer.me:6969/announce",
"udp://eddie4.nl:6969/announce",
"udp://exodus.desync.com:6969/announce",
"udp://ipv4.tracker.harry.lu:80/announce",
"udp://ipv6.tracker.harry.lu:80/announce",
"udp://open.demonii.si:1337/announce",
"udp://open.stealth.si:80/announce",
"udp://retracker.lanta-net.ru:2710/announce",
"udp://torrentclub.tech:6969/announce",
"udp://tracker.coppersurfer.tk:6969/announce",
"udp://tracker.cyberia.is:6969/announce",
"udp://tracker.internetwarriors.net:1337/announce",
"udp://tracker.justseed.it:1337/announce",
"udp://tracker.leechers-paradise.org:6969/announce",
"udp://tracker.mg64.net:6969/announce",
"udp://tracker.moeking.me:6969/announce",
"udp://tracker.open-internet.nl:6969/announce",
"udp://tracker.opentrackr.org:1337/announce",
"udp://tracker.pirateparty.gr:6969/announce",
"udp://tracker.port443.xyz:6969/announce",
"udp://tracker.tiny-vps.com:6969/announce",
"udp://tracker.torrent.eu.org:451/announce",
"udp://tracker.zer0day.to:1337/announce",
];
let magnet_url =
std::fs::read_to_string("tests/bittorrent-v1-emma-goldman.magnet").unwrap();
let magnet = MagnetLink::new(&magnet_url).unwrap();
let found = magnet
.trackers
.clone()
.into_iter()
.map(|tracker| tracker.url().to_string())
.collect::<Vec<_>>();
assert_eq!(found, expected);
let magnet_url = std::fs::read_to_string("tests/bittorrent-v2-test.magnet").unwrap();
let magnet = MagnetLink::new(&magnet_url).unwrap();
let found = magnet
.trackers
.into_iter()
.map(|tracker| tracker.url().to_string())
.collect::<Vec<_>>();
assert!(found.is_empty());
let magnet_url = std::fs::read_to_string("tests/bittorrent-v2-hybrid-test.magnet").unwrap();
let magnet = MagnetLink::new(&magnet_url).unwrap();
let found = magnet
.trackers
.into_iter()
.map(|tracker| tracker.url().to_string())
.collect::<Vec<_>>();
assert!(found.is_empty());
}
#[test]
fn serialization_roundtrip() {
let magnet_url = std::fs::read_to_string("tests/bittorrent-v2-test.magnet").unwrap();
let magnet = MagnetLink::new(&magnet_url).unwrap();
let json_url = serde_json::to_string(&magnet_url).unwrap();
let deserialized_magnet: MagnetLink = serde_json::from_str(&json_url).unwrap();
assert_eq!(deserialized_magnet, magnet,);
}
}