1use std::{
17 cmp,
18 collections::BTreeMap,
19 ffi::OsString,
20 fmt::Display,
21 fs::{File, OpenOptions, remove_file},
22 io::{BufReader, BufWriter, Read, Write},
23 path::{Path, PathBuf},
24 sync::OnceLock,
25 thread::sleep,
26 time::{Duration, Instant},
27};
28
29use aws_lc_rs::digest::{self, Context};
30use nautilus_core::hex;
31use nautilus_network::{http::HttpClient, retry::RetryConfig};
32use parking_lot::Mutex;
33use rand::{RngExt, rng};
34use serde_json::Value;
35
36static LARGE_CHECKSUMS_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
37
38fn lock_large_checksums() -> parking_lot::MutexGuard<'static, ()> {
39 LARGE_CHECKSUMS_LOCK.get_or_init(|| Mutex::new(())).lock()
40}
41
42#[derive(Debug)]
43enum DownloadError {
44 Retryable(String),
45 NonRetryable(String),
46}
47
48impl Display for DownloadError {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match self {
51 Self::Retryable(msg) => write!(f, "Retryable error: {msg}"),
52 Self::NonRetryable(msg) => write!(f, "Non-retryable error: {msg}"),
53 }
54 }
55}
56
57impl std::error::Error for DownloadError {}
58
59fn execute_with_retry_blocking<T, E, F>(
60 config: &RetryConfig,
61 mut op: F,
62 should_retry: impl Fn(&E) -> bool,
63) -> Result<T, E>
64where
65 E: std::error::Error,
66 F: FnMut() -> Result<T, E>,
67{
68 let start = Instant::now();
69 let mut delay = Duration::from_millis(config.initial_delay_ms);
70
71 for attempt in 0..=config.max_retries {
72 if attempt > 0 && !config.immediate_first {
73 let jitter = rng().random_range(0..=config.jitter_ms);
74 let sleep_for = delay + Duration::from_millis(jitter);
75 sleep(sleep_for);
76 delay = cmp::min(
77 next_retry_delay(delay, config.backoff_factor),
78 Duration::from_millis(config.max_delay_ms),
79 );
80 }
81
82 if let Some(max_total) = config.max_elapsed_ms
83 && start.elapsed() >= Duration::from_millis(max_total)
84 {
85 break;
86 }
87
88 match op() {
89 Ok(v) => return Ok(v),
90 Err(e) if attempt < config.max_retries && should_retry(&e) => {}
91 Err(e) => return Err(e),
92 }
93 }
94
95 op()
96}
97
98fn next_retry_delay(delay: Duration, backoff_factor: f64) -> Duration {
99 let next = delay.as_secs_f64() * backoff_factor;
100 if next.is_nan() || next.is_sign_negative() {
101 return Duration::ZERO;
102 }
103
104 Duration::try_from_secs_f64(next).unwrap_or(Duration::MAX)
105}
106
107pub fn prepare_test_data() -> anyhow::Result<()> {
115 let checksums = crate::common::get_test_data_large_checksums_filepath();
116 let manifest: BTreeMap<String, String> =
117 serde_json::from_reader(BufReader::new(File::open(&checksums)?))?;
118 let directory = nautilus_core::paths::get_test_data_path().join("large");
119
120 for filename in manifest.keys() {
121 let filepath = directory.join(filename);
122 let url = format!("https://test-data.nautechsystems.io/large/{filename}");
123 prepare_test_data_file(&filepath, &url, &checksums)?;
124 }
125 Ok(())
126}
127
128fn prepare_test_data_file(filepath: &Path, url: &str, checksums: &Path) -> anyhow::Result<()> {
129 if filepath.exists() {
130 if verify_sha256_checksum(filepath, checksums)? {
131 return Ok(());
132 }
133 remove_file(filepath)?;
134 }
135
136 download_file(filepath, url, 30, None)?;
137 if !verify_sha256_checksum(filepath, checksums)? {
138 remove_file(filepath)?;
139 anyhow::bail!("Checksum mismatch for {}", filepath.display());
140 }
141 Ok(())
142}
143
144pub fn ensure_file_exists_or_download_http(
164 filepath: &Path,
165 url: &str,
166 checksums: Option<&Path>,
167 timeout_secs: Option<u64>,
168) -> anyhow::Result<()> {
169 ensure_file_exists_or_download_http_with_config(
170 filepath,
171 url,
172 checksums,
173 timeout_secs.unwrap_or(30),
174 None,
175 None,
176 )
177}
178
179pub fn ensure_file_exists_or_download_http_with_timeout(
188 filepath: &Path,
189 url: &str,
190 checksums: Option<&Path>,
191 timeout_secs: u64,
192) -> anyhow::Result<()> {
193 ensure_file_exists_or_download_http_with_config(
194 filepath,
195 url,
196 checksums,
197 timeout_secs,
198 None,
199 None,
200 )
201}
202
203pub fn ensure_file_exists_or_download_http_with_config(
222 filepath: &Path,
223 url: &str,
224 checksums: Option<&Path>,
225 timeout_secs: u64,
226 retry_config: Option<RetryConfig>,
227 initial_jitter_ms: Option<u64>,
228) -> anyhow::Result<()> {
229 if filepath.exists() {
233 println!("File already exists (local/cached): {}", filepath.display());
234
235 if let Some(checksums_file) = checksums {
236 let _guard = lock_large_checksums();
237
238 if verify_sha256_checksum(filepath, checksums_file)? {
239 println!("Checksum verified");
240 return Ok(());
241 }
242
243 let new_checksum = calculate_sha256(filepath)?;
244 println!("Updating checksum for local file: {new_checksum}");
245 update_sha256_checksums(filepath, checksums_file, &new_checksum)?;
246 return Ok(());
247 }
248 return Ok(());
249 }
250
251 if let Some(jitter_ms) = initial_jitter_ms {
254 if jitter_ms > 0 {
255 sleep(Duration::from_millis(jitter_ms));
256 }
257 } else {
258 let jitter_delay = {
259 let mut r = rng();
260 Duration::from_millis(r.random_range(100..=600))
261 };
262 sleep(jitter_delay);
263 }
264
265 download_file(filepath, url, timeout_secs, retry_config.clone())?;
266
267 if let Some(checksums_file) = checksums {
269 let guard = lock_large_checksums();
270
271 if !verify_sha256_checksum(filepath, checksums_file)? {
272 let actual = calculate_sha256(filepath)?;
273 println!("Checksum mismatch after download (calculated {actual}), retrying...");
274 remove_file(filepath)?;
275 drop(guard);
276
277 download_file(filepath, url, timeout_secs, retry_config)?;
278
279 let _guard = lock_large_checksums();
280
281 if !verify_sha256_checksum(filepath, checksums_file)? {
282 let actual = calculate_sha256(filepath)?;
283 remove_file(filepath)?;
284 anyhow::bail!(
285 "Checksum mismatch after retry for {} (calculated {actual})",
286 filepath.file_name().unwrap_or_default().display(),
287 );
288 }
289 }
290 }
291
292 Ok(())
293}
294
295fn download_file(
296 filepath: &Path,
297 url: &str,
298 timeout_secs: u64,
299 retry_config: Option<RetryConfig>,
300) -> anyhow::Result<()> {
301 #[cfg(not(test))]
305 if !url.starts_with("https://") {
306 anyhow::bail!("URL must use HTTPS protocol for security: {url}");
307 }
308
309 println!("Downloading file from {url} to {}", filepath.display());
310
311 if let Some(parent) = filepath.parent() {
312 std::fs::create_dir_all(parent)?;
313 }
314
315 let runtime = tokio::runtime::Builder::new_current_thread()
316 .enable_all()
317 .build()?;
318 let client = HttpClient::builder().build()?;
319 let timeout = Duration::from_secs(timeout_secs);
320
321 let cfg = if let Some(config) = retry_config {
322 config
323 } else {
324 let max_retries = 5u32;
326 let op_timeout_ms = timeout_secs.saturating_mul(1000);
327 let per_attempt_ms = std::cmp::max(1000u64, op_timeout_ms / (u64::from(max_retries) + 1));
330 RetryConfig {
331 max_retries,
332 initial_delay_ms: 1_000,
333 max_delay_ms: 10_000,
334 backoff_factor: 2.0,
335 jitter_ms: 1_000,
336 operation_timeout_ms: Some(per_attempt_ms),
337 immediate_first: false,
338 max_elapsed_ms: Some(op_timeout_ms),
339 }
340 };
341
342 let partial_path = partial_path_for(filepath);
343
344 let op = || -> Result<(), DownloadError> {
345 let _ = remove_file(&partial_path);
347
348 match runtime.block_on(async {
349 Ok::<_, anyhow::Error>(
350 tokio::time::timeout(timeout, client.get_stream(url.to_owned())).await??,
351 )
352 }) {
353 Ok(mut response) => {
354 let status = response.status();
355 if status.is_success() {
356 let mut out = File::create(&partial_path)
357 .map_err(|e| DownloadError::NonRetryable(e.to_string()))?;
358 let copied = runtime.block_on(async {
362 while let Some(chunk) =
363 tokio::time::timeout(timeout, response.chunk()).await??
364 {
365 out.write_all(&chunk)?;
366 }
367 Ok::<(), anyhow::Error>(())
368 });
369
370 if let Err(e) = copied {
371 drop(out);
372 let _ = remove_file(&partial_path);
373 return Err(DownloadError::Retryable(format!("body stream error: {e}")));
374 }
375 drop(out);
376
377 if let Err(e) = std::fs::rename(&partial_path, filepath) {
378 let _ = remove_file(&partial_path);
379 return Err(DownloadError::NonRetryable(format!(
380 "rename {} -> {} failed: {e}",
381 partial_path.display(),
382 filepath.display(),
383 )));
384 }
385 println!("File downloaded to {}", filepath.display());
386 Ok(())
387 } else if status.is_server_error()
388 || status.as_u16() == 429
389 || status.as_u16() == 408
390 {
391 println!("HTTP error {status}, retrying...");
392 Err(DownloadError::Retryable(format!("HTTP {status}")))
393 } else {
394 Err(DownloadError::NonRetryable(format!(
396 "Client error: HTTP {status}"
397 )))
398 }
399 }
400 Err(e) => {
401 println!("Request failed: {e}");
402 Err(DownloadError::Retryable(e.to_string()))
403 }
404 }
405 };
406
407 let should_retry = |e: &DownloadError| matches!(e, DownloadError::Retryable(_));
408
409 execute_with_retry_blocking(&cfg, op, should_retry).map_err(|e| anyhow::anyhow!(e.to_string()))
410}
411
412fn partial_path_for(filepath: &Path) -> PathBuf {
413 let nanos = std::time::SystemTime::now()
416 .duration_since(std::time::UNIX_EPOCH)
417 .map_or(0, |d| d.as_nanos());
418 let mut p: OsString = filepath.as_os_str().to_owned();
419 p.push(format!(".partial.{}.{}", std::process::id(), nanos));
420 PathBuf::from(p)
421}
422
423fn calculate_sha256(filepath: &Path) -> anyhow::Result<String> {
424 let mut file = File::open(filepath)?;
425 let mut ctx = Context::new(&digest::SHA256);
426 let mut buffer = [0u8; 4096];
427
428 loop {
429 let count = file.read(&mut buffer)?;
430 if count == 0 {
431 break;
432 }
433 ctx.update(&buffer[..count]);
434 }
435
436 let digest = ctx.finish();
437 Ok(hex::encode(digest.as_ref()))
438}
439
440fn verify_sha256_checksum(filepath: &Path, checksums: &Path) -> anyhow::Result<bool> {
441 let file = File::open(checksums)?;
442 let reader = BufReader::new(file);
443 let checksums: Value = serde_json::from_reader(reader)?;
444
445 let filename = filepath.file_name().unwrap().to_str().unwrap();
446 if let Some(expected_checksum) = checksums.get(filename) {
447 let expected_checksum_str = expected_checksum.as_str().unwrap();
448 let expected_hash = expected_checksum_str
449 .strip_prefix("sha256:")
450 .unwrap_or(expected_checksum_str);
451 let calculated_checksum = calculate_sha256(filepath)?;
452 if expected_hash == calculated_checksum {
453 return Ok(true);
454 }
455 }
456
457 Ok(false)
458}
459
460fn update_sha256_checksums(
461 filepath: &Path,
462 checksums_file: &Path,
463 new_checksum: &str,
464) -> anyhow::Result<()> {
465 let checksums: Value = if checksums_file.exists() {
466 let file = File::open(checksums_file)?;
467 let reader = BufReader::new(file);
468 serde_json::from_reader(reader)?
469 } else {
470 serde_json::json!({})
471 };
472
473 let mut checksums_map = checksums.as_object().unwrap().clone();
474
475 let filename = filepath.file_name().unwrap().to_str().unwrap().to_string();
477 let prefixed_checksum = format!("sha256:{new_checksum}");
478 checksums_map.insert(filename, Value::String(prefixed_checksum));
479
480 let file = OpenOptions::new()
481 .write(true)
482 .create(true)
483 .truncate(true)
484 .open(checksums_file)?;
485 let writer = BufWriter::new(file);
486 serde_json::to_writer_pretty(writer, &serde_json::Value::Object(checksums_map))?;
487
488 Ok(())
489}
490
491#[cfg(test)]
492mod tests {
493 use std::{
494 fs,
495 io::{BufWriter, Write},
496 net::SocketAddr,
497 sync::{
498 Arc,
499 atomic::{AtomicUsize, Ordering},
500 },
501 };
502
503 use axum::{Router, http::StatusCode, routing::get, serve};
504 use rstest::*;
505 use serde_json::{json, to_writer};
506 use tempfile::TempDir;
507 use tokio::{
508 net::TcpListener,
509 task,
510 time::{Duration, sleep},
511 };
512
513 use super::*;
514
515 fn test_retry_config() -> RetryConfig {
518 RetryConfig {
519 max_retries: 5,
520 initial_delay_ms: 10,
521 max_delay_ms: 50,
522 backoff_factor: 2.0,
523 jitter_ms: 5,
524 operation_timeout_ms: Some(500),
525 immediate_first: false,
526 max_elapsed_ms: Some(2000),
527 }
528 }
529
530 #[rstest]
531 #[case::nan(f64::NAN, Duration::ZERO)]
532 #[case::negative(-1.0, Duration::ZERO)]
533 #[case::infinite(f64::INFINITY, Duration::MAX)]
534 #[case::normal(2.0, Duration::from_millis(20))]
535 fn next_retry_delay_handles_edge_factors(
536 #[case] backoff_factor: f64,
537 #[case] expected: Duration,
538 ) {
539 let delay = Duration::from_millis(10);
540
541 assert_eq!(next_retry_delay(delay, backoff_factor), expected);
542 }
543
544 async fn setup_test_server(
545 server_content: Option<String>,
546 status_code: StatusCode,
547 ) -> SocketAddr {
548 let server_content = Arc::new(server_content);
549 let server_content_clone = server_content.clone();
550 let app = Router::new().route(
551 "/testfile.txt",
552 get(move || {
553 let server_content = server_content_clone.clone();
554 async move {
555 let response_body = match &*server_content {
556 Some(content) => content.clone(),
557 None => "File not found".to_string(),
558 };
559 (status_code, response_body)
560 }
561 }),
562 );
563
564 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
565 let addr = listener.local_addr().unwrap();
566 let server = serve(listener, app);
567
568 task::spawn(async move {
569 if let Err(e) = server.await {
570 eprintln!("server error: {e}");
571 }
572 });
573
574 sleep(Duration::from_millis(100)).await;
575
576 addr
577 }
578
579 #[rstest]
580 #[case::missing(None, "verified fixture", true)]
581 #[case::cached(Some("verified fixture"), "verified fixture", true)]
582 #[case::stale_cached(Some("stale fixture"), "verified fixture", true)]
583 #[case::corrupt_download(None, "corrupt download", false)]
584 #[tokio::test]
585 async fn test_prepare_test_data_file(
586 #[case] cached: Option<&str>,
587 #[case] downloaded: &str,
588 #[case] valid: bool,
589 ) {
590 let temp_dir = TempDir::new().unwrap();
591 let filepath = temp_dir.path().join("testfile.txt");
592 let checksums = temp_dir.path().join("checksums.json");
593 let content = "verified fixture";
594 let manifest = serde_json::to_vec(&json!({
595 "testfile.txt": format!("sha256:{}", calculate_sha256_bytes(content.as_bytes()))
596 }))
597 .unwrap();
598 fs::write(&checksums, &manifest).unwrap();
599 if let Some(cached) = cached {
600 fs::write(&filepath, cached).unwrap();
601 }
602
603 let url = if cached == Some(content) {
604 "http://127.0.0.1:0/testfile.txt".to_string()
605 } else {
606 let addr = setup_test_server(Some(downloaded.to_string()), StatusCode::OK).await;
607 format!("http://{addr}/testfile.txt")
608 };
609 let filepath_clone = filepath.clone();
610 let checksums_clone = checksums.clone();
611 let result = tokio::task::spawn_blocking(move || {
612 prepare_test_data_file(&filepath_clone, &url, &checksums_clone)
613 })
614 .await
615 .unwrap();
616
617 if valid {
618 result.unwrap();
619 assert_eq!(fs::read_to_string(&filepath).unwrap(), content);
620 } else {
621 assert_eq!(
622 result.unwrap_err().to_string(),
623 format!("Checksum mismatch for {}", filepath.display()),
624 );
625 assert!(!filepath.exists());
626 }
627 assert_eq!(fs::read(&checksums).unwrap(), manifest);
628 }
629
630 #[tokio::test]
631 async fn test_file_already_exists() {
632 let temp_dir = TempDir::new().unwrap();
633 let file_path = temp_dir.path().join("testfile.txt");
634 fs::write(&file_path, "Existing file content").unwrap();
635
636 let url = "http://example.com/testfile.txt".to_string();
637 let result = ensure_file_exists_or_download_http(&file_path, &url, None, Some(5));
638
639 assert!(result.is_ok());
640 let content = fs::read_to_string(&file_path).unwrap();
641 assert_eq!(content, "Existing file content");
642 }
643
644 #[tokio::test]
645 async fn test_download_file_success() {
646 let temp_dir = TempDir::new().unwrap();
647 let filepath = temp_dir.path().join("testfile.txt");
648 let filepath_clone = filepath.clone();
649
650 let server_content = "Server file content".to_string();
651 let status_code = StatusCode::OK;
652 let addr = setup_test_server(Some(server_content.clone()), status_code).await;
653 let url = format!("http://{addr}/testfile.txt");
654
655 let result = tokio::task::spawn_blocking(move || {
656 ensure_file_exists_or_download_http_with_config(
657 &filepath_clone,
658 &url,
659 None,
660 5,
661 Some(test_retry_config()),
662 Some(0),
663 )
664 })
665 .await
666 .unwrap();
667
668 assert!(result.is_ok());
669 let content = fs::read_to_string(&filepath).unwrap();
670 assert_eq!(content, server_content);
671 }
672
673 #[tokio::test]
674 async fn test_download_file_not_found() {
675 let temp_dir = TempDir::new().unwrap();
676 let file_path = temp_dir.path().join("testfile.txt");
677
678 let server_content = None;
679 let status_code = StatusCode::NOT_FOUND;
680 let addr = setup_test_server(server_content, status_code).await;
681 let url = format!("http://{addr}/testfile.txt");
682
683 let result = tokio::task::spawn_blocking(move || {
684 ensure_file_exists_or_download_http_with_config(
685 &file_path,
686 &url,
687 None,
688 1,
689 Some(test_retry_config()),
690 Some(0),
691 )
692 })
693 .await
694 .unwrap();
695
696 assert!(result.is_err());
697 let err_msg = format!("{}", result.unwrap_err());
698 assert!(
699 err_msg.contains("Client error: HTTP"),
700 "Unexpected error message: {err_msg}"
701 );
702 }
703
704 #[tokio::test]
705 async fn test_network_error() {
706 let temp_dir = TempDir::new().unwrap();
707 let file_path = temp_dir.path().join("testfile.txt");
708
709 let url = "http://127.0.0.1:0/testfile.txt".to_string();
711
712 let result = tokio::task::spawn_blocking(move || {
713 ensure_file_exists_or_download_http_with_config(
714 &file_path,
715 &url,
716 None,
717 2,
718 Some(test_retry_config()),
719 Some(0),
720 )
721 })
722 .await
723 .unwrap();
724
725 assert!(result.is_err());
726 let err_msg = format!("{}", result.unwrap_err());
727 assert!(
728 err_msg.contains("error"),
729 "Unexpected error message: {err_msg}"
730 );
731 }
732
733 #[tokio::test]
734 async fn test_retry_then_success_on_500() {
735 let temp_dir = TempDir::new().unwrap();
736 let filepath = temp_dir.path().join("testfile.txt");
737 let filepath_clone = filepath.clone();
738
739 let counter = Arc::new(AtomicUsize::new(0));
740 let counter_clone = counter.clone();
741
742 let app = Router::new().route(
743 "/testfile.txt",
744 get(move || {
745 let c = counter_clone.clone();
746 async move {
747 let n = c.fetch_add(1, Ordering::SeqCst);
748 if n < 2 {
749 (StatusCode::INTERNAL_SERVER_ERROR, "temporary error")
750 } else {
751 (StatusCode::OK, "eventual success")
752 }
753 }
754 }),
755 );
756
757 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
758 let addr = listener.local_addr().unwrap();
759 let server = serve(listener, app);
760 task::spawn(async move {
761 let _ = server.await;
762 });
763 sleep(Duration::from_millis(100)).await;
764
765 let url = format!("http://{addr}/testfile.txt");
766
767 let result = tokio::task::spawn_blocking(move || {
768 ensure_file_exists_or_download_http_with_config(
769 &filepath_clone,
770 &url,
771 None,
772 5,
773 Some(test_retry_config()),
774 Some(0),
775 )
776 })
777 .await
778 .unwrap();
779
780 assert!(result.is_ok());
781 let content = std::fs::read_to_string(&filepath).unwrap();
782 assert_eq!(content, "eventual success");
783 assert!(counter.load(Ordering::SeqCst) >= 2);
784 }
785
786 #[tokio::test]
787 async fn test_retry_then_success_on_429() {
788 let temp_dir = TempDir::new().unwrap();
789 let filepath = temp_dir.path().join("testfile.txt");
790 let filepath_clone = filepath.clone();
791
792 let counter = Arc::new(AtomicUsize::new(0));
793 let counter_clone = counter.clone();
794
795 let app = Router::new().route(
796 "/testfile.txt",
797 get(move || {
798 let c = counter_clone.clone();
799 async move {
800 let n = c.fetch_add(1, Ordering::SeqCst);
801 if n < 1 {
802 (StatusCode::TOO_MANY_REQUESTS, "rate limited")
803 } else {
804 (StatusCode::OK, "ok after retry")
805 }
806 }
807 }),
808 );
809
810 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
811 let addr = listener.local_addr().unwrap();
812 let server = serve(listener, app);
813 task::spawn(async move {
814 let _ = server.await;
815 });
816 sleep(Duration::from_millis(100)).await;
817
818 let url = format!("http://{addr}/testfile.txt");
819
820 let result = tokio::task::spawn_blocking(move || {
821 ensure_file_exists_or_download_http_with_config(
822 &filepath_clone,
823 &url,
824 None,
825 5,
826 Some(test_retry_config()),
827 Some(0),
828 )
829 })
830 .await
831 .unwrap();
832
833 assert!(result.is_ok());
834 let content = std::fs::read_to_string(&filepath).unwrap();
835 assert_eq!(content, "ok after retry");
836 assert!(counter.load(Ordering::SeqCst) >= 2);
837 }
838
839 #[tokio::test]
840 async fn test_no_retry_on_404() {
841 let temp_dir = TempDir::new().unwrap();
842 let filepath = temp_dir.path().join("testfile.txt");
843 let filepath_clone = filepath.clone();
844
845 let counter = Arc::new(AtomicUsize::new(0));
846 let counter_clone = counter.clone();
847
848 let app = Router::new().route(
849 "/testfile.txt",
850 get(move || {
851 let c = counter_clone.clone();
852 async move {
853 c.fetch_add(1, Ordering::SeqCst);
854 (StatusCode::NOT_FOUND, "missing")
855 }
856 }),
857 );
858
859 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
860 let addr = listener.local_addr().unwrap();
861 let server = serve(listener, app);
862 task::spawn(async move {
863 let _ = server.await;
864 });
865 sleep(Duration::from_millis(100)).await;
866
867 let url = format!("http://{addr}/testfile.txt");
868
869 let result = tokio::task::spawn_blocking(move || {
870 ensure_file_exists_or_download_http_with_config(
871 &filepath_clone,
872 &url,
873 None,
874 5,
875 Some(test_retry_config()),
876 Some(0),
877 )
878 })
879 .await
880 .unwrap();
881
882 assert!(result.is_err());
883 assert_eq!(counter.load(Ordering::SeqCst), 1, "should not retry on 404");
884 }
885
886 #[tokio::test]
887 async fn test_checksum_mismatch_retry_then_success() {
888 let temp_dir = TempDir::new().unwrap();
889 let filepath = temp_dir.path().join("testfile.txt");
890 let filepath_clone = filepath.clone();
891
892 let good_content = "correct content";
893 let good_checksum = calculate_sha256_bytes(good_content.as_bytes());
894
895 let checksums_path = temp_dir.path().join("checksums.json");
896 let checksums_data = json!({
897 "testfile.txt": format!("sha256:{good_checksum}")
898 });
899 let checksums_file = File::create(&checksums_path).unwrap();
900 to_writer(BufWriter::new(checksums_file), &checksums_data).unwrap();
901 let checksums_clone = checksums_path.clone();
902
903 let counter = Arc::new(AtomicUsize::new(0));
905 let counter_clone = counter.clone();
906
907 let app = Router::new().route(
908 "/testfile.txt",
909 get(move || {
910 let c = counter_clone.clone();
911 async move {
912 let n = c.fetch_add(1, Ordering::SeqCst);
913 if n == 0 {
914 (StatusCode::OK, "corrupt data")
915 } else {
916 (StatusCode::OK, "correct content")
917 }
918 }
919 }),
920 );
921
922 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
923 let addr = listener.local_addr().unwrap();
924 let server = serve(listener, app);
925 task::spawn(async move {
926 let _ = server.await;
927 });
928 sleep(Duration::from_millis(100)).await;
929
930 let url = format!("http://{addr}/testfile.txt");
931
932 let result = tokio::task::spawn_blocking(move || {
933 ensure_file_exists_or_download_http_with_config(
934 &filepath_clone,
935 &url,
936 Some(&checksums_clone),
937 5,
938 Some(test_retry_config()),
939 Some(0),
940 )
941 })
942 .await
943 .unwrap();
944
945 assert!(result.is_ok());
946 let content = fs::read_to_string(&filepath).unwrap();
947 assert_eq!(content, good_content);
948 assert_eq!(counter.load(Ordering::SeqCst), 2);
949 }
950
951 #[tokio::test]
952 async fn test_checksum_mismatch_retry_then_fail() {
953 let temp_dir = TempDir::new().unwrap();
954 let filepath = temp_dir.path().join("testfile.txt");
955 let filepath_clone = filepath.clone();
956
957 let checksums_path = temp_dir.path().join("checksums.json");
959 let checksums_data = json!({
960 "testfile.txt": "sha256:0000000000000000000000000000000000000000000000000000000000000000"
961 });
962 let checksums_file = File::create(&checksums_path).unwrap();
963 to_writer(BufWriter::new(checksums_file), &checksums_data).unwrap();
964 let checksums_clone = checksums_path.clone();
965
966 let counter = Arc::new(AtomicUsize::new(0));
967 let counter_clone = counter.clone();
968
969 let app = Router::new().route(
970 "/testfile.txt",
971 get(move || {
972 let c = counter_clone.clone();
973 async move {
974 c.fetch_add(1, Ordering::SeqCst);
975 (StatusCode::OK, "always wrong content")
976 }
977 }),
978 );
979
980 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
981 let addr = listener.local_addr().unwrap();
982 let server = serve(listener, app);
983 task::spawn(async move {
984 let _ = server.await;
985 });
986 sleep(Duration::from_millis(100)).await;
987
988 let url = format!("http://{addr}/testfile.txt");
989
990 let result = tokio::task::spawn_blocking(move || {
991 ensure_file_exists_or_download_http_with_config(
992 &filepath_clone,
993 &url,
994 Some(&checksums_clone),
995 5,
996 Some(test_retry_config()),
997 Some(0),
998 )
999 })
1000 .await
1001 .unwrap();
1002
1003 assert!(result.is_err());
1004 let err_msg = format!("{}", result.unwrap_err());
1005 assert!(err_msg.contains("Checksum mismatch after retry"));
1006 assert_eq!(
1007 counter.load(Ordering::SeqCst),
1008 2,
1009 "should download exactly twice"
1010 );
1011 assert!(!filepath.exists(), "corrupt file should be cleaned up");
1012 }
1013
1014 async fn truncated_then_full_server(good_body: &'static str) -> (SocketAddr, Arc<AtomicUsize>) {
1016 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1017
1018 let counter = Arc::new(AtomicUsize::new(0));
1019 let counter_clone = counter.clone();
1020
1021 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1022 let addr = listener.local_addr().unwrap();
1023
1024 task::spawn(async move {
1025 loop {
1026 let Ok((mut sock, _)) = listener.accept().await else {
1027 break;
1028 };
1029 let counter = counter_clone.clone();
1030
1031 task::spawn(async move {
1032 let mut buf = [0u8; 1024];
1033 let _ = sock.read(&mut buf).await;
1034 let n = counter.fetch_add(1, Ordering::SeqCst);
1035
1036 if n == 0 {
1037 let resp = b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\nshort";
1038 let _ = sock.write_all(resp).await;
1039 } else {
1040 let resp = format!(
1041 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1042 good_body.len(),
1043 good_body,
1044 );
1045 let _ = sock.write_all(resp.as_bytes()).await;
1046 }
1047 let _ = sock.shutdown().await;
1048 });
1049 }
1050 });
1051
1052 sleep(Duration::from_millis(100)).await;
1053 (addr, counter)
1054 }
1055
1056 fn count_partial_siblings(filepath: &Path) -> usize {
1057 let parent = filepath.parent().unwrap();
1058 let stem = filepath.file_name().unwrap().to_string_lossy().into_owned();
1059 let prefix = format!("{stem}.partial.");
1060 fs::read_dir(parent)
1061 .unwrap()
1062 .filter_map(Result::ok)
1063 .filter(|e| e.file_name().to_string_lossy().starts_with(&prefix))
1064 .count()
1065 }
1066
1067 #[rstest]
1068 #[case::progressing(0, 400, Ok(()))]
1069 #[case::stalled_headers(1200, 0, Err("Retryable error: deadline has elapsed"))]
1070 #[case::stalled_body(
1071 0,
1072 1200,
1073 Err("Retryable error: body stream error: deadline has elapsed")
1074 )]
1075 #[tokio::test]
1076 async fn download_timeout_applies_to_each_read(
1077 #[case] header_delay_ms: u64,
1078 #[case] chunk_delay_ms: u64,
1079 #[case] expected: Result<(), &str>,
1080 ) {
1081 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1082
1083 let temp_dir = TempDir::new().unwrap();
1084 let filepath = temp_dir.path().join("streamed.txt");
1085 let destination = filepath.clone();
1086 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1087 let addr = listener.local_addr().unwrap();
1088 let peer = task::spawn(async move {
1089 let (mut socket, _) = listener.accept().await.unwrap();
1090 let mut request = Vec::new();
1091 while !request.ends_with(b"\r\n\r\n") {
1092 request.push(socket.read_u8().await.unwrap());
1093 }
1094 sleep(Duration::from_millis(header_delay_ms)).await;
1095
1096 if socket
1097 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: close\r\n\r\na")
1098 .await
1099 .is_err()
1100 {
1101 return;
1102 }
1103
1104 for byte in b"bcd" {
1105 sleep(Duration::from_millis(chunk_delay_ms)).await;
1106
1107 if socket.write_all(&[*byte]).await.is_err() {
1108 return;
1109 }
1110 }
1111 });
1112 let result = task::spawn_blocking(move || {
1113 let cfg = RetryConfig {
1114 max_retries: 0,
1115 max_elapsed_ms: None,
1116 ..test_retry_config()
1117 };
1118 download_file(
1119 &destination,
1120 &format!("http://{addr}/streamed"),
1121 1,
1122 Some(cfg),
1123 )
1124 .map_err(|e| e.to_string())
1125 })
1126 .await
1127 .unwrap();
1128 peer.await.unwrap();
1129
1130 assert_eq!(
1131 result.as_ref().map_err(String::as_str),
1132 expected.as_ref().map_err(|e| *e)
1133 );
1134 assert_eq!(count_partial_siblings(&filepath), 0);
1135 if expected.is_ok() {
1136 assert_eq!(fs::read(&filepath).unwrap(), b"abcd");
1137 } else {
1138 assert!(!filepath.exists());
1139 }
1140 }
1141
1142 #[tokio::test]
1143 async fn test_body_truncation_retries_and_recovers() {
1144 let temp_dir = TempDir::new().unwrap();
1145 let filepath = temp_dir.path().join("testfile.txt");
1146 let filepath_clone = filepath.clone();
1147
1148 let (addr, counter) = truncated_then_full_server("complete payload").await;
1149 let url = format!("http://{addr}/testfile.txt");
1150
1151 let result = tokio::task::spawn_blocking(move || {
1152 ensure_file_exists_or_download_http_with_config(
1153 &filepath_clone,
1154 &url,
1155 None,
1156 5,
1157 Some(test_retry_config()),
1158 Some(0),
1159 )
1160 })
1161 .await
1162 .unwrap();
1163
1164 assert!(result.is_ok(), "should retry past the truncated response");
1165 assert_eq!(counter.load(Ordering::SeqCst), 2);
1166 assert_eq!(
1167 count_partial_siblings(&filepath),
1168 0,
1169 "no .partial siblings must remain after success",
1170 );
1171 let content = fs::read_to_string(&filepath).unwrap();
1172 assert_eq!(content, "complete payload");
1173 }
1174
1175 #[tokio::test]
1176 async fn test_body_truncation_exhausts_retries_leaves_no_corrupt_file() {
1177 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1178
1179 let temp_dir = TempDir::new().unwrap();
1180 let filepath = temp_dir.path().join("testfile.txt");
1181 let filepath_clone = filepath.clone();
1182
1183 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1184 let addr = listener.local_addr().unwrap();
1185
1186 task::spawn(async move {
1187 loop {
1188 let Ok((mut sock, _)) = listener.accept().await else {
1189 break;
1190 };
1191
1192 task::spawn(async move {
1193 let mut buf = [0u8; 1024];
1194 let _ = sock.read(&mut buf).await;
1195 let resp =
1196 b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\nshort";
1197 let _ = sock.write_all(resp).await;
1198 let _ = sock.shutdown().await;
1199 });
1200 }
1201 });
1202
1203 sleep(Duration::from_millis(100)).await;
1204 let url = format!("http://{addr}/testfile.txt");
1205
1206 let result = tokio::task::spawn_blocking(move || {
1207 ensure_file_exists_or_download_http_with_config(
1208 &filepath_clone,
1209 &url,
1210 None,
1211 5,
1212 Some(test_retry_config()),
1213 Some(0),
1214 )
1215 })
1216 .await
1217 .unwrap();
1218
1219 assert!(result.is_err(), "all retries should fail");
1220 assert!(!filepath.exists(), "no corrupt file at final path");
1221 assert_eq!(
1222 count_partial_siblings(&filepath),
1223 0,
1224 "no .partial siblings may leak after exhausted retries",
1225 );
1226 }
1227
1228 #[rstest]
1229 fn test_partial_path_for_is_unique_and_marked() {
1230 let target = Path::new("/tmp/data.parquet");
1231 let stem = target.file_name().unwrap().to_string_lossy().into_owned();
1232 let expected_prefix = format!("{stem}.partial.");
1233
1234 let a = partial_path_for(target);
1235 let b = partial_path_for(target);
1236 assert_ne!(a, b, "partial paths must be unique per call");
1237 for p in [&a, &b] {
1238 let name = p.file_name().unwrap().to_string_lossy().into_owned();
1239 assert!(name.starts_with(&expected_prefix), "got {name}");
1240 }
1241 }
1242
1243 #[tokio::test]
1244 async fn test_unrelated_partial_sibling_not_clobbered() {
1245 let temp_dir = TempDir::new().unwrap();
1246 let filepath = temp_dir.path().join("testfile.txt");
1247 let filepath_clone = filepath.clone();
1248 let bystander = temp_dir.path().join("testfile.txt.partial");
1250 fs::write(&bystander, b"do not touch").unwrap();
1251
1252 let server_content = "downloaded".to_string();
1253 let addr = setup_test_server(Some(server_content.clone()), StatusCode::OK).await;
1254 let url = format!("http://{addr}/testfile.txt");
1255
1256 let result = tokio::task::spawn_blocking(move || {
1257 ensure_file_exists_or_download_http_with_config(
1258 &filepath_clone,
1259 &url,
1260 None,
1261 5,
1262 Some(test_retry_config()),
1263 Some(0),
1264 )
1265 })
1266 .await
1267 .unwrap();
1268
1269 assert!(result.is_ok());
1270 assert_eq!(fs::read_to_string(&filepath).unwrap(), server_content);
1271 assert_eq!(
1272 fs::read_to_string(&bystander).unwrap(),
1273 "do not touch",
1274 "unrelated sibling file must be preserved",
1275 );
1276 }
1277
1278 fn calculate_sha256_bytes(data: &[u8]) -> String {
1279 let mut ctx = digest::Context::new(&digest::SHA256);
1280 ctx.update(data);
1281 hex::encode(ctx.finish().as_ref())
1282 }
1283
1284 #[rstest]
1285 #[expect(clippy::panic_in_result_fn)]
1286 fn test_calculate_sha256() -> anyhow::Result<()> {
1287 let temp_dir = TempDir::new()?;
1288 let test_file_path = temp_dir.path().join("test_file.txt");
1289 let mut test_file = File::create(&test_file_path)?;
1290 let content = b"Hello, world!";
1291 test_file.write_all(content)?;
1292
1293 let expected_hash = "315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3";
1294 let calculated_hash = calculate_sha256(&test_file_path)?;
1295
1296 assert_eq!(calculated_hash, expected_hash);
1297 Ok(())
1298 }
1299
1300 #[rstest]
1301 #[expect(clippy::panic_in_result_fn)]
1302 fn test_verify_sha256_checksum() -> anyhow::Result<()> {
1303 let temp_dir = TempDir::new()?;
1304 let test_file_path = temp_dir.path().join("test_file.txt");
1305 let mut test_file = File::create(&test_file_path)?;
1306 let content = b"Hello, world!";
1307 test_file.write_all(content)?;
1308
1309 let calculated_checksum = calculate_sha256(&test_file_path)?;
1310
1311 let checksums_path = temp_dir.path().join("checksums.json");
1313 let checksums_data = json!({
1314 "test_file.txt": format!("sha256:{}", calculated_checksum)
1315 });
1316 let checksums_file = File::create(&checksums_path)?;
1317 let writer = BufWriter::new(checksums_file);
1318 to_writer(writer, &checksums_data)?;
1319
1320 let is_valid = verify_sha256_checksum(&test_file_path, &checksums_path)?;
1321 assert!(is_valid, "The checksum should be valid");
1322 Ok(())
1323 }
1324}