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
23pub struct CloneOpts {
24    pub group_name: String,
25    pub nickname: String,
26    pub repo_url: String,
27    pub branch: String,
28    pub credentials: Option<Credentials>,
29    pub follow_redirects: bool,
30    pub skip_existing: bool,
31    pub mirror: bool,
32}
33
34impl CloneOpts {
35    #[must_use]
36    pub fn from_root(
37        group: &str,
38        root: &GitRoot,
39        credentials: Option<Credentials>,
40        skip_existing: bool,
41        mirror: bool,
42    ) -> Self {
43        Self {
44            group_name: group.to_owned(),
45            nickname: root.nickname.clone(),
46            repo_url: root.url.clone(),
47            branch: root.branch.clone(),
48            credentials,
49            follow_redirects: root.follow_redirects(),
50            skip_existing,
51            mirror,
52        }
53    }
54}
55
56async fn dispatch_clone(opts: &CloneOpts, dest: &Path) -> Result<()> {
57    match &opts.credentials {
58        Some(Credentials {
59            kind: CredentialKind::Ssh { .. },
60        }) => ssh::clone_ssh(opts, dest).await,
61        Some(Credentials {
62            kind: CredentialKind::Https { .. },
63        }) => https::clone_https(opts, dest).await,
64        Some(Credentials {
65            kind: CredentialKind::Token { .. },
66        }) => https::clone_https_token(opts, dest).await,
67        Some(Credentials {
68            kind: CredentialKind::AwsRole { .. },
69        }) => codecommit::clone_codecommit(opts, dest).await,
70        None if opts.repo_url.starts_with("http") => https::clone_https_public(opts, dest).await,
71        _ => bail!("no suitable credentials found for root {:?}", opts.nickname),
72    }
73}
74
75pub async fn clone_root(opts: &CloneOpts) -> Result<()> {
76    let repo_dir = PathBuf::from("groups")
77        .join(&opts.group_name)
78        .join(&opts.nickname);
79
80    if opts.skip_existing {
81        if let Ok(entries) = fs::read_dir(&repo_dir) {
82            if entries.count() > 0 {
83                info!(nickname = opts.nickname, "skipping existing repo");
84                return Ok(());
85            }
86        }
87    }
88
89    // Use tempdir_in targeting repo_dir's parent to avoid cross-filesystem rename (EXDEV)
90    let tmp_parent = repo_dir.parent().unwrap_or_else(|| Path::new("."));
91    fs::create_dir_all(tmp_parent)
92        .with_context(|| format!("creating parent dir {}", tmp_parent.display()))?;
93    let tmp_dir = tempfile::tempdir_in(tmp_parent).context("creating temp dir")?;
94    let clone_dest = tmp_dir.path().join(&opts.nickname);
95
96    dispatch_clone(opts, &clone_dest)
97        .await
98        .with_context(|| format!("cloning {:?}", opts.nickname))?;
99
100    if opts.mirror {
101        convert_bare_to_worktree(&clone_dest)
102            .await
103            .with_context(|| format!("converting mirror to worktree for {:?}", opts.nickname))?;
104    }
105
106    if repo_dir.exists() {
107        fs::remove_dir_all(&repo_dir)
108            .with_context(|| format!("removing old repo dir {}", repo_dir.display()))?;
109    }
110    fs::rename(&clone_dest, &repo_dir)
111        .with_context(|| format!("moving cloned repo to {}", repo_dir.display()))?;
112
113    log_clone_result(&repo_dir, opts).await;
114
115    if opts.mirror {
116        if let Err(e) = write_mirror_info(&repo_dir, opts) {
117            warn!(error = %e, "failed to write .info.json");
118        }
119    }
120
121    Ok(())
122}
123
124async fn convert_bare_to_worktree(clone_dest: &Path) -> Result<()> {
125    let bare_tmp = clone_dest.with_extension("bare");
126    fs::rename(clone_dest, &bare_tmp)
127        .with_context(|| format!("renaming bare repo to {}", bare_tmp.display()))?;
128    fs::create_dir_all(clone_dest)
129        .with_context(|| format!("creating worktree dir {}", clone_dest.display()))?;
130    let git_dir = clone_dest.join(".git");
131    fs::rename(&bare_tmp, &git_dir)
132        .with_context(|| format!("moving bare contents to {}", git_dir.display()))?;
133
134    let dest_str = clone_dest.to_string_lossy().to_string();
135
136    let config_out = Command::new("git")
137        .args(["-C", &dest_str, "config", "core.bare", "false"])
138        .output()
139        .await
140        .context("running git config core.bare false")?;
141    if !config_out.status.success() {
142        bail!(
143            "git config core.bare false failed: {}",
144            String::from_utf8_lossy(&config_out.stderr)
145        );
146    }
147
148    let reset_out = Command::new("git")
149        .args(["-C", &dest_str, "reset", "--hard", "HEAD"])
150        .output()
151        .await
152        .context("running git reset --hard HEAD")?;
153    if !reset_out.status.success() {
154        bail!(
155            "git reset --hard HEAD failed: {}",
156            String::from_utf8_lossy(&reset_out.stderr)
157        );
158    }
159
160    Ok(())
161}
162
163async fn log_clone_result(repo_dir: &Path, opts: &CloneOpts) {
164    let label = if opts.mirror {
165        "cloned (mirror)"
166    } else {
167        "cloned"
168    };
169    let repo_dir_str = repo_dir.to_string_lossy().to_string();
170    match Command::new("git")
171        .args(["-C", &repo_dir_str, "log", "-1", "--format=%H %ai"])
172        .output()
173        .await
174    {
175        Ok(output) if output.status.success() => {
176            let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
177            info!(nickname = opts.nickname, head = head, label);
178        }
179        _ => {
180            info!(nickname = opts.nickname, dir = %repo_dir.display(), label);
181        }
182    }
183}
184
185fn write_mirror_info(repo_dir: &Path, opts: &CloneOpts) -> Result<()> {
186    let info = json!({
187        "fluid_branch": opts.branch,
188        "repo": opts.repo_url,
189    });
190    let data = serde_json::to_string_pretty(&info)?;
191    fs::write(repo_dir.join(".info.json"), data)?;
192    Ok(())
193}
194
195/// Clones a repo directly into `temp_dir/{uuid}` and returns
196/// `(Some(path), None)` on success or `(None, Some(error))` on failure.
197#[allow(clippy::too_many_arguments)]
198pub async fn clone_to_temp(
199    repo_url: &str,
200    repo_branch: &str,
201    temp_dir: &str,
202    credentials: Option<Credentials>,
203    follow_redirects: bool,
204    mirror: bool,
205) -> (Option<String>, Option<String>) {
206    let nickname = uuid::Uuid::new_v4().to_string();
207    let dest = PathBuf::from(temp_dir).join(&nickname);
208
209    if let Err(e) = fs::create_dir_all(temp_dir) {
210        return (None, Some(e.to_string()));
211    }
212
213    let opts = CloneOpts {
214        group_name: String::new(),
215        nickname: nickname.clone(),
216        repo_url: repo_url.to_owned(),
217        branch: repo_branch.to_owned(),
218        credentials,
219        follow_redirects,
220        skip_existing: false,
221        mirror,
222    };
223
224    let result = dispatch_clone(&opts, &dest).await;
225
226    match result {
227        Ok(()) => {
228            if opts.mirror {
229                if let Err(e) = convert_bare_to_worktree(&dest).await {
230                    let _ = fs::remove_dir_all(&dest);
231                    return (None, Some(e.to_string()));
232                }
233            }
234            log_clone_result(&dest, &opts).await;
235            if opts.mirror {
236                if let Err(e) = write_mirror_info(&dest, &opts) {
237                    warn!(error = %e, "failed to write .info.json");
238                }
239            }
240            (Some(dest.to_string_lossy().to_string()), None)
241        }
242        Err(e) => {
243            let _ = fs::remove_dir_all(&dest);
244            (None, Some(e.to_string()))
245        }
246    }
247}
248
249/// Builds the common clone arguments: config flags, clone subcommand, mirror/branch, URL, dest.
250#[must_use]
251pub fn build_clone_args(
252    opts: &CloneOpts,
253    repo_url: &str,
254    dest: &Path,
255    extra_config: &[String],
256) -> Vec<String> {
257    let mut args = vec![
258        "-c".to_owned(),
259        "core.symlinks=false".to_owned(),
260        "-c".to_owned(),
261        format!("http.followRedirects={}", opts.follow_redirects),
262        "-c".to_owned(),
263        "credential.helper=".to_owned(),
264    ];
265
266    args.extend(extra_config.iter().cloned());
267
268    args.push("clone".to_owned());
269
270    if opts.mirror {
271        args.push("--mirror".to_owned());
272    } else if !opts.branch.is_empty() {
273        args.extend([
274            "--branch".to_owned(),
275            opts.branch.clone(),
276            "--single-branch".to_owned(),
277        ]);
278    }
279
280    args.extend([
281        "--".to_owned(),
282        repo_url.to_owned(),
283        dest.to_string_lossy().to_string(),
284    ]);
285
286    args
287}
288
289#[cfg(test)]
290mod tests {
291    use std::path::Path;
292
293    use super::{build_clone_args, CloneOpts};
294
295    fn args_of(follow_redirects: bool) -> Vec<String> {
296        let opts = CloneOpts {
297            group_name: "a-group".to_owned(),
298            nickname: "a_root".to_owned(),
299            repo_url: "https://gitlab.com/org/repo".to_owned(),
300            branch: "trunk".to_owned(),
301            credentials: None,
302            follow_redirects,
303            skip_existing: false,
304            mirror: false,
305        };
306
307        build_clone_args(&opts, &opts.repo_url, Path::new("/tmp/a_root"), &[])
308    }
309
310    #[test]
311    fn a_clone_never_writes_a_symbolic_link() {
312        assert!(args_of(false).contains(&"core.symlinks=false".to_owned()));
313        assert!(args_of(true).contains(&"core.symlinks=false".to_owned()));
314    }
315
316    #[test]
317    fn a_root_that_takes_no_redirect_says_so_instead_of_leaving_it_to_git() {
318        assert!(args_of(false).contains(&"http.followRedirects=false".to_owned()));
319    }
320
321    #[test]
322    fn a_clone_asks_nobody_for_a_credential() {
323        assert!(args_of(false).contains(&"credential.helper=".to_owned()));
324    }
325
326    #[test]
327    fn a_root_that_needs_a_redirect_keeps_asking_for_one() {
328        assert!(args_of(true).contains(&"http.followRedirects=true".to_owned()));
329    }
330}