1use std::{
17 cmp,
18 ffi::OsString,
19 fmt::Display,
20 fs::{File, OpenOptions, remove_file},
21 io::{BufReader, BufWriter, Read, copy},
22 path::{Path, PathBuf},
23 sync::OnceLock,
24 thread::sleep,
25 time::{Duration, Instant},
26};
27
28use aws_lc_rs::digest::{self, Context};
29use nautilus_core::hex;
30use nautilus_network::retry::RetryConfig;
31use parking_lot::Mutex;
32use rand::{RngExt, rng};
33use reqwest::blocking::Client;
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 ensure_file_exists_or_download_http(
127 filepath: &Path,
128 url: &str,
129 checksums: Option<&Path>,
130 timeout_secs: Option<u64>,
131) -> anyhow::Result<()> {
132 ensure_file_exists_or_download_http_with_config(
133 filepath,
134 url,
135 checksums,
136 timeout_secs.unwrap_or(30),
137 None,
138 None,
139 )
140}
141
142pub fn ensure_file_exists_or_download_http_with_timeout(
151 filepath: &Path,
152 url: &str,
153 checksums: Option<&Path>,
154 timeout_secs: u64,
155) -> anyhow::Result<()> {
156 ensure_file_exists_or_download_http_with_config(
157 filepath,
158 url,
159 checksums,
160 timeout_secs,
161 None,
162 None,
163 )
164}
165
166pub fn ensure_file_exists_or_download_http_with_config(
185 filepath: &Path,
186 url: &str,
187 checksums: Option<&Path>,
188 timeout_secs: u64,
189 retry_config: Option<RetryConfig>,
190 initial_jitter_ms: Option<u64>,
191) -> anyhow::Result<()> {
192 if filepath.exists() {
196 println!("File already exists (local/cached): {}", filepath.display());
197
198 if let Some(checksums_file) = checksums {
199 let _guard = lock_large_checksums();
200
201 if verify_sha256_checksum(filepath, checksums_file)? {
202 println!("Checksum verified");
203 return Ok(());
204 }
205
206 let new_checksum = calculate_sha256(filepath)?;
207 println!("Updating checksum for local file: {new_checksum}");
208 update_sha256_checksums(filepath, checksums_file, &new_checksum)?;
209 return Ok(());
210 }
211 return Ok(());
212 }
213
214 if let Some(jitter_ms) = initial_jitter_ms {
217 if jitter_ms > 0 {
218 sleep(Duration::from_millis(jitter_ms));
219 }
220 } else {
221 let jitter_delay = {
222 let mut r = rng();
223 Duration::from_millis(r.random_range(100..=600))
224 };
225 sleep(jitter_delay);
226 }
227
228 download_file(filepath, url, timeout_secs, retry_config.clone())?;
229
230 if let Some(checksums_file) = checksums {
232 let guard = lock_large_checksums();
233
234 if !verify_sha256_checksum(filepath, checksums_file)? {
235 let actual = calculate_sha256(filepath)?;
236 println!("Checksum mismatch after download (calculated {actual}), retrying...");
237 remove_file(filepath)?;
238 drop(guard);
239
240 download_file(filepath, url, timeout_secs, retry_config)?;
241
242 let _guard = lock_large_checksums();
243
244 if !verify_sha256_checksum(filepath, checksums_file)? {
245 let actual = calculate_sha256(filepath)?;
246 remove_file(filepath)?;
247 anyhow::bail!(
248 "Checksum mismatch after retry for {} (calculated {actual})",
249 filepath.file_name().unwrap_or_default().display(),
250 );
251 }
252 }
253 }
254
255 Ok(())
256}
257
258fn download_file(
259 filepath: &Path,
260 url: &str,
261 timeout_secs: u64,
262 retry_config: Option<RetryConfig>,
263) -> anyhow::Result<()> {
264 #[cfg(not(test))]
268 if !url.starts_with("https://") {
269 anyhow::bail!("URL must use HTTPS protocol for security: {url}");
270 }
271
272 println!("Downloading file from {url} to {}", filepath.display());
273
274 if let Some(parent) = filepath.parent() {
275 std::fs::create_dir_all(parent)?;
276 }
277
278 let client = Client::builder()
279 .timeout(Duration::from_secs(timeout_secs))
280 .build()?;
281
282 let cfg = if let Some(config) = retry_config {
283 config
284 } else {
285 let max_retries = 5u32;
287 let op_timeout_ms = timeout_secs.saturating_mul(1000);
288 let per_attempt_ms = std::cmp::max(1000u64, op_timeout_ms / (u64::from(max_retries) + 1));
291 RetryConfig {
292 max_retries,
293 initial_delay_ms: 1_000,
294 max_delay_ms: 10_000,
295 backoff_factor: 2.0,
296 jitter_ms: 1_000,
297 operation_timeout_ms: Some(per_attempt_ms),
298 immediate_first: false,
299 max_elapsed_ms: Some(op_timeout_ms),
300 }
301 };
302
303 let partial_path = partial_path_for(filepath);
304
305 let op = || -> Result<(), DownloadError> {
306 let _ = remove_file(&partial_path);
308
309 match client.get(url).send() {
310 Ok(mut response) => {
311 let status = response.status();
312 if status.is_success() {
313 let mut out = File::create(&partial_path)
314 .map_err(|e| DownloadError::NonRetryable(e.to_string()))?;
315 if let Err(e) = copy(&mut response, &mut out) {
319 drop(out);
320 let _ = remove_file(&partial_path);
321 return Err(DownloadError::Retryable(format!("body stream error: {e}")));
322 }
323 drop(out);
324
325 if let Err(e) = std::fs::rename(&partial_path, filepath) {
326 let _ = remove_file(&partial_path);
327 return Err(DownloadError::NonRetryable(format!(
328 "rename {} -> {} failed: {e}",
329 partial_path.display(),
330 filepath.display(),
331 )));
332 }
333 println!("File downloaded to {}", filepath.display());
334 Ok(())
335 } else if status.is_server_error()
336 || status.as_u16() == 429
337 || status.as_u16() == 408
338 {
339 println!("HTTP error {status}, retrying...");
340 Err(DownloadError::Retryable(format!("HTTP {status}")))
341 } else {
342 Err(DownloadError::NonRetryable(format!(
344 "Client error: HTTP {status}"
345 )))
346 }
347 }
348 Err(e) => {
349 println!("Request failed: {e}");
350 Err(DownloadError::Retryable(e.to_string()))
351 }
352 }
353 };
354
355 let should_retry = |e: &DownloadError| matches!(e, DownloadError::Retryable(_));
356
357 execute_with_retry_blocking(&cfg, op, should_retry).map_err(|e| anyhow::anyhow!(e.to_string()))
358}
359
360fn partial_path_for(filepath: &Path) -> PathBuf {
361 let nanos = std::time::SystemTime::now()
364 .duration_since(std::time::UNIX_EPOCH)
365 .map_or(0, |d| d.as_nanos());
366 let mut p: OsString = filepath.as_os_str().to_owned();
367 p.push(format!(".partial.{}.{}", std::process::id(), nanos));
368 PathBuf::from(p)
369}
370
371fn calculate_sha256(filepath: &Path) -> anyhow::Result<String> {
372 let mut file = File::open(filepath)?;
373 let mut ctx = Context::new(&digest::SHA256);
374 let mut buffer = [0u8; 4096];
375
376 loop {
377 let count = file.read(&mut buffer)?;
378 if count == 0 {
379 break;
380 }
381 ctx.update(&buffer[..count]);
382 }
383
384 let digest = ctx.finish();
385 Ok(hex::encode(digest.as_ref()))
386}
387
388fn verify_sha256_checksum(filepath: &Path, checksums: &Path) -> anyhow::Result<bool> {
389 let file = File::open(checksums)?;
390 let reader = BufReader::new(file);
391 let checksums: Value = serde_json::from_reader(reader)?;
392
393 let filename = filepath.file_name().unwrap().to_str().unwrap();
394 if let Some(expected_checksum) = checksums.get(filename) {
395 let expected_checksum_str = expected_checksum.as_str().unwrap();
396 let expected_hash = expected_checksum_str
397 .strip_prefix("sha256:")
398 .unwrap_or(expected_checksum_str);
399 let calculated_checksum = calculate_sha256(filepath)?;
400 if expected_hash == calculated_checksum {
401 return Ok(true);
402 }
403 }
404
405 Ok(false)
406}
407
408fn update_sha256_checksums(
409 filepath: &Path,
410 checksums_file: &Path,
411 new_checksum: &str,
412) -> anyhow::Result<()> {
413 let checksums: Value = if checksums_file.exists() {
414 let file = File::open(checksums_file)?;
415 let reader = BufReader::new(file);
416 serde_json::from_reader(reader)?
417 } else {
418 serde_json::json!({})
419 };
420
421 let mut checksums_map = checksums.as_object().unwrap().clone();
422
423 let filename = filepath.file_name().unwrap().to_str().unwrap().to_string();
425 let prefixed_checksum = format!("sha256:{new_checksum}");
426 checksums_map.insert(filename, Value::String(prefixed_checksum));
427
428 let file = OpenOptions::new()
429 .write(true)
430 .create(true)
431 .truncate(true)
432 .open(checksums_file)?;
433 let writer = BufWriter::new(file);
434 serde_json::to_writer_pretty(writer, &serde_json::Value::Object(checksums_map))?;
435
436 Ok(())
437}
438
439#[cfg(test)]
440mod tests {
441 use std::{
442 fs,
443 io::{BufWriter, Write},
444 net::SocketAddr,
445 sync::{
446 Arc,
447 atomic::{AtomicUsize, Ordering},
448 },
449 };
450
451 use axum::{Router, http::StatusCode, routing::get, serve};
452 use rstest::*;
453 use serde_json::{json, to_writer};
454 use tempfile::TempDir;
455 use tokio::{
456 net::TcpListener,
457 task,
458 time::{Duration, sleep},
459 };
460
461 use super::*;
462
463 fn test_retry_config() -> RetryConfig {
466 RetryConfig {
467 max_retries: 5,
468 initial_delay_ms: 10,
469 max_delay_ms: 50,
470 backoff_factor: 2.0,
471 jitter_ms: 5,
472 operation_timeout_ms: Some(500),
473 immediate_first: false,
474 max_elapsed_ms: Some(2000),
475 }
476 }
477
478 #[rstest]
479 #[case::nan(f64::NAN, Duration::ZERO)]
480 #[case::negative(-1.0, Duration::ZERO)]
481 #[case::infinite(f64::INFINITY, Duration::MAX)]
482 #[case::normal(2.0, Duration::from_millis(20))]
483 fn next_retry_delay_handles_edge_factors(
484 #[case] backoff_factor: f64,
485 #[case] expected: Duration,
486 ) {
487 let delay = Duration::from_millis(10);
488
489 assert_eq!(next_retry_delay(delay, backoff_factor), expected);
490 }
491
492 async fn setup_test_server(
493 server_content: Option<String>,
494 status_code: StatusCode,
495 ) -> SocketAddr {
496 let server_content = Arc::new(server_content);
497 let server_content_clone = server_content.clone();
498 let app = Router::new().route(
499 "/testfile.txt",
500 get(move || {
501 let server_content = server_content_clone.clone();
502 async move {
503 let response_body = match &*server_content {
504 Some(content) => content.clone(),
505 None => "File not found".to_string(),
506 };
507 (status_code, response_body)
508 }
509 }),
510 );
511
512 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
513 let addr = listener.local_addr().unwrap();
514 let server = serve(listener, app);
515
516 task::spawn(async move {
517 if let Err(e) = server.await {
518 eprintln!("server error: {e}");
519 }
520 });
521
522 sleep(Duration::from_millis(100)).await;
523
524 addr
525 }
526
527 #[tokio::test]
528 async fn test_file_already_exists() {
529 let temp_dir = TempDir::new().unwrap();
530 let file_path = temp_dir.path().join("testfile.txt");
531 fs::write(&file_path, "Existing file content").unwrap();
532
533 let url = "http://example.com/testfile.txt".to_string();
534 let result = ensure_file_exists_or_download_http(&file_path, &url, None, Some(5));
535
536 assert!(result.is_ok());
537 let content = fs::read_to_string(&file_path).unwrap();
538 assert_eq!(content, "Existing file content");
539 }
540
541 #[tokio::test]
542 async fn test_download_file_success() {
543 let temp_dir = TempDir::new().unwrap();
544 let filepath = temp_dir.path().join("testfile.txt");
545 let filepath_clone = filepath.clone();
546
547 let server_content = "Server file content".to_string();
548 let status_code = StatusCode::OK;
549 let addr = setup_test_server(Some(server_content.clone()), status_code).await;
550 let url = format!("http://{addr}/testfile.txt");
551
552 let result = tokio::task::spawn_blocking(move || {
553 ensure_file_exists_or_download_http_with_config(
554 &filepath_clone,
555 &url,
556 None,
557 5,
558 Some(test_retry_config()),
559 Some(0),
560 )
561 })
562 .await
563 .unwrap();
564
565 assert!(result.is_ok());
566 let content = fs::read_to_string(&filepath).unwrap();
567 assert_eq!(content, server_content);
568 }
569
570 #[tokio::test]
571 async fn test_download_file_not_found() {
572 let temp_dir = TempDir::new().unwrap();
573 let file_path = temp_dir.path().join("testfile.txt");
574
575 let server_content = None;
576 let status_code = StatusCode::NOT_FOUND;
577 let addr = setup_test_server(server_content, status_code).await;
578 let url = format!("http://{addr}/testfile.txt");
579
580 let result = tokio::task::spawn_blocking(move || {
581 ensure_file_exists_or_download_http_with_config(
582 &file_path,
583 &url,
584 None,
585 1,
586 Some(test_retry_config()),
587 Some(0),
588 )
589 })
590 .await
591 .unwrap();
592
593 assert!(result.is_err());
594 let err_msg = format!("{}", result.unwrap_err());
595 assert!(
596 err_msg.contains("Client error: HTTP"),
597 "Unexpected error message: {err_msg}"
598 );
599 }
600
601 #[tokio::test]
602 async fn test_network_error() {
603 let temp_dir = TempDir::new().unwrap();
604 let file_path = temp_dir.path().join("testfile.txt");
605
606 let url = "http://127.0.0.1:0/testfile.txt".to_string();
608
609 let result = tokio::task::spawn_blocking(move || {
610 ensure_file_exists_or_download_http_with_config(
611 &file_path,
612 &url,
613 None,
614 2,
615 Some(test_retry_config()),
616 Some(0),
617 )
618 })
619 .await
620 .unwrap();
621
622 assert!(result.is_err());
623 let err_msg = format!("{}", result.unwrap_err());
624 assert!(
625 err_msg.contains("error"),
626 "Unexpected error message: {err_msg}"
627 );
628 }
629
630 #[tokio::test]
631 async fn test_retry_then_success_on_500() {
632 let temp_dir = TempDir::new().unwrap();
633 let filepath = temp_dir.path().join("testfile.txt");
634 let filepath_clone = filepath.clone();
635
636 let counter = Arc::new(AtomicUsize::new(0));
637 let counter_clone = counter.clone();
638
639 let app = Router::new().route(
640 "/testfile.txt",
641 get(move || {
642 let c = counter_clone.clone();
643 async move {
644 let n = c.fetch_add(1, Ordering::SeqCst);
645 if n < 2 {
646 (StatusCode::INTERNAL_SERVER_ERROR, "temporary error")
647 } else {
648 (StatusCode::OK, "eventual success")
649 }
650 }
651 }),
652 );
653
654 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
655 let addr = listener.local_addr().unwrap();
656 let server = serve(listener, app);
657 task::spawn(async move {
658 let _ = server.await;
659 });
660 sleep(Duration::from_millis(100)).await;
661
662 let url = format!("http://{addr}/testfile.txt");
663
664 let result = tokio::task::spawn_blocking(move || {
665 ensure_file_exists_or_download_http_with_config(
666 &filepath_clone,
667 &url,
668 None,
669 5,
670 Some(test_retry_config()),
671 Some(0),
672 )
673 })
674 .await
675 .unwrap();
676
677 assert!(result.is_ok());
678 let content = std::fs::read_to_string(&filepath).unwrap();
679 assert_eq!(content, "eventual success");
680 assert!(counter.load(Ordering::SeqCst) >= 2);
681 }
682
683 #[tokio::test]
684 async fn test_retry_then_success_on_429() {
685 let temp_dir = TempDir::new().unwrap();
686 let filepath = temp_dir.path().join("testfile.txt");
687 let filepath_clone = filepath.clone();
688
689 let counter = Arc::new(AtomicUsize::new(0));
690 let counter_clone = counter.clone();
691
692 let app = Router::new().route(
693 "/testfile.txt",
694 get(move || {
695 let c = counter_clone.clone();
696 async move {
697 let n = c.fetch_add(1, Ordering::SeqCst);
698 if n < 1 {
699 (StatusCode::TOO_MANY_REQUESTS, "rate limited")
700 } else {
701 (StatusCode::OK, "ok after retry")
702 }
703 }
704 }),
705 );
706
707 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
708 let addr = listener.local_addr().unwrap();
709 let server = serve(listener, app);
710 task::spawn(async move {
711 let _ = server.await;
712 });
713 sleep(Duration::from_millis(100)).await;
714
715 let url = format!("http://{addr}/testfile.txt");
716
717 let result = tokio::task::spawn_blocking(move || {
718 ensure_file_exists_or_download_http_with_config(
719 &filepath_clone,
720 &url,
721 None,
722 5,
723 Some(test_retry_config()),
724 Some(0),
725 )
726 })
727 .await
728 .unwrap();
729
730 assert!(result.is_ok());
731 let content = std::fs::read_to_string(&filepath).unwrap();
732 assert_eq!(content, "ok after retry");
733 assert!(counter.load(Ordering::SeqCst) >= 2);
734 }
735
736 #[tokio::test]
737 async fn test_no_retry_on_404() {
738 let temp_dir = TempDir::new().unwrap();
739 let filepath = temp_dir.path().join("testfile.txt");
740 let filepath_clone = filepath.clone();
741
742 let counter = Arc::new(AtomicUsize::new(0));
743 let counter_clone = counter.clone();
744
745 let app = Router::new().route(
746 "/testfile.txt",
747 get(move || {
748 let c = counter_clone.clone();
749 async move {
750 c.fetch_add(1, Ordering::SeqCst);
751 (StatusCode::NOT_FOUND, "missing")
752 }
753 }),
754 );
755
756 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
757 let addr = listener.local_addr().unwrap();
758 let server = serve(listener, app);
759 task::spawn(async move {
760 let _ = server.await;
761 });
762 sleep(Duration::from_millis(100)).await;
763
764 let url = format!("http://{addr}/testfile.txt");
765
766 let result = tokio::task::spawn_blocking(move || {
767 ensure_file_exists_or_download_http_with_config(
768 &filepath_clone,
769 &url,
770 None,
771 5,
772 Some(test_retry_config()),
773 Some(0),
774 )
775 })
776 .await
777 .unwrap();
778
779 assert!(result.is_err());
780 assert_eq!(counter.load(Ordering::SeqCst), 1, "should not retry on 404");
781 }
782
783 #[tokio::test]
784 async fn test_checksum_mismatch_retry_then_success() {
785 let temp_dir = TempDir::new().unwrap();
786 let filepath = temp_dir.path().join("testfile.txt");
787 let filepath_clone = filepath.clone();
788
789 let good_content = "correct content";
790 let good_checksum = calculate_sha256_bytes(good_content.as_bytes());
791
792 let checksums_path = temp_dir.path().join("checksums.json");
793 let checksums_data = json!({
794 "testfile.txt": format!("sha256:{good_checksum}")
795 });
796 let checksums_file = File::create(&checksums_path).unwrap();
797 to_writer(BufWriter::new(checksums_file), &checksums_data).unwrap();
798 let checksums_clone = checksums_path.clone();
799
800 let counter = Arc::new(AtomicUsize::new(0));
802 let counter_clone = counter.clone();
803
804 let app = Router::new().route(
805 "/testfile.txt",
806 get(move || {
807 let c = counter_clone.clone();
808 async move {
809 let n = c.fetch_add(1, Ordering::SeqCst);
810 if n == 0 {
811 (StatusCode::OK, "corrupt data")
812 } else {
813 (StatusCode::OK, "correct content")
814 }
815 }
816 }),
817 );
818
819 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
820 let addr = listener.local_addr().unwrap();
821 let server = serve(listener, app);
822 task::spawn(async move {
823 let _ = server.await;
824 });
825 sleep(Duration::from_millis(100)).await;
826
827 let url = format!("http://{addr}/testfile.txt");
828
829 let result = tokio::task::spawn_blocking(move || {
830 ensure_file_exists_or_download_http_with_config(
831 &filepath_clone,
832 &url,
833 Some(&checksums_clone),
834 5,
835 Some(test_retry_config()),
836 Some(0),
837 )
838 })
839 .await
840 .unwrap();
841
842 assert!(result.is_ok());
843 let content = fs::read_to_string(&filepath).unwrap();
844 assert_eq!(content, good_content);
845 assert_eq!(counter.load(Ordering::SeqCst), 2);
846 }
847
848 #[tokio::test]
849 async fn test_checksum_mismatch_retry_then_fail() {
850 let temp_dir = TempDir::new().unwrap();
851 let filepath = temp_dir.path().join("testfile.txt");
852 let filepath_clone = filepath.clone();
853
854 let checksums_path = temp_dir.path().join("checksums.json");
856 let checksums_data = json!({
857 "testfile.txt": "sha256:0000000000000000000000000000000000000000000000000000000000000000"
858 });
859 let checksums_file = File::create(&checksums_path).unwrap();
860 to_writer(BufWriter::new(checksums_file), &checksums_data).unwrap();
861 let checksums_clone = checksums_path.clone();
862
863 let counter = Arc::new(AtomicUsize::new(0));
864 let counter_clone = counter.clone();
865
866 let app = Router::new().route(
867 "/testfile.txt",
868 get(move || {
869 let c = counter_clone.clone();
870 async move {
871 c.fetch_add(1, Ordering::SeqCst);
872 (StatusCode::OK, "always wrong content")
873 }
874 }),
875 );
876
877 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
878 let addr = listener.local_addr().unwrap();
879 let server = serve(listener, app);
880 task::spawn(async move {
881 let _ = server.await;
882 });
883 sleep(Duration::from_millis(100)).await;
884
885 let url = format!("http://{addr}/testfile.txt");
886
887 let result = tokio::task::spawn_blocking(move || {
888 ensure_file_exists_or_download_http_with_config(
889 &filepath_clone,
890 &url,
891 Some(&checksums_clone),
892 5,
893 Some(test_retry_config()),
894 Some(0),
895 )
896 })
897 .await
898 .unwrap();
899
900 assert!(result.is_err());
901 let err_msg = format!("{}", result.unwrap_err());
902 assert!(err_msg.contains("Checksum mismatch after retry"));
903 assert_eq!(
904 counter.load(Ordering::SeqCst),
905 2,
906 "should download exactly twice"
907 );
908 assert!(!filepath.exists(), "corrupt file should be cleaned up");
909 }
910
911 async fn truncated_then_full_server(good_body: &'static str) -> (SocketAddr, Arc<AtomicUsize>) {
913 use tokio::io::{AsyncReadExt, AsyncWriteExt};
914
915 let counter = Arc::new(AtomicUsize::new(0));
916 let counter_clone = counter.clone();
917
918 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
919 let addr = listener.local_addr().unwrap();
920
921 task::spawn(async move {
922 loop {
923 let Ok((mut sock, _)) = listener.accept().await else {
924 break;
925 };
926 let counter = counter_clone.clone();
927
928 task::spawn(async move {
929 let mut buf = [0u8; 1024];
930 let _ = sock.read(&mut buf).await;
931 let n = counter.fetch_add(1, Ordering::SeqCst);
932
933 if n == 0 {
934 let resp = b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\nshort";
935 let _ = sock.write_all(resp).await;
936 } else {
937 let resp = format!(
938 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
939 good_body.len(),
940 good_body,
941 );
942 let _ = sock.write_all(resp.as_bytes()).await;
943 }
944 let _ = sock.shutdown().await;
945 });
946 }
947 });
948
949 sleep(Duration::from_millis(100)).await;
950 (addr, counter)
951 }
952
953 fn count_partial_siblings(filepath: &Path) -> usize {
954 let parent = filepath.parent().unwrap();
955 let stem = filepath.file_name().unwrap().to_string_lossy().into_owned();
956 let prefix = format!("{stem}.partial.");
957 fs::read_dir(parent)
958 .unwrap()
959 .filter_map(Result::ok)
960 .filter(|e| e.file_name().to_string_lossy().starts_with(&prefix))
961 .count()
962 }
963
964 #[tokio::test]
965 async fn test_body_truncation_retries_and_recovers() {
966 let temp_dir = TempDir::new().unwrap();
967 let filepath = temp_dir.path().join("testfile.txt");
968 let filepath_clone = filepath.clone();
969
970 let (addr, counter) = truncated_then_full_server("complete payload").await;
971 let url = format!("http://{addr}/testfile.txt");
972
973 let result = tokio::task::spawn_blocking(move || {
974 ensure_file_exists_or_download_http_with_config(
975 &filepath_clone,
976 &url,
977 None,
978 5,
979 Some(test_retry_config()),
980 Some(0),
981 )
982 })
983 .await
984 .unwrap();
985
986 assert!(result.is_ok(), "should retry past the truncated response");
987 assert_eq!(counter.load(Ordering::SeqCst), 2);
988 assert_eq!(
989 count_partial_siblings(&filepath),
990 0,
991 "no .partial siblings must remain after success",
992 );
993 let content = fs::read_to_string(&filepath).unwrap();
994 assert_eq!(content, "complete payload");
995 }
996
997 #[tokio::test]
998 async fn test_body_truncation_exhausts_retries_leaves_no_corrupt_file() {
999 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1000
1001 let temp_dir = TempDir::new().unwrap();
1002 let filepath = temp_dir.path().join("testfile.txt");
1003 let filepath_clone = filepath.clone();
1004
1005 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1006 let addr = listener.local_addr().unwrap();
1007
1008 task::spawn(async move {
1009 loop {
1010 let Ok((mut sock, _)) = listener.accept().await else {
1011 break;
1012 };
1013
1014 task::spawn(async move {
1015 let mut buf = [0u8; 1024];
1016 let _ = sock.read(&mut buf).await;
1017 let resp =
1018 b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\nshort";
1019 let _ = sock.write_all(resp).await;
1020 let _ = sock.shutdown().await;
1021 });
1022 }
1023 });
1024
1025 sleep(Duration::from_millis(100)).await;
1026 let url = format!("http://{addr}/testfile.txt");
1027
1028 let result = tokio::task::spawn_blocking(move || {
1029 ensure_file_exists_or_download_http_with_config(
1030 &filepath_clone,
1031 &url,
1032 None,
1033 5,
1034 Some(test_retry_config()),
1035 Some(0),
1036 )
1037 })
1038 .await
1039 .unwrap();
1040
1041 assert!(result.is_err(), "all retries should fail");
1042 assert!(!filepath.exists(), "no corrupt file at final path");
1043 assert_eq!(
1044 count_partial_siblings(&filepath),
1045 0,
1046 "no .partial siblings may leak after exhausted retries",
1047 );
1048 }
1049
1050 #[rstest]
1051 fn test_partial_path_for_is_unique_and_marked() {
1052 let target = Path::new("/tmp/data.parquet");
1053 let stem = target.file_name().unwrap().to_string_lossy().into_owned();
1054 let expected_prefix = format!("{stem}.partial.");
1055
1056 let a = partial_path_for(target);
1057 let b = partial_path_for(target);
1058 assert_ne!(a, b, "partial paths must be unique per call");
1059 for p in [&a, &b] {
1060 let name = p.file_name().unwrap().to_string_lossy().into_owned();
1061 assert!(name.starts_with(&expected_prefix), "got {name}");
1062 }
1063 }
1064
1065 #[tokio::test]
1066 async fn test_unrelated_partial_sibling_not_clobbered() {
1067 let temp_dir = TempDir::new().unwrap();
1068 let filepath = temp_dir.path().join("testfile.txt");
1069 let filepath_clone = filepath.clone();
1070 let bystander = temp_dir.path().join("testfile.txt.partial");
1072 fs::write(&bystander, b"do not touch").unwrap();
1073
1074 let server_content = "downloaded".to_string();
1075 let addr = setup_test_server(Some(server_content.clone()), StatusCode::OK).await;
1076 let url = format!("http://{addr}/testfile.txt");
1077
1078 let result = tokio::task::spawn_blocking(move || {
1079 ensure_file_exists_or_download_http_with_config(
1080 &filepath_clone,
1081 &url,
1082 None,
1083 5,
1084 Some(test_retry_config()),
1085 Some(0),
1086 )
1087 })
1088 .await
1089 .unwrap();
1090
1091 assert!(result.is_ok());
1092 assert_eq!(fs::read_to_string(&filepath).unwrap(), server_content);
1093 assert_eq!(
1094 fs::read_to_string(&bystander).unwrap(),
1095 "do not touch",
1096 "unrelated sibling file must be preserved",
1097 );
1098 }
1099
1100 fn calculate_sha256_bytes(data: &[u8]) -> String {
1101 let mut ctx = digest::Context::new(&digest::SHA256);
1102 ctx.update(data);
1103 hex::encode(ctx.finish().as_ref())
1104 }
1105
1106 #[rstest]
1107 #[expect(clippy::panic_in_result_fn)]
1108 fn test_calculate_sha256() -> anyhow::Result<()> {
1109 let temp_dir = TempDir::new()?;
1110 let test_file_path = temp_dir.path().join("test_file.txt");
1111 let mut test_file = File::create(&test_file_path)?;
1112 let content = b"Hello, world!";
1113 test_file.write_all(content)?;
1114
1115 let expected_hash = "315f5bdb76d078c43b8ac0064e4a0164612b1fce77c869345bfc94c75894edd3";
1116 let calculated_hash = calculate_sha256(&test_file_path)?;
1117
1118 assert_eq!(calculated_hash, expected_hash);
1119 Ok(())
1120 }
1121
1122 #[rstest]
1123 #[expect(clippy::panic_in_result_fn)]
1124 fn test_verify_sha256_checksum() -> anyhow::Result<()> {
1125 let temp_dir = TempDir::new()?;
1126 let test_file_path = temp_dir.path().join("test_file.txt");
1127 let mut test_file = File::create(&test_file_path)?;
1128 let content = b"Hello, world!";
1129 test_file.write_all(content)?;
1130
1131 let calculated_checksum = calculate_sha256(&test_file_path)?;
1132
1133 let checksums_path = temp_dir.path().join("checksums.json");
1135 let checksums_data = json!({
1136 "test_file.txt": format!("sha256:{}", calculated_checksum)
1137 });
1138 let checksums_file = File::create(&checksums_path)?;
1139 let writer = BufWriter::new(checksums_file);
1140 to_writer(writer, &checksums_data)?;
1141
1142 let is_valid = verify_sha256_checksum(&test_file_path, &checksums_path)?;
1143 assert!(is_valid, "The checksum should be valid");
1144 Ok(())
1145 }
1146}