Skip to main content

forest/utils/net/
download_file.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4//! File download utilities with parallel connection support.
5//!
6//! This module provides high-performance file downloads similar to `aria2c -x5`,
7//! using multiple parallel HTTP connections to download different parts of a file
8//! simultaneously.
9//!
10//! # Configuration
11//!
12//! The number of parallel connections can be configured via the
13//! `FOREST_DOWNLOAD_CONNECTIONS` environment variable:
14//!
15//! # Example
16//!
17//! ```no_run
18//! use forest::doctest_private::{download_to, DownloadFileOption};
19//! use url::Url;
20//! use std::path::Path;
21//!
22//! # async fn example() -> anyhow::Result<()> {
23//! let url = Url::parse("https://example.com/large-file.zst")?;
24//! let destination = Path::new("./large-file.zst");
25//!
26//! // Download with parallel connections (automatic for Resumable option)
27//! download_to(&url, destination, DownloadFileOption::Resumable, None).await?;
28//! # Ok(())
29//! # }
30//! ```
31
32use crate::utils::{RetryArgs, net::global_http_client, retry};
33use anyhow::{Context as _, ensure};
34use backon::{ExponentialBuilder, Retryable as _};
35use base64::{Engine, prelude::BASE64_STANDARD};
36use digest_io::IoWrapper;
37use futures::stream::{self, StreamExt as _, TryStreamExt as _};
38use human_repr::HumanCount as _;
39use humantime::format_duration;
40use md5::{Digest as _, Md5};
41use std::sync::atomic::Ordering;
42use std::{
43    ffi::OsStr,
44    fs::File,
45    io::BufReader,
46    path::{Path, PathBuf},
47    sync::Arc,
48    time::{Duration, Instant},
49};
50use tokio::io::{AsyncSeekExt, AsyncWriteExt};
51use url::Url;
52
53/// Number of parallel connections to use for downloads (like aria2c -x flag)
54/// Can be overridden with `FOREST_DOWNLOAD_CONNECTIONS` environment variable
55fn get_num_download_connections() -> usize {
56    std::env::var("FOREST_DOWNLOAD_CONNECTIONS")
57        .ok()
58        .and_then(|s| s.parse().ok())
59        .unwrap_or(5) // Default to 5 like aria2c -x5
60}
61
62/// Generate a temporary download path with `.frdownload` extension
63fn gen_tmp_download_path(dst_path: &Path) -> PathBuf {
64    const DOWNLOAD_EXTENSION: &str = "frdownload";
65    let mut path = dst_path.to_path_buf();
66    if let Some(ext) = path.extension() {
67        path.set_extension(format!(
68            "{}.{DOWNLOAD_EXTENSION}",
69            ext.to_str().unwrap_or_default()
70        ));
71    } else {
72        path.set_extension(DOWNLOAD_EXTENSION);
73    }
74    path
75}
76
77/// Call user-provided callback with progress percentage
78fn call_progress_callback(
79    callback: Option<&(dyn Fn(String) + Sync + Send)>,
80    downloaded: u64,
81    total_size: u64,
82) {
83    if let Some(cb) = callback {
84        let progress_pct = if total_size > 0 {
85            ((downloaded as f64 / total_size as f64) * 100.0) as u8
86        } else {
87            0
88        };
89        cb(format!("{progress_pct}%"));
90    }
91}
92
93#[derive(Debug, Copy, Clone)]
94pub enum DownloadFileOption {
95    NonResumable,
96    Resumable,
97}
98
99#[derive(Debug, Clone)]
100pub struct DownloadFileResult {
101    pub path: PathBuf,
102    #[allow(dead_code)]
103    pub cache_hit: bool,
104}
105
106pub async fn download_file_with_cache(
107    url: &Url,
108    cache_dir: &Path,
109    option: DownloadFileOption,
110) -> anyhow::Result<DownloadFileResult> {
111    let cache_file_path =
112        cache_dir.join(url.path().strip_prefix('/').unwrap_or_else(|| url.path()));
113    if let Some(cache_file_dir) = cache_file_path.parent()
114        && !cache_file_dir.is_dir()
115    {
116        std::fs::create_dir_all(cache_file_dir)?;
117    }
118
119    let cache_hit = match get_file_md5_hash(&cache_file_path) {
120        Ok(file_md5) => match get_content_md5_hash_from_url(url.clone()).await? {
121            Some(url_md5) => {
122                if file_md5 == url_md5 {
123                    true
124                } else {
125                    tracing::warn!(
126                        "download again due to md5 hash mismatch, url: {url}, local cache: {}, remote: {}",
127                        hex::encode(&file_md5),
128                        hex::encode(&url_md5)
129                    );
130                    false
131                }
132            }
133            None => {
134                anyhow::bail!("failed to extract md5 content hash from remote url {url}");
135            }
136        },
137        Err(_) => false,
138    };
139
140    if cache_hit {
141        tracing::debug!(%url, "loaded from cache");
142    } else {
143        download_file_with_retry(
144            url,
145            cache_file_path.parent().unwrap_or_else(|| Path::new(".")),
146            cache_file_path
147                .file_name()
148                .and_then(OsStr::to_str)
149                .with_context(|| {
150                    format!(
151                        "Error getting the file name of {}",
152                        cache_file_path.display()
153                    )
154                })?,
155            option,
156            None,
157        )
158        .await?;
159    }
160
161    Ok(DownloadFileResult {
162        path: cache_file_path,
163        cache_hit,
164    })
165}
166
167fn get_file_md5_hash(path: &Path) -> anyhow::Result<Vec<u8>> {
168    let mut hasher = IoWrapper(Md5::new());
169    let mut reader = BufReader::new(File::open(path)?);
170    std::io::copy(&mut reader, &mut hasher)?;
171    Ok(hasher.0.finalize().to_vec())
172}
173
174async fn get_content_md5_hash_from_url(url: Url) -> anyhow::Result<Option<Vec<u8>>> {
175    const TIMEOUT: Duration = Duration::from_secs(5);
176    let response = (|| {
177        global_http_client()
178            .head(url.clone())
179            .timeout(TIMEOUT)
180            .send()
181    })
182    .retry(ExponentialBuilder::default())
183    .await?;
184    let headers = response.headers();
185    // Github release assets
186    if let Some(ms_blob_md5) = headers.get("x-ms-blob-content-md5") {
187        return Ok(Some(BASE64_STANDARD.decode(ms_blob_md5)?));
188    }
189
190    static HOSTS_WITH_MD5_ETAG: [&str; 2] =
191        ["filecoin-actors.chainsafe.dev", ".digitaloceanspaces.com"];
192    if url
193        .host_str()
194        .map(|h| HOSTS_WITH_MD5_ETAG.iter().any(|h_part| h.contains(h_part)))
195        .unwrap_or_default()
196    {
197        let md5 = headers
198            .get("etag")
199            .and_then(|v| v.to_str().ok().map(|v| hex::decode(v.replace('"', ""))))
200            .transpose()?;
201        Ok(md5)
202    } else {
203        anyhow::bail!(
204            "unsupported host, register in HOSTS_WITH_MD5_ETAG if it's known to use md5 as etag algorithm. url: {url}"
205        )
206    }
207}
208
209/// Download a file using multiple parallel connections (like aria2c -x5)
210///
211/// This function splits the file into chunks and downloads them in parallel,
212/// which can significantly improve download speeds for large files.
213async fn download_http_parallel(
214    url: &Url,
215    directory: &Path,
216    filename: &str,
217    num_connections: usize,
218    callback: Option<Arc<dyn Fn(String) + Sync + Send>>,
219) -> anyhow::Result<PathBuf> {
220    ensure!(
221        num_connections > 0,
222        "Number of connections must be greater than 0"
223    );
224    if !directory.is_dir() {
225        std::fs::create_dir_all(directory)?;
226    }
227    let dst_path = directory.join(filename);
228    let tmp_dst_path = gen_tmp_download_path(&dst_path);
229
230    let client = global_http_client();
231
232    // Check if server supports range requests by attempting a small range request.
233    // We test with an actual range request (bytes=0-0) instead of checking Accept-Ranges
234    // header because:
235    // 1. Some servers (especially CDNs with redirects) don't include Accept-Ranges in HEAD
236    // 2. This follows redirects automatically and tests the final endpoint
237    // 3. It's the same approach used by aria2c and other download managers
238    // 4. Only costs 1 byte of bandwidth to verify
239    let test_response = client
240        .get(url.clone())
241        .header(http::header::RANGE, "bytes=0-0")
242        .send()
243        .await?;
244
245    // Server supports ranges if it returns 206 Partial Content
246    let supports_ranges = test_response.status() == http::StatusCode::PARTIAL_CONTENT;
247
248    // Get the actual file size from Content-Range or Content-Length
249    let total_size = if supports_ranges {
250        // Parse Content-Range header: "bytes 0-0/12345" -> 12345
251        test_response
252            .headers()
253            .get(http::header::CONTENT_RANGE)
254            .and_then(|v| v.to_str().ok())
255            .and_then(|s| s.split('/').nth(1))
256            .and_then(|s| s.parse::<u64>().ok())
257            .context("Failed to parse Content-Range header")?
258    } else {
259        // Fallback to Content-Length if range not supported
260        test_response.content_length().unwrap_or(0)
261    };
262
263    if !supports_ranges || total_size == 0 {
264        tracing::info!(
265            %url,
266            status = %test_response.status(),
267            "Server doesn't support range requests, falling back to single connection"
268        );
269        return download_http_single(
270            url,
271            directory,
272            filename,
273            DownloadFileOption::Resumable,
274            callback,
275        )
276        .await;
277    }
278
279    // Create the file and allocate space
280    let file = tokio::fs::File::create(&tmp_dst_path)
281        .await
282        .context("couldn't create destination file")?;
283    file.set_len(total_size)
284        .await
285        .context("couldn't allocate file space")?;
286
287    // Prevent underflow when file is smaller than connection count
288    // Use at most as many connections as there are bytes
289    let effective_connections = (num_connections as u64).min(total_size.max(1));
290    let chunk_size = total_size / effective_connections;
291
292    tracing::debug!(
293        %url,
294        path = %dst_path.display(),
295        size = %total_size,
296        connections = %effective_connections,
297        "downloading with parallel connections"
298    );
299
300    // Progress tracking - log every 5 seconds like the forest::progress system
301    let bytes_downloaded = Arc::new(std::sync::atomic::AtomicU64::new(0));
302    let last_logged_bytes = Arc::new(std::sync::atomic::AtomicU64::new(0));
303    // Store elapsed millis since start_time to avoid needing a Mutex<Instant>.
304    let last_logged_millis = Arc::new(std::sync::atomic::AtomicU64::new(0));
305    let start_time = Instant::now();
306    const UPDATE_FREQUENCY: Duration = Duration::from_secs(5);
307    const UPDATE_FREQUENCY_MS: u64 = UPDATE_FREQUENCY.as_millis() as u64;
308
309    // Download chunks in parallel
310    let download_tasks = (0..effective_connections).map(|i| {
311        let client = client.clone();
312        let url = url.clone();
313        let tmp_path = tmp_dst_path.clone();
314        let bytes_downloaded = Arc::clone(&bytes_downloaded);
315        let last_logged_bytes = Arc::clone(&last_logged_bytes);
316        let last_logged_millis = Arc::clone(&last_logged_millis);
317        let callback = callback.clone();
318
319        let start = i * chunk_size;
320        let end = if i == effective_connections - 1 {
321            total_size - 1
322        } else {
323            ((i + 1) * chunk_size - 1).min(total_size - 1)
324        };
325
326        async move {
327            let range = format!("bytes={}-{}", start, end);
328            let expected_size = (end - start + 1) as usize;
329
330            // Retry logic for each chunk
331            let download_chunk = || async {
332                let response = client
333                    .get(url.clone())
334                    .header(http::header::RANGE, &range)
335                    .send()
336                    .await?;
337
338                if !response.status().is_success()
339                    && response.status() != http::StatusCode::PARTIAL_CONTENT
340                {
341                    anyhow::bail!("Failed to download chunk {}: {}", i, response.status());
342                }
343
344                // Open file for writing this chunk
345                let mut file = tokio::fs::OpenOptions::new()
346                    .write(true)
347                    .open(&tmp_path)
348                    .await?;
349                file.seek(std::io::SeekFrom::Start(start)).await?;
350
351                // Stream bytes and update progress incrementally
352                let mut stream = response.bytes_stream();
353                let mut chunk_bytes_written = 0u64;
354
355                let result: anyhow::Result<()> = async {
356                    while let Some(chunk_result) = stream.try_next().await? {
357                        file.write_all(&chunk_result).await?;
358                        chunk_bytes_written += chunk_result.len() as u64;
359
360                        let downloaded = bytes_downloaded
361                            .fetch_add(chunk_result.len() as u64, Ordering::Relaxed)
362                            + chunk_result.len() as u64;
363
364                        // Log progress every 5 seconds (lockless fast path)
365                        let elapsed_ms = start_time.elapsed().as_millis() as u64;
366                        let prev_ms = last_logged_millis.load(Ordering::Relaxed);
367                        if elapsed_ms.saturating_sub(prev_ms) >= UPDATE_FREQUENCY_MS
368                            && last_logged_millis
369                                // Spurious failure is fine — another task logs instead.
370                                .compare_exchange_weak(
371                                    prev_ms,
372                                    elapsed_ms,
373                                    Ordering::Relaxed,
374                                    Ordering::Relaxed,
375                                )
376                                .is_ok()
377                        {
378                            let last_bytes = last_logged_bytes.load(Ordering::Relaxed);
379                            let elapsed_secs = elapsed_ms as f64 / 1000.0;
380                            let seconds_since_last = (elapsed_ms - prev_ms) as f64 / 1000.0;
381                            let speed = downloaded.saturating_sub(last_bytes) as f64
382                                / seconds_since_last.max(0.1);
383                            let percent = downloaded
384                                .checked_mul(100)
385                                .and_then(|v| v.checked_div(total_size))
386                                .unwrap_or(0);
387                            tracing::info!(
388                                target: "forest::progress",
389                                "Loading {} / {}, {}%, {}/s, elapsed time: {}",
390                                downloaded.human_count_bytes(),
391                                total_size.human_count_bytes(),
392                                percent,
393                                speed.human_count_bytes(),
394                                format_duration(Duration::from_secs(
395                                    elapsed_secs as u64
396                                ))
397                            );
398
399                            last_logged_bytes.store(downloaded, Ordering::Relaxed);
400                        }
401
402                        call_progress_callback(callback.as_deref(), downloaded, total_size);
403                    }
404
405                    file.flush().await?;
406                    ensure!(
407                        chunk_bytes_written == expected_size as u64,
408                        "Chunk {i} size mismatch: expected {expected_size} \
409                         bytes, got {chunk_bytes_written}"
410                    );
411                    Ok(())
412                }
413                .await;
414
415                // On failure, undo progress so retries don't push past 100%.
416                result.inspect_err(|e| {
417                    tracing::warn!(
418                        "Chunk {i} download failed after {}: {e:#}",
419                        chunk_bytes_written.human_count_bytes(),
420                    );
421                    bytes_downloaded.fetch_sub(chunk_bytes_written, Ordering::Relaxed);
422                })
423            };
424
425            download_chunk
426                .retry(ExponentialBuilder::default().with_max_times(5))
427                .await
428                .with_context(|| format!("Failed to download chunk {} after retries", i))
429        }
430    });
431
432    // Execute all downloads in parallel and collect results
433    let results: Vec<_> = stream::iter(download_tasks)
434        .buffer_unordered(effective_connections as usize)
435        .collect()
436        .await;
437
438    // Check if any chunk failed
439    for (i, result) in results.into_iter().enumerate() {
440        result.with_context(|| format!("Chunk {} failed", i))?;
441    }
442
443    // Rename to final destination
444    tokio::fs::rename(&tmp_dst_path, &dst_path)
445        .await
446        .context("couldn't rename file")?;
447
448    tracing::debug!("successfully downloaded file to {}", dst_path.display());
449    Ok(dst_path)
450}
451
452/// Download the file at `url` with a single HTTP connection, returning the path to the downloaded file
453async fn download_http_single(
454    url: &Url,
455    directory: &Path,
456    filename: &str,
457    option: DownloadFileOption,
458    callback: Option<Arc<dyn Fn(String) + Sync + Send>>,
459) -> anyhow::Result<PathBuf> {
460    if !directory.is_dir() {
461        std::fs::create_dir_all(directory)?;
462    }
463    let dst_path = directory.join(filename);
464    let tmp_dst_path = gen_tmp_download_path(&dst_path);
465    let destination = dst_path.display();
466    tracing::info!(%url, %destination, "downloading with single connection");
467    let mut reader = crate::utils::net::reader(url.as_str(), option, callback).await?;
468    const WRITE_BUFFER_SIZE: usize = 1024 * 1024;
469    let file = tokio::fs::File::create(&tmp_dst_path)
470        .await
471        .context("couldn't create destination file")?;
472    let mut tempfile = tokio::io::BufWriter::with_capacity(WRITE_BUFFER_SIZE, file);
473    tokio::io::copy(&mut reader, &mut tempfile)
474        .await
475        .context("couldn't download file")?;
476    tempfile.flush().await.context("couldn't flush file")?;
477    tokio::fs::rename(&tmp_dst_path, &dst_path)
478        .await
479        .context("couldn't rename file")?;
480    Ok(dst_path)
481}
482
483/// Download the file at `url` using the global HTTP client (via [`download_http_parallel`] or
484/// [`download_http_single`]), returning the path to the downloaded file.
485///
486/// Uses [`global_http_client`] for all HTTP requests.
487pub async fn download_http(
488    url: &Url,
489    directory: &Path,
490    filename: &str,
491    option: DownloadFileOption,
492    callback: Option<Arc<dyn Fn(String) + Sync + Send>>,
493) -> anyhow::Result<PathBuf> {
494    // Use parallel downloads for Resumable option, single connection otherwise
495    match option {
496        DownloadFileOption::Resumable => {
497            let num_connections = get_num_download_connections();
498
499            // Try parallel download, fall back to single connection on error
500            match download_http_parallel(
501                url,
502                directory,
503                filename,
504                num_connections,
505                callback.clone(),
506            )
507            .await
508            {
509                Ok(path) => Ok(path),
510                Err(e) => {
511                    tracing::warn!(
512                        "Parallel download failed ({}), falling back to single connection",
513                        e
514                    );
515                    download_http_single(
516                        url,
517                        directory,
518                        filename,
519                        DownloadFileOption::Resumable,
520                        callback,
521                    )
522                    .await
523                }
524            }
525        }
526        DownloadFileOption::NonResumable => {
527            download_http_single(url, directory, filename, option, callback).await
528        }
529    }
530}
531
532pub async fn download_file_with_retry(
533    url: &Url,
534    directory: &Path,
535    filename: &str,
536    option: DownloadFileOption,
537    callback: Option<Arc<dyn Fn(String) + Sync + Send>>,
538) -> anyhow::Result<PathBuf> {
539    Ok(retry(
540        RetryArgs {
541            timeout: None,
542            ..Default::default()
543        },
544        || download_http(url, directory, filename, option, callback.clone()),
545    )
546    .await?)
547}
548
549pub async fn download_to(
550    url: &Url,
551    destination: &Path,
552    option: DownloadFileOption,
553    callback: Option<Arc<dyn Fn(String) + Sync + Send>>,
554) -> anyhow::Result<()> {
555    download_file_with_retry(
556        url,
557        destination.parent().with_context(|| {
558            format!(
559                "Error getting the parent directory of {}",
560                destination.display()
561            )
562        })?,
563        destination
564            .file_name()
565            .and_then(OsStr::to_str)
566            .with_context(|| format!("Error getting the file name of {}", destination.display()))?,
567        option,
568        callback,
569    )
570    .await?;
571
572    Ok(())
573}
574
575#[cfg(test)]
576mod test {
577    use super::*;
578    use axum::{
579        Router,
580        body::Body,
581        extract::Request,
582        http::{StatusCode, header},
583        response::Response,
584        routing::get,
585    };
586    use std::net::SocketAddr;
587    use tokio::net::TcpListener;
588
589    /// Test file data with known MD5 hash
590    const TEST_FILE_CONTENT: &[u8] = b"ph'nglui mglw'nafh Cthulhu R'lyeh wgah'nagl fhtagn ph'nglui mglw'nafh Cthulhu R'lyeh wgah'nagl fhtagn ph'nglui mglw'nafh Cthulhu R'lyeh wgah'nagl fhtagn";
591
592    /// MD5 hash of `TEST_FILE_CONTENT` (binary)
593    fn test_file_md5() -> Vec<u8> {
594        Md5::digest(TEST_FILE_CONTENT).to_vec()
595    }
596
597    /// Test server that supports range requests
598    struct TestServer {
599        addr: SocketAddr,
600        shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
601    }
602
603    impl TestServer {
604        /// Start a new test server that serves `TEST_FILE_CONTENT` with range request support
605        async fn start() -> Self {
606            Self::start_with_content(TEST_FILE_CONTENT).await
607        }
608
609        /// Start a new test server with custom content
610        async fn start_with_content(content: &'static [u8]) -> Self {
611            let app = Router::new()
612                .route(
613                    "/test-file",
614                    get(move |req: Request| async move { handle_file_request(req, content).await }),
615                )
616                .route(
617                    "/test-file-no-ranges",
618                    get(move |_req: Request| async move {
619                        // Server that doesn't support range requests
620                        Response::builder()
621                            .status(StatusCode::OK)
622                            .header(header::CONTENT_TYPE, "application/octet-stream")
623                            .header(header::CONTENT_LENGTH, content.len())
624                            .body(Body::from(content))
625                            .unwrap()
626                    }),
627                )
628                .route(
629                    "/test-file-with-md5-etag",
630                    get(move |req: Request| async move {
631                        let mut response = handle_file_request(req, content).await;
632                        // Add MD5 hash as ETag (like filecoin-actors.chainsafe.dev)
633                        let md5_hex = hex::encode(Md5::digest(content));
634                        response
635                            .headers_mut()
636                            .insert(header::ETAG, format!("\"{md5_hex}\"").parse().unwrap());
637                        response
638                    }),
639                )
640                .route(
641                    "/test-file-with-ms-blob-md5",
642                    get(move |req: Request| async move {
643                        let mut response = handle_file_request(req, content).await;
644                        // Add MD5 hash as x-ms-blob-content-md5 (like GitHub releases)
645                        let md5 = Md5::digest(content);
646                        let md5_base64 = BASE64_STANDARD.encode(md5);
647                        response
648                            .headers_mut()
649                            .insert("x-ms-blob-content-md5", md5_base64.parse().unwrap());
650                        response
651                    }),
652                );
653
654            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
655            let addr = listener.local_addr().unwrap();
656
657            let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
658
659            tokio::spawn(async move {
660                axum::serve(listener, app)
661                    .with_graceful_shutdown(async {
662                        shutdown_rx.await.ok();
663                    })
664                    .await
665                    .unwrap();
666            });
667
668            Self {
669                addr,
670                shutdown_tx: Some(shutdown_tx),
671            }
672        }
673
674        fn url(&self, path: &str) -> Url {
675            format!("http://{}{}", self.addr, path).parse().unwrap()
676        }
677    }
678
679    impl Drop for TestServer {
680        fn drop(&mut self) {
681            // Trigger graceful shutdown (best effort, ignore errors)
682            if let Some(tx) = self.shutdown_tx.take() {
683                let _ = tx.send(());
684            }
685        }
686    }
687
688    /// Handle file requests with range support
689    async fn handle_file_request(req: Request, content: &'static [u8]) -> Response {
690        let headers = req.headers();
691        let content_len = content.len() as u64;
692
693        // Check if this is a range request
694        if let Some(range_header) = headers.get(header::RANGE)
695            && let Ok(range_str) = range_header.to_str()
696        {
697            // Parse range header: "bytes=0-0" or "bytes=100-200"
698            if let Some(range) = range_str.strip_prefix("bytes=") {
699                let parts: Vec<&str> = range.split('-').collect();
700                if parts.len() == 2 {
701                    let start: u64 = parts
702                        .first()
703                        .and_then(|s| s.parse::<u64>().ok())
704                        .unwrap_or(0);
705                    let end: u64 = parts
706                        .get(1)
707                        .filter(|s| !s.is_empty())
708                        .and_then(|s| s.parse::<u64>().ok())
709                        .unwrap_or(content_len.saturating_sub(1));
710
711                    // Handle empty content case
712                    if content_len == 0 {
713                        return Response::builder()
714                            .status(StatusCode::RANGE_NOT_SATISFIABLE)
715                            .header(header::CONTENT_RANGE, format!("bytes */{}", content_len))
716                            .body(Body::empty())
717                            .unwrap();
718                    }
719
720                    let start = start.min(content_len - 1);
721                    let end = end.min(content_len - 1);
722
723                    if start <= end {
724                        // Use .get() instead of direct indexing to safely handle edge cases
725                        if let Some(range_content) = content.get(start as usize..=end as usize) {
726                            return Response::builder()
727                                .status(StatusCode::PARTIAL_CONTENT)
728                                .header(header::CONTENT_TYPE, "application/octet-stream")
729                                .header(header::CONTENT_LENGTH, range_content.len())
730                                .header(
731                                    header::CONTENT_RANGE,
732                                    format!("bytes {}-{}/{}", start, end, content_len),
733                                )
734                                .header(header::ACCEPT_RANGES, "bytes")
735                                .body(Body::from(range_content))
736                                .unwrap();
737                        } else {
738                            // Range is out of bounds
739                            return Response::builder()
740                                .status(StatusCode::RANGE_NOT_SATISFIABLE)
741                                .header(header::CONTENT_RANGE, format!("bytes */{}", content_len))
742                                .body(Body::empty())
743                                .unwrap();
744                        }
745                    }
746                }
747            }
748        }
749
750        // Return full content
751        Response::builder()
752            .status(StatusCode::OK)
753            .header(header::CONTENT_TYPE, "application/octet-stream")
754            .header(header::CONTENT_LENGTH, content_len)
755            .header(header::ACCEPT_RANGES, "bytes")
756            .body(Body::from(content))
757            .unwrap()
758    }
759
760    #[tokio::test]
761    async fn test_get_content_md5_hash_from_url_1() {
762        let server = TestServer::start().await;
763        let url = server.url("/test-file-with-md5-etag");
764
765        // This will fail because 127.0.0.1 is not in HOSTS_WITH_MD5_ETAG
766        let md5 = get_content_md5_hash_from_url(url).await;
767        assert!(
768            md5.is_err(),
769            "Should fail for localhost (not in HOSTS_WITH_MD5_ETAG)"
770        );
771    }
772
773    #[tokio::test]
774    async fn test_get_content_md5_hash_from_url_2() {
775        let server = TestServer::start().await;
776        let url = server.url("/test-file-with-ms-blob-md5");
777
778        let md5 = get_content_md5_hash_from_url(url).await.unwrap();
779
780        assert_eq!(md5, Some(test_file_md5()));
781    }
782
783    #[tokio::test]
784    async fn test_download_file_with_cache() {
785        let server = TestServer::start().await;
786        let temp_dir = tempfile::tempdir().unwrap();
787        let url = server.url("/test-file-with-ms-blob-md5");
788
789        let result =
790            download_file_with_cache(&url, temp_dir.path(), DownloadFileOption::NonResumable)
791                .await
792                .unwrap();
793        assert!(!result.cache_hit);
794
795        let result =
796            download_file_with_cache(&url, temp_dir.path(), DownloadFileOption::NonResumable)
797                .await
798                .unwrap();
799        assert!(result.cache_hit);
800    }
801
802    #[tokio::test]
803    async fn test_parallel_download() {
804        let server = TestServer::start().await;
805        let temp_dir = tempfile::tempdir().unwrap();
806        let url = server.url("/test-file");
807
808        let result = download_http_parallel(
809            &url,
810            temp_dir.path(),
811            "test-parallel.dat",
812            3, // Use 3 connections for testing
813            None,
814        )
815        .await
816        .unwrap();
817
818        assert!(result.exists());
819
820        // Verify the file is not corrupted by checking its MD5
821        let downloaded_md5 = get_file_md5_hash(&result).unwrap();
822        assert_eq!(downloaded_md5, test_file_md5());
823    }
824
825    #[tokio::test]
826    async fn test_download_http_uses_parallel() {
827        let server = TestServer::start().await;
828        let temp_dir = tempfile::tempdir().unwrap();
829        let url = server.url("/test-file");
830
831        // Test with Resumable option (should use parallel)
832        let result = download_http(
833            &url,
834            temp_dir.path(),
835            "test-resumable.dat",
836            DownloadFileOption::Resumable,
837            None,
838        )
839        .await
840        .unwrap();
841
842        assert!(result.exists());
843
844        // Verify integrity
845        let downloaded_md5 = get_file_md5_hash(&result).unwrap();
846        assert_eq!(downloaded_md5, test_file_md5());
847    }
848
849    #[tokio::test]
850    async fn test_parallel_download_with_progress() {
851        let server = TestServer::start().await;
852        let temp_dir = tempfile::tempdir().unwrap();
853        let url = server.url("/test-file");
854
855        // Track progress updates
856        let progress_updates = Arc::new(parking_lot::Mutex::new(Vec::new()));
857        let progress_updates_clone = Arc::clone(&progress_updates);
858
859        let callback = Arc::new(move |msg: String| {
860            progress_updates_clone.lock().push(msg);
861        });
862
863        let result = download_http_parallel(
864            &url,
865            temp_dir.path(),
866            "test-progress.dat",
867            3,
868            Some(callback),
869        )
870        .await
871        .unwrap();
872
873        assert!(result.exists());
874
875        // Verify we got progress updates
876        let updates = progress_updates.lock();
877        assert!(!updates.is_empty(), "Should have received progress updates");
878
879        // Verify progress increases monotonically
880        let mut last_progress = 0;
881        for update in updates.iter() {
882            if let Some(progress_str) = update.strip_suffix('%')
883                && let Ok(progress) = progress_str.parse::<u8>()
884            {
885                assert!(
886                    progress >= last_progress,
887                    "Progress should increase: {} < {}",
888                    progress,
889                    last_progress
890                );
891                last_progress = progress;
892            }
893        }
894
895        // Should reach 100% for small test files
896        assert!(
897            last_progress >= 90,
898            "Should reach at least 90% progress, got {}",
899            last_progress
900        );
901
902        println!("Progress updates: {:?}", updates);
903    }
904
905    #[tokio::test]
906    async fn test_fallback_to_single_connection() {
907        let server = TestServer::start().await;
908        let temp_dir = tempfile::tempdir().unwrap();
909        // Use the endpoint that doesn't support range requests
910        let url = server.url("/test-file-no-ranges");
911
912        // Try to download with parallel (should fallback to single connection)
913        let result = download_http(
914            &url,
915            temp_dir.path(),
916            "test-fallback.dat",
917            DownloadFileOption::Resumable,
918            None,
919        )
920        .await
921        .unwrap();
922
923        assert!(result.exists());
924
925        // Verify content is correct despite fallback
926        let content = std::fs::read(&result).unwrap();
927        assert_eq!(content, TEST_FILE_CONTENT);
928    }
929
930    #[tokio::test]
931    async fn test_small_file_with_many_connections() {
932        // Test edge case: file smaller than connection count
933        // This tests the underflow prevention when chunk_size would be 0
934        let small_content: &[u8] = b"Hi!"; // 3 bytes
935        let server = TestServer::start_with_content(small_content).await;
936        let temp_dir = tempfile::tempdir().unwrap();
937        let url = server.url("/test-file");
938
939        // Try to download with more connections than bytes
940        let result = download_http_parallel(&url, temp_dir.path(), "tiny.dat", 5, None)
941            .await
942            .unwrap();
943
944        assert!(result.exists());
945
946        // Verify content is correct
947        let downloaded = std::fs::read(&result).unwrap();
948        assert_eq!(downloaded, small_content);
949    }
950}