1use anyhow::{Context, Result};
2use base64::Engine;
3use http::uri::{PathAndQuery, Uri};
4use std::fmt;
5use std::path::Path;
6use tokio::process::Command;
7use url::Url;
8
9use super::{build_clone_args, reason, redirect_config, stall_config, CloneOpts};
10use crate::errors::{GitCallError, HttpsAccessError};
11use crate::types::CredentialKind;
12
13const USER_VAR: &str = "FLUIDATTACKS_GIT_USER";
14const PASSWORD_VAR: &str = "FLUIDATTACKS_GIT_PASSWORD";
15
16fn env_helper() -> String {
18 format!(
19 "!f() {{ if [ \"$1\" = get ]; then \
20 printf 'username=%s\\npassword=%s\\n' \"${USER_VAR}\" \"${PASSWORD_VAR}\"; fi; }}; f"
21 )
22}
23
24struct BareUrl {
27 url: String,
28 origin: String,
29}
30
31pub struct HttpsAccess {
38 url: String,
39 secret: Option<String>,
40 config: Vec<(String, String)>,
41 env: Vec<(String, String)>,
42}
43
44impl fmt::Debug for HttpsAccess {
46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47 f.debug_struct("HttpsAccess")
48 .field("url", &self.url)
49 .finish_non_exhaustive()
50 }
51}
52
53impl HttpsAccess {
54 pub fn new(url: &str, credential: Option<&CredentialKind>) -> Result<Self, HttpsAccessError> {
55 match credential {
56 None => Ok(Self::public(url)),
57 Some(CredentialKind::Https { user, password }) => Self::basic(url, user, password),
58 Some(CredentialKind::Token {
59 token,
60 is_pat,
61 oauth_type,
62 }) => {
63 let (user, password) = token_login(token, oauth_type, *is_pat);
64 Ok(Self {
65 secret: Some(token.clone()),
66 ..Self::basic(url, user, password)?
67 })
68 }
69 Some(CredentialKind::Ssh { .. } | CredentialKind::AwsRole { .. }) => {
70 Err(HttpsAccessError::NotHttpsCredential)
71 }
72 }
73 }
74
75 fn public(url: &str) -> Self {
76 Self {
77 url: url.to_owned(),
78 secret: None,
79 config: Vec::new(),
80 env: Vec::new(),
81 }
82 }
83
84 fn basic(raw_url: &str, user: &str, password: &str) -> Result<Self, HttpsAccessError> {
85 if [user, password]
86 .iter()
87 .any(|value| value.contains(['\n', '\r', '\0']))
88 {
89 return Err(HttpsAccessError::CredentialLineBreak);
90 }
91 let url = bare_url(raw_url)?;
92 Ok(Self {
93 secret: Some(password.to_owned()),
94 config: vec![(format!("credential.{}.helper", url.origin), env_helper())],
95 env: vec![
96 (USER_VAR.to_owned(), user.to_owned()),
97 (PASSWORD_VAR.to_owned(), password.to_owned()),
98 ],
99 url: url.url,
100 })
101 }
102
103 #[must_use]
104 pub fn url(&self) -> &str {
105 &self.url
106 }
107
108 #[must_use]
110 pub fn git(&self) -> Command {
111 let mut command = Command::new("git");
112 command.args(["-c", "credential.helper="]);
116 for (key, value) in &self.config {
117 command.arg("-c").arg(format!("{key}={value}"));
118 }
119 command
120 .env("GIT_TERMINAL_PROMPT", "0")
121 .env_remove("GIT_TRACE_REDACT")
123 .env_remove("GIT_TRACE_CURL")
125 .envs(self.env.iter().cloned());
126 command
127 }
128
129 #[must_use]
130 pub fn secret(&self) -> Option<&str> {
131 self.secret.as_deref()
132 }
133}
134
135fn bare_url(raw_url: &str) -> Result<BareUrl, HttpsAccessError> {
136 let uri: Uri = raw_url.parse().map_err(|_| HttpsAccessError::Unparsable)?;
137 let scheme = uri
138 .scheme_str()
139 .filter(|scheme| matches!(*scheme, "http" | "https"))
140 .ok_or(HttpsAccessError::NotHttp)?;
141 let authority = uri.authority().ok_or(HttpsAccessError::Unparsable)?;
142 let port = authority
143 .port()
144 .map_or_else(String::new, |port| format!(":{port}"));
145 let host = format!("{}{port}", authority.host());
146 if authority.host().is_empty() || !authority.as_str().ends_with(&host) {
148 return Err(HttpsAccessError::Unparsable);
149 }
150 let origin = format!("{scheme}://{host}");
151 let path = uri.path_and_query().map_or("", PathAndQuery::as_str);
152 Ok(BareUrl {
153 url: format!("{origin}{path}"),
154 origin,
155 })
156}
157
158const fn token_login<'a>(token: &'a str, provider: &str, is_pat: bool) -> (&'a str, &'a str) {
161 if is_pat {
162 ("pat", token)
163 } else if provider.eq_ignore_ascii_case("BITBUCKET") {
164 ("x-token-auth", token)
165 } else if provider.is_empty() {
166 (token, "")
167 } else {
168 ("oauth2", token)
169 }
170}
171
172pub async fn clone_https(opts: &CloneOpts, dest: &Path) -> Result<()> {
173 let kind = &opts
174 .credentials
175 .as_ref()
176 .context("missing credentials")?
177 .kind;
178 if !matches!(kind, CredentialKind::Https { .. }) {
179 anyhow::bail!("expected HTTPS credentials");
180 }
181 Ok(run_git_clone(opts, dest, &HttpsAccess::new(&opts.repo_url, Some(kind))?).await?)
182}
183
184pub async fn clone_https_token(opts: &CloneOpts, dest: &Path) -> Result<()> {
185 let kind = &opts
186 .credentials
187 .as_ref()
188 .context("missing credentials")?
189 .kind;
190 if !matches!(kind, CredentialKind::Token { .. }) {
191 anyhow::bail!("expected Token credentials");
192 }
193 Ok(run_git_clone(opts, dest, &HttpsAccess::new(&opts.repo_url, Some(kind))?).await?)
194}
195
196pub async fn clone_https_public(opts: &CloneOpts, dest: &Path) -> Result<()> {
197 Ok(run_git_clone(opts, dest, &HttpsAccess::public(&opts.repo_url)).await?)
198}
199
200#[must_use]
201pub fn build_https_config_args(
202 follow_redirects: bool,
203 is_pat: bool,
204 token: Option<&str>,
205 disable_credential_prompt: bool,
206) -> Vec<String> {
207 let mut args = vec![
208 "-c".to_owned(),
209 "http.sslVerify=false".to_owned(),
210 "-c".to_owned(),
211 format!("http.followRedirects={follow_redirects}"),
212 ];
213 if is_pat {
214 if let Some(t) = token {
215 let encoded = base64::engine::general_purpose::STANDARD.encode(format!(":{t}"));
216 args.extend([
217 "-c".to_owned(),
218 format!("http.extraHeader=Authorization: Basic {encoded}"),
219 ]);
220 }
221 }
222 if disable_credential_prompt {
223 args.extend(["-c".to_owned(), "credential.helper=".to_owned()]);
224 }
225 args
226}
227
228#[allow(clippy::too_many_arguments)]
229pub fn format_https_url(
230 repo_url: &str,
231 user: Option<&str>,
232 password: Option<&str>,
233 token: Option<&str>,
234 provider: Option<&str>,
235 is_pat: bool,
236) -> Result<String> {
237 let mut parsed = Url::parse(repo_url).context("parsing URL")?;
238 let path = parsed.path().to_owned();
240 parsed.set_path(&path);
241
242 if is_pat {
243 return Ok(parsed.to_string());
244 }
245
246 if let Some(t) = token {
247 let user_part = match provider {
248 Some(p) if p.eq_ignore_ascii_case("BITBUCKET") => "x-token-auth",
249 Some("") | None => "",
250 Some(_) => "oauth2",
251 };
252 if user_part.is_empty() {
253 parsed
255 .set_username(t)
256 .map_err(|()| anyhow::anyhow!("set username"))?;
257 parsed
258 .set_password(None)
259 .map_err(|()| anyhow::anyhow!("set password"))?;
260 } else {
261 parsed
263 .set_username(user_part)
264 .map_err(|()| anyhow::anyhow!("set username"))?;
265 parsed
266 .set_password(Some(t))
267 .map_err(|()| anyhow::anyhow!("set password"))?;
268 }
269 return Ok(parsed.to_string());
270 }
271
272 if let (Some(u), Some(p)) = (user, password) {
273 parsed
274 .set_username(u)
275 .map_err(|()| anyhow::anyhow!("set username"))?;
276 parsed
277 .set_password(Some(p))
278 .map_err(|()| anyhow::anyhow!("set password"))?;
279 return Ok(parsed.to_string());
280 }
281
282 Ok(parsed.to_string())
283}
284
285pub(super) async fn run_git_clone(
286 opts: &CloneOpts,
287 dest: &Path,
288 access: &HttpsAccess,
289) -> Result<(), GitCallError> {
290 let mut command = access.git();
291 command.args(clone_args(opts, dest, access));
292 reason::run(&mut command, access.secret().as_slice()).await
293}
294
295fn clone_args(opts: &CloneOpts, dest: &Path, access: &HttpsAccess) -> Vec<std::ffi::OsString> {
296 let mut extra_config = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
297 extra_config.extend(redirect_config(opts.follow_redirects));
298 extra_config.extend(stall_config());
299 build_clone_args(opts, access.url(), dest, &extra_config)
300}
301
302#[cfg(test)]
303mod tests {
304 use super::{clone_args, format_https_url, token_login, HttpsAccess};
305 use crate::clone::tests::opts;
306 use crate::clone::{CloneOpts, CloneShape};
307 use crate::errors::HttpsAccessError;
308 use crate::types::CredentialKind;
309 use std::ffi::OsString;
310
311 fn args(access: &HttpsAccess) -> Vec<String> {
312 access
313 .git()
314 .as_std()
315 .get_args()
316 .map(|arg| arg.to_string_lossy().into_owned())
317 .collect()
318 }
319
320 fn token(token: &str, provider: &str) -> CredentialKind {
321 CredentialKind::Token {
322 token: token.to_owned(),
323 is_pat: false,
324 oauth_type: provider.to_owned(),
325 }
326 }
327
328 #[test]
329 fn a_token_with_no_provider_logs_in_as_the_user_with_no_password() {
330 assert_eq!(token_login("tok", "", false), ("tok", ""));
331 }
332
333 #[test]
334 fn the_provider_names_the_user_even_off_its_own_domain() {
335 assert_eq!(
336 token_login("tok", "BITBUCKET", false),
337 ("x-token-auth", "tok")
338 );
339 assert_eq!(token_login("tok", "GITLAB", false), ("oauth2", "tok"));
340 }
341
342 #[test]
343 fn the_provider_is_matched_ignoring_the_case() {
344 let url = "https://host/a/b.git";
345
346 assert_eq!(
347 format_https_url(url, None, None, Some("tok"), Some("Bitbucket"), false).unwrap(),
348 format_https_url(url, None, None, Some("tok"), Some("BITBUCKET"), false).unwrap()
349 );
350 assert_eq!(
351 token_login("tok", "Bitbucket", false),
352 token_login("tok", "BITBUCKET", false)
353 );
354 }
355
356 fn basic(user: &str, password: &str) -> CredentialKind {
357 CredentialKind::Https {
358 user: user.to_owned(),
359 password: password.to_owned(),
360 }
361 }
362
363 #[test]
364 fn the_url_drops_whatever_credential_it_carried() {
365 let access = HttpsAccess::new(
366 "https://olduser:oldpass@host/a/b.git",
367 Some(&token("tok", "")),
368 )
369 .unwrap();
370 assert_eq!(access.url(), "https://host/a/b.git");
371 }
372
373 #[test]
374 fn a_pat_logs_in_as_a_placeholder_user_whatever_the_provider() {
375 assert_eq!(token_login("tok", "", true), ("pat", "tok"));
376 assert_eq!(token_login("tok", "BITBUCKET", true), ("pat", "tok"));
377 }
378
379 #[test]
380 fn the_host_is_left_as_written_for_git_to_read() {
381 for (url, expected) in [
382 ("https://Host:443/a.git", "https://Host:443/a.git"),
383 ("https://[::1]:8443/a.git", "https://[::1]:8443/a.git"),
384 ("https://u:p@w@host/a.git", "https://host/a.git"),
385 ] {
386 let access = HttpsAccess::new(url, Some(&token("tok", ""))).unwrap();
387 let origin = expected.trim_end_matches("/a.git");
388 assert_eq!(access.url(), expected, "{url}");
389 let helper = format!("credential.{origin}.helper=");
390 let host = args(&access).get(3).cloned().unwrap_or_default();
391 assert!(host.starts_with(&helper), "{url}");
392 }
393 }
394
395 #[test]
396 fn each_failure_says_what_the_python_said_and_never_the_url() {
397 for (url, expected) in [
398 ("ftp://u:hunter2@host/a.git", HttpsAccessError::NotHttp),
399 ("host/a.git", HttpsAccessError::Unparsable),
400 ("https:u:hunter2@host/a.git", HttpsAccessError::Unparsable),
401 ("https:///host/a.git", HttpsAccessError::Unparsable),
402 ("https://u:hunter2@/a.git", HttpsAccessError::Unparsable),
403 ("https://u:hunter2@:443/a.git", HttpsAccessError::Unparsable),
404 (
405 "https://u:hunter2@host:99999/a.git",
406 HttpsAccessError::Unparsable,
407 ),
408 (
409 "https://u:hunter2@faß.de/a.git",
410 HttpsAccessError::Unparsable,
411 ),
412 (
413 "https://tok\\hunter2@host/a.git",
414 HttpsAccessError::Unparsable,
415 ),
416 ] {
417 let error = HttpsAccess::new(url, Some(&token("tok", ""))).unwrap_err();
418 assert_eq!(error, expected, "{url}");
419 assert!(!error.to_string().contains("hunter2"), "{url}");
420 }
421 assert_eq!(
422 HttpsAccessError::NotHttp.to_string(),
423 "expected an http or https url"
424 );
425 assert_eq!(
426 HttpsAccessError::Unparsable.to_string(),
427 "Invalid url: its host or port could not be parsed"
428 );
429 }
430
431 #[test]
432 fn no_credential_leaves_the_url_unchecked() {
433 let access = HttpsAccess::new("ftp://host:99999/a.git", None).unwrap();
434 assert_eq!(access.url(), "ftp://host:99999/a.git");
435 assert_eq!(args(&access), ["-c", "credential.helper="]);
436 }
437
438 #[test]
439 fn the_debug_shows_neither_the_login_nor_the_environment() {
440 let access =
441 HttpsAccess::new("https://host/a.git", Some(&basic("someone", "s3cr3t"))).unwrap();
442 let shown = format!("{access:?}");
443 assert!(shown.contains("https://host/a.git"), "{shown}");
444 assert!(!shown.contains("s3cr3t"), "{shown}");
445 assert!(!shown.contains("someone"), "{shown}");
446 }
447
448 #[test]
449 fn the_machine_helpers_are_dropped_before_the_host_helper() {
450 let access = HttpsAccess::new("https://host/a.git", Some(&token("tok", ""))).unwrap();
451 let args = args(&access);
452 let [flag, machine, host_flag, host] = args.as_slice() else {
453 panic!("{args:?}");
454 };
455 assert_eq!(
456 [flag, machine, host_flag],
457 ["-c", "credential.helper=", "-c"]
458 );
459 assert!(
460 host.starts_with("credential.https://host.helper=!f()"),
461 "{host}"
462 );
463 }
464
465 #[test]
466 fn the_login_goes_through_the_environment() {
467 let access = HttpsAccess::new("https://host/a.git", Some(&basic("u", "p"))).unwrap();
468 let envs: Vec<_> = access
469 .git()
470 .as_std()
471 .get_envs()
472 .map(|(name, value)| (name.to_owned(), value.map(ToOwned::to_owned)))
473 .collect();
474 let set = |value: &str| Some(OsString::from(value));
475 assert_eq!(
476 envs,
477 [
478 ("FLUIDATTACKS_GIT_PASSWORD".into(), set("p")),
479 ("FLUIDATTACKS_GIT_USER".into(), set("u")),
480 ("GIT_TERMINAL_PROMPT".into(), set("0")),
481 ("GIT_TRACE_CURL".into(), None),
482 ("GIT_TRACE_REDACT".into(), None),
483 ]
484 );
485 }
486
487 #[test]
488 fn a_credential_with_a_line_break_or_a_nul_is_refused() {
489 for credential in [
490 token("tok\nhost=x", ""),
491 token("tok\0x", "GITLAB"),
492 basic("u\rx", "p"),
493 basic("u", "p\nx"),
494 ] {
495 let error = HttpsAccess::new("https://host/a/b.git", Some(&credential)).unwrap_err();
496 assert_eq!(error, HttpsAccessError::CredentialLineBreak);
497 assert_eq!(
498 error.to_string(),
499 "Invalid credentials: a line break or a nul byte"
500 );
501 }
502 }
503
504 #[test]
505 fn an_empty_provider_formats_the_url_like_no_provider_at_all() {
506 let url = "https://gitlab.com/a/b.git";
507 let no_provider = format_https_url(url, None, None, Some("tok"), None, false).unwrap();
508 assert_eq!(no_provider, "https://tok@gitlab.com/a/b.git");
509 assert_eq!(
510 format_https_url(url, None, None, Some("tok"), Some(""), false).unwrap(),
511 no_provider
512 );
513 }
514
515 #[test]
516 fn a_redirect_is_followed_only_when_the_root_asks_for_one() {
517 for follow_redirects in [false, true] {
518 let opts = CloneOpts {
519 follow_redirects,
520 ..opts(CloneShape::SingleBranch)
521 };
522 let access = HttpsAccess::new(&opts.repo_url, None).unwrap();
523
524 let args = clone_args(&opts, std::path::Path::new("/tmp/a_root"), &access);
525
526 let config = format!("http.followRedirects={follow_redirects}");
527 assert!(args.windows(2).any(|pair| pair == ["-c", config.as_str()]));
528 }
529 }
530
531 #[test]
532 fn a_clone_that_stops_receiving_data_is_cut_after_ten_minutes() {
533 let opts = opts(CloneShape::SingleBranch);
534 let access = HttpsAccess::new(&opts.repo_url, None).unwrap();
535
536 let args = clone_args(&opts, std::path::Path::new("/tmp/a_root"), &access);
537
538 assert!(args
539 .windows(2)
540 .any(|pair| pair == ["-c", "http.lowSpeedLimit=1000"]));
541 assert!(args
542 .windows(2)
543 .any(|pair| pair == ["-c", "http.lowSpeedTime=600"]));
544 }
545}