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