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"];
20const 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 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
168fn 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 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
262pub 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 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 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}