diff --git a/CHANGELOG.md b/CHANGELOG.md index bb36a6f..c8b37d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `TorrentFile`, `DecodedTorrent` and `DecodedInfo` implement `PartialEq` - `MagnetFile` implements `FromStr`, `TryFrom`, and `Into` - `MagnetFile` supports serde de/serialization +- `PeerSource`, `Tracker` and `TrackerScheme` now derive `Eq`, `PartialOrd` + and `Ord` so that they can be sorted in collections +- `TorrentFile::trackers` returns the trackers in the torrent file, sorted + and deduplicated; the sort order is not specified +- `TorrentFile::magnet_link` produces a `MagnetLink` that's roughly equivalent + to the torrent file; some information may not be ported over, and this is + a one-way operation + +### Changed + +- `MagnetFile` sorts and deduplicates the trackers provided in the magnet URL; + `MagnetFile::trackers` returns them in an order, which is not specified + +### Fixed + +- `MagnetLink` equality is no longer affected by the exact representation of the + magnet URL; if the name, hash, and (sorted/deduped) trackers are equal, then + two magnets are considered equal ## Version 0.4.0 (2025-11-10) diff --git a/src/magnet.rs b/src/magnet.rs index 016ece6..e2ff3bd 100644 --- a/src/magnet.rs +++ b/src/magnet.rs @@ -262,6 +262,11 @@ impl MagnetLink { hash1.hybrid(hash2)? }; + // Reorder the trackers to provide equality + trackers.sort_unstable(); + // Remove duplicates + trackers.dedup(); + Ok(MagnetLink { hash: final_hash, name: name.to_string(), @@ -317,6 +322,9 @@ impl MagnetLink { } /// Returns the list of [`Tracker`](crate::tracker::Tracker) for the MagnetLink. + /// + /// The list is guaranteed to be sorted in a specific order and deduplicated, + /// but the order is not specified. pub fn trackers(&self) -> &[Tracker] { &self.trackers } @@ -330,7 +338,14 @@ impl std::fmt::Display for MagnetLink { impl PartialEq for MagnetLink { fn eq(&self, other: &Self) -> bool { - self.query == other.query + // Here we may have different ordering of the URL params so we check the + // values. This is more expensive but more correct. + // + // In the future, we may optimize this by reproducing a normalized query. + self.name == other.name + && self.hash == other.hash + // Trackers have been sorted in the constructor + && self.trackers == other.trackers } } @@ -360,8 +375,6 @@ impl From for String { mod tests { use super::*; - use crate::TrackerScheme; - #[test] fn can_load_v1() { let magnet_source = @@ -369,7 +382,7 @@ mod tests { let magnet = MagnetLink::new(&magnet_source).unwrap(); assert_eq!( magnet.name, - "Emma Goldman - Essential Works of Anarchism (16 books)".to_string() + "Goldman, Emma - Essential Works of Anarchism".to_string() ); assert_eq!( magnet.hash, @@ -540,15 +553,38 @@ mod tests { #[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.opentrackr.org:1337/announce", - "udp://exodus.desync.com:6969", - "udp://tracker.opentrackr.org:1337/announce", - "http://tracker.openbittorrent.com:80/announce", - "udp://opentracker.i2p.rocks: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://coppersurfer.tk: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", ]; @@ -562,19 +598,6 @@ mod tests { .map(|tracker| tracker.url().to_string()) .collect::>(); - // Did we find all trackers? - assert_eq!(found.len(), expected.len(),); - // Did we find that there's 1 HTTP tracker? - assert_eq!( - magnet - .trackers - .into_iter() - .filter(|tracker| tracker.scheme() == &TrackerScheme::Http) - .collect::>() - .len(), - 1 - ); - // Do we have the correct list? assert_eq!(found, expected); let magnet_url = std::fs::read_to_string("tests/bittorrent-v2-test.magnet").unwrap(); diff --git a/src/torrent_file.rs b/src/torrent_file.rs index 440cd29..ede1748 100644 --- a/src/torrent_file.rs +++ b/src/torrent_file.rs @@ -1,4 +1,8 @@ use bt_bencode::Value as BencodeValue; +use fluent_uri::pct_enc::{ + encoder::{Data, Query}, + EStr, EString, +}; use rustc_hex::ToHex; #[cfg(feature = "sea_orm")] use sea_orm::prelude::*; @@ -8,7 +12,10 @@ use sha1::{Digest, Sha1}; use std::collections::HashMap; use std::path::PathBuf; -use crate::{InfoHash, InfoHashError, PieceLength, TorrentContent, TorrentID, Tracker}; +use crate::{ + InfoHash, InfoHashError, MagnetLink, MagnetLinkError, PieceLength, TorrentContent, TorrentID, + Tracker, +}; /// Error occurred during parsing a [`TorrentFile`](crate::torrent_file::TorrentFile). #[derive(Clone, Debug, PartialEq)] @@ -330,6 +337,70 @@ impl TorrentFile { pub fn id(&self) -> TorrentID { TorrentID::from_infohash(&self.hash) } + + /// List the trackers in the torrent, ensuring + /// they're sorted and deduplicated. + pub fn trackers(&self) -> Vec { + let mut trackers = vec![]; + if let Some(tracker) = &self.decoded.announce { + trackers.push(tracker.clone()); + } + + for tier in &self.decoded.announce_list { + for tracker in tier { + trackers.push(tracker.clone()); + } + } + + trackers.sort_unstable(); + trackers.dedup(); + + trackers + } + + /// Lossy transformation into a [`MagnetLink`]. + /// + /// This is a lossy operation because: + /// + /// - magnet links do not contain detailed pieces information, + /// so there is no reverse operation without network resolution + /// - not all metadata about the torrent may be added to the magnet link + /// + /// What's added to the magnet link: + /// + /// - name + /// - torrentID + /// - trackers + /// + /// For the moment, this is a fallible operation because we make sure + /// the produced MagnetLink can be parsed again. This operation may not + /// produce errors in a future release. + pub fn magnet_link(&self) -> Result { + // Not sure how to build a URI with `magnet:` and not `magnet://` + // without the `Uri::builder`. + let mut uri = match &self.hash { + InfoHash::V1(h) => format!("magnet:?xt=urn:btih:{h}"), + InfoHash::V2(h) => format!("magnet:?xt=urn:btmh:1220{h}"), + InfoHash::Hybrid((h1, h2)) => format!("magnet:?xt=urn:btih:{h1}&xt=urn:btmh:1220{h2}"), + }; + + let mut buf = EString::::new(); + + if !self.name.is_empty() { + buf.push_estr(EStr::new_or_panic("&dn=")); + buf.encode_str::(&self.name); + } + + // We use the helper to avoid duplicates or unsorted entries + for tracker in self.trackers() { + buf.push_estr(EStr::new_or_panic("&tr=")); + buf.encode_str::(tracker.url()); + } + + uri.push_str(buf.as_str()); + + MagnetLink::new(&uri) + } } #[cfg(feature = "sea_orm")] @@ -509,4 +580,57 @@ mod tests { let res = TorrentFile::from_slice(&slice); assert!(res.is_err()); } + + #[test] + fn test_torrent_to_magnet_v2() { + let slice = std::fs::read("tests/bittorrent-v2-test.torrent").unwrap(); + let res = TorrentFile::from_slice(&slice); + let torrent = res.unwrap(); + + let expected = std::fs::read_to_string("tests/bittorrent-v2-test.magnet").unwrap(); + let magnet = torrent.magnet_link().unwrap(); + assert_eq!(expected, magnet.to_string(),); + } + + #[test] + fn test_torrent_to_magnet_hybrid() { + let slice = std::fs::read("tests/bittorrent-v2-hybrid-test.torrent").unwrap(); + let res = TorrentFile::from_slice(&slice); + let torrent = res.unwrap(); + + let expected = std::fs::read_to_string("tests/bittorrent-v2-hybrid-test.magnet").unwrap(); + let magnet = torrent.magnet_link().unwrap(); + assert_eq!(expected, magnet.to_string(),); + } + + #[test] + fn test_torrent_to_magnet_v1() { + let slice = std::fs::read("tests/bittorrent-v1-emma-goldman.torrent").unwrap(); + let res = TorrentFile::from_slice(&slice); + let torrent = res.unwrap(); + + let expected = MagnetLink::new( + &std::fs::read_to_string("tests/bittorrent-v1-emma-goldman.magnet").unwrap(), + ) + .unwrap(); + let magnet = torrent.magnet_link().unwrap(); + + assert_eq!(expected.name(), magnet.name(),); + + assert_eq!(expected.hash(), magnet.hash(),); + + // Check for duplicates. This is useful because the `announce` in a torrent + // file may also be in the `announce_list` so we wanna make sure we don't have it twice. + for tracker in magnet.trackers() { + if magnet.trackers().iter().filter(|x| x == &tracker).count() > 1 { + panic!("Duplicate tracker: {tracker:?}"); + } + } + + // Check the trackers are actually equal + assert_eq!(magnet.trackers(), expected.trackers()); + + // Check for complete equality in this specific case (no information loss on the test) + assert_eq!(expected, magnet); + } } diff --git a/src/tracker.rs b/src/tracker.rs index 67097b4..dbed19b 100644 --- a/src/tracker.rs +++ b/src/tracker.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::str::FromStr; /// A source of peers. Can be a [`Tracker`](crate::tracker::Tracker) or a decentralized source. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub enum PeerSource { DHT, PEX, @@ -16,7 +16,7 @@ pub enum PeerSource { /// /// This is usually parsed directly from a [`TorrentFile`](crate::torrent_file::TorrentFile) /// or a [`MagnetLink`](crate::magnet::MagnetLink). -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct Tracker { /// Tracker URL scheme (usually `ws`, `http(s)`, or `udp`) scheme: TrackerScheme, @@ -94,7 +94,7 @@ impl FromStr for Tracker { /// Does not implement Serialize/Deserialize because it's actually not in the /// torrent data. It is constructed from the parsed tracker URLs contained in /// the torrent data. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub enum TrackerScheme { Websocket, Http, diff --git a/tests/bittorrent-v1-emma-goldman.magnet b/tests/bittorrent-v1-emma-goldman.magnet index 4194aae..27b79ff 100644 --- a/tests/bittorrent-v1-emma-goldman.magnet +++ b/tests/bittorrent-v1-emma-goldman.magnet @@ -1 +1 @@ -magnet:?xt=urn:btih:C811B41641A09D192B8ED81B14064FFF55D85CE3&dn=Emma+Goldman+-+Essential+Works+of+Anarchism+%2816+books%29&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce&tr=udp%3A%2F%2Fexodus.desync.com%3A6969&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 \ No newline at end of file +magnet:?xt=urn:btih:c811b41641a09d192b8ed81b14064fff55d85ce3&dn=Goldman%2C%20Emma%20-%20Essential%20Works%20of%20Anarchism&xl=80121936&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.internetwarriors.net%3A1337%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.pirateparty.gr%3A6969%2Fannounce&tr=udp%3A%2F%2F9.rarbg.to%3A2730%2Fannounce&tr=udp%3A%2F%2F9.rarbg.to%3A2710%2Fannounce&tr=udp%3A%2F%2Fbt.xxx-tracker.com%3A2710%2Fannounce&tr=udp%3A%2F%2Ftracker.cyberia.is%3A6969%2Fannounce&tr=udp%3A%2F%2Fretracker.lanta-net.ru%3A2710%2Fannounce&tr=udp%3A%2F%2F9.rarbg.to%3A2770%2Fannounce&tr=udp%3A%2F%2F9.rarbg.me%3A2730%2Fannounce&tr=udp%3A%2F%2Feddie4.nl%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.mg64.net%3A6969%2Fannounce&tr=udp%3A%2F%2Fopen.demonii.si%3A1337%2Fannounce&tr=udp%3A%2F%2Ftracker.zer0day.to%3A1337%2Fannounce&tr=udp%3A%2F%2Ftracker.tiny-vps.com%3A6969%2Fannounce&tr=udp%3A%2F%2Fipv6.tracker.harry.lu%3A80%2Fannounce&tr=udp%3A%2F%2F9.rarbg.me%3A2740%2Fannounce&tr=udp%3A%2F%2F9.rarbg.me%3A2770%2Fannounce&tr=udp%3A%2F%2Fdenis.stalker.upeer.me%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.port443.xyz%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.moeking.me%3A6969%2Fannounce&tr=udp%3A%2F%2Fexodus.desync.com%3A6969%2Fannounce&tr=udp%3A%2F%2F9.rarbg.to%3A2740%2Fannounce&tr=udp%3A%2F%2F9.rarbg.to%3A2720%2Fannounce&tr=udp%3A%2F%2Ftracker.justseed.it%3A1337%2Fannounce&tr=udp%3A%2F%2Ftracker.torrent.eu.org%3A451%2Fannounce&tr=udp%3A%2F%2Fipv4.tracker.harry.lu%3A80%2Fannounce&tr=udp%3A%2F%2Ftracker.open-internet.nl%3A6969%2Fannounce&tr=udp%3A%2F%2Ftorrentclub.tech%3A6969%2Fannounce&tr=udp%3A%2F%2Fopen.stealth.si%3A80%2Fannounce&tr=http%3A%2F%2Ftracker.tfile.co%3A80%2Fannounce \ No newline at end of file