diff --git a/Cargo.lock b/Cargo.lock index e5bb9b6..828039d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,7 @@ dependencies = [ "arr-parse", "arr-probe", "axum", + "base64", "chrono", "include_dir", "mime_guess", diff --git a/crates/arr-daemon/Cargo.toml b/crates/arr-daemon/Cargo.toml index 242635e..8f0c2d8 100644 --- a/crates/arr-daemon/Cargo.toml +++ b/crates/arr-daemon/Cargo.toml @@ -36,6 +36,7 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } [dev-dependencies] +base64 = { workspace = true } tempfile = { workspace = true } wiremock = { workspace = true } diff --git a/crates/arr-daemon/src/grab.rs b/crates/arr-daemon/src/grab.rs index b0ebc85..598426a 100644 --- a/crates/arr-daemon/src/grab.rs +++ b/crates/arr-daemon/src/grab.rs @@ -22,7 +22,7 @@ use arr_core::policy::{evaluate, Candidate}; use arr_core::{score::score, Language, Policy, TitleOverrides, Verdict}; use arr_db::{blacklist, Blacklist, Db, MoviePolicy}; use arr_dl::{AddTorrent, TorrentSource, TransmissionClient}; -use arr_indexer::{ProwlarrClient, SearchRelease, SearchRequest}; +use arr_indexer::{Download, ProwlarrClient, SearchRelease, SearchRequest}; use arr_meta::TmdbClient; use crate::indexers::{DiscoveryError, IndexerDirectory}; @@ -78,6 +78,8 @@ pub enum GrabError { InvalidTmdbId(i64), #[error("transmission: {0}")] Transmission(#[from] arr_dl::Error), + #[error("download link: {0}")] + Download(#[from] arr_indexer::DownloadError), #[error("release {name}: {source}")] Parsed { name: String, @@ -105,8 +107,8 @@ impl GrabAction { ) -> Self { Self { indexers: IndexerDirectory::new(prowlarr.clone()), + grabber: Grabber::new(prowlarr.clone(), transmission, download_dir, seeding), prowlarr, - grabber: Grabber::new(transmission, download_dir, seeding), tmdb: None, } } @@ -380,6 +382,9 @@ impl GrabAction { /// release onward they are the same writes, so they share this. #[derive(Debug)] pub(crate) struct Grabber { + /// Resolves the winner's indexer link before it is sent on: Transmission + /// cannot reach Prowlarr and arr can (issue #100). + prowlarr: ProwlarrClient, transmission: TransmissionClient, download_dir: PathBuf, seeding: SeedingRules, @@ -441,11 +446,13 @@ impl GrabScope { impl Grabber { pub(crate) fn new( + prowlarr: ProwlarrClient, transmission: TransmissionClient, download_dir: PathBuf, seeding: SeedingRules, ) -> Self { Self { + prowlarr, transmission, download_dir, seeding, @@ -553,6 +560,30 @@ impl Grabber { } } + /// Resolve the winner's indexer link and add it to Transmission. + /// + /// The link is resolved here rather than passed on, because Transmission + /// has no route to Prowlarr and cannot follow its redirect to a magnet + /// (issue #100). + async fn send_to_transmission( + &self, + winner: &Eligible, + loaded: &MoviePolicy, + ) -> Result { + let seeding = self.seeding.for_indexer(winner.indexer_id); + let source = torrent_source(self.prowlarr.download(&winner.download_url).await?); + Ok(self + .transmission + .add_torrent(AddTorrent { + source, + label: label(loaded), + download_dir: self.download_dir.clone(), + seed_ratio_limit: seeding.ratio, + seed_idle_limit_minutes: seeding.idle_minutes, + }) + .await?) + } + /// Add the winning release to Transmission and record the grab. /// /// The search still counts as an attempt (§6.2) on every exit that is @@ -567,22 +598,11 @@ impl Grabber { blacklist: &Blacklist, winner: Eligible, ) -> Result, GrabError> { - let seeding = self.seeding.for_indexer(winner.indexer_id); - let added = match self - .transmission - .add_torrent(AddTorrent { - source: torrent_source(&winner.download_url), - label: label(loaded), - download_dir: self.download_dir.clone(), - seed_ratio_limit: seeding.ratio, - seed_idle_limit_minutes: seeding.idle_minutes, - }) - .await - { + let added = match self.send_to_transmission(&winner, loaded).await { Ok(added) => added, Err(error) => { self.record_attempt(database, target).await?; - return Err(error.into()); + return Err(error); } }; let infohash = added.hash.to_ascii_lowercase(); @@ -1120,13 +1140,11 @@ pub(crate) fn label_for_root(kind: &str, audience: &str) -> String { format!("{kind}-{audience}") } -/// Indexers hand out magnets and `.torrent` links interchangeably; -/// Transmission takes either in the same field. -fn torrent_source(download_url: &str) -> TorrentSource { - if download_url.starts_with("magnet:") { - TorrentSource::Magnet(download_url.to_owned()) - } else { - TorrentSource::Url(download_url.to_owned()) +/// A resolved download, in the shape Transmission takes it. +fn torrent_source(download: Download) -> TorrentSource { + match download { + Download::Magnet(uri) => TorrentSource::Magnet(uri), + Download::Torrent(bytes) => TorrentSource::Metainfo(bytes), } } @@ -1151,9 +1169,61 @@ fn rfc3339(time: SystemTime) -> Option { chrono::DateTime::from_timestamp(seconds, 0).map(|date| date.to_rfc3339()) } +/// Fixture support for the download-link resolve step (issue #100). Grab +/// fixtures carry indexer links, and every lane now fetches them, so the mock +/// indexer has to answer the way Prowlarr does. +#[cfg(test)] +#[allow(clippy::unwrap_used)] +pub(crate) mod test_downloads { + use wiremock::matchers::{method, path_regex}; + use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + + /// Where rewritten fixture links point on the mock indexer. + const PREFIX: &str = "/dl/"; + + /// Rewrites the download links in a recorded feed onto `server`. + pub(crate) fn rewrite(feed: &str, links: &str, server: &MockServer) -> String { + feed.replace(links, &format!("{}{PREFIX}", server.uri())) + } + + /// Prowlarr's live behaviour: the download endpoint 302s to a magnet. + pub(crate) async fn mount(server: &MockServer) { + Mock::given(method("GET")) + .and(path_regex(format!("^{PREFIX}"))) + .respond_with(MagnetRedirect) + .mount(server) + .await; + } + + struct MagnetRedirect; + + impl Respond for MagnetRedirect { + fn respond(&self, request: &Request) -> ResponseTemplate { + let name = request + .url + .path() + .rsplit('/') + .next() + .unwrap_or_default() + .to_owned(); + // Deterministic stand-in for the infohash the tracker would give, + // and distinct per release so duplicates still collapse. + let mut hash: u64 = 5381; + for byte in name.as_bytes() { + hash = hash.wrapping_mul(33) ^ u64::from(*byte); + } + ResponseTemplate::new(302).insert_header( + "location", + format!("magnet:?xt=urn:btih:{hash:040x}&dn={name}"), + ) + } + } +} + #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { + use base64::Engine as _; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -1192,8 +1262,11 @@ mod tests { } fn add(&self, arguments: &Value) -> ResponseTemplate { + // A magnet arrives as `filename`, a torrent body as base64 + // `metainfo` — either identifies the torrent for the fake. let source = arguments["filename"] .as_str() + .or_else(|| arguments["metainfo"].as_str()) .unwrap_or_default() .to_owned(); let labels: Vec = arguments["labels"] @@ -1317,13 +1390,21 @@ mod tests { async fn prowlarr() -> MockServer { let server = MockServer::start().await; + let feed = test_downloads::rewrite(RSS, "https://tracker/", &server); + test_downloads::mount(&server).await; + mount_indexer(&server, &feed).await; + server + } + + /// One indexer advertising a text search, serving `feed` to every query. + async fn mount_indexer(server: &MockServer, feed: &str) { Mock::given(method("GET")) .and(path("/api/v1/indexer")) .respond_with( ResponseTemplate::new(200) .set_body_json(json!([{"id": 7, "name": "tracker", "enable": true}])), ) - .mount(&server) + .mount(server) .await; Mock::given(method("GET")) .and(path("/7/api")) @@ -1331,15 +1412,14 @@ mod tests { .respond_with(ResponseTemplate::new(200).set_body_string( r#""#, )) - .mount(&server) + .mount(server) .await; Mock::given(method("GET")) .and(path("/7/api")) .and(query_param("t", "search")) - .respond_with(ResponseTemplate::new(200).set_body_string(RSS)) - .mount(&server) + .respond_with(ResponseTemplate::new(200).set_body_string(feed)) + .mount(server) .await; - server } async fn empty_prowlarr() -> MockServer { @@ -2004,15 +2084,70 @@ mod tests { assert!(fake.torrents().is_empty()); } + /// §100: Transmission has no route to the indexer, so a download link + /// that answers with the torrent itself is forwarded as inline metainfo + /// and the link never leaves arr. + #[tokio::test] + async fn a_torrent_body_is_forwarded_inline_and_the_link_never_leaves_arr() { + const TORRENT: &[u8] = b"d4:infod6:lengthi1e4:name8:good.mkvee"; + + let (_dir, database) = wanted_movie().await; + let indexer = MockServer::start().await; + let feed = RSS.replace("https://tracker/", &format!("{}/dl/", indexer.uri())); + mount_indexer(&indexer, &feed).await; + Mock::given(method("GET")) + .and(path("/dl/good.torrent")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(TORRENT, "application/x-bittorrent"), + ) + .mount(&indexer) + .await; + let (downloader, fake) = transmission().await; + + action(&indexer, &downloader).tick(&database).await.unwrap(); + + let sent = base64::engine::general_purpose::STANDARD + .decode(&fake.torrents()[0].source) + .expect("the torrent is sent as base64 metainfo"); + assert_eq!(sent, TORRENT); + let fetched_with_key = indexer + .received_requests() + .await + .unwrap() + .into_iter() + .filter(|request| request.url.path() == "/dl/good.torrent") + .all(|request| request.headers.contains_key("x-api-key")); + assert!(fetched_with_key, "arr resolves the link with its own key"); + } + + /// A link that redirects to a magnet — Prowlarr's usual answer — reaches + /// Transmission as the magnet, which it can act on without the indexer. + #[tokio::test] + async fn a_link_that_redirects_to_a_magnet_is_sent_as_the_magnet() { + let (_dir, database) = wanted_movie().await; + let indexer = prowlarr().await; + let (downloader, fake) = transmission().await; + + action(&indexer, &downloader).tick(&database).await.unwrap(); + + assert!( + fake.torrents()[0] + .source + .starts_with("magnet:?xt=urn:btih:"), + "{}", + fake.torrents()[0].source + ); + } + #[test] - fn indexer_links_and_magnets_both_reach_transmission() { + fn a_resolved_download_keeps_its_shape() { assert_eq!( - torrent_source("magnet:?xt=urn:btih:abc"), + torrent_source(Download::Magnet("magnet:?xt=urn:btih:abc".into())), TorrentSource::Magnet("magnet:?xt=urn:btih:abc".into()) ); assert_eq!( - torrent_source("https://tracker/good.torrent"), - TorrentSource::Url("https://tracker/good.torrent".into()) + torrent_source(Download::Torrent(b"d4:infodee".to_vec())), + TorrentSource::Metainfo(b"d4:infodee".to_vec()) ); } } diff --git a/crates/arr-daemon/src/rss.rs b/crates/arr-daemon/src/rss.rs index a899a4d..f8479d6 100644 --- a/crates/arr-daemon/src/rss.rs +++ b/crates/arr-daemon/src/rss.rs @@ -44,8 +44,8 @@ impl RssAction { ) -> Self { Self { indexers: IndexerDirectory::new(prowlarr.clone()), + grabber: Grabber::new(prowlarr.clone(), transmission, download_dir, seeding), prowlarr, - grabber: Grabber::new(transmission, download_dir, seeding), } } @@ -304,7 +304,7 @@ mod tests { use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; use super::*; - use crate::grab::SeedingLimits; + use crate::grab::{test_downloads, SeedingLimits}; use arr_dl::TransmissionClient; /// The recorded feed: two releases of one wanted title, one that only a @@ -352,6 +352,8 @@ mod tests { /// feed to an empty query. async fn prowlarr() -> MockServer { let server = MockServer::start().await; + let feed = test_downloads::rewrite(FEED, "https://indexer.invalid/download/", &server); + test_downloads::mount(&server).await; Mock::given(method("GET")) .and(path("/api/v1/indexer")) .respond_with(ResponseTemplate::new(200).set_body_json(json!([ @@ -374,7 +376,7 @@ mod tests { .and(path(format!("/{id}/api"))) .and(query_param("t", "search")) .and(query_param_is_missing("q")) - .respond_with(ResponseTemplate::new(200).set_body_string(FEED)) + .respond_with(ResponseTemplate::new(200).set_body_string(&feed)) .mount(&server) .await; } diff --git a/crates/arr-daemon/src/tv_grab.rs b/crates/arr-daemon/src/tv_grab.rs index 134f3b4..99dd9a1 100644 --- a/crates/arr-daemon/src/tv_grab.rs +++ b/crates/arr-daemon/src/tv_grab.rs @@ -53,8 +53,8 @@ impl TvGrabAction { ) -> Self { Self { indexers: IndexerDirectory::new(prowlarr.clone()), + grabber: Grabber::new(prowlarr.clone(), transmission, download_dir, seeding), prowlarr, - grabber: Grabber::new(transmission, download_dir, seeding), } } @@ -678,7 +678,7 @@ mod tests { use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; - use crate::grab::SeedingLimits; + use crate::grab::{test_downloads, SeedingLimits}; use super::*; @@ -786,6 +786,8 @@ mod tests { async fn prowlarr() -> MockServer { let server = MockServer::start().await; + let feed = test_downloads::rewrite(TV_RSS, "https://tracker/", &server); + test_downloads::mount(&server).await; Mock::given(method("GET")) .and(path("/api/v1/indexer")) .respond_with( @@ -805,7 +807,7 @@ mod tests { Mock::given(method("GET")) .and(path("/7/api")) .and(query_param("t", "search")) - .respond_with(ResponseTemplate::new(200).set_body_string(TV_RSS)) + .respond_with(ResponseTemplate::new(200).set_body_string(feed)) .mount(&server) .await; server diff --git a/crates/arr-dl/src/lib.rs b/crates/arr-dl/src/lib.rs index 2edb7c5..7a8827b 100644 --- a/crates/arr-dl/src/lib.rs +++ b/crates/arr-dl/src/lib.rs @@ -16,12 +16,14 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); /// Where Transmission is to get the torrent from. +/// +/// Deliberately not an indexer URL: Transmission has no route to Prowlarr and +/// cannot follow its redirect to a magnet, so arr resolves the link itself and +/// hands over the result (issue #100). #[derive(Clone, Debug, Eq, PartialEq)] pub enum TorrentSource { Magnet(String), - /// An HTTP link to a `.torrent`, fetched by Transmission itself. Indexer - /// download links arrive in this form. - Url(String), + /// The bytes of a `.torrent` file, sent inline. Metainfo(Vec), } @@ -162,7 +164,7 @@ impl TransmissionClient { }); match request.source { - TorrentSource::Magnet(uri) | TorrentSource::Url(uri) => { + TorrentSource::Magnet(uri) => { arguments["filename"] = json!(uri); } TorrentSource::Metainfo(bytes) => { diff --git a/crates/arr-indexer/src/lib.rs b/crates/arr-indexer/src/lib.rs index 49981f4..d617e63 100644 --- a/crates/arr-indexer/src/lib.rs +++ b/crates/arr-indexer/src/lib.rs @@ -7,10 +7,14 @@ pub use search::{IndexerSearch, SearchError, SearchRelease, SearchRequest, TvSel use std::{collections::BTreeSet, time::Duration}; use quick_xml::events::Event; -use reqwest::{Client, StatusCode}; +use reqwest::{header::LOCATION, Client, StatusCode, Url}; use serde::Deserialize; use thiserror::Error; +/// How many redirects a download link may take before arr gives up. Prowlarr +/// takes one hop to a magnet or to the tracker's own file; the rest is slack. +const MAX_DOWNLOAD_REDIRECTS: usize = 5; + /// A Prowlarr indexer and the Torznab capabilities it advertises. #[derive(Clone, Debug, Eq, PartialEq)] pub struct Indexer { @@ -62,6 +66,9 @@ pub struct ProwlarrClient { base_url: String, api_key: String, client: Client, + /// Kept apart from `client` because resolving a download link depends on + /// seeing the redirect rather than following it (§7.1, issue #100). + downloads: Client, } impl ProwlarrClient { @@ -75,11 +82,17 @@ impl ProwlarrClient { .timeout(Duration::from_secs(30)) .build() .map_err(|_| Error::Client)?; + let downloads = Client::builder() + .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| Error::Client)?; Ok(Self { base_url: base_url.into().trim_end_matches('/').to_owned(), api_key: api_key.into(), client, + downloads, }) } @@ -147,6 +160,101 @@ impl ProwlarrClient { })?; parse_capabilities(&body) } + + /// Resolves an indexer download link into something the download client + /// can take without reaching the indexer itself. + /// + /// Handing Transmission the Prowlarr link fails twice over: Transmission + /// has no route to Prowlarr, and Prowlarr answers a `.torrent` link with a + /// redirect to a magnet, which a plain file fetch cannot follow. arr has + /// the route and the API key, so it resolves the link here and passes on + /// the result (issue #100). + /// + /// # Errors + /// + /// Returns an error when the link cannot be fetched or resolves to + /// something that is neither a magnet nor a torrent. + pub async fn download(&self, download_url: &str) -> Result { + if let Some(magnet) = as_magnet(download_url) { + return Ok(Download::Magnet(magnet)); + } + + let mut url = Url::parse(download_url).map_err(|_| DownloadError::InvalidUrl)?; + for _ in 0..MAX_DOWNLOAD_REDIRECTS { + let mut request = self.downloads.get(url.clone()); + // Only ever to Prowlarr itself: a redirect can leave for a tracker + // and the key must not go with it. + if self.is_prowlarr(&url) { + request = request.header("X-Api-Key", &self.api_key); + } + let response = request + .send() + .await + .map_err(|error| DownloadError::Request { + status: error.status(), + })?; + + if response.status().is_redirection() { + let location = response + .headers() + .get(LOCATION) + .and_then(|value| value.to_str().ok()) + .ok_or(DownloadError::UnusableRedirect)?; + if let Some(magnet) = as_magnet(location) { + return Ok(Download::Magnet(magnet)); + } + url = url + .join(location) + .map_err(|_| DownloadError::UnusableRedirect)?; + continue; + } + + let body = response + .error_for_status() + .map_err(|error| DownloadError::Request { + status: error.status(), + })? + .bytes() + .await + .map_err(|error| DownloadError::Request { + status: error.status(), + })?; + + // Some indexers answer with the magnet as the body rather than as + // a redirect. + if let Some(magnet) = std::str::from_utf8(&body).ok().and_then(as_magnet) { + return Ok(Download::Magnet(magnet)); + } + // A torrent is a bencoded dictionary. An HTML error page or a + // login form must not reach the download client as metainfo. + if body.first() != Some(&b'd') { + return Err(DownloadError::NotATorrent); + } + return Ok(Download::Torrent(body.to_vec())); + } + + Err(DownloadError::TooManyRedirects(MAX_DOWNLOAD_REDIRECTS)) + } + + fn is_prowlarr(&self, url: &Url) -> bool { + Url::parse(&self.base_url).is_ok_and(|base| base.origin() == url.origin()) + } +} + +/// What an indexer's download link resolved to. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Download { + Magnet(String), + /// The bytes of a `.torrent` file. + Torrent(Vec), +} + +fn as_magnet(value: &str) -> Option { + let value = value.trim(); + value + .get(..7) + .is_some_and(|scheme| scheme.eq_ignore_ascii_case("magnet:")) + .then(|| value.to_owned()) } /// Errors returned while discovering Prowlarr indexers. @@ -160,6 +268,21 @@ pub enum Error { Response, } +/// A download link that could not be turned into a magnet or a torrent. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum DownloadError { + #[error("download link request failed (status: {status:?})")] + Request { status: Option }, + #[error("download link is not a valid URL")] + InvalidUrl, + #[error("download link redirected to a location that cannot be used")] + UnusableRedirect, + #[error("download link returned neither a torrent nor a magnet")] + NotATorrent, + #[error("download link still redirected after {0} hops")] + TooManyRedirects(usize), +} + /// A failed capabilities request for an otherwise usable Prowlarr indexer. #[derive(Clone, Debug, Eq, Error, PartialEq)] pub enum CapabilityError { @@ -290,8 +413,8 @@ fn is_available(value: &str) -> bool { #[cfg(test)] mod tests { use super::{ - parse_capabilities, Capabilities, CapabilityError, ProwlarrClient, SearchRequest, - TvSelector, TvTarget, + parse_capabilities, Capabilities, CapabilityError, Download, DownloadError, ProwlarrClient, + SearchRequest, TvSelector, TvTarget, }; use reqwest::StatusCode; use wiremock::{ @@ -301,6 +424,10 @@ mod tests { const API_KEY: &str = "test-api-key"; + fn client(server: &MockServer) -> ProwlarrClient { + ProwlarrClient::new(server.uri(), API_KEY).expect("HTTP client can be created") + } + #[tokio::test] async fn enumerates_indexers_with_recorded_capabilities() { let server = MockServer::start().await; @@ -369,6 +496,145 @@ mod tests { ); } + /// #100: Prowlarr answers a `.torrent` link with a redirect to a magnet. + /// The download client cannot follow that, so arr does it here. + #[tokio::test] + async fn a_redirect_to_a_magnet_resolves_to_the_magnet() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/download")) + .and(header("X-Api-Key", API_KEY)) + .respond_with( + ResponseTemplate::new(302) + .insert_header("location", "magnet:?xt=urn:btih:abc&dn=release"), + ) + .mount(&server) + .await; + + let download = client(&server) + .download(&format!("{}/download", server.uri())) + .await + .expect("the redirect resolves"); + + assert_eq!( + download, + Download::Magnet("magnet:?xt=urn:btih:abc&dn=release".to_owned()) + ); + } + + #[tokio::test] + async fn a_torrent_body_resolves_to_its_bytes() { + let body: &[u8] = b"d4:infod6:lengthi1e4:name7:one.mkvee"; + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/download")) + .respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/x-bittorrent")) + .mount(&server) + .await; + + let download = client(&server) + .download(&format!("{}/download", server.uri())) + .await + .expect("the body is a torrent"); + + assert_eq!(download, Download::Torrent(body.to_vec())); + } + + /// A magnet in the feed needs no request at all. + #[tokio::test] + async fn a_magnet_link_resolves_without_a_request() { + let server = MockServer::start().await; + + let download = client(&server) + .download("magnet:?xt=urn:btih:abc") + .await + .expect("a magnet is already resolved"); + + assert_eq!( + download, + Download::Magnet("magnet:?xt=urn:btih:abc".to_owned()) + ); + assert!(server + .received_requests() + .await + .expect("recorded") + .is_empty()); + } + + /// The API key belongs to Prowlarr. A redirect that leaves for a tracker + /// must not carry it there. + #[tokio::test] + async fn the_api_key_never_follows_a_redirect_off_prowlarr() { + let tracker = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/file.torrent")) + .respond_with( + ResponseTemplate::new(200) + .set_body_raw(b"d4:infodee".as_slice(), "application/x-bittorrent"), + ) + .mount(&tracker) + .await; + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/download")) + .respond_with( + ResponseTemplate::new(302) + .insert_header("location", format!("{}/file.torrent", tracker.uri())), + ) + .mount(&server) + .await; + + let download = client(&server) + .download(&format!("{}/download", server.uri())) + .await + .expect("the tracker serves the file"); + + assert_eq!(download, Download::Torrent(b"d4:infodee".to_vec())); + let sent_key = tracker + .received_requests() + .await + .expect("recorded") + .into_iter() + .any(|request| request.headers.contains_key("x-api-key")); + assert!(!sent_key); + } + + /// An HTML error page or a login form is not a torrent, and passing it on + /// as metainfo would only fail later and less clearly. + #[tokio::test] + async fn a_body_that_is_not_a_torrent_is_refused() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/download")) + .respond_with(ResponseTemplate::new(200).set_body_string("login")) + .mount(&server) + .await; + + let error = client(&server) + .download(&format!("{}/download", server.uri())) + .await + .expect_err("HTML is not a torrent"); + + assert_eq!(error, DownloadError::NotATorrent); + } + + #[tokio::test] + async fn a_redirect_loop_gives_up() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/download")) + .respond_with(ResponseTemplate::new(302).insert_header("location", "/download")) + .mount(&server) + .await; + + let error = client(&server) + .download(&format!("{}/download", server.uri())) + .await + .expect_err("the link never resolves"); + + assert_eq!(error, DownloadError::TooManyRedirects(5)); + } + #[test] fn tv_requests_degrade_to_what_each_indexer_accepts() { let alpha = capabilities(include_str!("../tests/fixtures/alpha-caps.xml"));