Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>`, and `Into<String>`
- `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)

Expand Down
69 changes: 46 additions & 23 deletions src/magnet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
}

Expand Down Expand Up @@ -360,16 +375,14 @@ impl From<MagnetLink> for String {
mod tests {
use super::*;

use crate::TrackerScheme;

#[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,
"Emma Goldman - Essential Works of Anarchism (16 books)".to_string()
"Goldman, Emma - Essential Works of Anarchism".to_string()
);
assert_eq!(
magnet.hash,
Expand Down Expand Up @@ -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",
];

Expand All @@ -562,19 +598,6 @@ mod tests {
.map(|tracker| tracker.url().to_string())
.collect::<Vec<_>>();

// 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::<Vec<_>>()
.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();
Expand Down
126 changes: 125 additions & 1 deletion src/torrent_file.rs
Original file line number Diff line number Diff line change
@@ -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::*;
Expand All @@ -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)]
Expand Down Expand Up @@ -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<Tracker> {
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<MagnetLink, MagnetLinkError> {
// 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::<Query>::new();

if !self.name.is_empty() {
buf.push_estr(EStr::new_or_panic("&dn="));
buf.encode_str::<Data>(&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::<Data>(tracker.url());
}

uri.push_str(buf.as_str());

MagnetLink::new(&uri)
}
}

#[cfg(feature = "sea_orm")]
Expand Down Expand Up @@ -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);
}
}
6 changes: 3 additions & 3 deletions src/tracker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion tests/bittorrent-v1-emma-goldman.magnet
Original file line number Diff line number Diff line change
@@ -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
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
Loading