1use 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
54fn 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) }
62
63fn 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
78fn 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 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
210async 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 let test_response = client
241 .get(url.clone())
242 .header(http::header::RANGE, "bytes=0-0")
243 .send()
244 .await?;
245
246 let supports_ranges = test_response.status() == http::StatusCode::PARTIAL_CONTENT;
248
249 let total_size = if supports_ranges {
251 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 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 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 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 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 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 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 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 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 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 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 .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 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 let results: Vec<_> = stream::iter(download_tasks)
435 .buffer_unordered(effective_connections as usize)
436 .collect()
437 .await;
438
439 for (i, result) in results.into_iter().enumerate() {
441 result.with_context(|| format!("Chunk {} failed", i))?;
442 }
443
444 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
453async 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
484pub 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 match option {
497 DownloadFileOption::Resumable => {
498 let num_connections = get_num_download_connections();
499
500 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 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 fn test_file_md5() -> Vec<u8> {
595 Md5::digest(TEST_FILE_CONTENT).to_vec()
596 }
597
598 struct TestServer {
600 addr: SocketAddr,
601 shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
602 }
603
604 impl TestServer {
605 async fn start() -> Self {
607 Self::start_with_content(TEST_FILE_CONTENT).await
608 }
609
610 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 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 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 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 if let Some(tx) = self.shutdown_tx.take() {
684 let _ = tx.send(());
685 }
686 }
687 }
688
689 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 if let Some(range_header) = headers.get(header::RANGE)
696 && let Ok(range_str) = range_header.to_str()
697 {
698 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 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 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 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 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 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, None,
815 )
816 .await
817 .unwrap();
818
819 assert!(result.exists());
820
821 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 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 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 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 let updates = progress_updates.lock();
878 assert!(!updates.is_empty(), "Should have received progress updates");
879
880 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 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 let url = server.url("/test-file-no-ranges");
912
913 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 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 let small_content: &[u8] = b"Hi!"; 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 let result = download_http_parallel(&url, temp_dir.path(), "tiny.dat", 5, None)
942 .await
943 .unwrap();
944
945 assert!(result.exists());
946
947 let downloaded = std::fs::read(&result).unwrap();
949 assert_eq!(downloaded, small_content);
950 }
951}