Skip to main content

fluidattacks_core_git/
download.rs

1use anyhow::{bail, Context, Result};
2use std::path::{Path, PathBuf};
3use tokio::io::{AsyncWriteExt, BufWriter};
4use tracing::warn;
5
6const MAX_RETRIES: u32 = 3;
7const RETRY_SLEEP_SECS: u64 = 60;
8const TOTAL_TIMEOUT_SECS: u64 = 60 * 60;
9const CONNECT_TIMEOUT_SECS: u64 = 30;
10const DEFAULT_DOWNLOAD_BUFFER_SIZE: usize = 64 * 1024;
11const MAX_DOWNLOAD_BUFFER_SIZE: usize = 16 * 1024 * 1024;
12const GZIP_MAGIC: &[u8] = &[0x1f, 0x8b];
13const TMP_PREFIX: &str = "fluidattacks_";
14const ARCHIVE_NAME: &str = "repo.tar.gz";
15const GIT_DIR_DATA: [&str; 6] = ["HEAD", "index", "objects", "packed-refs", "refs", "shallow"];
16// Inside `objects/`, these point git at object stores elsewhere on disk.
17const OBJECT_STORE_POINTERS: [&str; 2] = ["alternates", "http-alternates"];
18const TRUSTED_CONFIG: &str = "[core]\nrepositoryformatversion = 0\nbare = false\n";
19
20enum Failure {
21    Transient(anyhow::Error),
22    Permanent(anyhow::Error),
23}
24
25fn retryable_status(status: reqwest::StatusCode) -> bool {
26    status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
27}
28
29fn resolve_buffer_size(buffer_size: Option<usize>) -> Result<usize> {
30    match buffer_size {
31        None => Ok(DEFAULT_DOWNLOAD_BUFFER_SIZE),
32        Some(0) => bail!("the download buffer size must be greater than zero"),
33        Some(size) if size > MAX_DOWNLOAD_BUFFER_SIZE => bail!(
34            "the download buffer size must not exceed {MAX_DOWNLOAD_BUFFER_SIZE} bytes, got {size}"
35        ),
36        Some(size) => Ok(size),
37    }
38}
39
40/// Writes the body of `url` to `path`.
41///
42/// `buffer_size` bounds a single write to disk; `None` takes the 64 KiB the
43/// python defaults to, and anything above 16 MiB is rejected rather than
44/// clamped, since the batch container that clones has 7600 MiB for the whole
45/// job. Retries a transport error, a 5xx and a 429; an expired or a wrong URL
46/// fails at once. Leaves a partial archive at `path` when the body is not
47/// gzip, which the caller must not hand to an extractor.
48pub async fn download_to(url: &str, path: &Path, buffer_size: Option<usize>) -> Result<()> {
49    let buffer_size = resolve_buffer_size(buffer_size)?;
50    let mut last_err = None;
51
52    for attempt in 0..MAX_RETRIES {
53        match do_download_to(url, path, buffer_size).await {
54            Ok(()) => return Ok(()),
55            Err(Failure::Permanent(e)) => return Err(e),
56            Err(Failure::Transient(e)) => {
57                warn!(
58                    attempt = attempt.saturating_add(1),
59                    max = MAX_RETRIES,
60                    error = %e,
61                    "download failed, retrying"
62                );
63                last_err = Some(e);
64                if attempt < MAX_RETRIES - 1 {
65                    tokio::time::sleep(std::time::Duration::from_secs(RETRY_SLEEP_SECS)).await;
66                }
67            }
68        }
69    }
70
71    Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed")))
72}
73
74async fn open_stream(url: &str) -> Result<reqwest::Response, Failure> {
75    let resp = reqwest::Client::builder()
76        .timeout(std::time::Duration::from_secs(TOTAL_TIMEOUT_SECS))
77        .connect_timeout(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
78        .build()
79        .context("building the HTTP client")
80        .map_err(Failure::Permanent)?
81        .get(url)
82        .send()
83        .await
84        .context("HTTP request failed")
85        .map_err(Failure::Transient)?;
86
87    let status = resp.status();
88    if status == reqwest::StatusCode::OK {
89        return Ok(resp);
90    }
91
92    let error = anyhow::anyhow!("HTTP {status}");
93    Err(if retryable_status(status) {
94        Failure::Transient(error)
95    } else {
96        Failure::Permanent(error)
97    })
98}
99
100async fn do_download_to(url: &str, path: &Path, buffer_size: usize) -> Result<(), Failure> {
101    let mut resp = open_stream(url).await?;
102
103    let file = tokio::fs::File::create(path)
104        .await
105        .with_context(|| format!("creating {}", path.display()))
106        .map_err(Failure::Permanent)?;
107    let mut writer = BufWriter::with_capacity(buffer_size, file);
108    let mut header = Vec::with_capacity(GZIP_MAGIC.len());
109    let mut written: usize = 0;
110
111    while let Some(chunk) = resp
112        .chunk()
113        .await
114        .context("reading response body")
115        .map_err(Failure::Transient)?
116    {
117        take_header(&mut header, &chunk).map_err(Failure::Permanent)?;
118        for slice in chunk.chunks(buffer_size) {
119            writer
120                .write_all(slice)
121                .await
122                .context("writing the archive")
123                .map_err(Failure::Permanent)?;
124        }
125        written = written.saturating_add(chunk.len());
126    }
127    writer
128        .flush()
129        .await
130        .context("flushing the archive")
131        .map_err(Failure::Permanent)?;
132
133    if !header.starts_with(GZIP_MAGIC) {
134        return Err(Failure::Permanent(anyhow::anyhow!(
135            "response is not a valid gzip file (got {written} bytes)"
136        )));
137    }
138
139    Ok(())
140}
141
142fn take_header(header: &mut Vec<u8>, chunk: &[u8]) -> Result<()> {
143    let missing = GZIP_MAGIC.len().saturating_sub(header.len());
144    if missing == 0 {
145        return Ok(());
146    }
147
148    header.extend(chunk.iter().take(missing).copied());
149    if header.len() == GZIP_MAGIC.len() && !header.starts_with(GZIP_MAGIC) {
150        bail!("response is not a valid gzip file");
151    }
152
153    Ok(())
154}
155
156fn archive_parent(dest: &Path) -> Option<&Path> {
157    match dest.parent() {
158        Some(parent) if parent.as_os_str().is_empty() => Some(Path::new(".")),
159        parent => parent,
160    }
161}
162
163fn extracted_dir(tmp_path: &Path) -> Result<PathBuf> {
164    let mut dirs: Vec<PathBuf> = std::fs::read_dir(tmp_path)
165        .with_context(|| format!("reading {}", tmp_path.display()))?
166        .filter_map(std::result::Result::ok)
167        .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
168        .map(|entry| entry.path())
169        .collect();
170    dirs.sort();
171
172    if dirs.len() > 1 {
173        warn!(
174            count = dirs.len(),
175            "multiple directories found in archive, using the first one"
176        );
177    }
178
179    dirs.into_iter()
180        .next()
181        .context("no directory found in the extracted archive")
182}
183
184fn move_or_copy(from: &Path, to: &Path) -> Result<()> {
185    if let Err(rename_error) = std::fs::rename(from, to) {
186        super::fs::copy_dir_all(from, to).with_context(|| {
187            format!(
188                "moving {} to {} failed with {rename_error}, and so did copying it",
189                from.display(),
190                to.display()
191            )
192        })?;
193    }
194    Ok(())
195}
196
197// A `.git` file or link would redirect git to a repository outside the archive.
198fn keep_only_history(repo: &Path) -> Result<()> {
199    let git_dir = repo.join(".git");
200    let is_dir = std::fs::symlink_metadata(&git_dir).is_ok_and(|meta| meta.is_dir());
201    if !is_dir {
202        bail!("no .git directory found in the extracted archive");
203    }
204
205    for entry in
206        std::fs::read_dir(&git_dir).with_context(|| format!("reading {}", git_dir.display()))?
207    {
208        let entry = entry.with_context(|| format!("reading {}", git_dir.display()))?;
209        if GIT_DIR_DATA.iter().any(|kept| entry.file_name() == *kept) {
210            continue;
211        }
212        let path = entry.path();
213        let removed = if entry.file_type().is_ok_and(|kind| kind.is_dir()) {
214            std::fs::remove_dir_all(&path)
215        } else {
216            std::fs::remove_file(&path)
217        };
218        removed.with_context(|| format!("removing {}", path.display()))?;
219    }
220    for pointer in OBJECT_STORE_POINTERS {
221        let path = git_dir.join("objects/info").join(pointer);
222        match std::fs::remove_file(&path) {
223            Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
224                return Err(error).with_context(|| format!("removing {}", path.display()));
225            }
226            _ => {}
227        }
228    }
229
230    let config = git_dir.join("config");
231    std::fs::write(&config, TRUSTED_CONFIG).with_context(|| format!("writing {}", config.display()))
232}
233
234fn extract_and_replace(archive_path: &Path, tmp_path: &Path, dest: &Path) -> Result<()> {
235    let archive = std::fs::File::open(archive_path)
236        .with_context(|| format!("opening {}", archive_path.display()))?;
237    super::tar::extract(archive, tmp_path)?;
238    let _ = std::fs::remove_file(archive_path);
239
240    let extracted = extracted_dir(tmp_path)?;
241    // Still inside the temporary directory, so a failure leaves nothing behind.
242    keep_only_history(&extracted)?;
243
244    if dest.exists() {
245        std::fs::remove_dir_all(dest).with_context(|| format!("removing {}", dest.display()))?;
246    }
247
248    move_or_copy(&extracted, dest)
249}
250
251async fn place_repo(url: &str, dest: &Path, buffer_size: Option<usize>) -> bool {
252    let Some(parent) = archive_parent(dest) else {
253        warn!("the destination has no parent directory");
254        return false;
255    };
256    if let Err(e) = std::fs::create_dir_all(parent) {
257        warn!(error = %e, "failed to create parent dir");
258        return false;
259    }
260
261    let tmp_dir = match tempfile::Builder::new().prefix(TMP_PREFIX).tempdir() {
262        Ok(d) => d,
263        Err(e) => {
264            warn!(error = %e, "failed to create temp dir");
265            return false;
266        }
267    };
268    let tmp_path = tmp_dir.path().to_path_buf();
269    let archive_path = tmp_path.join(ARCHIVE_NAME);
270
271    if let Err(e) = download_to(url, &archive_path, buffer_size).await {
272        warn!(error = %e, "failed to download");
273        return false;
274    }
275
276    let dest_path = dest.to_path_buf();
277    let extracted = tokio::task::spawn_blocking(move || {
278        extract_and_replace(&archive_path, &tmp_path, &dest_path)
279    })
280    .await
281    .unwrap_or_else(|e| Err(anyhow::Error::new(e)));
282
283    if let Err(e) = extracted {
284        warn!(error = %e, "failed to extract archive");
285        return false;
286    }
287
288    true
289}
290
291/// Leaves the repository behind `url` at `dest`.
292///
293/// The archive holds only `.git`, so the working tree is restored with a
294/// `git reset --hard`. Pass `checkout = false` to keep just the history: peak
295/// disk stays at the size of the archive plus `.git`, which is what a consumer
296/// that walks commits needs. `git_ignore` prunes working-tree files and has no
297/// effect on a history-only download. `buffer_size` bounds a single write of
298/// the archive to disk, as it does in the python, and `None` takes the library
299/// default. Anything already at `dest` is replaced.
300/// The archive is untrusted, so only its history is kept and its config is replaced.
301pub async fn download_and_extract_repo(
302    url: &str,
303    dest: &Path,
304    git_ignore: &[String],
305    buffer_size: Option<usize>,
306    checkout: bool,
307) -> bool {
308    if !place_repo(url, dest, buffer_size).await {
309        return false;
310    }
311
312    if !checkout {
313        return true;
314    }
315
316    if super::cli::reset_hard(dest).await.is_err() {
317        let _ = std::fs::remove_dir_all(dest);
318        return false;
319    }
320
321    let dest_path = dest.to_path_buf();
322    let patterns = git_ignore.to_vec();
323    let pruned = tokio::task::spawn_blocking(move || {
324        super::fs::remove_symlinks(&dest_path)?;
325        super::fs::delete_gitignored_files(&dest_path, &patterns)
326    })
327    .await;
328    match pruned {
329        Ok(Ok(())) => true,
330        Ok(Err(error)) => {
331            warn!(error = %error, "failed to prune the working tree");
332            false
333        }
334        Err(error) => {
335            warn!(error = %error, "failed to prune the working tree");
336            false
337        }
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::{
344        extract_and_replace, extracted_dir, resolve_buffer_size, retryable_status, take_header,
345        DEFAULT_DOWNLOAD_BUFFER_SIZE, GZIP_MAGIC, MAX_DOWNLOAD_BUFFER_SIZE, TRUSTED_CONFIG,
346    };
347    use flate2::write::GzEncoder;
348    use flate2::Compression;
349    use std::path::{Path, PathBuf};
350
351    fn write_tar_gz(path: &Path, name: &str, contents: &[u8]) {
352        let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
353        let mut header = tar::Header::new_gnu();
354        header.set_size(u64::try_from(contents.len()).unwrap());
355        header.set_mode(0o644);
356        builder.append_data(&mut header, name, contents).unwrap();
357        let archive = builder.into_inner().unwrap().finish().unwrap();
358        std::fs::write(path, archive).unwrap();
359    }
360
361    #[test]
362    fn the_magic_bytes_can_arrive_split_across_chunks() {
363        let mut header = Vec::new();
364        assert!(take_header(&mut header, &[0x1f]).is_ok());
365        assert!(take_header(&mut header, &[0x8b, 0x08]).is_ok());
366        assert_eq!(header, GZIP_MAGIC);
367    }
368
369    #[test]
370    fn a_body_that_is_not_gzip_is_rejected_on_the_first_chunk() {
371        let mut header = Vec::new();
372        assert!(take_header(&mut header, b"<?xml version=\"1.0\"?>").is_err());
373    }
374
375    #[test]
376    fn later_chunks_are_not_inspected_once_the_magic_is_known() {
377        let mut header = Vec::new();
378        assert!(take_header(&mut header, GZIP_MAGIC).is_ok());
379        assert!(take_header(&mut header, b"anything at all").is_ok());
380        assert_eq!(header, GZIP_MAGIC);
381    }
382
383    #[test]
384    fn a_body_shorter_than_the_magic_never_completes_it() {
385        let mut header = Vec::new();
386        assert!(take_header(&mut header, &[0x1f]).is_ok());
387        assert!(!header.starts_with(GZIP_MAGIC));
388    }
389
390    #[test]
391    fn the_archive_replaces_an_existing_destination_and_is_deleted() {
392        let tmp = tempfile::tempdir().unwrap();
393        let work = tmp.path().join("work");
394        std::fs::create_dir_all(&work).unwrap();
395        let archive_path = work.join("repo.tar.gz");
396        write_tar_gz(&archive_path, "repo/.git/HEAD", b"ref: refs/heads/trunk\n");
397
398        let dest = tmp.path().join("dest");
399        std::fs::create_dir_all(&dest).unwrap();
400        std::fs::write(dest.join("stale"), b"old").unwrap();
401
402        extract_and_replace(&archive_path, &work, &dest).unwrap();
403
404        assert_eq!(
405            std::fs::read_to_string(dest.join(".git/HEAD")).unwrap(),
406            "ref: refs/heads/trunk\n"
407        );
408        assert!(!dest.join("stale").exists());
409        assert!(!archive_path.exists());
410    }
411
412    #[test]
413    fn an_archive_without_a_directory_is_rejected() {
414        let tmp = tempfile::tempdir().unwrap();
415        let work = tmp.path().join("work");
416        std::fs::create_dir_all(&work).unwrap();
417        let archive_path = work.join("repo.tar.gz");
418        write_tar_gz(&archive_path, "HEAD", b"ref: refs/heads/trunk\n");
419
420        let dest = tmp.path().join("dest");
421
422        assert!(extract_and_replace(&archive_path, &work, &dest).is_err());
423        assert!(!dest.exists());
424    }
425
426    #[test]
427    fn a_url_that_is_expired_or_wrong_is_not_retried() {
428        assert!(!retryable_status(reqwest::StatusCode::FORBIDDEN));
429        assert!(!retryable_status(reqwest::StatusCode::NOT_FOUND));
430        assert!(retryable_status(reqwest::StatusCode::TOO_MANY_REQUESTS));
431        assert!(retryable_status(reqwest::StatusCode::INTERNAL_SERVER_ERROR));
432        assert!(retryable_status(reqwest::StatusCode::SERVICE_UNAVAILABLE));
433    }
434
435    #[test]
436    fn an_absent_buffer_size_takes_the_default_of_the_python() {
437        assert_eq!(
438            resolve_buffer_size(None).unwrap(),
439            DEFAULT_DOWNLOAD_BUFFER_SIZE
440        );
441        assert_eq!(resolve_buffer_size(Some(8 * 1024)).unwrap(), 8 * 1024);
442    }
443
444    #[test]
445    fn a_buffer_size_of_zero_is_rejected_instead_of_writing_an_empty_archive() {
446        assert!(resolve_buffer_size(Some(0)).is_err());
447    }
448
449    #[test]
450    fn a_buffer_size_above_the_maximum_is_rejected_instead_of_being_clamped() {
451        assert_eq!(
452            resolve_buffer_size(Some(MAX_DOWNLOAD_BUFFER_SIZE)).unwrap(),
453            MAX_DOWNLOAD_BUFFER_SIZE
454        );
455        assert!(resolve_buffer_size(Some(MAX_DOWNLOAD_BUFFER_SIZE + 1)).is_err());
456    }
457
458    fn git(repo: &Path, args: &[&str]) {
459        let status = std::process::Command::new("git")
460            .arg("-C")
461            .arg(repo)
462            .args([
463                "-c",
464                "user.email=dev@fluidattacks.com",
465                "-c",
466                "user.name=dev",
467            ])
468            .args(args)
469            .status()
470            .unwrap();
471        assert!(status.success());
472    }
473
474    fn hostile_archive(dir: &Path, key: &str) -> (PathBuf, PathBuf) {
475        let marker = dir.join("pwned");
476        let payload = dir.join("payload.sh");
477        std::fs::write(
478            &payload,
479            format!("#!/bin/sh\ntouch {}\ncat\n", marker.display()),
480        )
481        .unwrap();
482        #[cfg(unix)]
483        {
484            use std::os::unix::fs::PermissionsExt;
485            std::fs::set_permissions(&payload, std::fs::Permissions::from_mode(0o755)).unwrap();
486        }
487
488        let source = dir.join("source");
489        std::fs::create_dir_all(&source).unwrap();
490        git(&source, &["init", "-q"]);
491        std::fs::write(source.join(".gitattributes"), b"* filter=pwn\n").unwrap();
492        std::fs::write(source.join("tracked.txt"), b"content\n").unwrap();
493        git(&source, &["add", "-A"]);
494        git(&source, &["commit", "-q", "-m", "add tracked file"]);
495        git(&source, &["config", key, &payload.to_string_lossy()]);
496        std::fs::create_dir_all(source.join(".git/objects/info")).unwrap();
497        std::fs::write(
498            source.join(".git/objects/info/alternates"),
499            format!("{}\n", dir.display()),
500        )
501        .unwrap();
502
503        let archive = dir.join("work/repo.tar.gz");
504        std::fs::create_dir_all(archive.parent().unwrap()).unwrap();
505        let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
506        builder
507            .append_dir_all("source/.git", source.join(".git"))
508            .unwrap();
509        std::fs::write(&archive, builder.into_inner().unwrap().finish().unwrap()).unwrap();
510        std::fs::remove_dir_all(&source).unwrap();
511        (archive, marker)
512    }
513
514    #[tokio::test]
515    async fn a_restored_archive_runs_nothing_it_configures() {
516        for key in ["filter.pwn.smudge", "filter.pwn.process", "core.fsmonitor"] {
517            let tmp = tempfile::tempdir().unwrap();
518            let (archive, marker) = hostile_archive(tmp.path(), key);
519            let dest = tmp.path().join("dest");
520
521            extract_and_replace(&archive, archive.parent().unwrap(), &dest).unwrap();
522            crate::cli::reset_hard(&dest).await.unwrap();
523
524            assert!(!marker.exists(), "{key} ran");
525            assert_eq!(
526                std::fs::read_to_string(dest.join("tracked.txt")).unwrap(),
527                "content\n"
528            );
529        }
530    }
531
532    #[test]
533    fn only_the_history_of_the_archive_is_kept() {
534        let tmp = tempfile::tempdir().unwrap();
535        let (archive, _) = hostile_archive(tmp.path(), "core.fsmonitor");
536        let dest = tmp.path().join("dest");
537
538        extract_and_replace(&archive, archive.parent().unwrap(), &dest).unwrap();
539
540        let git_dir = dest.join(".git");
541        assert_eq!(
542            std::fs::read_to_string(git_dir.join("config")).unwrap(),
543            TRUSTED_CONFIG
544        );
545        assert!(!git_dir.join("hooks").exists());
546        assert!(!git_dir.join("objects/info/alternates").exists());
547        assert!(git_dir.join("HEAD").is_file());
548    }
549
550    #[test]
551    fn a_git_file_in_the_archive_is_rejected() {
552        let tmp = tempfile::tempdir().unwrap();
553        let work = tmp.path().join("work");
554        std::fs::create_dir_all(&work).unwrap();
555        let archive_path = work.join("repo.tar.gz");
556        write_tar_gz(&archive_path, "repo/.git", b"gitdir: /elsewhere/.git\n");
557        let dest = tmp.path().join("dest");
558
559        assert!(extract_and_replace(&archive_path, &work, &dest).is_err());
560        assert!(!dest.exists());
561    }
562
563    #[test]
564    fn the_first_directory_is_taken_in_a_stable_order() {
565        let tmp = tempfile::tempdir().unwrap();
566        std::fs::write(tmp.path().join("repo.tar.gz"), b"").unwrap();
567        std::fs::create_dir_all(tmp.path().join("b_repo")).unwrap();
568        std::fs::create_dir_all(tmp.path().join("a_repo")).unwrap();
569
570        assert_eq!(
571            extracted_dir(tmp.path()).unwrap(),
572            tmp.path().join("a_repo")
573        );
574    }
575}