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