fluidattacks_core/git/clone/
mod.rs1pub 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 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
190pub 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
258pub 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}