Skip to main content

fast_down/reqwest/
mod.rs

1#![cfg(not(target_family = "wasm"))]
2
3//! A `reqwest`-based implementation of the [`crate::http`] HTTP traits with
4//! smart redirect handling.
5//!
6//! This module adapts `reqwest` to the backend-agnostic [`crate::http::HttpClient`]
7//! trait family and, more importantly, provides [`SmartRedirectClient`]: a
8//! `reqwest::Client` wrapper that follows redirects **manually** so it can honor
9//! the `Referrer-Policy` header and strip resource-specific headers
10//! (`Origin` / `Authorization` / `Cookie`) on cross-origin hops, per RFC 9110
11//! §15.4. The corresponding request builder is [`ManualRedirectRequestBuilder`].
12//!
13//! Most users do not construct these types directly; instead they build a
14//! `FastDownPuller` via `build_client`, which creates a
15//! correctly-configured [`SmartRedirectClient`].
16
17use crate::http::{
18    HttpClient, HttpHeaders, HttpRequestBuilder, HttpResponse,
19    manual_redirect::{ReferrerPolicy, compute_referer},
20};
21use fast_pull::{ProgressEntry, PullResult};
22use httpdate::parse_http_date;
23use reqwest::{
24    Client, RequestBuilder, Response, StatusCode,
25    header::{self, HeaderMap, HeaderValue},
26};
27use std::{
28    borrow::Cow,
29    time::{Duration, SystemTime},
30};
31use url::Url;
32
33mod error;
34pub use error::*;
35
36impl HttpClient for Client {
37    type RequestBuilder = RequestBuilder;
38    fn get(&self, url: Url, range: Option<ProgressEntry>) -> Self::RequestBuilder {
39        let mut req = self.get(url);
40        if let Some(range) = range {
41            req = req.header(
42                header::RANGE,
43                format!("bytes={}-{}", range.start, range.end.saturating_sub(1)),
44            );
45        }
46        req
47    }
48}
49
50impl HttpRequestBuilder for RequestBuilder {
51    type Response = Response;
52    type RequestError = ReqwestResponseError;
53    async fn send(self) -> PullResult<Self::Response, Self::RequestError> {
54        let res = self
55            .send()
56            .await
57            .map_err(|e| (ReqwestResponseError::Request(e.into()), None))?;
58        let status = res.status();
59        if status.is_success() {
60            Ok(res)
61        } else {
62            let retry_after = parse_retry_after(res.headers());
63            Err((ReqwestResponseError::from_response(res), retry_after))
64        }
65    }
66}
67
68impl HttpResponse for Response {
69    type Headers = HeaderMap;
70    type ChunkError = reqwest::Error;
71    fn headers(&self) -> &Self::Headers {
72        self.headers()
73    }
74    fn url(&self) -> &Url {
75        self.url()
76    }
77    async fn chunk(&mut self) -> Result<Option<bytes::Bytes>, Self::ChunkError> {
78        self.chunk().await
79    }
80}
81
82impl HttpHeaders for HeaderMap {
83    type GetHeaderError = ReqwestGetHeaderError;
84    fn get(&self, header: &str) -> Result<Cow<'_, str>, Self::GetHeaderError> {
85        let header_value = self.get(header).ok_or(ReqwestGetHeaderError::NotFound)?;
86        Ok(String::from_utf8_lossy(header_value.as_bytes()))
87    }
88}
89
90/// Parse the `Retry-After` response header into a [`Duration`].
91///
92/// Supports both the delta-seconds format (integer) and the HTTP-date format.
93/// Returns `None` if the header is missing or unparseable.
94#[must_use]
95pub fn parse_retry_after(headers: &HeaderMap) -> Option<Duration> {
96    let retry_after = headers.get(header::RETRY_AFTER)?;
97    let retry_str = retry_after.to_str().ok()?;
98    if let Ok(secs) = retry_str.parse::<u64>() {
99        return Some(Duration::from_secs(secs));
100    }
101    let target_time = parse_http_date(retry_str).ok()?;
102    let duration = target_time.duration_since(SystemTime::now()).ok()?;
103    Some(duration)
104}
105
106/// A [`reqwest::Client`] wrapper that handles HTTP redirects manually,
107/// respecting the `Referrer-Policy` header and RFC 9110 §15.4 redirect rules.
108#[derive(Debug, Clone)]
109pub struct SmartRedirectClient {
110    client: Client,
111    initial_referer: Option<HeaderValue>,
112    referrer_policy: Option<ReferrerPolicy>,
113    /// Per RFC 9110 §15.4 item 2.5, resource-specific headers that are
114    /// stripped on redirect and injected only on the first request hop.
115    origin: Option<HeaderValue>,
116    authorization: Option<HeaderValue>,
117    cookie: Option<HeaderValue>,
118    max_redirects: usize,
119}
120
121impl SmartRedirectClient {
122    /// Build a [`SmartRedirectClient`] from an already-constructed `reqwest::Client`.
123    ///
124    /// * `client` — the underlying client. **Must** be built with
125    ///   `redirect(reqwest::redirect::Policy::none())`, otherwise the manual
126    ///   redirect logic here conflicts with reqwest's own auto-follow.
127    /// * `initial_referer` — the `Referer` sent on the first request.
128    /// * `referrer_policy` — the policy applied when no `Referrer-Policy`
129    ///   header is present on a response; per-hop headers override it.
130    /// * `origin` / `authorization` / `cookie` — resource-specific headers
131    ///   injected only on the first hop and stripped on redirect (RFC 9110 §15.4).
132    /// * `max_redirects` — the maximum number of redirects to follow before
133    ///   failing with a `StatusCode` error.
134    #[must_use]
135    pub const fn new(
136        client: Client,
137        initial_referer: Option<HeaderValue>,
138        referrer_policy: Option<ReferrerPolicy>,
139        origin: Option<HeaderValue>,
140        authorization: Option<HeaderValue>,
141        cookie: Option<HeaderValue>,
142        max_redirects: usize,
143    ) -> Self {
144        Self {
145            client,
146            initial_referer,
147            referrer_policy,
148            origin,
149            authorization,
150            cookie,
151            max_redirects,
152        }
153    }
154}
155
156impl HttpClient for SmartRedirectClient {
157    type RequestBuilder = ManualRedirectRequestBuilder;
158
159    fn get(&self, url: Url, range: Option<ProgressEntry>) -> Self::RequestBuilder {
160        ManualRedirectRequestBuilder {
161            client: self.client.clone(),
162            url,
163            range,
164            next_referer: self.initial_referer.clone(),
165            referrer_policy: self.referrer_policy,
166            origin: self.origin.clone(),
167            authorization: self.authorization.clone(),
168            cookie: self.cookie.clone(),
169            max_redirects: self.max_redirects,
170            redirect_count: 0,
171        }
172    }
173}
174
175/// Request builder that follows redirects manually.
176///
177/// On each redirect it:
178/// - Reads `Referrer-Policy` from the response (overriding the previous policy).
179/// - Applies the policy to compute the `Referer` for the next request.
180/// - Strips resource-specific headers (Origin, Authorization, Cookie) per
181///   RFC 9110 §15.4 item 2.5.
182/// - Inherits the fragment from the original URL if the Location header
183///   lacks one, per RFC 9110 §10.2.2.
184/// - Follows only 301, 302, 303, 307, 308 status codes.
185pub struct ManualRedirectRequestBuilder {
186    client: Client,
187    url: Url,
188    range: Option<ProgressEntry>,
189    next_referer: Option<HeaderValue>,
190    referrer_policy: Option<ReferrerPolicy>,
191    /// Resource-specific headers injected only on the first hop.
192    origin: Option<HeaderValue>,
193    authorization: Option<HeaderValue>,
194    cookie: Option<HeaderValue>,
195    max_redirects: usize,
196    redirect_count: usize,
197}
198
199impl HttpRequestBuilder for ManualRedirectRequestBuilder {
200    type Response = Response;
201    type RequestError = ReqwestResponseError;
202
203    async fn send(mut self) -> PullResult<Response, Self::RequestError> {
204        loop {
205            let mut req = self.client.get(self.url.clone());
206            if let Some(ref range) = self.range {
207                req = req.header(
208                    header::RANGE,
209                    format!("bytes={}-{}", range.start, range.end.saturating_sub(1)),
210                );
211            }
212            if let Some(ref referer) = self.next_referer {
213                req = req.header(header::REFERER, referer);
214            }
215            // Per RFC 9110 §15.4 item 2.5, resource-specific headers are
216            // only sent on the first hop and stripped on redirect.
217            if self.redirect_count == 0 {
218                if let Some(ref origin) = self.origin {
219                    req = req.header(header::ORIGIN, origin);
220                }
221                if let Some(ref auth) = self.authorization {
222                    req = req.header(header::AUTHORIZATION, auth);
223                }
224                if let Some(ref cookie) = self.cookie {
225                    req = req.header(header::COOKIE, cookie);
226                }
227            }
228            let resp = req
229                .send()
230                .await
231                .map_err(|e| (ReqwestResponseError::Request(e.into()), None))?;
232
233            // DEBUG ASSERT: If reqwest auto-followed redirects, resp.url() will differ
234            // from the URL we sent the request to. This means the inner Client was NOT
235            // built with `redirect::Policy::none()`, which breaks manual redirect logic.
236            // This check is removed entirely in release builds (zero cost).
237            debug_assert!(
238                resp.url() == &self.url,
239                "SmartRedirectClient: inner reqwest::Client has auto-redirect ENABLED. \
240                 Build it with `.redirect(reqwest::redirect::Policy::none())`. \
241                 The Referer-Policy aware redirect logic requires full control over redirects."
242            );
243
244            let status = resp.status();
245            if !is_redirection(status) {
246                return if status.is_success() {
247                    Ok(resp)
248                } else {
249                    let retry_after = parse_retry_after(resp.headers());
250                    Err((ReqwestResponseError::from_response(resp), retry_after))
251                };
252            }
253            if self.redirect_count >= self.max_redirects {
254                let retry_after = parse_retry_after(resp.headers());
255                return Err((ReqwestResponseError::from_response(resp), retry_after));
256            }
257            let location = if let Some(v) = resp.headers().get(header::LOCATION)
258                && let Ok(s) = v.to_str()
259            {
260                s
261            } else {
262                let retry_after = parse_retry_after(resp.headers());
263                return Err((ReqwestResponseError::from_response(resp), retry_after));
264            };
265            let Ok(mut next_url) = self.url.join(location) else {
266                let retry_after = parse_retry_after(resp.headers());
267                return Err((ReqwestResponseError::from_response(resp), retry_after));
268            };
269            // RFC 9110 §10.2.2: If the Location header lacks a fragment,
270            // inherit it from the original request URI.
271            if next_url.fragment().is_none()
272                && let Some(fragment) = self.url.fragment()
273            {
274                next_url.set_fragment(Some(fragment));
275            }
276            if let Some(policy_header) = resp.headers().get("referrer-policy")
277                && let Ok(s) = policy_header.to_str()
278                && let Some(p) = ReferrerPolicy::parse(s)
279            {
280                self.referrer_policy = Some(p);
281            }
282            self.next_referer = compute_referer(self.referrer_policy, &self.url, &next_url)
283                .and_then(|s| HeaderValue::from_str(&s).ok());
284            self.url = next_url;
285            self.redirect_count += 1;
286        }
287    }
288}
289
290fn is_redirection(status: StatusCode) -> bool {
291    matches!(
292        status,
293        StatusCode::MOVED_PERMANENTLY
294            | StatusCode::FOUND
295            | StatusCode::SEE_OTHER
296            | StatusCode::TEMPORARY_REDIRECT
297            | StatusCode::PERMANENT_REDIRECT
298    )
299}
300
301#[cfg(test)]
302mod tests {
303    #![allow(
304        clippy::unwrap_used,
305        clippy::expect_used,
306        clippy::panic,
307        clippy::significant_drop_tightening
308    )]
309    use super::*;
310    use crate::{
311        http::{HttpPuller, Prefetch},
312        url_info::FileId,
313    };
314    use fast_pull::{
315        Event, MemPusher, Merge,
316        mock::build_mock_data,
317        multi::{self, download_multi},
318        single::{self, download_single},
319    };
320    use reqwest::{Client, StatusCode};
321    use std::sync::Arc;
322    use std::time::Duration;
323
324    #[tokio::test]
325    async fn test_redirect_and_content_range() {
326        let mut server = mockito::Server::new_async().await;
327        let client = Client::builder().no_proxy().build().unwrap();
328
329        let _mock_redirect = server
330            .mock("GET", "/redirect")
331            .with_status(301)
332            .with_header("Location", "/%e4%bd%a0%e5%a5%bd.txt")
333            .create_async()
334            .await;
335
336        let _mock_file = server
337            .mock("GET", "/%e4%bd%a0%e5%a5%bd.txt")
338            .with_status(206)
339            .with_header("Content-Length", "1024")
340            .with_header("Content-Range", "bytes 0-0/1024")
341            .with_body(vec![0; 1024])
342            .create_async()
343            .await;
344
345        let url = Url::parse(&format!("{}/redirect", server.url())).unwrap();
346        let (url_info, _) = client.prefetch(url).await.expect("Request should succeed");
347
348        assert_eq!(
349            url_info.final_url.as_str(),
350            format!("{}/%e4%bd%a0%e5%a5%bd.txt", server.url())
351        );
352        assert_eq!(url_info.size, 1024);
353        assert_eq!(url_info.raw_name, "你好.txt");
354        assert!(url_info.supports_range);
355    }
356
357    #[tokio::test]
358    async fn test_filename_sources() {
359        let mut server = mockito::Server::new_async().await;
360        let client = Client::builder().no_proxy().build().unwrap();
361
362        // Test with Content-Disposition header
363        let _mock1 = server
364            .mock("GET", "/test1")
365            .with_header("Content-Disposition", r#"attachment; filename="test.txt""#)
366            .create_async()
367            .await;
368        let url = Url::parse(&format!("{}/test1", server.url())).unwrap();
369        let (url_info, _) = client.prefetch(url).await.unwrap();
370        assert_eq!(url_info.raw_name, "test.txt");
371
372        // Test filename* only (UTF-8 encoding)
373        let _mock_star = server
374            .mock("GET", "/test_star")
375            .with_header(
376                "Content-Disposition",
377                "attachment; filename*=UTF-8''%E6%B5%8B%E8%AF%95.txt",
378            ) // decodes to "测试.txt"
379            .create_async()
380            .await;
381        let url = Url::parse(&format!("{}/test_star", server.url())).unwrap();
382        let (url_info, _) = client.prefetch(url).await.unwrap();
383        assert_eq!(url_info.raw_name, "测试.txt");
384
385        let _mock_both = server
386            .mock("GET", "/test_both")
387            .with_header(
388                "Content-Disposition",
389                r#"attachment; filename="fallback.txt"; filename*=UTF-8''%E6%B5%8B%E8%AF%95.txt"#,
390            )
391            .create_async()
392            .await;
393        let url = Url::parse(&format!("{}/test_both", server.url())).unwrap();
394        let (url_info, _) = client.prefetch(url).await.unwrap();
395        assert_eq!(url_info.raw_name, "测试.txt");
396
397        // Test URL path source
398        let _mock2 = server
399            .mock("GET", "/test2/%E5%A5%BD%E5%A5%BD%E5%A5%BD.pdf")
400            .create_async()
401            .await;
402        let url = Url::parse(&format!(
403            "{}/test2/%E5%A5%BD%E5%A5%BD%E5%A5%BD.pdf",
404            server.url()
405        ))
406        .unwrap();
407        let (url_info, _) = client.prefetch(url).await.unwrap();
408        assert_eq!(url_info.raw_name, "好好好.pdf");
409    }
410
411    #[tokio::test]
412    async fn test_concurrent_download() {
413        // 8 MiB is ample to exercise many concurrent range requests over a real
414        // socket; a larger payload only slows the test down (the server copies
415        // each requested slice) without covering more behavior.
416        let mock_data = build_mock_data(8 * 1024 * 1024);
417        let mut server = mockito::Server::new_async().await;
418        let client = Client::builder().no_proxy().build().unwrap();
419        let _mock = mount_range_endpoint(&mut server, "/concurrent", mock_data.clone()).await;
420        let puller = HttpPuller::new(
421            Arc::new(format!("{}/concurrent", server.url()).parse().unwrap()),
422            client,
423            None,
424            FileId::default(),
425        );
426        let pusher = MemPusher::with_capacity(mock_data.len());
427        // Keep only the data handle for the final assertion; the whole `pusher`
428        // (whose listener holds a clone of the `event_chain` sender) is moved into
429        // the download, so `event_chain` closes once the push thread finishes,
430        // terminating the single drain loop below.
431        let receive = pusher.receive.clone();
432        #[allow(clippy::single_range_in_vec_init)]
433        let download_chunks = vec![0..mock_data.len() as u64];
434        let result = download_multi(
435            puller,
436            pusher,
437            multi::DownloadOptions {
438                concurrent: 32,
439                retry_gap: Duration::from_secs(1),
440                push_queue_cap: 1024,
441                download_chunks: download_chunks.iter().cloned(),
442                pull_timeout: Duration::from_secs(5),
443                min_chunk_size: 1,
444                max_speculative: 3,
445            },
446        );
447
448        let mut pull_progress: Vec<ProgressEntry> = Vec::new();
449        let mut push_progress: Vec<ProgressEntry> = Vec::new();
450        // `PushProgress` now flows on the same `event_chain` as the engine events
451        // (the sink's listener emits it the moment data is actually written), so a
452        // single drain collects both pull and push progress.
453        while let Ok(e) = result.event_chain().recv().await {
454            match e {
455                Event::PullProgress(_, p) => pull_progress.merge_progress(p),
456                Event::PushProgress(p) => push_progress.merge_progress(p),
457                _ => {}
458            }
459        }
460        dbg!(&pull_progress);
461        dbg!(&push_progress);
462        assert_eq!(pull_progress, download_chunks);
463        assert_eq!(push_progress, download_chunks);
464
465        assert_eq!(&**receive.lock(), mock_data);
466    }
467
468    /// Mount an endpoint that serves `body` and honours `Range` requests, as a
469    /// server advertising `Accept-Ranges: bytes` would.
470    async fn mount_range_endpoint(
471        server: &mut mockito::ServerGuard,
472        path: &str,
473        body: Vec<u8>,
474    ) -> mockito::Mock {
475        server
476            .mock("GET", path)
477            .with_status(206)
478            .with_header("Accept-Ranges", "bytes")
479            .with_body_from_request(move |request| {
480                if !request.has_header("Range") {
481                    return body.clone();
482                }
483                request.header("Range")[0]
484                    .to_str()
485                    .unwrap()
486                    .rsplit('=')
487                    .next()
488                    .unwrap()
489                    .split(',')
490                    .map(|p| p.trim().splitn(2, '-'))
491                    .map(|mut p| {
492                        let start = p.next().unwrap().parse::<usize>().unwrap();
493                        let end = p.next().unwrap().parse::<usize>().unwrap();
494                        start..=end
495                    })
496                    .flat_map(|p| body[p].to_vec())
497                    .collect()
498            })
499            .create_async()
500            .await
501    }
502
503    /// Create an empty temp file of `size` bytes and map it for writing.
504    ///
505    /// The returned pusher owns the mapping, so the `File` handle is dropped
506    /// here: both `mmap` and Windows file mappings keep the underlying object
507    /// alive independently of the descriptor used to create them.
508    #[cfg(feature = "file")]
509    async fn temp_mmap_target(
510        tag: &str,
511        size: u64,
512    ) -> (fast_pull::MmapFilePusher, std::path::PathBuf) {
513        let dir = std::env::temp_dir().join(format!("fast-down-{tag}-{}", std::process::id()));
514        std::fs::create_dir_all(&dir).unwrap();
515        let path = dir.join("out.bin");
516        let file = tokio::fs::OpenOptions::new()
517            .read(true)
518            .write(true)
519            .create(true)
520            .truncate(true)
521            .open(&path)
522            .await
523            .unwrap();
524        let pusher = fast_pull::MmapFilePusher::new(&file, size, false)
525            .await
526            .unwrap();
527        (pusher, path)
528    }
529
530    /// Deterministic xorshift64 so a failing resize sequence can be replayed.
531    #[cfg(feature = "file")]
532    fn next_rand(state: &mut u64) -> u64 {
533        *state ^= *state << 13;
534        *state ^= *state >> 7;
535        *state ^= *state << 17;
536        *state
537    }
538
539    /// Repeatedly resize the worker pool while a real HTTP download streams
540    /// into a real memory-mapped file.
541    ///
542    /// The in-memory unit tests resize a pool fed by a mock puller and sink, so
543    /// they cannot observe how a resize interacts with live sockets, chunked
544    /// transfer and page-cache writes. This drives all three at once and checks
545    /// the bytes that actually reached the disk.
546    #[cfg(feature = "file")]
547    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
548    async fn test_concurrent_download_thread_churn_preserves_all_bytes() {
549        use std::collections::BTreeSet;
550        use std::sync::atomic::{AtomicU64, Ordering};
551
552        let mock_data = build_mock_data(4 * 1024 * 1024);
553        let mut server = mockito::Server::new_async().await;
554        let client = Client::builder().no_proxy().build().unwrap();
555        let _mock = mount_range_endpoint(&mut server, "/churn", mock_data.clone()).await;
556
557        let size = mock_data.len() as u64;
558        let (pusher, path) = temp_mmap_target("churn", size).await;
559
560        let puller = HttpPuller::new(
561            Arc::new(format!("{}/churn", server.url()).parse().unwrap()),
562            client,
563            None,
564            FileId::default(),
565        );
566        #[allow(clippy::single_range_in_vec_init)]
567        let download_chunks = vec![0..size];
568        // Start with a single worker so growth has somewhere to go, and keep
569        // `min_chunk_size` at 1 so a resize can split whatever remains.
570        let result = download_multi(
571            puller,
572            pusher,
573            multi::DownloadOptions {
574                concurrent: 1,
575                retry_gap: Duration::from_secs(1),
576                push_queue_cap: 1024,
577                download_chunks: download_chunks.iter().cloned(),
578                pull_timeout: Duration::from_secs(30),
579                min_chunk_size: 1,
580                max_speculative: 3,
581            },
582        );
583
584        let written = Arc::new(AtomicU64::new(0));
585        let churner = result.clone();
586        let probe = written.clone();
587        let churn = tokio::spawn(async move {
588            let mut state = 0x2545_F491_4F6C_DD1D_u64;
589            let mut seen = BTreeSet::new();
590            let mut inflight = 0usize;
591            // Track the transfer instead of resizing a fixed number of times: a
592            // fixed count would drain long before the download does and only
593            // exercise the idle pool.
594            for _ in 0..3000 {
595                let done = probe.load(Ordering::Relaxed);
596                if done >= size {
597                    break;
598                }
599                if done > 0 {
600                    inflight += 1;
601                }
602                let threads = usize::try_from(next_rand(&mut state) % 8 + 1).unwrap();
603                seen.insert(threads);
604                churner.set_threads(threads);
605                tokio::time::sleep(Duration::from_millis(5)).await;
606            }
607            // Leave a healthy pool behind so the remaining ranges drain.
608            churner.set_threads(8);
609            (seen, inflight)
610        });
611
612        let mut pulling_total = 0usize;
613        let mut push_progress: Vec<ProgressEntry> = Vec::new();
614        while let Ok(e) = result.event_chain().recv().await {
615            match e {
616                Event::Pulling(_) => pulling_total += 1,
617                Event::PushProgress(p) => {
618                    written.fetch_add(p.end - p.start, Ordering::Relaxed);
619                    push_progress.merge_progress(p);
620                }
621                _ => {}
622            }
623        }
624        let (seen, inflight) = churn.await.unwrap();
625
626        // Without these the test silently degrades into a plain download the
627        // moment resizing stops taking effect.
628        assert!(
629            seen.len() > 2,
630            "churn never varied the pool size, so nothing was exercised: {seen:?}"
631        );
632        assert!(
633            inflight > 0,
634            "every resize landed outside the transfer, so churn was a no-op"
635        );
636        // Every effective resize reclaims and re-hands-out the remaining range,
637        // so a live pool emits far more `Pulling` events than the session had
638        // starting chunks. Equality with the chunk count means resizing was
639        // inert and one worker pulled everything.
640        assert!(
641            pulling_total >= 8,
642            "ranges were never redistributed across workers (pulling={pulling_total}, chunks={})",
643            download_chunks.len()
644        );
645        assert_eq!(
646            push_progress, download_chunks,
647            "repeated resizing lost or duplicated pushed ranges"
648        );
649
650        // The mapping is unmapped once the push driver drops the pusher, so the
651        // file now holds everything the session claimed to have written.
652        let on_disk = tokio::fs::read(&path).await.unwrap();
653        assert!(
654            on_disk == mock_data,
655            "repeated resizing corrupted the bytes written to disk"
656        );
657        let _ = std::fs::remove_dir_all(path.parent().unwrap());
658    }
659
660    #[tokio::test]
661    async fn test_sequential_download() {
662        // 8 MiB is enough to stream a real response body end-to-end; the point
663        // is the single-stream path, not raw size.
664        let mock_data = build_mock_data(8 * 1024 * 1024);
665        let mut server = mockito::Server::new_async().await;
666        let client = Client::builder().no_proxy().build().unwrap();
667        let _mock = server
668            .mock("GET", "/sequential")
669            .with_status(200)
670            .with_body(mock_data.clone())
671            .create_async()
672            .await;
673        let puller = HttpPuller::new(
674            Arc::new(format!("{}/sequential", server.url()).parse().unwrap()),
675            client,
676            None,
677            FileId::default(),
678        );
679        let pusher = MemPusher::with_capacity(mock_data.len());
680        // Keep only the data handle for the final assertion; the whole `pusher`
681        // (whose listener holds a clone of the `event_chain` sender) is moved into
682        // the download, so `event_chain` closes once the push thread finishes,
683        // terminating the single drain loop below.
684        let receive = pusher.receive.clone();
685        #[allow(clippy::single_range_in_vec_init)]
686        let download_chunks = vec![0..mock_data.len() as u64];
687        let result = download_single(
688            puller,
689            pusher,
690            single::DownloadOptions {
691                retry_gap: Duration::from_secs(1),
692                push_queue_cap: 1024,
693            },
694        );
695
696        let mut pull_progress: Vec<ProgressEntry> = Vec::new();
697        let mut push_progress: Vec<ProgressEntry> = Vec::new();
698        // `PushProgress` now flows on the same `event_chain` as the engine events
699        // (the sink's listener emits it the moment data is actually written), so a
700        // single drain collects both pull and push progress.
701        while let Ok(e) = result.event_chain().recv().await {
702            match e {
703                Event::PullProgress(_, p) => pull_progress.merge_progress(p),
704                Event::PushProgress(p) => push_progress.merge_progress(p),
705                _ => {}
706            }
707        }
708        dbg!(&pull_progress);
709        dbg!(&push_progress);
710        assert_eq!(pull_progress, download_chunks);
711        assert_eq!(push_progress, download_chunks);
712
713        assert_eq!(&**receive.lock(), mock_data);
714    }
715
716    /// Drives `ManualRedirectRequestBuilder::send` directly via `SmartRedirectClient`
717    /// (the production code path, as opposed to the plain `reqwest::Client` impl that
718    /// relies on reqwest's native auto-redirect). Confirms that `self.url = next_url`
719    /// correctly advances the request URL across a manual redirect so the final
720    /// response lands on the redirected location.
721    #[tokio::test]
722    async fn test_smart_redirect_follows_redirect() {
723        let mut server = mockito::Server::new_async().await;
724        // The inner reqwest client MUST use Policy::none() so that the manual
725        // redirect logic in `ManualRedirectRequestBuilder` is the one driving
726        // following (this is what `build_client` does in production).
727        let client = Client::builder()
728            .no_proxy()
729            .redirect(reqwest::redirect::Policy::none())
730            .build()
731            .unwrap();
732
733        let _mock_redirect = server
734            .mock("GET", "/src")
735            .with_status(302)
736            .with_header("Location", "/dst")
737            .create_async()
738            .await;
739        let _mock_dst = server
740            .mock("GET", "/dst")
741            .with_status(200)
742            .with_header("Content-Length", "11")
743            .with_body("hello world")
744            .create_async()
745            .await;
746
747        let redirect_client = SmartRedirectClient::new(client, None, None, None, None, None, 10);
748        let url = Url::parse(&format!("{}/src", server.url())).unwrap();
749        let resp = redirect_client
750            .get(url, None)
751            .send()
752            .await
753            .expect("manual redirect should succeed");
754        assert_eq!(resp.status(), StatusCode::OK);
755        // `ManualRedirectRequestBuilder::send` must have advanced `self.url`
756        // to the redirected destination.
757        assert_eq!(resp.url().path(), "/dst");
758    }
759
760    /// Confirms `ManualRedirectRequestBuilder::send` honors `max_redirects`
761    /// (an infinite redirect loop must fail once the cap is exceeded, rather
762    /// than looping forever or silently succeeding).
763    #[tokio::test]
764    async fn test_smart_redirect_respects_max_redirects() {
765        let mut server = mockito::Server::new_async().await;
766        let client = Client::builder()
767            .no_proxy()
768            .redirect(reqwest::redirect::Policy::none())
769            .build()
770            .unwrap();
771        let _mock_loop = server
772            .mock("GET", "/loop")
773            .with_status(301)
774            .with_header("Location", "/loop")
775            .create_async()
776            .await;
777
778        let redirect_client = SmartRedirectClient::new(client, None, None, None, None, None, 2);
779        let url = Url::parse(&format!("{}/loop", server.url())).unwrap();
780        let err = redirect_client
781            .get(url, None)
782            .send()
783            .await
784            .expect_err("exceeding max_redirects should fail");
785        assert!(matches!(err.0, ReqwestResponseError::StatusCode(_)));
786    }
787
788    #[test]
789    fn parse_retry_after_missing_is_none() {
790        let headers = HeaderMap::new();
791        assert_eq!(parse_retry_after(&headers), None);
792    }
793
794    #[test]
795    fn parse_retry_after_delta_seconds() {
796        let mut headers = HeaderMap::new();
797        headers.insert(header::RETRY_AFTER, HeaderValue::from_static("120"));
798        assert_eq!(parse_retry_after(&headers), Some(Duration::from_mins(2)));
799    }
800
801    #[test]
802    fn parse_retry_after_http_date() {
803        let mut headers = HeaderMap::new();
804        // Build a guaranteed-valid future HTTP date via httpdate's own formatter,
805        // so `parse_http_date` round-trips it successfully.
806        let future = SystemTime::now() + Duration::from_hours(1);
807        let date_str = httpdate::fmt_http_date(future);
808        headers.insert(
809            header::RETRY_AFTER,
810            HeaderValue::from_str(&date_str).unwrap(),
811        );
812        let delay = parse_retry_after(&headers);
813        assert!(delay.is_some());
814        assert!(delay.unwrap() > Duration::ZERO);
815    }
816
817    #[test]
818    fn parse_retry_after_unparseable_is_none() {
819        // Not a number and not a valid HTTP date -> falls through to `None`.
820        let mut headers = HeaderMap::new();
821        headers.insert(header::RETRY_AFTER, HeaderValue::from_static("not-a-time"));
822        assert_eq!(parse_retry_after(&headers), None);
823    }
824
825    #[test]
826    fn parse_retry_after_past_date_is_none() {
827        // A valid HTTP date in the past yields a negative duration -> `None`.
828        let mut headers = HeaderMap::new();
829        headers.insert(
830            header::RETRY_AFTER,
831            HeaderValue::from_static("Mon, 01 Jan 2001 00:00:00 GMT"),
832        );
833        assert_eq!(parse_retry_after(&headers), None);
834    }
835
836    #[tokio::test]
837    async fn test_smart_redirect_404_returns_status_code_error() {
838        let mut server = mockito::Server::new_async().await;
839        let client = Client::builder()
840            .no_proxy()
841            .redirect(reqwest::redirect::Policy::none())
842            .build()
843            .unwrap();
844        let _mock = server
845            .mock("GET", "/404")
846            .with_status(404)
847            .create_async()
848            .await;
849        let redirect_client = SmartRedirectClient::new(client, None, None, None, None, None, 10);
850        let url = Url::parse(&format!("{}/404", server.url())).unwrap();
851        let err = redirect_client
852            .get(url, None)
853            .send()
854            .await
855            .expect_err("404 should produce a StatusCode error");
856        assert!(matches!(err.0, ReqwestResponseError::StatusCode(_)));
857    }
858
859    #[tokio::test]
860    async fn test_smart_redirect_302_without_location_errors() {
861        let mut server = mockito::Server::new_async().await;
862        let client = Client::builder()
863            .no_proxy()
864            .redirect(reqwest::redirect::Policy::none())
865            .build()
866            .unwrap();
867        let _mock = server
868            .mock("GET", "/redir")
869            .with_status(302)
870            .create_async()
871            .await;
872        let redirect_client = SmartRedirectClient::new(client, None, None, None, None, None, 10);
873        let url = Url::parse(&format!("{}/redir", server.url())).unwrap();
874        let err = redirect_client
875            .get(url, None)
876            .send()
877            .await
878            .expect_err("a 302 without Location should error");
879        assert!(matches!(err.0, ReqwestResponseError::StatusCode(_)));
880    }
881
882    #[tokio::test]
883    async fn test_smart_redirect_302_unjoinable_location_errors() {
884        let mut server = mockito::Server::new_async().await;
885        let client = Client::builder()
886            .no_proxy()
887            .redirect(reqwest::redirect::Policy::none())
888            .build()
889            .unwrap();
890        let _mock = server
891            .mock("GET", "/redir")
892            .with_status(302)
893            .with_header("Location", "http://")
894            .create_async()
895            .await;
896        let redirect_client = SmartRedirectClient::new(client, None, None, None, None, None, 10);
897        let url = Url::parse(&format!("{}/redir", server.url())).unwrap();
898        let err = redirect_client
899            .get(url, None)
900            .send()
901            .await
902            .expect_err("an unjoinable Location should error");
903        assert!(matches!(err.0, ReqwestResponseError::StatusCode(_)));
904    }
905
906    #[tokio::test]
907    async fn test_smart_redirect_reads_referrer_policy() {
908        let mut server = mockito::Server::new_async().await;
909        let client = Client::builder()
910            .no_proxy()
911            .redirect(reqwest::redirect::Policy::none())
912            .build()
913            .unwrap();
914        let _mock_redir = server
915            .mock("GET", "/src")
916            .with_status(302)
917            .with_header("Location", "/dst")
918            .with_header("Referrer-Policy", "no-referrer")
919            .create_async()
920            .await;
921        let _mock_dst = server
922            .mock("GET", "/dst")
923            .with_status(200)
924            .with_header("Content-Length", "5")
925            .with_body("hello")
926            .create_async()
927            .await;
928        let redirect_client = SmartRedirectClient::new(client, None, None, None, None, None, 10);
929        let url = Url::parse(&format!("{}/src", server.url())).unwrap();
930        let resp = redirect_client
931            .get(url, None)
932            .send()
933            .await
934            .expect("redirect with Referrer-Policy should succeed");
935        assert_eq!(resp.status(), StatusCode::OK);
936    }
937
938    #[tokio::test]
939    async fn test_smart_redirect_injects_resource_headers() {
940        let mut server = mockito::Server::new_async().await;
941        let client = Client::builder()
942            .no_proxy()
943            .redirect(reqwest::redirect::Policy::none())
944            .build()
945            .unwrap();
946        let _mock = server
947            .mock("GET", "/dst")
948            .with_status(200)
949            .with_header("Content-Length", "5")
950            .with_body("hello")
951            .create_async()
952            .await;
953        // First-hop resource-specific headers are injected only on redirect_count == 0.
954        let redirect_client = SmartRedirectClient::new(
955            client,
956            None,
957            Some(ReferrerPolicy::NoReferrer),
958            Some(HeaderValue::from_static("https://origin.example")),
959            Some(HeaderValue::from_static("secret-token")),
960            Some(HeaderValue::from_static("cookie-value")),
961            10,
962        );
963        let url = Url::parse(&format!("{}/dst", server.url())).unwrap();
964        let resp = redirect_client
965            .get(url, None)
966            .send()
967            .await
968            .expect("request with resource headers should succeed");
969        assert_eq!(resp.status(), StatusCode::OK);
970    }
971
972    #[tokio::test]
973    async fn test_smart_redirect_with_range_request() {
974        let mut server = mockito::Server::new_async().await;
975        let client = Client::builder()
976            .no_proxy()
977            .redirect(reqwest::redirect::Policy::none())
978            .build()
979            .unwrap();
980        let _mock = server
981            .mock("GET", "/range")
982            .with_status(200)
983            .with_header("Content-Length", "4")
984            .with_body("data")
985            .create_async()
986            .await;
987        let redirect_client = SmartRedirectClient::new(client, None, None, None, None, None, 10);
988        let url = Url::parse(&format!("{}/range", server.url())).unwrap();
989        // A ranged request exercises the `if let Some(ref range)` branch that
990        // attaches the `Range` header (lines 219-224).
991        let resp = redirect_client
992            .get(url, Some(0..3))
993            .send()
994            .await
995            .expect("ranged request must succeed");
996        assert_eq!(resp.status(), StatusCode::OK);
997    }
998
999    /// Verifies the `Range` header survives a manual redirect: the request is
1000    /// re-issued to the redirect target with the same byte range, so a ranged
1001    /// download still works when the URL redirects.
1002    #[tokio::test]
1003    async fn test_smart_redirect_preserves_range_across_redirect() {
1004        let mut server = mockito::Server::new_async().await;
1005        let client = Client::builder()
1006            .no_proxy()
1007            .redirect(reqwest::redirect::Policy::none())
1008            .build()
1009            .unwrap();
1010        let _mock_src = server
1011            .mock("GET", "/src")
1012            .with_status(302)
1013            .with_header("Location", "/dst")
1014            .create_async()
1015            .await;
1016        // The target only matches if the Range header survives the redirect hop.
1017        let _mock_dst = server
1018            .mock("GET", "/dst")
1019            .match_header("Range", "bytes=0-2")
1020            .with_status(206)
1021            .with_header("Content-Length", "3")
1022            .with_body("dat")
1023            .create_async()
1024            .await;
1025        let redirect_client = SmartRedirectClient::new(client, None, None, None, None, None, 10);
1026        let url = Url::parse(&format!("{}/src", server.url())).unwrap();
1027        // Range 0..3 -> "bytes=0-2"; must be re-sent to /dst after the hop.
1028        let resp = redirect_client
1029            .get(url, Some(0..3))
1030            .send()
1031            .await
1032            .expect("ranged request across redirect must succeed");
1033        assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT);
1034    }
1035}
1036
1037#[cfg(test)]
1038mod range_underflow_tests {
1039    #![allow(clippy::unwrap_used, clippy::expect_used)]
1040    use super::*;
1041    use crate::http::HttpClient;
1042
1043    #[test]
1044    fn client_get_empty_range_does_not_underflow() {
1045        // Regression for hypothesis D: `range.end.saturating_sub(1)` must not
1046        // underflow u64 when `range.end == 0`. Building the Range header for an
1047        // empty range (`0..0`) must construct a request without panicking.
1048        let client = reqwest::Client::builder().no_proxy().build().unwrap();
1049        let _ = HttpClient::get(
1050            &client,
1051            Url::parse("http://example.com/x").unwrap(),
1052            Some(0..0u64),
1053        );
1054    }
1055
1056    #[tokio::test]
1057    async fn smart_redirect_get_empty_range_does_not_underflow() {
1058        // Same fix in the production path (`ManualRedirectRequestBuilder::send`).
1059        // A local mock server avoids a real network call while still exercising
1060        // the Range-header construction inside `send`.
1061        let mut server = mockito::Server::new_async().await;
1062        let client = reqwest::Client::builder()
1063            .no_proxy()
1064            .redirect(reqwest::redirect::Policy::none())
1065            .build()
1066            .unwrap();
1067        let _mock = server
1068            .mock("GET", "/x")
1069            .with_status(200)
1070            .with_header("Content-Length", "1")
1071            .with_body("a")
1072            .create_async()
1073            .await;
1074        let rc = SmartRedirectClient::new(client, None, None, None, None, None, 10);
1075        let resp = rc
1076            .get(
1077                Url::parse(&format!("{}/x", server.url())).unwrap(),
1078                Some(0..0u64),
1079            )
1080            .send()
1081            .await;
1082        // The mock server is no longer needed once the response is received.
1083        drop(server);
1084        assert!(resp.is_ok(), "empty-range request must not panic");
1085    }
1086}