diff --git a/Makefile b/Makefile index 373536f..53bd9b1 100644 --- a/Makefile +++ b/Makefile @@ -44,6 +44,10 @@ install: build-release $(MAKE) sign-release install target/release/rqbit "$(HOME)/bin/" +@PHONY: test +test: + ulimit -n unlimited && cargo test + @PHONY: release-macos-universal release-macos-universal: cargo build --target aarch64-apple-darwin --profile release-github diff --git a/crates/librqbit/examples/custom_storage.rs b/crates/librqbit/examples/custom_storage.rs index 74e3949..f85fd37 100644 --- a/crates/librqbit/examples/custom_storage.rs +++ b/crates/librqbit/examples/custom_storage.rs @@ -1,3 +1,4 @@ +use bytes::Bytes; use librqbit::{ storage::{StorageFactory, StorageFactoryExt, TorrentStorage}, SessionOptions, @@ -79,9 +80,9 @@ async fn main() -> anyhow::Result<()> { .await?; let handle = s .add_torrent( - librqbit::AddTorrent::TorrentFileBytes( - include_bytes!("../resources/ubuntu-21.04-live-server-amd64.iso.torrent").into(), - ), + librqbit::AddTorrent::TorrentFileBytes(Bytes::from_static(include_bytes!( + "../resources/ubuntu-21.04-live-server-amd64.iso.torrent" + ))), Some(librqbit::AddTorrentOptions { storage_factory: Some(CustomStorageFactory::default().boxed()), paused: false, diff --git a/crates/librqbit/src/create_torrent_file.rs b/crates/librqbit/src/create_torrent_file.rs index fe78ca0..dcd6e58 100644 --- a/crates/librqbit/src/create_torrent_file.rs +++ b/crates/librqbit/src/create_torrent_file.rs @@ -6,6 +6,7 @@ use std::path::Path; use anyhow::Context; use bencode::bencode_serialize_to_writer; use buffers::ByteBufOwned; +use bytes::Bytes; use librqbit_core::torrent_metainfo::{TorrentMetaV1File, TorrentMetaV1Info, TorrentMetaV1Owned}; use librqbit_core::Id20; use sha1w::{ISha1, Sha1}; @@ -185,10 +186,10 @@ impl CreateTorrentResult { self.meta.info_hash } - pub fn as_bytes(&self) -> anyhow::Result> { + pub fn as_bytes(&self) -> anyhow::Result { let mut b = Vec::new(); bencode_serialize_to_writer(&self.meta, &mut b).context("error serializing torrent")?; - Ok(b) + Ok(b.into()) } } diff --git a/crates/librqbit/src/dht_utils.rs b/crates/librqbit/src/dht_utils.rs index d97a98d..ce054e5 100644 --- a/crates/librqbit/src/dht_utils.rs +++ b/crates/librqbit/src/dht_utils.rs @@ -67,18 +67,14 @@ pub async fn read_metainfo_from_peer_receiver + Unp unordered.push(read_info_guarded(a)); } + let mut addrs_completed = false; + loop { + if addrs_completed && unordered.is_empty() { + return ReadMetainfoResult::ChannelClosed { seen }; + } + tokio::select! { - next_addr = addrs.next() => { - match next_addr { - Some(addr) => { - if seen.insert(addr) { - unordered.push(read_info_guarded(addr)); - } - }, - None => return ReadMetainfoResult::ChannelClosed { seen }, - } - }, done = unordered.next(), if !unordered.is_empty() => { match done { Some(Ok((info, info_bytes))) => return ReadMetainfoResult::Found { info, info_bytes, seen, rx: addrs }, @@ -88,6 +84,20 @@ pub async fn read_metainfo_from_peer_receiver + Unp None => unreachable!() } } + + next_addr = addrs.next(), if !addrs_completed => { + match next_addr { + Some(addr) => { + if seen.insert(addr) { + unordered.push(read_info_guarded(addr)); + } + continue; + }, + None => { + addrs_completed = true; + }, + } + } }; } } diff --git a/crates/librqbit/src/peer_connection.rs b/crates/librqbit/src/peer_connection.rs index 0091f34..8d9af9d 100644 --- a/crates/librqbit/src/peer_connection.rs +++ b/crates/librqbit/src/peer_connection.rs @@ -37,6 +37,12 @@ pub trait PeerConnectionHandler { fn should_transmit_have(&self, id: ValidPieceIndex) -> bool; fn on_uploaded_bytes(&self, bytes: u32); fn read_chunk(&self, chunk: &ChunkInfo, buf: &mut [u8]) -> anyhow::Result<()>; + fn update_my_extended_handshake( + &self, + _handshake: &mut ExtendedHandshake, + ) -> anyhow::Result<()> { + Ok(()) + } } #[derive(Debug)] @@ -239,8 +245,10 @@ impl PeerConnection { let supports_extended = handshake_supports_extended; if supports_extended { - let my_extended = - Message::Extended(ExtendedMessage::Handshake(ExtendedHandshake::new())); + let mut my_extended = ExtendedHandshake::new(); + self.handler + .update_my_extended_handshake(&mut my_extended)?; + let my_extended = Message::Extended(ExtendedMessage::Handshake(my_extended)); trace!("sending extended handshake: {:?}", &my_extended); my_extended.serialize(&mut write_buf, &|| None).unwrap(); with_timeout(rwtimeout, conn.write_all(&write_buf)) diff --git a/crates/librqbit/src/session.rs b/crates/librqbit/src/session.rs index 29f1c3d..7526002 100644 --- a/crates/librqbit/src/session.rs +++ b/crates/librqbit/src/session.rs @@ -44,10 +44,7 @@ use librqbit_core::{ magnet::Magnet, peer_id::generate_peer_id, spawn_utils::spawn_with_cancel, - torrent_metainfo::{ - torrent_from_bytes as bencode_torrent_from_bytes, TorrentMetaV1Borrowed, TorrentMetaV1Info, - TorrentMetaV1Owned, - }, + torrent_metainfo::{TorrentMetaV1Info, TorrentMetaV1Owned}, }; use parking_lot::RwLock; use peer_binary_protocol::Handshake; @@ -62,12 +59,23 @@ pub const SUPPORTED_SCHEMES: [&str; 3] = ["http:", "https:", "magnet:"]; pub type TorrentId = usize; -fn torrent_from_bytes(bytes: &[u8]) -> anyhow::Result { +struct ParsedTorrentFile { + info: TorrentMetaV1Owned, + info_bytes: Bytes, + torrent_bytes: Bytes, +} + +fn torrent_from_bytes(bytes: Bytes) -> anyhow::Result { debug!( "all fields in torrent: {:#?}", - bencode::dyn_from_bytes::(bytes) + bencode::dyn_from_bytes::(&bytes) ); - bencode_torrent_from_bytes(bytes) + let parsed = librqbit_core::torrent_metainfo::torrent_from_bytes_ext::(&bytes)?; + Ok(ParsedTorrentFile { + info: parsed.meta.clone_to_owned(Some(&bytes)), + info_bytes: parsed.info_bytes.clone_to_owned(Some(&bytes)).0, + torrent_bytes: bytes, + }) } #[derive(Default)] @@ -242,7 +250,7 @@ pub struct Session { async fn torrent_from_url( reqwest_client: &reqwest::Client, url: &str, -) -> anyhow::Result<(TorrentMetaV1Owned, ByteBufOwned)> { +) -> anyhow::Result { let response = reqwest_client .get(url) .send() @@ -255,12 +263,7 @@ async fn torrent_from_url( .bytes() .await .with_context(|| format!("error reading response body from {url}"))?; - Ok(( - torrent_from_bytes(&b) - .context("error decoding torrent")? - .clone_to_owned(Some(&b)), - b.into(), - )) + torrent_from_bytes(b).context("error decoding torrent") } fn compute_only_files_regex>( @@ -421,8 +424,8 @@ pub fn read_local_file_including_stdin(filename: &str) -> anyhow::Result pub enum AddTorrent<'a> { Url(Cow<'a, str>), - TorrentFileBytes(Cow<'a, [u8]>), - TorrentInfo(Box, Bytes), + TorrentFileBytes(Bytes), + TorrentInfo(Box), } impl<'a> AddTorrent<'a> { @@ -439,7 +442,7 @@ impl<'a> AddTorrent<'a> { Self::Url(url.into()) } - pub fn from_bytes(bytes: impl Into>) -> Self { + pub fn from_bytes(bytes: impl Into) -> Self { Self::TorrentFileBytes(bytes.into()) } @@ -448,13 +451,13 @@ impl<'a> AddTorrent<'a> { pub fn from_local_filename(filename: &str) -> anyhow::Result { let file = read_local_file_including_stdin(filename) .with_context(|| format!("error reading local file {filename:?}"))?; - Ok(Self::TorrentFileBytes(Cow::Owned(file))) + Ok(Self::TorrentFileBytes(file.into())) } - pub fn into_bytes(self) -> Vec { + pub fn into_bytes(self) -> Bytes { match self { - Self::Url(s) => s.into_owned().into_bytes(), - Self::TorrentFileBytes(b) => b.into_owned(), + Self::Url(s) => s.into_owned().into_bytes().into(), + Self::TorrentFileBytes(b) => b, Self::TorrentInfo(..) => unimplemented!(), } } @@ -539,6 +542,7 @@ struct InternalAddResult { info_hash: Id20, info: TorrentMetaV1Info, torrent_bytes: Bytes, + info_bytes: Bytes, trackers: Vec, peer_rx: Option, initial_peers: Vec, @@ -887,31 +891,24 @@ impl Session { let torrent_bytes = storrent.torrent_bytes; - let info = if !torrent_bytes.is_empty() { - torrent_from_bytes(&torrent_bytes) - .map(|t| t.clone_to_owned(Some(&torrent_bytes))) - .ok() + let add_torrent = if !torrent_bytes.is_empty() { + AddTorrent::TorrentFileBytes(torrent_bytes) } else { - None - }; - let info = match info { - Some(info) => info, - None => { - let info_hash = Id20::from_str(&storrent.info_hash)?; - debug!(?info_hash, "torrent added before 6.1.0, need to readd"); - TorrentMetaV1Owned { - announce: trackers.first().cloned(), - announce_list: vec![trackers], - info: storrent.info, - comment: None, - created_by: None, - encoding: None, - publisher: None, - publisher_url: None, - creation_date: None, - info_hash, - } - } + let info_hash = Id20::from_str(&storrent.info_hash)?; + debug!(?info_hash, "torrent added before 6.1.0, need to readd"); + let info = TorrentMetaV1Owned { + announce: trackers.first().cloned(), + announce_list: vec![trackers], + info: storrent.info, + comment: None, + created_by: None, + encoding: None, + publisher: None, + publisher_url: None, + creation_date: None, + info_hash, + }; + AddTorrent::TorrentInfo(Box::new(info)) }; futures.push({ @@ -919,7 +916,7 @@ impl Session { async move { session .add_torrent( - AddTorrent::TorrentInfo(Box::new(info), torrent_bytes), + add_torrent, Some(AddTorrentOptions { paused: storrent.is_paused, output_folder: Some( @@ -1012,16 +1009,22 @@ impl Session { announce_port, opts.force_tracker_interval, )?; + let initial_peers_stream = opts + .initial_peers + .clone() + .and_then(|v| if v.is_empty() { None } else { Some(v) }) + .map(futures::stream::iter); + let peer_rx = merge_two_optional_streams(peer_rx, initial_peers_stream); let peer_rx = match peer_rx { Some(peer_rx) => peer_rx, - None => bail!("can't find peers: DHT disabled and no trackers in magnet"), + None => bail!("can't find peers: DHT is disabled, no trackers in magnet, and no initial peers provided"), }; debug!(?info_hash, "querying DHT"); match read_metainfo_from_peer_receiver( self.peer_id, info_hash, - opts.initial_peers.clone().unwrap_or_default(), + Default::default(), peer_rx, Some(self.merge_peer_opts(opts.peer_opts)), self.connector.clone(), @@ -1042,6 +1045,7 @@ impl Session { &info_bytes, &trackers, )?, + info_bytes: info_bytes.0, info, trackers, peer_rx: Some(rx), @@ -1049,12 +1053,12 @@ impl Session { } } ReadMetainfoResult::ChannelClosed { .. } => { - bail!("DHT died, no way to discover torrent metainfo") + bail!("input address stream exhausted, no way to discover torrent metainfo") } } } other => { - let (torrent, bytes) = match other { + let torrent = match other { AddTorrent::Url(url) if url.starts_with("http://") || url.starts_with("https://") => { @@ -1066,22 +1070,21 @@ impl Session { url ) } - AddTorrent::TorrentFileBytes(bytes) => { - let bytes = match bytes { - Cow::Borrowed(b) => ::bytes::Bytes::copy_from_slice(b), - Cow::Owned(v) => ::bytes::Bytes::from(v), - }; - ( - torrent_from_bytes(&bytes) - .map(|t| t.clone_to_owned(Some(&bytes))) - .context("error decoding torrent")?, - ByteBufOwned(bytes), - ) - } - AddTorrent::TorrentInfo(t, bytes) => (*t, bytes.into()), + AddTorrent::TorrentFileBytes(bytes) => + torrent_from_bytes(bytes) + .context("error decoding torrent")? + , + AddTorrent::TorrentInfo(t) => { + // TODO: remove this branch entirely + ParsedTorrentFile{ + info: *t, + info_bytes: Default::default(), + torrent_bytes: Default::default(), + } + }, }; - let trackers = torrent + let trackers = torrent.info .iter_announce() .unique() .filter_map(|tracker| match std::str::from_utf8(tracker.as_ref()) { @@ -1097,7 +1100,7 @@ impl Session { None } else { self.make_peer_rx( - torrent.info_hash, + torrent.info.info_hash, if opts.disable_trackers { Default::default() } else { @@ -1109,9 +1112,10 @@ impl Session { }; InternalAddResult { - info_hash: torrent.info_hash, - info: torrent.info, - torrent_bytes: bytes.0, + info_hash: torrent.info.info_hash, + info: torrent.info.info, + torrent_bytes: torrent.torrent_bytes, + info_bytes: torrent.info_bytes, trackers, peer_rx, initial_peers: opts @@ -1169,6 +1173,7 @@ impl Session { peer_rx, initial_peers, torrent_bytes, + info_bytes, } = add_res; debug!("Torrent info: {:#?}", &info); @@ -1213,6 +1218,7 @@ impl Session { info, info_hash, torrent_bytes, + info_bytes, output_folder, storage_factory, ); diff --git a/crates/librqbit/src/tests/e2e.rs b/crates/librqbit/src/tests/e2e.rs index 2b81d15..dedc2e8 100644 --- a/crates/librqbit/src/tests/e2e.rs +++ b/crates/librqbit/src/tests/e2e.rs @@ -1,11 +1,11 @@ use std::{ - borrow::Cow, net::{Ipv4Addr, SocketAddr}, time::Duration, }; use anyhow::bail; use futures::{stream::FuturesUnordered, StreamExt}; +use librqbit_core::magnet::Magnet; use rand::Rng; use tokio::{ spawn, @@ -84,7 +84,7 @@ async fn test_e2e_download() { let handle = session .add_torrent( - crate::AddTorrent::TorrentFileBytes(Cow::Owned(torrent_file_bytes)), + crate::AddTorrent::TorrentFileBytes(torrent_file_bytes), Some(AddTorrentOptions { overwrite: true, output_folder: Some(tempdir.to_str().unwrap().to_owned()), @@ -139,6 +139,8 @@ async fn test_e2e_download() { .and_then(|v| v.parse().ok()) .unwrap_or(1usize); + let magnet = Magnet::from_id20(torrent_file.info_hash(), Vec::new()).to_string(); + // 3. Start a client with the initial peers, and download the file. for _ in 0..client_iters { let outdir = tempfile::TempDir::with_prefix("rqbit_e2e_client").unwrap(); @@ -163,7 +165,7 @@ async fn test_e2e_download() { let (id, handle) = { let r = session .add_torrent( - crate::AddTorrent::TorrentFileBytes(Cow::Owned(torrent_file_bytes.clone())), + crate::AddTorrent::Url((&magnet).into()), Some(AddTorrentOptions { initial_peers: Some(peers.clone()), // only_files: Some(vec![0]), @@ -235,7 +237,7 @@ async fn test_e2e_download() { // 4. After downloading, recheck its integrity. let handle = session .add_torrent( - crate::AddTorrent::TorrentFileBytes(Cow::Owned(torrent_file_bytes.clone())), + crate::AddTorrent::TorrentFileBytes(torrent_file_bytes.clone()), Some(AddTorrentOptions { paused: true, overwrite: true, diff --git a/crates/librqbit/src/tests/e2e_stream.rs b/crates/librqbit/src/tests/e2e_stream.rs index 770f4d1..a2ab08e 100644 --- a/crates/librqbit/src/tests/e2e_stream.rs +++ b/crates/librqbit/src/tests/e2e_stream.rs @@ -5,7 +5,9 @@ use tempfile::TempDir; use tokio::{io::AsyncReadExt, time::timeout}; use tracing::info; -use crate::{create_torrent, AddTorrent, CreateTorrentOptions, Session}; +use crate::{ + create_torrent, tests::test_util::TestPeerMetadata, AddTorrent, CreateTorrentOptions, Session, +}; use super::test_util::create_default_random_dir_with_torrents; @@ -21,11 +23,11 @@ async fn e2e_stream() -> anyhow::Result<()> { .await?; let orig_content = std::fs::read(files.path().join("0.data")).unwrap(); - let server_session = Session::new_with_opts( files.path().into(), crate::SessionOptions { disable_dht: true, + peer_id: Some(TestPeerMetadata::good().as_peer_id()), persistence: false, listen_port_range: Some(16001..16100), enable_upnp_port_forwarding: false, @@ -71,6 +73,7 @@ async fn e2e_stream() -> anyhow::Result<()> { crate::SessionOptions { disable_dht: true, persistence: false, + peer_id: Some(TestPeerMetadata::good().as_peer_id()), listen_port_range: None, enable_upnp_port_forwarding: false, ..Default::default() diff --git a/crates/librqbit/src/tests/test_util.rs b/crates/librqbit/src/tests/test_util.rs index 4eba7a5..965658a 100644 --- a/crates/librqbit/src/tests/test_util.rs +++ b/crates/librqbit/src/tests/test_util.rs @@ -1,7 +1,7 @@ use std::{io::Write, path::Path}; use librqbit_core::Id20; -use rand::{RngCore, SeedableRng}; +use rand::{thread_rng, Rng, RngCore, SeedableRng}; use tempfile::TempDir; pub fn create_new_file_with_random_content(path: &Path, mut size: usize) { @@ -43,8 +43,16 @@ pub struct TestPeerMetadata { } impl TestPeerMetadata { + pub fn good() -> Self { + Self { + server_id: 0, + max_random_sleep_ms: 0, + } + } + pub fn as_peer_id(&self) -> Id20 { let mut peer_id = Id20::default(); + thread_rng().fill(&mut peer_id.0); peer_id.0[0] = self.server_id; peer_id.0[1] = self.max_random_sleep_ms; peer_id diff --git a/crates/librqbit/src/torrent_state/live/mod.rs b/crates/librqbit/src/torrent_state/live/mod.rs index 596a5d1..a0d51db 100644 --- a/crates/librqbit/src/torrent_state/live/mod.rs +++ b/crates/librqbit/src/torrent_state/live/mod.rs @@ -58,6 +58,7 @@ use backoff::backoff::Backoff; use buffers::{ByteBuf, ByteBufOwned}; use clone_to_owned::CloneToOwned; use librqbit_core::{ + constants::CHUNK_SIZE, hash_id::Id20, lengths::{ChunkInfo, Lengths, ValidPieceIndex}, spawn_utils::spawn_with_cancel, @@ -66,7 +67,8 @@ use librqbit_core::{ }; use parking_lot::{RwLock, RwLockReadGuard, RwLockWriteGuard}; use peer_binary_protocol::{ - extended::handshake::ExtendedHandshake, Handshake, Message, MessageOwned, Piece, Request, + extended::{handshake::ExtendedHandshake, ut_metadata::UtMetadata, ExtendedMessage}, + Handshake, Message, MessageOwned, Piece, Request, }; use tokio::{ sync::{ @@ -798,6 +800,12 @@ impl<'a> PeerConnectionHandler for &'a PeerHandler { Message::Cancel(_) => { trace!("received \"cancel\", but we don't process it yet") } + Message::Extended(ExtendedMessage::UtMetadata(UtMetadata::Request( + metadata_piece_id, + ))) => { + self.send_metadata_piece(metadata_piece_id) + .with_context(|| format!("error sending metadata piece {metadata_piece_id}"))?; + } message => { warn!("received unsupported message {:?}, ignoring", message); } @@ -849,6 +857,19 @@ impl<'a> PeerConnectionHandler for &'a PeerHandler { .unwrap_or(true); !have } + + fn update_my_extended_handshake( + &self, + handshake: &mut ExtendedHandshake, + ) -> anyhow::Result<()> { + let info_bytes = &self.state.meta().info_bytes; + if !info_bytes.is_empty() { + if let Ok(len) = info_bytes.len().try_into() { + handshake.metadata_size = Some(len); + } + } + Ok(()) + } } impl PeerHandler { @@ -1504,4 +1525,34 @@ impl PeerHandler { Ok(()) } + + fn send_metadata_piece(&self, piece_id: u32) -> anyhow::Result<()> { + let data = &self.state.meta().info_bytes; + let metadata_size = data.len(); + if metadata_size == 0 { + anyhow::bail!("peer requested for info metadata but we don't have it") + } + let total_pieces: usize = (metadata_size as u64) + .div_ceil(CHUNK_SIZE as u64) + .try_into()?; + + if piece_id as usize > total_pieces { + bail!("piece out of bounds") + } + + let offset = piece_id * CHUNK_SIZE; + let end = (offset + CHUNK_SIZE).min(data.len().try_into()?); + let data = data.slice(offset as usize..end as usize); + + self.tx + .send(WriterRequest::Message(Message::Extended( + ExtendedMessage::UtMetadata(UtMetadata::Data { + piece: piece_id, + total_size: end - offset, + data: data.into(), + }), + ))) + .context("error sending UtMetadata: channel closed")?; + Ok(()) + } } diff --git a/crates/librqbit/src/torrent_state/mod.rs b/crates/librqbit/src/torrent_state/mod.rs index d4d935b..7537dbc 100644 --- a/crates/librqbit/src/torrent_state/mod.rs +++ b/crates/librqbit/src/torrent_state/mod.rs @@ -101,6 +101,7 @@ pub(crate) struct ManagedTorrentOptions { pub struct ManagedTorrentInfo { pub info: TorrentMetaV1Info, pub torrent_bytes: Bytes, + pub info_bytes: Bytes, pub info_hash: Id20, pub(crate) spawner: BlockingSpawner, pub trackers: HashSet, @@ -504,6 +505,7 @@ pub(crate) struct ManagedTorrentBuilder { output_folder: PathBuf, info_hash: Id20, torrent_bytes: Bytes, + info_bytes: Bytes, force_tracker_interval: Option, peer_connect_timeout: Option, peer_read_write_timeout: Option, @@ -522,6 +524,7 @@ impl ManagedTorrentBuilder { info: TorrentMetaV1Info, info_hash: Id20, torrent_bytes: Bytes, + info_bytes: Bytes, output_folder: PathBuf, storage_factory: BoxStorageFactory, ) -> Self { @@ -529,6 +532,7 @@ impl ManagedTorrentBuilder { info, info_hash, torrent_bytes, + info_bytes, spawner: None, force_tracker_interval: None, peer_connect_timeout: None, @@ -614,6 +618,7 @@ impl ManagedTorrentBuilder { file_infos, info: self.info, torrent_bytes: self.torrent_bytes, + info_bytes: self.info_bytes, info_hash: self.info_hash, trackers: self.trackers.into_iter().collect(), spawner: self.spawner.unwrap_or_default(), diff --git a/crates/librqbit_core/src/magnet.rs b/crates/librqbit_core/src/magnet.rs index f0942bb..203f260 100644 --- a/crates/librqbit_core/src/magnet.rs +++ b/crates/librqbit_core/src/magnet.rs @@ -20,6 +20,14 @@ impl Magnet { self.id32 } + pub fn from_id20(id20: Id20, trackers: Vec) -> Self { + Self { + id20: Some(id20), + id32: None, + trackers, + } + } + /// Parse a magnet link. pub fn parse(url: &str) -> anyhow::Result { let url = url::Url::parse(url).context("magnet link must be a valid URL")?; @@ -63,37 +71,44 @@ impl Magnet { } impl std::fmt::Display for Magnet { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let (Some(id20), Some(id32)) = (self.id20, self.id32) { - write!( - f, - "magnet:?xt=urn:btih:{}?xt=urn:btmh:1220{}&tr={}", - id20.as_string(), - id32.as_string(), - self.trackers.join("&tr=") - ) - } else if let Some(id20) = self.id20 { - write!( - f, - "magnet:?xt=urn:btih:{}&tr={}", - id20.as_string(), - self.trackers.join("&tr=") - ) - } else if let Some(id32) = self.id32 { - write!( - f, - "magnet:?xt=urn:btmh:1220{}&tr={}", - id32.as_string(), - self.trackers.join("&tr=") - ) - } else { - panic!("no infohash") + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "magnet:")?; + let mut write_ampersand = { + let mut written_so_far = 0; + move |f: &mut std::fmt::Formatter<'_>| -> core::fmt::Result { + if written_so_far == 0 { + write!(f, "?")?; + } else { + write!(f, "&")?; + } + written_so_far += 1; + Ok(()) + } + }; + if let Some(id20) = self.id20 { + write_ampersand(f)?; + write!(f, "xt=urn:btih:{}", id20.as_string(),)?; } + if let Some(id32) = self.id32 { + write_ampersand(f)?; + write!(f, "xt=xt=urn:btmh:1220{}", id32.as_string(),)?; + } + for tracker in self.trackers.iter() { + write_ampersand(f)?; + write!(f, "tr={tracker}")?; + } + Ok(()) } } #[cfg(test)] mod tests { + use std::str::FromStr; + + use crate::Id20; + + use super::Magnet; + #[test] fn test_parse_magnet_as_url() { let magnet = "magnet:?xt=urn:btih:a621779b5e3d486e127c3efbca9b6f8d135f52e5&dn=rutor.info_%D0%92%D0%BE%D0%B9%D0%BD%D0%B0+%D0%B1%D1%83%D0%B4%D1%83%D1%89%D0%B5%D0%B3%D0%BE+%2F+The+Tomorrow+War+%282021%29+WEB-DLRip+%D0%BE%D1%82+MegaPeer+%7C+P+%7C+NewComers&tr=udp://opentor.org:2710&tr=udp://opentor.org:2710&tr=http://retracker.local/announce"; @@ -113,4 +128,18 @@ mod tests { let m = Magnet::parse(magnet).unwrap(); assert!(m.as_id32() == Some(info_hash)); } + + #[test] + fn test_magnet_to_string() { + let id20 = Id20::from_str("a621779b5e3d486e127c3efbca9b6f8d135f52e5").unwrap(); + assert_eq!( + &Magnet::from_id20(id20, Default::default()).to_string(), + "magnet:?xt=urn:btih:a621779b5e3d486e127c3efbca9b6f8d135f52e5" + ); + + assert_eq!( + &Magnet::from_id20(id20, vec!["foo".to_string(), "bar".to_string()]).to_string(), + "magnet:?xt=urn:btih:a621779b5e3d486e127c3efbca9b6f8d135f52e5&tr=foo&tr=bar" + ); + } }