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