Skip to main content

mtorrent_core/input/
magnet.rs

1use std::error::Error;
2use std::net::SocketAddr;
3use std::{iter, net, str};
4use thiserror::Error;
5
6/// Parsed magnet link.
7#[derive(Clone)]
8pub struct MagnetLink {
9    info_hash: [u8; 20],
10    name: Option<String>,
11    trackers: Vec<String>,
12    peers: Vec<SocketAddr>,
13}
14
15/// Error that can be produced while parsing a magnet link.
16#[derive(Debug, Error)]
17pub enum ParseError {
18    #[error("malformed uri: {0}")]
19    MalformedUri(#[from] url::ParseError),
20    #[error("invalid scheme: {0}")]
21    UnsupportedScheme(String),
22    #[error("no info hash")]
23    NoInfoHash,
24    #[error("invalid info hash: {0}")]
25    InvalidInfoHash(#[source] Box<dyn Error + Send + Sync>),
26    #[error("invalid peer addr: {0}")]
27    InvalidPeerAddr(#[from] net::AddrParseError),
28}
29
30impl str::FromStr for MagnetLink {
31    type Err = ParseError;
32
33    fn from_str(s: &str) -> Result<Self, Self::Err> {
34        let parsed = url::Url::parse(s)?;
35        if parsed.scheme() != "magnet" {
36            return Err(ParseError::UnsupportedScheme(parsed.scheme().to_string()));
37        }
38        let mut info_hash = None;
39        let mut name = None;
40        let mut trackers = Vec::<String>::new();
41        let mut peers = Vec::<SocketAddr>::new();
42        for (key, value) in parsed.query_pairs() {
43            match key.as_ref() {
44                "xt" => match value.strip_prefix("urn:btih:") {
45                    Some(hex_str) if hex_str.len() == 40 => {
46                        let mut bytes = [0u8; 20];
47                        for (src, dest) in
48                            iter::zip(hex_str.as_bytes().chunks_exact(2), bytes.iter_mut())
49                        {
50                            let src_str = str::from_utf8(src)
51                                .map_err(|e| ParseError::InvalidInfoHash(Box::new(e)))?;
52                            *dest = u8::from_str_radix(src_str, 16)
53                                .map_err(|e| ParseError::InvalidInfoHash(Box::new(e)))?;
54                        }
55                        info_hash = Some(bytes);
56                    }
57                    _ => return Err(ParseError::InvalidInfoHash(format!("{value}").into())),
58                },
59                "dn" => name = Some(value.to_string()),
60                "tr" => trackers.push(value.to_string()),
61                "x.pe" => peers.push(value.parse()?),
62                _ => (),
63            }
64        }
65        Ok(Self {
66            info_hash: info_hash.ok_or(ParseError::NoInfoHash)?,
67            name,
68            trackers,
69            peers,
70        })
71    }
72}
73
74impl MagnetLink {
75    /// SHA-1 hash of the torrent's metainfo.
76    pub fn info_hash(&self) -> &[u8; 20] {
77        &self.info_hash
78    }
79
80    /// Name of the torrent.
81    pub fn name(&self) -> Option<&str> {
82        self.name.as_deref()
83    }
84
85    /// Tracker addresses.
86    pub fn trackers(&self) -> impl ExactSizeIterator<Item = &str> {
87        self.trackers.iter().map(AsRef::as_ref)
88    }
89
90    /// Peer addresses.
91    pub fn peers(&self) -> impl ExactSizeIterator<Item = &SocketAddr> {
92        self.peers.iter()
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn parse_magnet_link() {
102        let link = "magnet:?xt=urn:btih:1EBD3DBFBB25C1333F51C99C7EE670FC2A1727C9&dn=Dune.Part.Two.2024.1080p.HD-TS.X264-EMIN3M%5BTGx%5D&tr=udp%3A%2F%2Fopen.stealth.si%3A80%2Fannounce&tr=udp%3A%2F%2Ftracker.tiny-vps.com%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce&tr=udp%3A%2F%2Ftracker.torrent.eu.org%3A451%2Fannounce&tr=udp%3A%2F%2Fexplodie.org%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.cyberia.is%3A6969%2Fannounce&tr=udp%3A%2F%2Fipv4.tracker.harry.lu%3A80%2Fannounce&tr=udp%3A%2F%2Fp4p.arenabg.com%3A1337%2Fannounce&tr=udp%3A%2F%2Ftracker.birkenwald.de%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.moeking.me%3A6969%2Fannounce&tr=udp%3A%2F%2Fopentor.org%3A2710%2Fannounce&tr=udp%3A%2F%2Ftracker.dler.org%3A6969%2Fannounce&tr=udp%3A%2F%2Fuploads.gamecoast.net%3A6969%2Fannounce&tr=https%3A%2F%2Ftracker.foreverpirates.co%3A443%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce&tr=http%3A%2F%2Ftracker.openbittorrent.com%3A80%2Fannounce&tr=udp%3A%2F%2Fopentracker.i2p.rocks%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.internetwarriors.net%3A1337%2Fannounce&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969%2Fannounce&tr=udp%3A%2F%2Fcoppersurfer.tk%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.zer0day.to%3A1337%2Fannounce";
103
104        let magnet = link.parse::<MagnetLink>().unwrap();
105
106        assert_eq!(
107            &[
108                30, 189, 61, 191, 187, 37, 193, 51, 63, 81, 201, 156, 126, 230, 112, 252, 42, 23,
109                39, 201
110            ],
111            magnet.info_hash()
112        );
113        assert_eq!(Some("Dune.Part.Two.2024.1080p.HD-TS.X264-EMIN3M[TGx]"), magnet.name());
114        assert_eq!(
115            vec![
116                "udp://open.stealth.si:80/announce",
117                "udp://tracker.tiny-vps.com:6969/announce",
118                "udp://tracker.opentrackr.org:1337/announce",
119                "udp://tracker.torrent.eu.org:451/announce",
120                "udp://explodie.org:6969/announce",
121                "udp://tracker.cyberia.is:6969/announce",
122                "udp://ipv4.tracker.harry.lu:80/announce",
123                "udp://p4p.arenabg.com:1337/announce",
124                "udp://tracker.birkenwald.de:6969/announce",
125                "udp://tracker.moeking.me:6969/announce",
126                "udp://opentor.org:2710/announce",
127                "udp://tracker.dler.org:6969/announce",
128                "udp://uploads.gamecoast.net:6969/announce",
129                "https://tracker.foreverpirates.co:443/announce",
130                "udp://tracker.opentrackr.org:1337/announce",
131                "http://tracker.openbittorrent.com:80/announce",
132                "udp://opentracker.i2p.rocks:6969/announce",
133                "udp://tracker.internetwarriors.net:1337/announce",
134                "udp://tracker.leechers-paradise.org:6969/announce",
135                "udp://coppersurfer.tk:6969/announce",
136                "udp://tracker.zer0day.to:1337/announce"
137            ],
138            magnet.trackers().collect::<Vec<_>>()
139        );
140    }
141
142    #[test]
143    fn parse_magnet_link_with_invalid_info_hash() {
144        let link = "magnet:?xt=urn:btih:1EBD3DBFBB25C1333F51C99C7EE670FC2A1727C"; // missing last character
145        assert!(matches!(link.parse::<MagnetLink>(), Err(ParseError::InvalidInfoHash(_))));
146
147        let link = "magnet:?xt=urn:btih:1EBD3DBFBB25C1333F51C99C7EE670FC2A1727C99"; // too many characters
148        assert!(matches!(link.parse::<MagnetLink>(), Err(ParseError::InvalidInfoHash(_))));
149    }
150}