Skip to main content

nautilus_testkit/
files.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use 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
107/// Downloads missing large test fixtures and verifies every file against the checksum manifest.
108///
109/// Replaces cached files whose checksums differ. The manifest remains unchanged.
110///
111/// # Errors
112///
113/// Returns an error if the manifest cannot be read, a download fails, or a checksum differs.
114pub 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
144/// Ensures that a file exists at the specified path by downloading it if necessary.
145///
146/// If the file already exists, it checks the integrity of the file using a SHA-256 checksum
147/// from the optional `checksums` file. If the checksum is valid, the function exits early. If
148/// the checksum is invalid or missing, the function updates the checksums file with the correct
149/// hash for the existing file without redownloading it.
150///
151/// If the file does not exist, it downloads the file from the specified `url` and updates the
152/// checksums file (if provided) with the calculated SHA-256 checksum of the downloaded file.
153///
154/// The `timeout_secs` parameter specifies the timeout in seconds for the HTTP request.
155/// If `None` is provided, a default timeout of 30 seconds will be used.
156///
157/// # Errors
158///
159/// Returns an error if:
160/// - The HTTP request cannot be sent or returns a non-success status code.
161/// - Any I/O operation fails during file creation, reading, or writing.
162/// - Checksum verification or JSON parsing fails.
163pub 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
179/// Ensures that a file exists at the specified path by downloading it if necessary, with a custom timeout.
180///
181/// # Errors
182///
183/// Returns an error if:
184/// - The HTTP request cannot be sent or returns a non-success status code after retries.
185/// - Any I/O operation fails during file creation, reading, or writing.
186/// - Checksum verification or JSON parsing fails.
187pub 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
203/// Ensures that a file exists at the specified path by downloading it if necessary,
204/// with custom timeout, retry config, and initial jitter delay.
205///
206/// # Parameters
207///
208/// - `filepath`: The path where the file should exist.
209/// - `url`: The URL to download from if the file doesn't exist.
210/// - `checksums`: Optional path to checksums file for verification.
211/// - `timeout_secs`: Timeout in seconds for response headers and each body read.
212/// - `retry_config`: Optional custom retry configuration (uses sensible defaults if None).
213/// - `initial_jitter_ms`: Optional initial jitter delay in milliseconds before download (defaults to 100-600ms if None).
214///
215/// # Errors
216///
217/// Returns an error if:
218/// - The HTTP request cannot be sent or returns a non-success status code after retries.
219/// - Any I/O operation fails during file creation, reading, or writing.
220/// - Checksum verification or JSON parsing fails.
221pub 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    // Local/cached file path: accept the file if it exists, updating the
230    // checksum record when it differs (e.g. after local regeneration).
231    // This is intentionally lenient - download verification below is strict.
232    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    // Add a small random delay to avoid bursting the remote server when
252    // many downloads start concurrently. Can be disabled by passing Some(0).
253    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    // Verify checksum after download, retry once on mismatch (corrupt download)
268    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    // Validate HTTPS for security in production builds,
302    // HTTP is intentionally allowed in test builds for local test servers (127.0.0.1),
303    // CodeQL flags this as "non-https-url" but it's a deliberate design choice for testkit.
304    #[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        // Default production config
325        let max_retries = 5u32;
326        let op_timeout_ms = timeout_secs.saturating_mul(1000);
327        // Make the provided timeout a hard ceiling for total elapsed time.
328        // Split it across attempts (at least 1000 ms per attempt) and cap total at op_timeout_ms.
329        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        // Discard any leftover partial from a prior attempt before writing
346        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                    // Stream body to a sibling .partial path so a truncated copy never reaches the final filepath,
359                    // body-stream errors (TCP reset, chunked-encoding decode, premature EOF) are typically transient,
360                    // so surface them as Retryable.
361                    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                    // Preserve existing error text used by tests
395                    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    // Suffix with pid + nanos so the staging path is unique per invocation,
414    // preventing collision with a real user file or another concurrent downloader.
415    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    // Add or update the checksum
476    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    /// Creates a fast, deterministic retry config for tests.
516    /// Uses very short delays to make tests run quickly without introducing flakiness.
517    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        // Use an unreachable address to simulate a network error
710        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        // First request returns corrupt data, second returns correct data
904        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        // Checksum for content that the server will never return
958        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    /// First call overstates Content-Length to fail the body read mid-stream; subsequent calls succeed.
1015    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        // A user-owned file whose name happens to start with `<target>.partial`
1249        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        // Create checksums.json containing the checksum
1312        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}