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