Skip to main content

fluidattacks_core_git/
download.rs

1use anyhow::{bail, Context, Result};
2use std::path::Path;
3use tracing::warn;
4
5const MAX_RETRIES: u32 = 3;
6const RETRY_SLEEP_SECS: u64 = 60;
7
8pub async fn download(url: &str) -> Result<Vec<u8>> {
9    let mut last_err = None;
10
11    for attempt in 0..MAX_RETRIES {
12        match do_download(url).await {
13            Ok(data) => return Ok(data),
14            Err(e) => {
15                warn!(
16                    attempt = attempt.saturating_add(1),
17                    max = MAX_RETRIES,
18                    error = %e,
19                    "download failed, retrying"
20                );
21                last_err = Some(e);
22                if attempt < MAX_RETRIES - 1 {
23                    tokio::time::sleep(std::time::Duration::from_secs(RETRY_SLEEP_SECS)).await;
24                }
25            }
26        }
27    }
28
29    Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed")))
30}
31
32async fn do_download(url: &str) -> Result<Vec<u8>> {
33    let resp = reqwest::Client::new()
34        .get(url)
35        .send()
36        .await
37        .context("HTTP request failed")?;
38
39    let status = resp.status();
40    if !status.is_success() {
41        bail!("HTTP {status}");
42    }
43
44    let data = resp.bytes().await.context("reading response body")?;
45
46    if data.is_empty() || !is_gzip(&data) {
47        bail!(
48            "response is not a valid gzip file (got {} bytes)",
49            data.len()
50        );
51    }
52
53    Ok(data.to_vec())
54}
55
56const fn is_gzip(data: &[u8]) -> bool {
57    matches!(data, [0x1f, 0x8b, ..])
58}
59
60fn find_extracted_dir(tmp_path: &Path) -> Option<std::path::PathBuf> {
61    std::fs::read_dir(tmp_path)
62        .ok()?
63        .filter_map(std::result::Result::ok)
64        .find(|e| e.file_type().is_ok_and(|t| t.is_dir()))
65        .map(|e| e.path())
66}
67
68fn move_or_copy(from: &Path, to: &Path) -> bool {
69    if let Err(e) = std::fs::rename(from, to) {
70        if let Err(e2) = super::fs::copy_dir_all(from, to) {
71            warn!(rename_error = %e, copy_error = %e2, "failed to move extracted dir");
72            return false;
73        }
74    }
75    true
76}
77
78/// Downloads a gzip archive from `url`, extracts it, moves the result to
79/// `dest`, resets the git repo, and deletes gitignored files.
80pub async fn download_and_extract_repo(url: &str, dest: &Path, git_ignore: &[String]) -> bool {
81    if let Some(parent) = dest.parent() {
82        if let Err(e) = std::fs::create_dir_all(parent) {
83            warn!(error = %e, "failed to create parent dir");
84            return false;
85        }
86    }
87
88    let data = match download(url).await {
89        Ok(d) => d,
90        Err(e) => {
91            warn!(error = %e, "failed to download");
92            return false;
93        }
94    };
95
96    let tmp_dir = match tempfile::tempdir() {
97        Ok(d) => d,
98        Err(e) => {
99            warn!(error = %e, "failed to create temp dir");
100            return false;
101        }
102    };
103
104    if let Err(e) = super::tar::extract(&data, tmp_dir.path()) {
105        warn!(error = %e, "failed to extract archive");
106        return false;
107    }
108
109    let Some(extracted_dir) = find_extracted_dir(tmp_dir.path()) else {
110        warn!("no directory found in extracted archive");
111        return false;
112    };
113
114    if dest.exists() {
115        let _ = std::fs::remove_dir_all(dest);
116    }
117    if !move_or_copy(&extracted_dir, dest) {
118        return false;
119    }
120
121    let dest_str = dest.to_string_lossy().to_string();
122    let _ = super::cli::set_safe_directory().await;
123    if super::cli::reset_hard(&dest_str).await.is_err() {
124        let _ = std::fs::remove_dir_all(dest);
125        return false;
126    }
127    let _ = super::fs::remove_symlinks(dest);
128    let _ = super::fs::delete_gitignored_files(dest, git_ignore);
129
130    true
131}