fluidattacks_core_git/clone/
https.rs1use anyhow::{Context, Result};
2use base64::Engine;
3use std::path::Path;
4use tokio::process::Command;
5use url::Url;
6
7use super::{build_clone_args, reason, CloneOpts};
8use crate::types::CredentialKind;
9
10pub async fn clone_https(opts: &CloneOpts, dest: &Path) -> Result<()> {
11 let CredentialKind::Https { user, password } = &opts
12 .credentials
13 .as_ref()
14 .context("missing credentials")?
15 .kind
16 else {
17 anyhow::bail!("expected HTTPS credentials");
18 };
19 let repo_url = embed_credentials_in_url(&opts.repo_url, user, password)?;
20
21 let extra_config = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
22 let args = build_clone_args(opts, &repo_url, dest, &extra_config);
23 run_git_clone(&args, &[password.as_str()]).await
24}
25
26pub async fn clone_https_token(opts: &CloneOpts, dest: &Path) -> Result<()> {
27 let CredentialKind::Token {
28 token,
29 is_pat,
30 oauth_type,
31 } = &opts
32 .credentials
33 .as_ref()
34 .context("missing credentials")?
35 .kind
36 else {
37 anyhow::bail!("expected Token credentials");
38 };
39
40 if *is_pat {
41 let encoded = base64::engine::general_purpose::STANDARD.encode(format!(":{token}"));
42 let extra_config = vec![
43 "-c".to_owned(),
44 "http.sslVerify=false".to_owned(),
45 "-c".to_owned(),
46 format!("http.extraHeader=Authorization: Basic {encoded}"),
47 ];
48 let args = build_clone_args(opts, &opts.repo_url, dest, &extra_config);
49 return run_git_clone(&args, &[token.as_str(), &encoded]).await;
50 }
51
52 let repo_url = embed_token_in_url(&opts.repo_url, token, oauth_type)?;
53 let extra_config = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
54 let args = build_clone_args(opts, &repo_url, dest, &extra_config);
55 run_git_clone(&args, &[token.as_str()]).await
56}
57
58pub async fn clone_https_public(opts: &CloneOpts, dest: &Path) -> Result<()> {
59 let extra_config = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
60 let args = build_clone_args(opts, &opts.repo_url, dest, &extra_config);
61 run_git_clone(&args, &[]).await
62}
63
64pub fn embed_token_in_url(url: &str, token: &str, provider: &str) -> Result<String> {
65 if provider.eq_ignore_ascii_case("BITBUCKET") {
66 return embed_credentials_in_url(url, "x-token-auth", token);
67 }
68
69 match provider {
70 "" => {
71 let mut parsed = Url::parse(url).context("parsing URL")?;
72 parsed
73 .set_username(token)
74 .map_err(|()| anyhow::anyhow!("failed to set the token as the user"))?;
75 parsed
76 .set_password(None)
77 .map_err(|()| anyhow::anyhow!("failed to drop the password"))?;
78 Ok(parsed.to_string())
79 }
80 _ => embed_credentials_in_url(url, "oauth2", token),
81 }
82}
83
84pub fn embed_credentials_in_url(raw_url: &str, user: &str, password: &str) -> Result<String> {
85 let mut url = Url::parse(raw_url).context("parsing URL")?;
86 url.set_username(user)
87 .map_err(|()| anyhow::anyhow!("failed to set username"))?;
88 url.set_password(Some(password))
89 .map_err(|()| anyhow::anyhow!("failed to set password"))?;
90 Ok(url.to_string())
91}
92
93#[must_use]
95pub fn build_https_config_args(
96 follow_redirects: bool,
97 is_pat: bool,
98 token: Option<&str>,
99 disable_credential_prompt: bool,
100) -> Vec<String> {
101 let mut args = vec![
102 "-c".to_owned(),
103 "http.sslVerify=false".to_owned(),
104 "-c".to_owned(),
105 format!("http.followRedirects={follow_redirects}"),
106 ];
107 if is_pat {
108 if let Some(t) = token {
109 let encoded = base64::engine::general_purpose::STANDARD.encode(format!(":{t}"));
110 args.extend([
111 "-c".to_owned(),
112 format!("http.extraHeader=Authorization: Basic {encoded}"),
113 ]);
114 }
115 }
116 if disable_credential_prompt {
117 args.extend(["-c".to_owned(), "credential.helper=".to_owned()]);
118 }
119 args
120}
121
122#[allow(clippy::too_many_arguments)]
124pub fn format_https_url(
125 repo_url: &str,
126 user: Option<&str>,
127 password: Option<&str>,
128 token: Option<&str>,
129 provider: Option<&str>,
130 is_pat: bool,
131) -> Result<String> {
132 let mut parsed = Url::parse(repo_url).context("parsing URL")?;
133 let path = parsed.path().to_owned();
135 parsed.set_path(&path);
136
137 if is_pat {
138 return Ok(parsed.to_string());
139 }
140
141 if let Some(t) = token {
142 let user_part = match provider {
143 Some(p) if p.eq_ignore_ascii_case("BITBUCKET") => "x-token-auth",
144 Some("") | None => "",
145 Some(_) => "oauth2",
146 };
147 if user_part.is_empty() {
148 parsed
150 .set_username(t)
151 .map_err(|()| anyhow::anyhow!("set username"))?;
152 parsed
153 .set_password(None)
154 .map_err(|()| anyhow::anyhow!("set password"))?;
155 } else {
156 parsed
158 .set_username(user_part)
159 .map_err(|()| anyhow::anyhow!("set username"))?;
160 parsed
161 .set_password(Some(t))
162 .map_err(|()| anyhow::anyhow!("set password"))?;
163 }
164 return Ok(parsed.to_string());
165 }
166
167 if let (Some(u), Some(p)) = (user, password) {
168 parsed
169 .set_username(u)
170 .map_err(|()| anyhow::anyhow!("set username"))?;
171 parsed
172 .set_password(Some(p))
173 .map_err(|()| anyhow::anyhow!("set password"))?;
174 return Ok(parsed.to_string());
175 }
176
177 Ok(parsed.to_string())
178}
179
180async fn run_git_clone(args: &[String], secrets: &[&str]) -> Result<()> {
181 reason::run(Command::new("git").args(args), "HTTPS", secrets).await
182}
183
184#[cfg(test)]
185mod tests {
186 use super::{embed_token_in_url, format_https_url};
187
188 #[test]
189 fn a_credential_with_no_provider_carries_the_token_with_no_user() {
190 assert_eq!(
191 embed_token_in_url("https://gitlab.com/a/b.git", "tok", "").unwrap(),
192 "https://tok@gitlab.com/a/b.git"
193 );
194 }
195
196 #[test]
197 fn the_provider_names_the_user_even_off_its_own_domain() {
198 assert_eq!(
199 embed_token_in_url("https://git.acme.com/a/b.git", "tok", "BITBUCKET").unwrap(),
200 "https://x-token-auth:tok@git.acme.com/a/b.git"
201 );
202 assert_eq!(
203 embed_token_in_url("https://gitlab.com/a/b.git", "tok", "GITLAB").unwrap(),
204 "https://oauth2:tok@gitlab.com/a/b.git"
205 );
206 }
207
208 #[test]
209 fn the_token_replaces_whatever_the_url_already_carried() {
210 assert_eq!(
211 embed_token_in_url("https://olduser:oldpass@host/a/b.git", "tok", "").unwrap(),
212 "https://tok@host/a/b.git"
213 );
214 }
215
216 #[test]
217 fn the_provider_is_matched_ignoring_the_case() {
218 let url = "https://host/a/b.git";
219
220 assert_eq!(
221 format_https_url(url, None, None, Some("tok"), Some("Bitbucket"), false).unwrap(),
222 format_https_url(url, None, None, Some("tok"), Some("BITBUCKET"), false).unwrap()
223 );
224 assert_eq!(
225 embed_token_in_url(url, "tok", "Bitbucket").unwrap(),
226 embed_token_in_url(url, "tok", "BITBUCKET").unwrap()
227 );
228 }
229
230 #[test]
231 fn an_empty_provider_formats_the_url_like_no_provider_at_all() {
232 let url = "https://gitlab.com/a/b.git";
233 let no_provider = format_https_url(url, None, None, Some("tok"), None, false).unwrap();
234 assert_eq!(no_provider, "https://tok@gitlab.com/a/b.git");
235 assert_eq!(
236 format_https_url(url, None, None, Some("tok"), Some(""), false).unwrap(),
237 no_provider
238 );
239 }
240}