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
10const USER_VAR: &str = "FLUIDATTACKS_GIT_USER";
11const PASSWORD_VAR: &str = "FLUIDATTACKS_GIT_PASSWORD";
12
13fn env_helper() -> String {
15 format!(
16 "!f() {{ if [ \"$1\" = get ]; then \
17 printf 'username=%s\\npassword=%s\\n' \"${USER_VAR}\" \"${PASSWORD_VAR}\"; fi; }}; f"
18 )
19}
20
21pub(crate) struct HttpsAccess {
28 pub url: String,
29 secret: Option<String>,
30 config: Vec<(String, String)>,
31 env: Vec<(String, String)>,
32}
33
34impl HttpsAccess {
35 pub fn new(url: &str, credential: Option<&CredentialKind>) -> Result<Self> {
36 match credential {
37 None => Ok(Self::public(url)),
38 Some(CredentialKind::Https { user, password }) => Self::basic(url, user, password),
39 Some(CredentialKind::Token {
40 token,
41 is_pat,
42 oauth_type,
43 }) => {
44 let (user, password) = token_login(token, oauth_type, *is_pat);
45 Ok(Self {
46 secret: Some(token.clone()),
47 ..Self::basic(url, user, password)?
48 })
49 }
50 Some(CredentialKind::Ssh { .. } | CredentialKind::AwsRole { .. }) => {
51 anyhow::bail!("expected an https credential")
52 }
53 }
54 }
55
56 fn public(url: &str) -> Self {
57 Self {
58 url: url.to_owned(),
59 secret: None,
60 config: Vec::new(),
61 env: Vec::new(),
62 }
63 }
64
65 fn basic(raw_url: &str, user: &str, password: &str) -> Result<Self> {
66 if [user, password]
67 .iter()
68 .any(|value| value.contains(['\n', '\r', '\0']))
69 {
70 anyhow::bail!("Invalid credentials: a line break or a nul byte");
71 }
72 let url = bare_url(raw_url)?;
73 Ok(Self {
74 secret: Some(password.to_owned()),
75 config: vec![(
76 format!("credential.{}.helper", url.origin().ascii_serialization()),
77 env_helper(),
78 )],
79 env: vec![
80 (USER_VAR.to_owned(), user.to_owned()),
81 (PASSWORD_VAR.to_owned(), password.to_owned()),
82 ],
83 url: url.to_string(),
84 })
85 }
86
87 pub fn git(&self) -> Command {
89 let mut command = Command::new("git");
90 command.args(["-c", "credential.helper="]);
94 for (key, value) in &self.config {
95 command.arg("-c").arg(format!("{key}={value}"));
96 }
97 command
98 .env("GIT_TERMINAL_PROMPT", "0")
99 .env_remove("GIT_TRACE_REDACT")
101 .env_remove("GIT_TRACE_CURL")
103 .envs(self.env.iter().cloned());
104 command
105 }
106
107 pub fn secret(&self) -> Option<&str> {
108 self.secret.as_deref()
109 }
110}
111
112fn bare_url(raw_url: &str) -> Result<Url> {
113 let mut url = Url::parse(raw_url).context("parsing URL")?;
114 if !matches!(url.scheme(), "http" | "https") {
115 anyhow::bail!("expected an http or https url");
116 }
117 url.set_username("")
118 .map_err(|()| anyhow::anyhow!("failed to drop the user"))?;
119 url.set_password(None)
120 .map_err(|()| anyhow::anyhow!("failed to drop the password"))?;
121 Ok(url)
122}
123
124const fn token_login<'a>(token: &'a str, provider: &str, is_pat: bool) -> (&'a str, &'a str) {
127 if is_pat {
128 ("pat", token)
129 } else if provider.eq_ignore_ascii_case("BITBUCKET") {
130 ("x-token-auth", token)
131 } else if provider.is_empty() {
132 (token, "")
133 } else {
134 ("oauth2", token)
135 }
136}
137
138pub async fn clone_https(opts: &CloneOpts, dest: &Path) -> Result<()> {
139 let kind = &opts
140 .credentials
141 .as_ref()
142 .context("missing credentials")?
143 .kind;
144 if !matches!(kind, CredentialKind::Https { .. }) {
145 anyhow::bail!("expected HTTPS credentials");
146 }
147 run_git_clone(opts, dest, &HttpsAccess::new(&opts.repo_url, Some(kind))?).await
148}
149
150pub async fn clone_https_token(opts: &CloneOpts, dest: &Path) -> Result<()> {
151 let kind = &opts
152 .credentials
153 .as_ref()
154 .context("missing credentials")?
155 .kind;
156 if !matches!(kind, CredentialKind::Token { .. }) {
157 anyhow::bail!("expected Token credentials");
158 }
159 run_git_clone(opts, dest, &HttpsAccess::new(&opts.repo_url, Some(kind))?).await
160}
161
162pub async fn clone_https_public(opts: &CloneOpts, dest: &Path) -> Result<()> {
163 run_git_clone(opts, dest, &HttpsAccess::public(&opts.repo_url)).await
164}
165
166#[must_use]
168pub fn build_https_config_args(
169 follow_redirects: bool,
170 is_pat: bool,
171 token: Option<&str>,
172 disable_credential_prompt: bool,
173) -> Vec<String> {
174 let mut args = vec![
175 "-c".to_owned(),
176 "http.sslVerify=false".to_owned(),
177 "-c".to_owned(),
178 format!("http.followRedirects={follow_redirects}"),
179 ];
180 if is_pat {
181 if let Some(t) = token {
182 let encoded = base64::engine::general_purpose::STANDARD.encode(format!(":{t}"));
183 args.extend([
184 "-c".to_owned(),
185 format!("http.extraHeader=Authorization: Basic {encoded}"),
186 ]);
187 }
188 }
189 if disable_credential_prompt {
190 args.extend(["-c".to_owned(), "credential.helper=".to_owned()]);
191 }
192 args
193}
194
195#[allow(clippy::too_many_arguments)]
197pub fn format_https_url(
198 repo_url: &str,
199 user: Option<&str>,
200 password: Option<&str>,
201 token: Option<&str>,
202 provider: Option<&str>,
203 is_pat: bool,
204) -> Result<String> {
205 let mut parsed = Url::parse(repo_url).context("parsing URL")?;
206 let path = parsed.path().to_owned();
208 parsed.set_path(&path);
209
210 if is_pat {
211 return Ok(parsed.to_string());
212 }
213
214 if let Some(t) = token {
215 let user_part = match provider {
216 Some(p) if p.eq_ignore_ascii_case("BITBUCKET") => "x-token-auth",
217 Some("") | None => "",
218 Some(_) => "oauth2",
219 };
220 if user_part.is_empty() {
221 parsed
223 .set_username(t)
224 .map_err(|()| anyhow::anyhow!("set username"))?;
225 parsed
226 .set_password(None)
227 .map_err(|()| anyhow::anyhow!("set password"))?;
228 } else {
229 parsed
231 .set_username(user_part)
232 .map_err(|()| anyhow::anyhow!("set username"))?;
233 parsed
234 .set_password(Some(t))
235 .map_err(|()| anyhow::anyhow!("set password"))?;
236 }
237 return Ok(parsed.to_string());
238 }
239
240 if let (Some(u), Some(p)) = (user, password) {
241 parsed
242 .set_username(u)
243 .map_err(|()| anyhow::anyhow!("set username"))?;
244 parsed
245 .set_password(Some(p))
246 .map_err(|()| anyhow::anyhow!("set password"))?;
247 return Ok(parsed.to_string());
248 }
249
250 Ok(parsed.to_string())
251}
252
253async fn run_git_clone(opts: &CloneOpts, dest: &Path, access: &HttpsAccess) -> Result<()> {
254 let extra_config = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
255 let args = build_clone_args(opts, &access.url, dest, &extra_config);
256 let mut command = access.git();
257 command.args(&args);
258 reason::run(&mut command, "HTTPS", access.secret().as_slice()).await
259}
260
261#[cfg(test)]
262mod tests {
263 use super::{format_https_url, token_login, HttpsAccess};
264 use crate::types::CredentialKind;
265
266 fn token(token: &str, provider: &str) -> CredentialKind {
267 CredentialKind::Token {
268 token: token.to_owned(),
269 is_pat: false,
270 oauth_type: provider.to_owned(),
271 }
272 }
273
274 #[test]
275 fn a_token_with_no_provider_logs_in_as_the_user_with_no_password() {
276 assert_eq!(token_login("tok", "", false), ("tok", ""));
277 }
278
279 #[test]
280 fn the_provider_names_the_user_even_off_its_own_domain() {
281 assert_eq!(
282 token_login("tok", "BITBUCKET", false),
283 ("x-token-auth", "tok")
284 );
285 assert_eq!(token_login("tok", "GITLAB", false), ("oauth2", "tok"));
286 }
287
288 #[test]
289 fn the_provider_is_matched_ignoring_the_case() {
290 let url = "https://host/a/b.git";
291
292 assert_eq!(
293 format_https_url(url, None, None, Some("tok"), Some("Bitbucket"), false).unwrap(),
294 format_https_url(url, None, None, Some("tok"), Some("BITBUCKET"), false).unwrap()
295 );
296 assert_eq!(
297 token_login("tok", "Bitbucket", false),
298 token_login("tok", "BITBUCKET", false)
299 );
300 }
301
302 #[test]
303 fn the_url_drops_whatever_credential_it_carried() {
304 let access = HttpsAccess::new(
305 "https://olduser:oldpass@host/a/b.git",
306 Some(&token("tok", "")),
307 )
308 .unwrap();
309 assert_eq!(access.url, "https://host/a/b.git");
310 }
311
312 #[test]
313 fn a_pat_logs_in_as_a_placeholder_user_whatever_the_provider() {
314 assert_eq!(token_login("tok", "", true), ("pat", "tok"));
315 assert_eq!(token_login("tok", "BITBUCKET", true), ("pat", "tok"));
316 }
317
318 #[test]
319 fn a_curl_trace_the_caller_set_is_dropped() {
320 let access = HttpsAccess::new("https://host/a/b.git", Some(&token("tok", ""))).unwrap();
321 assert!(access
322 .git()
323 .as_std()
324 .get_envs()
325 .any(|(name, value)| name == "GIT_TRACE_CURL" && value.is_none()));
326 }
327
328 #[test]
329 fn a_credential_with_a_line_break_is_refused() {
330 for tok in ["tok\nhost=x", "tok\rhost=x"] {
331 assert!(HttpsAccess::new("https://host/a/b.git", Some(&token(tok, ""))).is_err());
332 }
333 }
334
335 #[test]
336 fn an_empty_provider_formats_the_url_like_no_provider_at_all() {
337 let url = "https://gitlab.com/a/b.git";
338 let no_provider = format_https_url(url, None, None, Some("tok"), None, false).unwrap();
339 assert_eq!(no_provider, "https://tok@gitlab.com/a/b.git");
340 assert_eq!(
341 format_https_url(url, None, None, Some("tok"), Some(""), false).unwrap(),
342 no_provider
343 );
344 }
345}