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
234async fn extract_and_replace(archive_path: &Path, tmp_path: &Path, dest: &Path) -> Result<()> {
235    let archive = tokio::fs::File::open(archive_path)
236        .await
237        .with_context(|| format!("opening {}", archive_path.display()))?;
238    super::tar::extract(archive, tmp_path).await?;
239    let _ = tokio::fs::remove_file(archive_path).await;
240
241    let (tmp_path, dest) = (tmp_path.to_path_buf(), dest.to_path_buf());
242    tokio::task::spawn_blocking(move || replace_destination(&tmp_path, &dest)).await?
243}
244
245fn replace_destination(tmp_path: &Path, dest: &Path) -> Result<()> {
246    let extracted = extracted_dir(tmp_path)?;
247    // Still inside the temporary directory, so a failure leaves nothing behind.
248    keep_only_history(&extracted)?;
249
250    if dest.exists() {
251        std::fs::remove_dir_all(dest).with_context(|| format!("removing {}", dest.display()))?;
252    }
253
254    move_or_copy(&extracted, dest)
255}
256
257async fn place_repo(url: &str, dest: &Path, buffer_size: Option<usize>) -> bool {
258    let Some(parent) = archive_parent(dest) else {
259        warn!("the destination has no parent directory");
260        return false;
261    };
262    if let Err(e) = std::fs::create_dir_all(parent) {
263        warn!(error = %e, "failed to create parent dir");
264        return false;
265    }
266
267    let tmp_dir = match tempfile::Builder::new().prefix(TMP_PREFIX).tempdir() {
268        Ok(d) => d,
269        Err(e) => {
270            warn!(error = %e, "failed to create temp dir");
271            return false;
272        }
273    };
274    let tmp_path = tmp_dir.path().to_path_buf();
275    let archive_path = tmp_path.join(ARCHIVE_NAME);
276
277    if let Err(e) = download_to(url, &archive_path, buffer_size).await {
278        warn!(error = %e, "failed to download");
279        return false;
280    }
281
282    if let Err(e) = extract_and_replace(&archive_path, &tmp_path, dest).await {
283        warn!(error = %e, "failed to extract archive");
284        return false;
285    }
286
287    true
288}
289
290/// Leaves the repository behind `url` at `dest`.
291///
292/// The archive holds only `.git`, so the working tree is restored with a
293/// `git reset --hard`. Pass `checkout = false` to keep just the history: peak
294/// disk stays at the size of the archive plus `.git`, which is what a consumer
295/// that walks commits needs. `git_ignore` prunes working-tree files and has no
296/// effect on a history-only download. `buffer_size` bounds a single write of
297/// the archive to disk, as it does in the python, and `None` takes the library
298/// default. Anything already at `dest` is replaced.
299/// The archive is untrusted, so only its history is kept and its config is replaced.
300pub async fn download_and_extract_repo(
301    url: &str,
302    dest: &Path,
303    git_ignore: &[String],
304    buffer_size: Option<usize>,
305    checkout: bool,
306) -> bool {
307    if !place_repo(url, dest, buffer_size).await {
308        return false;
309    }
310
311    if !checkout {
312        return true;
313    }
314
315    if super::cli::reset_hard(dest).await.is_err() {
316        let _ = std::fs::remove_dir_all(dest);
317        return false;
318    }
319
320    let dest_path = dest.to_path_buf();
321    let patterns = git_ignore.to_vec();
322    let pruned = tokio::task::spawn_blocking(move || {
323        super::fs::remove_symlinks(&dest_path)?;
324        super::fs::delete_gitignored_files(&dest_path, &patterns)
325    })
326    .await;
327    match pruned {
328        Ok(Ok(())) => true,
329        Ok(Err(error)) => {
330            warn!(error = %error, "failed to prune the working tree");
331            false
332        }
333        Err(error) => {
334            warn!(error = %error, "failed to prune the working tree");
335            false
336        }
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::{
343        extract_and_replace, extracted_dir, resolve_buffer_size, retryable_status, take_header,
344        DEFAULT_DOWNLOAD_BUFFER_SIZE, GZIP_MAGIC, MAX_DOWNLOAD_BUFFER_SIZE, TRUSTED_CONFIG,
345    };
346    use flate2::write::GzEncoder;
347    use flate2::Compression;
348    use std::path::{Path, PathBuf};
349
350    fn write_tar_gz(path: &Path, name: &str, contents: &[u8]) {
351        let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
352        let mut header = tar::Header::new_gnu();
353        header.set_size(u64::try_from(contents.len()).unwrap());
354        header.set_mode(0o644);
355        builder.append_data(&mut header, name, contents).unwrap();
356        let archive = builder.into_inner().unwrap().finish().unwrap();
357        std::fs::write(path, archive).unwrap();
358    }
359
360    #[test]
361    fn the_magic_bytes_can_arrive_split_across_chunks() {
362        let mut header = Vec::new();
363        assert!(take_header(&mut header, &[0x1f]).is_ok());
364        assert!(take_header(&mut header, &[0x8b, 0x08]).is_ok());
365        assert_eq!(header, GZIP_MAGIC);
366    }
367
368    #[test]
369    fn a_body_that_is_not_gzip_is_rejected_on_the_first_chunk() {
370        let mut header = Vec::new();
371        assert!(take_header(&mut header, b"<?xml version=\"1.0\"?>").is_err());
372    }
373
374    #[test]
375    fn later_chunks_are_not_inspected_once_the_magic_is_known() {
376        let mut header = Vec::new();
377        assert!(take_header(&mut header, GZIP_MAGIC).is_ok());
378        assert!(take_header(&mut header, b"anything at all").is_ok());
379        assert_eq!(header, GZIP_MAGIC);
380    }
381
382    #[test]
383    fn a_body_shorter_than_the_magic_never_completes_it() {
384        let mut header = Vec::new();
385        assert!(take_header(&mut header, &[0x1f]).is_ok());
386        assert!(!header.starts_with(GZIP_MAGIC));
387    }
388
389    #[tokio::test]
390    async fn the_archive_replaces_an_existing_destination_and_is_deleted() {
391        let tmp = tempfile::tempdir().unwrap();
392        let work = tmp.path().join("work");
393        std::fs::create_dir_all(&work).unwrap();
394        let archive_path = work.join("repo.tar.gz");
395        write_tar_gz(&archive_path, "repo/.git/HEAD", b"ref: refs/heads/trunk\n");
396
397        let dest = tmp.path().join("dest");
398        std::fs::create_dir_all(&dest).unwrap();
399        std::fs::write(dest.join("stale"), b"old").unwrap();
400
401        extract_and_replace(&archive_path, &work, &dest)
402            .await
403            .unwrap();
404
405        assert_eq!(
406            std::fs::read_to_string(dest.join(".git/HEAD")).unwrap(),
407            "ref: refs/heads/trunk\n"
408        );
409        assert!(!dest.join("stale").exists());
410        assert!(!archive_path.exists());
411    }
412
413    #[tokio::test]
414    async fn an_archive_without_a_directory_is_rejected() {
415        let tmp = tempfile::tempdir().unwrap();
416        let work = tmp.path().join("work");
417        std::fs::create_dir_all(&work).unwrap();
418        let archive_path = work.join("repo.tar.gz");
419        write_tar_gz(&archive_path, "HEAD", b"ref: refs/heads/trunk\n");
420
421        let dest = tmp.path().join("dest");
422
423        assert!(extract_and_replace(&archive_path, &work, &dest)
424            .await
425            .is_err());
426        assert!(!dest.exists());
427    }
428
429    #[test]
430    fn a_url_that_is_expired_or_wrong_is_not_retried() {
431        assert!(!retryable_status(reqwest::StatusCode::FORBIDDEN));
432        assert!(!retryable_status(reqwest::StatusCode::NOT_FOUND));
433        assert!(retryable_status(reqwest::StatusCode::TOO_MANY_REQUESTS));
434        assert!(retryable_status(reqwest::StatusCode::INTERNAL_SERVER_ERROR));
435        assert!(retryable_status(reqwest::StatusCode::SERVICE_UNAVAILABLE));
436    }
437
438    #[test]
439    fn an_absent_buffer_size_takes_the_default_of_the_python() {
440        assert_eq!(
441            resolve_buffer_size(None).unwrap(),
442            DEFAULT_DOWNLOAD_BUFFER_SIZE
443        );
444        assert_eq!(resolve_buffer_size(Some(8 * 1024)).unwrap(), 8 * 1024);
445    }
446
447    #[test]
448    fn a_buffer_size_of_zero_is_rejected_instead_of_writing_an_empty_archive() {
449        assert!(resolve_buffer_size(Some(0)).is_err());
450    }
451
452    #[test]
453    fn a_buffer_size_above_the_maximum_is_rejected_instead_of_being_clamped() {
454        assert_eq!(
455            resolve_buffer_size(Some(MAX_DOWNLOAD_BUFFER_SIZE)).unwrap(),
456            MAX_DOWNLOAD_BUFFER_SIZE
457        );
458        assert!(resolve_buffer_size(Some(MAX_DOWNLOAD_BUFFER_SIZE + 1)).is_err());
459    }
460
461    fn git(repo: &Path, args: &[&str]) {
462        let status = std::process::Command::new("git")
463            .arg("-C")
464            .arg(repo)
465            .args([
466                "-c",
467                "user.email=dev@fluidattacks.com",
468                "-c",
469                "user.name=dev",
470            ])
471            .args(args)
472            .status()
473            .unwrap();
474        assert!(status.success());
475    }
476
477    fn hostile_archive(dir: &Path, key: &str) -> (PathBuf, PathBuf) {
478        let marker = dir.join("pwned");
479        let payload = dir.join("payload.sh");
480        std::fs::write(
481            &payload,
482            format!("#!/bin/sh\ntouch {}\ncat\n", marker.display()),
483        )
484        .unwrap();
485        #[cfg(unix)]
486        {
487            use std::os::unix::fs::PermissionsExt;
488            std::fs::set_permissions(&payload, std::fs::Permissions::from_mode(0o755)).unwrap();
489        }
490
491        let source = dir.join("source");
492        std::fs::create_dir_all(&source).unwrap();
493        git(&source, &["init", "-q"]);
494        std::fs::write(source.join(".gitattributes"), b"* filter=pwn\n").unwrap();
495        std::fs::write(source.join("tracked.txt"), b"content\n").unwrap();
496        git(&source, &["add", "-A"]);
497        git(&source, &["commit", "-q", "-m", "add tracked file"]);
498        git(&source, &["config", key, &payload.to_string_lossy()]);
499        std::fs::create_dir_all(source.join(".git/objects/info")).unwrap();
500        std::fs::write(
501            source.join(".git/objects/info/alternates"),
502            format!("{}\n", dir.display()),
503        )
504        .unwrap();
505
506        let archive = dir.join("work/repo.tar.gz");
507        std::fs::create_dir_all(archive.parent().unwrap()).unwrap();
508        let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default()));
509        builder
510            .append_dir_all("source/.git", source.join(".git"))
511            .unwrap();
512        std::fs::write(&archive, builder.into_inner().unwrap().finish().unwrap()).unwrap();
513        std::fs::remove_dir_all(&source).unwrap();
514        (archive, marker)
515    }
516
517    #[tokio::test]
518    async fn a_restored_archive_runs_nothing_it_configures() {
519        for key in ["filter.pwn.smudge", "filter.pwn.process", "core.fsmonitor"] {
520            let tmp = tempfile::tempdir().unwrap();
521            let (archive, marker) = hostile_archive(tmp.path(), key);
522            let dest = tmp.path().join("dest");
523
524            extract_and_replace(&archive, archive.parent().unwrap(), &dest)
525                .await
526                .unwrap();
527            crate::cli::reset_hard(&dest).await.unwrap();
528
529            assert!(!marker.exists(), "{key} ran");
530            assert_eq!(
531                std::fs::read_to_string(dest.join("tracked.txt")).unwrap(),
532                "content\n"
533            );
534        }
535    }
536
537    #[tokio::test]
538    async fn only_the_history_of_the_archive_is_kept() {
539        let tmp = tempfile::tempdir().unwrap();
540        let (archive, _) = hostile_archive(tmp.path(), "core.fsmonitor");
541        let dest = tmp.path().join("dest");
542
543        extract_and_replace(&archive, archive.parent().unwrap(), &dest)
544            .await
545            .unwrap();
546
547        let git_dir = dest.join(".git");
548        assert_eq!(
549            std::fs::read_to_string(git_dir.join("config")).unwrap(),
550            TRUSTED_CONFIG
551        );
552        assert!(!git_dir.join("hooks").exists());
553        assert!(!git_dir.join("objects/info/alternates").exists());
554        assert!(git_dir.join("HEAD").is_file());
555    }
556
557    #[tokio::test]
558    async fn a_git_file_in_the_archive_is_rejected() {
559        let tmp = tempfile::tempdir().unwrap();
560        let work = tmp.path().join("work");
561        std::fs::create_dir_all(&work).unwrap();
562        let archive_path = work.join("repo.tar.gz");
563        write_tar_gz(&archive_path, "repo/.git", b"gitdir: /elsewhere/.git\n");
564        let dest = tmp.path().join("dest");
565
566        assert!(extract_and_replace(&archive_path, &work, &dest)
567            .await
568            .is_err());
569        assert!(!dest.exists());
570    }
571
572    #[test]
573    fn the_first_directory_is_taken_in_a_stable_order() {
574        let tmp = tempfile::tempdir().unwrap();
575        std::fs::write(tmp.path().join("repo.tar.gz"), b"").unwrap();
576        std::fs::create_dir_all(tmp.path().join("b_repo")).unwrap();
577        std::fs::create_dir_all(tmp.path().join("a_repo")).unwrap();
578
579        assert_eq!(
580            extracted_dir(tmp.path()).unwrap(),
581            tmp.path().join("a_repo")
582        );
583    }
584}