mtorrent-core 0.5.4

Fundamentals for building asynchronous BitTorrent clients
Documentation
use std::error::Error;
use std::net::SocketAddr;
use std::{iter, net, str};
use thiserror::Error;

/// Parsed magnet link.
#[derive(Clone)]
pub struct MagnetLink {
    info_hash: [u8; 20],
    name: Option<String>,
    trackers: Vec<String>,
    peers: Vec<SocketAddr>,
}

/// Error that can be produced while parsing a magnet link.
#[derive(Debug, Error)]
pub enum ParseError {
    #[error("malformed uri: {0}")]
    MalformedUri(#[from] url::ParseError),
    #[error("invalid scheme: {0}")]
    UnsupportedScheme(String),
    #[error("no info hash")]
    NoInfoHash,
    #[error("invalid info hash: {0}")]
    InvalidInfoHash(#[source] Box<dyn Error + Send + Sync>),
    #[error("invalid peer addr: {0}")]
    InvalidPeerAddr(#[from] net::AddrParseError),
}

impl str::FromStr for MagnetLink {
    type Err = ParseError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let parsed = url::Url::parse(s)?;
        if parsed.scheme() != "magnet" {
            return Err(ParseError::UnsupportedScheme(parsed.scheme().to_string()));
        }
        let mut info_hash = None;
        let mut name = None;
        let mut trackers = Vec::<String>::new();
        let mut peers = Vec::<SocketAddr>::new();
        for (key, value) in parsed.query_pairs() {
            match key.as_ref() {
                "xt" => match value.strip_prefix("urn:btih:") {
                    Some(hex_str) if hex_str.len() == 40 => {
                        let mut bytes = [0u8; 20];
                        for (src, dest) in
                            iter::zip(hex_str.as_bytes().chunks_exact(2), bytes.iter_mut())
                        {
                            let src_str = str::from_utf8(src)
                                .map_err(|e| ParseError::InvalidInfoHash(Box::new(e)))?;
                            *dest = u8::from_str_radix(src_str, 16)
                                .map_err(|e| ParseError::InvalidInfoHash(Box::new(e)))?;
                        }
                        info_hash = Some(bytes);
                    }
                    _ => return Err(ParseError::InvalidInfoHash(format!("{value}").into())),
                },
                "dn" => name = Some(value.to_string()),
                "tr" => trackers.push(value.to_string()),
                "x.pe" => peers.push(value.parse()?),
                _ => (),
            }
        }
        Ok(Self {
            info_hash: info_hash.ok_or(ParseError::NoInfoHash)?,
            name,
            trackers,
            peers,
        })
    }
}

impl MagnetLink {
    /// SHA-1 hash of the torrent's metainfo.
    pub fn info_hash(&self) -> &[u8; 20] {
        &self.info_hash
    }

    /// Name of the torrent.
    pub fn name(&self) -> Option<&str> {
        self.name.as_deref()
    }

    /// Tracker addresses.
    pub fn trackers(&self) -> impl ExactSizeIterator<Item = &str> {
        self.trackers.iter().map(AsRef::as_ref)
    }

    /// Peer addresses.
    pub fn peers(&self) -> impl ExactSizeIterator<Item = &SocketAddr> {
        self.peers.iter()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_magnet_link() {
        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";

        let magnet = link.parse::<MagnetLink>().unwrap();

        assert_eq!(
            &[
                30, 189, 61, 191, 187, 37, 193, 51, 63, 81, 201, 156, 126, 230, 112, 252, 42, 23,
                39, 201
            ],
            magnet.info_hash()
        );
        assert_eq!(Some("Dune.Part.Two.2024.1080p.HD-TS.X264-EMIN3M[TGx]"), magnet.name());
        assert_eq!(
            vec![
                "udp://open.stealth.si:80/announce",
                "udp://tracker.tiny-vps.com:6969/announce",
                "udp://tracker.opentrackr.org:1337/announce",
                "udp://tracker.torrent.eu.org:451/announce",
                "udp://explodie.org:6969/announce",
                "udp://tracker.cyberia.is:6969/announce",
                "udp://ipv4.tracker.harry.lu:80/announce",
                "udp://p4p.arenabg.com:1337/announce",
                "udp://tracker.birkenwald.de:6969/announce",
                "udp://tracker.moeking.me:6969/announce",
                "udp://opentor.org:2710/announce",
                "udp://tracker.dler.org:6969/announce",
                "udp://uploads.gamecoast.net:6969/announce",
                "https://tracker.foreverpirates.co:443/announce",
                "udp://tracker.opentrackr.org:1337/announce",
                "http://tracker.openbittorrent.com:80/announce",
                "udp://opentracker.i2p.rocks:6969/announce",
                "udp://tracker.internetwarriors.net:1337/announce",
                "udp://tracker.leechers-paradise.org:6969/announce",
                "udp://coppersurfer.tk:6969/announce",
                "udp://tracker.zer0day.to:1337/announce"
            ],
            magnet.trackers().collect::<Vec<_>>()
        );
    }

    #[test]
    fn parse_magnet_link_with_invalid_info_hash() {
        let link = "magnet:?xt=urn:btih:1EBD3DBFBB25C1333F51C99C7EE670FC2A1727C"; // missing last character
        assert!(matches!(link.parse::<MagnetLink>(), Err(ParseError::InvalidInfoHash(_))));

        let link = "magnet:?xt=urn:btih:1EBD3DBFBB25C1333F51C99C7EE670FC2A1727C99"; // too many characters
        assert!(matches!(link.parse::<MagnetLink>(), Err(ParseError::InvalidInfoHash(_))));
    }
}