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