Skip to main content

fluidattacks_core/git/clone/
mod.rs

1pub mod codecommit;
2pub mod https;
3pub mod ssh;
4
5pub use https::build_https_config_args;
6pub use https::embed_credentials_in_url;
7pub use https::format_https_url;
8pub use ssh::normalize_ssh_url;
9pub use ssh::setup_ssh_env;
10pub use ssh::url_has_port;
11
12use anyhow::{bail, Context, Result};
13use serde_json::json;
14use std::fs;
15use std::path::{Path, PathBuf};
16use tokio::process::Command;
17use tracing::{info, warn};
18
19use crate::types::{CredentialKind, Credentials, GitRoot};
20
21pub struct CloneOpts {
22    pub group_name: String,
23    pub nickname: String,
24    pub repo_url: String,
25    pub branch: String,
26    pub credentials: Option<Credentials>,
27    pub follow_redirects: bool,
28    pub skip_existing: bool,
29    pub mirror: bool,
30}
31
32impl CloneOpts {
33    pub fn from_root(
34        group: &str,
35        root: &GitRoot,
36        credentials: Option<Credentials>,
37        skip_existing: bool,
38        mirror: bool,
39    ) -> Self {
40        Self {
41            group_name: group.to_string(),
42            nickname: root.nickname.clone(),
43            repo_url: root.url.clone(),
44            branch: root.branch.clone(),
45            credentials,
46            follow_redirects: root.follow_redirects(),
47            skip_existing,
48            mirror,
49        }
50    }
51}
52
53pub async fn clone_root(opts: &CloneOpts) -> Result<()> {
54    let repo_dir = PathBuf::from("groups")
55        .join(&opts.group_name)
56        .join(&opts.nickname);
57
58    if opts.skip_existing {
59        if let Ok(entries) = fs::read_dir(&repo_dir) {
60            if entries.count() > 0 {
61                info!(nickname = opts.nickname, "skipping existing repo");
62                return Ok(());
63            }
64        }
65    }
66
67    // Use tempdir_in targeting repo_dir's parent to avoid cross-filesystem rename (EXDEV)
68    let tmp_parent = repo_dir.parent().unwrap_or(Path::new("."));
69    fs::create_dir_all(tmp_parent)
70        .with_context(|| format!("creating parent dir {}", tmp_parent.display()))?;
71    let tmp_dir = tempfile::tempdir_in(tmp_parent).context("creating temp dir")?;
72    let clone_dest = tmp_dir.path().join(&opts.nickname);
73
74    let result = match &opts.credentials {
75        Some(Credentials {
76            kind: CredentialKind::Ssh { .. },
77        }) => ssh::clone_ssh(opts, &clone_dest).await,
78        Some(Credentials {
79            kind: CredentialKind::Https { .. },
80        }) => https::clone_https(opts, &clone_dest).await,
81        Some(Credentials {
82            kind: CredentialKind::Token { .. },
83        }) => https::clone_https_token(opts, &clone_dest).await,
84        Some(Credentials {
85            kind: CredentialKind::AwsRole { .. },
86        }) => codecommit::clone_codecommit(opts, &clone_dest).await,
87        None if opts.repo_url.starts_with("http") => {
88            https::clone_https_public(opts, &clone_dest).await
89        }
90        _ => bail!("no suitable credentials found for root {:?}", opts.nickname,),
91    };
92
93    result.with_context(|| format!("cloning {:?}", opts.nickname))?;
94
95    if opts.mirror {
96        convert_bare_to_worktree(&clone_dest)
97            .await
98            .with_context(|| format!("converting mirror to worktree for {:?}", opts.nickname))?;
99    }
100
101    if repo_dir.exists() {
102        fs::remove_dir_all(&repo_dir)
103            .with_context(|| format!("removing old repo dir {}", repo_dir.display()))?;
104    }
105    fs::rename(&clone_dest, &repo_dir)
106        .with_context(|| format!("moving cloned repo to {}", repo_dir.display()))?;
107
108    log_clone_result(&repo_dir, opts).await;
109
110    if opts.mirror {
111        if let Err(e) = write_mirror_info(&repo_dir, opts) {
112            warn!(error = %e, "failed to write .info.json");
113        }
114    }
115
116    Ok(())
117}
118
119async fn convert_bare_to_worktree(clone_dest: &Path) -> Result<()> {
120    let bare_tmp = clone_dest.with_extension("bare");
121    fs::rename(clone_dest, &bare_tmp)
122        .with_context(|| format!("renaming bare repo to {}", bare_tmp.display()))?;
123    fs::create_dir_all(clone_dest)
124        .with_context(|| format!("creating worktree dir {}", clone_dest.display()))?;
125    let git_dir = clone_dest.join(".git");
126    fs::rename(&bare_tmp, &git_dir)
127        .with_context(|| format!("moving bare contents to {}", git_dir.display()))?;
128
129    let dest_str = clone_dest.to_string_lossy().to_string();
130
131    let config_out = Command::new("git")
132        .args(["-C", &dest_str, "config", "core.bare", "false"])
133        .output()
134        .await
135        .context("running git config core.bare false")?;
136    if !config_out.status.success() {
137        bail!(
138            "git config core.bare false failed: {}",
139            String::from_utf8_lossy(&config_out.stderr)
140        );
141    }
142
143    let reset_out = Command::new("git")
144        .args(["-C", &dest_str, "reset", "--hard", "HEAD"])
145        .output()
146        .await
147        .context("running git reset --hard HEAD")?;
148    if !reset_out.status.success() {
149        bail!(
150            "git reset --hard HEAD failed: {}",
151            String::from_utf8_lossy(&reset_out.stderr)
152        );
153    }
154
155    Ok(())
156}
157
158async fn log_clone_result(repo_dir: &Path, opts: &CloneOpts) {
159    let label = if opts.mirror {
160        "cloned (mirror)"
161    } else {
162        "cloned"
163    };
164    let repo_dir_str = repo_dir.to_string_lossy().to_string();
165    match Command::new("git")
166        .args(["-C", &repo_dir_str, "log", "-1", "--format=%H %ai"])
167        .output()
168        .await
169    {
170        Ok(output) if output.status.success() => {
171            let head = String::from_utf8_lossy(&output.stdout).trim().to_string();
172            info!(nickname = opts.nickname, head = head, label);
173        }
174        _ => {
175            info!(nickname = opts.nickname, dir = %repo_dir.display(), label);
176        }
177    }
178}
179
180fn write_mirror_info(repo_dir: &Path, opts: &CloneOpts) -> Result<()> {
181    let info = json!({
182        "fluid_branch": opts.branch,
183        "repo": opts.repo_url,
184    });
185    let data = serde_json::to_string_pretty(&info)?;
186    fs::write(repo_dir.join(".info.json"), data)?;
187    Ok(())
188}
189
190/// Clones a repo directly into `temp_dir/{uuid}` and returns
191/// `(Some(path), None)` on success or `(None, Some(error))` on failure.
192pub async fn clone_to_temp(
193    repo_url: &str,
194    repo_branch: &str,
195    temp_dir: &str,
196    credentials: Option<Credentials>,
197    follow_redirects: bool,
198    mirror: bool,
199) -> (Option<String>, Option<String>) {
200    let nickname = uuid::Uuid::new_v4().to_string();
201    let dest = PathBuf::from(temp_dir).join(&nickname);
202
203    if let Err(e) = fs::create_dir_all(temp_dir) {
204        return (None, Some(e.to_string()));
205    }
206
207    let opts = CloneOpts {
208        group_name: String::new(),
209        nickname: nickname.clone(),
210        repo_url: repo_url.to_string(),
211        branch: repo_branch.to_string(),
212        credentials,
213        follow_redirects,
214        skip_existing: false,
215        mirror,
216    };
217
218    let result = match &opts.credentials {
219        Some(Credentials {
220            kind: CredentialKind::Ssh { .. },
221        }) => ssh::clone_ssh(&opts, &dest).await,
222        Some(Credentials {
223            kind: CredentialKind::Https { .. },
224        }) => https::clone_https(&opts, &dest).await,
225        Some(Credentials {
226            kind: CredentialKind::Token { .. },
227        }) => https::clone_https_token(&opts, &dest).await,
228        Some(Credentials {
229            kind: CredentialKind::AwsRole { .. },
230        }) => codecommit::clone_codecommit(&opts, &dest).await,
231        None if opts.repo_url.starts_with("http") => https::clone_https_public(&opts, &dest).await,
232        _ => Err(anyhow::anyhow!("no suitable credentials")),
233    };
234
235    match result {
236        Ok(()) => {
237            if opts.mirror {
238                if let Err(e) = convert_bare_to_worktree(&dest).await {
239                    let _ = fs::remove_dir_all(&dest);
240                    return (None, Some(e.to_string()));
241                }
242            }
243            log_clone_result(&dest, &opts).await;
244            if opts.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, mirror/branch, URL, dest.
259pub fn build_clone_args(
260    opts: &CloneOpts,
261    repo_url: &str,
262    dest: &Path,
263    extra_config: &[String],
264) -> Vec<String> {
265    let mut args = Vec::new();
266
267    if opts.follow_redirects {
268        args.extend(["-c".to_string(), "http.followRedirects=true".to_string()]);
269    }
270
271    args.extend(extra_config.iter().cloned());
272
273    args.push("clone".to_string());
274
275    if opts.mirror {
276        args.push("--mirror".to_string());
277    } else if !opts.branch.is_empty() {
278        args.extend([
279            "--branch".to_string(),
280            opts.branch.clone(),
281            "--single-branch".to_string(),
282        ]);
283    }
284
285    args.extend([
286        "--".to_string(),
287        repo_url.to_string(),
288        dest.to_string_lossy().to_string(),
289    ]);
290
291    args
292}