Skip to main content

fluidattacks_core_git/clone/
mod.rs

1pub mod codecommit;
2pub mod https;
3pub mod ssh;
4
5pub(crate) mod reason;
6
7pub use https::build_https_config_args;
8pub use https::format_https_url;
9pub use ssh::normalize_ssh_url;
10pub use ssh::setup_ssh_env;
11pub use ssh::url_has_port;
12
13use anyhow::{bail, Context, Result};
14use serde_json::json;
15use std::fs;
16use std::path::{Path, PathBuf};
17use tokio::process::Command;
18use tracing::{info, warn};
19
20use crate::types::{CredentialKind, Credentials, GitRoot};
21
22/// Which refs a clone takes, and what it leaves on disk.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum CloneShape {
25    /// The named branch and only it, with a working tree.
26    SingleBranch,
27    /// Every ref the remote advertises, as a bare mirror.
28    Mirror,
29    /// Every branch head — `refs/heads/*` plus `HEAD` — as a bare repo:
30    /// no tags, no hidden refs, no mirror config remnants.
31    BareHeads,
32}
33
34pub struct CloneOpts {
35    pub group_name: String,
36    pub nickname: String,
37    pub repo_url: String,
38    pub branch: String,
39    pub credentials: Option<Credentials>,
40    pub follow_redirects: bool,
41    pub skip_existing: bool,
42    pub shape: CloneShape,
43}
44
45impl CloneOpts {
46    #[must_use]
47    pub fn from_root(
48        group: &str,
49        root: &GitRoot,
50        credentials: Option<Credentials>,
51        skip_existing: bool,
52        shape: CloneShape,
53    ) -> Self {
54        Self {
55            group_name: group.to_owned(),
56            nickname: root.nickname.clone(),
57            repo_url: root.url.clone(),
58            branch: root.branch.clone(),
59            credentials,
60            follow_redirects: root.follow_redirects(),
61            skip_existing,
62            shape,
63        }
64    }
65}
66
67async fn dispatch_clone(opts: &CloneOpts, dest: &Path) -> Result<()> {
68    match &opts.credentials {
69        Some(Credentials {
70            kind: CredentialKind::Ssh { .. },
71        }) => ssh::clone_ssh(opts, dest).await,
72        Some(Credentials {
73            kind: CredentialKind::Https { .. },
74        }) => https::clone_https(opts, dest).await,
75        Some(Credentials {
76            kind: CredentialKind::Token { .. },
77        }) => https::clone_https_token(opts, dest).await,
78        Some(Credentials {
79            kind: CredentialKind::AwsRole { .. },
80        }) => codecommit::clone_codecommit(opts, dest).await,
81        None if opts.repo_url.starts_with("http") => https::clone_https_public(opts, dest).await,
82        _ => bail!("no suitable credentials found for root {:?}", opts.nickname),
83    }
84}
85
86pub async fn clone_root(opts: &CloneOpts) -> Result<()> {
87    let repo_dir = PathBuf::from("groups")
88        .join(&opts.group_name)
89        .join(&opts.nickname);
90
91    if opts.skip_existing {
92        if let Ok(entries) = fs::read_dir(&repo_dir) {
93            if entries.count() > 0 {
94                info!(nickname = opts.nickname, "skipping existing repo");
95                return Ok(());
96            }
97        }
98    }
99
100    // Use tempdir_in targeting repo_dir's parent to avoid cross-filesystem rename (EXDEV)
101    let tmp_parent = repo_dir.parent().unwrap_or_else(|| Path::new("."));
102    fs::create_dir_all(tmp_parent)
103        .with_context(|| format!("creating parent dir {}", tmp_parent.display()))?;
104    let tmp_dir = tempfile::tempdir_in(tmp_parent).context("creating temp dir")?;
105    let clone_dest = tmp_dir.path().join(&opts.nickname);
106
107    dispatch_clone(opts, &clone_dest)
108        .await
109        .with_context(|| format!("cloning {:?}", opts.nickname))?;
110
111    if opts.shape == CloneShape::Mirror {
112        convert_bare_to_worktree(&clone_dest)
113            .await
114            .with_context(|| format!("converting mirror to worktree for {:?}", opts.nickname))?;
115    }
116
117    if repo_dir.exists() {
118        fs::remove_dir_all(&repo_dir)
119            .with_context(|| format!("removing old repo dir {}", repo_dir.display()))?;
120    }
121    fs::rename(&clone_dest, &repo_dir)
122        .with_context(|| format!("moving cloned repo to {}", repo_dir.display()))?;
123
124    log_clone_result(&repo_dir, opts).await;
125
126    if opts.shape == CloneShape::Mirror {
127        if let Err(e) = write_mirror_info(&repo_dir, opts) {
128            warn!(error = %e, "failed to write .info.json");
129        }
130    }
131
132    Ok(())
133}
134
135async fn convert_bare_to_worktree(clone_dest: &Path) -> Result<()> {
136    let bare_tmp = clone_dest.with_extension("bare");
137    fs::rename(clone_dest, &bare_tmp)
138        .with_context(|| format!("renaming bare repo to {}", bare_tmp.display()))?;
139    fs::create_dir_all(clone_dest)
140        .with_context(|| format!("creating worktree dir {}", clone_dest.display()))?;
141    let git_dir = clone_dest.join(".git");
142    fs::rename(&bare_tmp, &git_dir)
143        .with_context(|| format!("moving bare contents to {}", git_dir.display()))?;
144
145    let dest_str = clone_dest.to_string_lossy().to_string();
146
147    let config_out = Command::new("git")
148        .args(["-C", &dest_str, "config", "core.bare", "false"])
149        .output()
150        .await
151        .context("running git config core.bare false")?;
152    if !config_out.status.success() {
153        bail!(
154            "git config core.bare false failed: {}",
155            String::from_utf8_lossy(&config_out.stderr)
156        );
157    }
158
159    let reset_out = Command::new("git")
160        .args(["-C", &dest_str, "reset", "--hard", "HEAD"])
161        .output()
162        .await
163        .context("running git reset --hard HEAD")?;
164    if !reset_out.status.success() {
165        bail!(
166            "git reset --hard HEAD failed: {}",
167            String::from_utf8_lossy(&reset_out.stderr)
168        );
169    }
170
171    Ok(())
172}
173
174async fn log_clone_result(repo_dir: &Path, opts: &CloneOpts) {
175    let label = match opts.shape {
176        CloneShape::Mirror => "cloned (mirror)",
177        CloneShape::BareHeads => "cloned (bare heads)",
178        CloneShape::SingleBranch => "cloned",
179    };
180    let repo_dir_str = repo_dir.to_string_lossy().to_string();
181    match Command::new("git")
182        .args(["-C", &repo_dir_str, "log", "-1", "--format=%H %ai"])
183        .output()
184        .await
185    {
186        Ok(output) if output.status.success() => {
187            let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
188            info!(nickname = opts.nickname, head = head, label);
189        }
190        _ => {
191            info!(nickname = opts.nickname, dir = %repo_dir.display(), label);
192        }
193    }
194}
195
196fn write_mirror_info(repo_dir: &Path, opts: &CloneOpts) -> Result<()> {
197    let info = json!({
198        "fluid_branch": opts.branch,
199        "repo": opts.repo_url,
200    });
201    let data = serde_json::to_string_pretty(&info)?;
202    fs::write(repo_dir.join(".info.json"), data)?;
203    Ok(())
204}
205
206/// Clones a repo directly into `temp_dir/{uuid}` and returns
207/// `(Some(path), None)` on success or `(None, Some(error))` on failure.
208#[allow(clippy::too_many_arguments)]
209pub async fn clone_to_temp(
210    repo_url: &str,
211    repo_branch: &str,
212    temp_dir: &str,
213    credentials: Option<Credentials>,
214    follow_redirects: bool,
215    mirror: bool,
216) -> (Option<String>, Option<String>) {
217    let nickname = uuid::Uuid::new_v4().to_string();
218    let dest = PathBuf::from(temp_dir).join(&nickname);
219
220    if let Err(e) = fs::create_dir_all(temp_dir) {
221        return (None, Some(e.to_string()));
222    }
223
224    let opts = CloneOpts {
225        group_name: String::new(),
226        nickname: nickname.clone(),
227        repo_url: repo_url.to_owned(),
228        branch: repo_branch.to_owned(),
229        credentials,
230        follow_redirects,
231        skip_existing: false,
232        shape: if mirror {
233            CloneShape::Mirror
234        } else {
235            CloneShape::SingleBranch
236        },
237    };
238
239    let result = dispatch_clone(&opts, &dest).await;
240
241    match result {
242        Ok(()) => {
243            log_clone_result(&dest, &opts).await;
244            if opts.shape == CloneShape::Mirror {
245                if let Err(e) = write_mirror_info(&dest, &opts) {
246                    warn!(error = %e, "failed to write .info.json");
247                }
248            }
249            (Some(dest.to_string_lossy().to_string()), None)
250        }
251        Err(e) => {
252            let _ = fs::remove_dir_all(&dest);
253            (None, Some(e.to_string()))
254        }
255    }
256}
257
258/// Builds the common clone arguments: config flags, clone subcommand, shape, URL, dest.
259#[must_use]
260pub fn build_clone_args(
261    opts: &CloneOpts,
262    repo_url: &str,
263    dest: &Path,
264    extra_config: &[String],
265) -> Vec<String> {
266    let mut args = vec![
267        "-c".to_owned(),
268        "core.symlinks=false".to_owned(),
269        "-c".to_owned(),
270        format!("http.followRedirects={}", opts.follow_redirects),
271    ];
272
273    args.extend(extra_config.iter().cloned());
274
275    args.push("clone".to_owned());
276
277    match opts.shape {
278        CloneShape::Mirror => args.push("--mirror".to_owned()),
279        // Every branch head and nothing else: unlike --mirror this skips
280        // refs/pull, refs/merge-requests and keep-around refs, and writes no
281        // mirror refspec into the clone's config.
282        CloneShape::BareHeads => args.extend(["--bare".to_owned(), "--no-tags".to_owned()]),
283        CloneShape::SingleBranch => {
284            if !opts.branch.is_empty() {
285                args.extend([
286                    "--branch".to_owned(),
287                    opts.branch.clone(),
288                    "--single-branch".to_owned(),
289                ]);
290            }
291        }
292    }
293
294    args.extend([
295        "--".to_owned(),
296        repo_url.to_owned(),
297        dest.to_string_lossy().to_string(),
298    ]);
299
300    args
301}
302
303#[cfg(test)]
304mod tests {
305    use std::path::Path;
306    use std::process::Command;
307
308    use super::{build_clone_args, CloneOpts, CloneShape};
309
310    fn opts(shape: CloneShape) -> CloneOpts {
311        CloneOpts {
312            group_name: "a-group".to_owned(),
313            nickname: "a_root".to_owned(),
314            repo_url: "https://gitlab.com/org/repo".to_owned(),
315            branch: "trunk".to_owned(),
316            credentials: None,
317            follow_redirects: false,
318            skip_existing: false,
319            shape,
320        }
321    }
322
323    fn args_of(follow_redirects: bool) -> Vec<String> {
324        let opts = CloneOpts {
325            follow_redirects,
326            ..opts(CloneShape::SingleBranch)
327        };
328
329        build_clone_args(&opts, &opts.repo_url, Path::new("/tmp/a_root"), &[])
330    }
331
332    fn shape_args(shape: CloneShape) -> Vec<String> {
333        let opts = opts(shape);
334
335        // Between "clone" and "--" sits exactly the shape.
336        build_clone_args(&opts, &opts.repo_url, Path::new("/tmp/a_root"), &[])
337            .iter()
338            .skip_while(|arg| *arg != "clone")
339            .skip(1)
340            .take_while(|arg| *arg != "--")
341            .cloned()
342            .collect()
343    }
344
345    fn run_git(dir: &Path, args: &[&str]) -> String {
346        let output = Command::new("git")
347            .arg("-C")
348            .arg(dir)
349            .args([
350                "-c",
351                "user.email=test@example.com",
352                "-c",
353                "user.name=Test",
354                "-c",
355                "commit.gpgsign=false",
356                "-c",
357                "tag.gpgsign=false",
358            ])
359            .args(args)
360            .output()
361            .expect("git runs");
362        assert!(
363            output.status.success(),
364            "git {args:?} failed: {}",
365            String::from_utf8_lossy(&output.stderr)
366        );
367        String::from_utf8_lossy(&output.stdout).trim().to_owned()
368    }
369
370    #[test]
371    fn a_clone_never_writes_a_symbolic_link() {
372        assert!(args_of(false).contains(&"core.symlinks=false".to_owned()));
373        assert!(args_of(true).contains(&"core.symlinks=false".to_owned()));
374    }
375
376    #[test]
377    fn a_root_that_takes_no_redirect_says_so_instead_of_leaving_it_to_git() {
378        assert!(args_of(false).contains(&"http.followRedirects=false".to_owned()));
379    }
380
381    #[test]
382    fn a_root_that_needs_a_redirect_keeps_asking_for_one() {
383        assert!(args_of(true).contains(&"http.followRedirects=true".to_owned()));
384    }
385
386    #[test]
387    fn a_branch_clone_is_single_branch() {
388        assert_eq!(
389            shape_args(CloneShape::SingleBranch),
390            ["--branch", "trunk", "--single-branch"]
391        );
392    }
393
394    #[test]
395    fn a_mirror_clone_takes_every_ref() {
396        assert_eq!(shape_args(CloneShape::Mirror), ["--mirror"]);
397    }
398
399    #[test]
400    fn a_bare_heads_clone_takes_heads_without_tags() {
401        assert_eq!(shape_args(CloneShape::BareHeads), ["--bare", "--no-tags"]);
402    }
403
404    #[test]
405    fn a_bare_heads_clone_holds_exactly_the_branch_heads() {
406        let tmp = tempfile::tempdir().expect("tempdir");
407        let source = tmp.path().join("source");
408        std::fs::create_dir(&source).expect("mkdir");
409        run_git(&source, &["init", "--initial-branch=main"]);
410        std::fs::write(source.join("file.txt"), "content\n").expect("write");
411        run_git(&source, &["add", "file.txt"]);
412        run_git(&source, &["commit", "-m", "first"]);
413        run_git(&source, &["branch", "feature"]);
414        run_git(&source, &["tag", "v1"]);
415        // The kind of hidden ref --mirror would drag in.
416        run_git(&source, &["update-ref", "refs/pull/1/head", "HEAD"]);
417
418        let dest = tmp.path().join("dest");
419        let opts = opts(CloneShape::BareHeads);
420        let args = build_clone_args(&opts, &source.to_string_lossy(), &dest, &[]);
421        let clone = Command::new("git").args(&args).output().expect("git runs");
422        assert!(
423            clone.status.success(),
424            "clone failed: {}",
425            String::from_utf8_lossy(&clone.stderr)
426        );
427
428        let refs = run_git(&dest, &["for-each-ref", "--format=%(refname)"]);
429        let mut held: Vec<&str> = refs.lines().collect();
430        held.sort_unstable();
431        assert_eq!(held, ["refs/heads/feature", "refs/heads/main"]);
432        assert_eq!(run_git(&dest, &["symbolic-ref", "HEAD"]), "refs/heads/main");
433        assert_eq!(run_git(&dest, &["config", "core.bare"]), "true");
434        // No mirror remnants: neither the flag nor a fetch refspec.
435        for key in ["remote.origin.mirror", "remote.origin.fetch"] {
436            let lookup = Command::new("git")
437                .args(["-C", &dest.to_string_lossy(), "config", "--get", key])
438                .output()
439                .expect("git runs");
440            assert!(!lookup.status.success(), "{key} is set");
441        }
442    }
443}