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