fluidattacks_core_git/clone/
mod.rs1pub 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 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#[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#[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}