Skip to main content

fluidattacks_core_git/
download.rs

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