1use std::time::Duration;
2use tokio::process::Command;
3
4use super::aws::{self, AwsTempCreds};
5use super::types::{CredentialKind, Credentials, LsRemoteResult, RemoteHead};
6use crate::call::not_run;
7use crate::clone::https::HttpsAccess;
8use crate::clone::reason::hide;
9use crate::clone::ssh::{drop_scp_scheme, SshKey, ONLY_SSH};
10use crate::clone::{has_http_scheme, redirect_config};
11use crate::errors::{GitCallError, HttpsAccessError, LsRemoteError};
12
13const LS_REMOTE_TIMEOUT: Duration = Duration::from_secs(20);
14
15const LS_REMOTE_TIMEOUT_MESSAGE: &str = "git ls-remote time out";
17
18struct AwsRole {
19 arn: String,
20 external_id: String,
21 via: Option<String>,
22}
23
24fn branch_refs(branch: &str) -> Vec<String> {
27 if branch == "HEAD" {
28 return vec!["HEAD".to_owned()];
29 }
30 vec![
31 format!("refs/heads/{branch}"),
32 format!("refs/tags/{branch}"),
33 ]
34}
35
36fn ls_remote_args(url: &str, flags: &[&str], pattern: &str) -> Vec<String> {
37 let mut args = vec!["ls-remote".to_owned()];
38 args.extend(flags.iter().map(|&flag| flag.to_owned()));
39 args.extend(["--".to_owned(), url.to_owned(), pattern.to_owned()]);
40 args
41}
42
43async fn run_ls_remote(command: &mut Command, secrets: &[&str]) -> Result<String, GitCallError> {
45 let Ok(output) =
46 tokio::time::timeout(LS_REMOTE_TIMEOUT, command.kill_on_drop(true).output()).await
47 else {
48 return Err(GitCallError::TimedOut {
49 command: "ls-remote",
50 after: LS_REMOTE_TIMEOUT,
51 });
52 };
53
54 let output = output.map_err(|source| not_run("ls-remote", source))?;
55 if !output.status.success() {
56 return Err(GitCallError::Refused {
57 command: "ls-remote",
58 status: output.status,
59 stderr: hide(&String::from_utf8_lossy(&output.stderr), secrets),
60 });
61 }
62 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
63}
64
65pub async fn ls_remote(
69 url: &str,
70 branch: &str,
71 credentials: Option<&Credentials>,
72 follow_redirects: bool,
73) -> Result<LsRemoteResult, LsRemoteError> {
74 match ask(url, &[], branch, credentials, follow_redirects).await {
75 Ok(listing) => Ok(LsRemoteResult {
76 commit: Some(commit_of(&listing, branch)),
77 error: None,
78 }),
79 Err(LsRemoteError::Git(GitCallError::Refused { stderr, .. })) => Ok(LsRemoteResult {
80 commit: None,
81 error: Some(stderr),
82 }),
83 Err(LsRemoteError::Git(GitCallError::TimedOut { .. })) => Ok(LsRemoteResult {
84 commit: None,
85 error: Some(LS_REMOTE_TIMEOUT_MESSAGE.to_owned()),
86 }),
87 Err(error) => Err(error),
88 }
89}
90
91pub async fn remote_head(
101 url: &str,
102 credentials: Option<&Credentials>,
103 follow_redirects: bool,
104) -> Result<RemoteHead, LsRemoteError> {
105 match ask(url, &["--symref"], "HEAD", credentials, follow_redirects).await {
106 Ok(listing) => Ok(RemoteHead {
107 branch: head_of(&listing),
108 error: None,
109 }),
110 Err(LsRemoteError::Git(GitCallError::Refused { stderr, .. })) => Ok(RemoteHead {
111 branch: None,
112 error: Some(stderr),
113 }),
114 Err(LsRemoteError::Git(GitCallError::TimedOut { .. })) => Ok(RemoteHead {
115 branch: None,
116 error: Some(LS_REMOTE_TIMEOUT_MESSAGE.to_owned()),
117 }),
118 Err(error) => Err(error),
119 }
120}
121
122async fn ask(
123 url: &str,
124 flags: &[&str],
125 pattern: &str,
126 credentials: Option<&Credentials>,
127 follow_redirects: bool,
128) -> Result<String, LsRemoteError> {
129 match credentials.map(|c| &c.kind) {
130 Some(CredentialKind::Ssh { key }) => ls_remote_ssh(url, flags, pattern, key).await,
131 Some(CredentialKind::AwsRole {
132 arn,
133 external_id,
134 via,
135 }) => {
136 let role = AwsRole {
137 arn: arn.clone(),
138 external_id: external_id.clone(),
139 via: via.clone(),
140 };
141 ls_remote_codecommit(url, flags, pattern, &role, follow_redirects).await
142 }
143 kind => ls_remote_https(url, flags, pattern, kind, follow_redirects).await,
144 }
145}
146
147async fn ls_remote_ssh(
148 url: &str,
149 flags: &[&str],
150 pattern: &str,
151 key_b64: &str,
152) -> Result<String, LsRemoteError> {
153 let key = SshKey::write(key_b64)?;
154 let args = ls_remote_args(drop_scp_scheme(url), flags, pattern);
155
156 let mut command = Command::new("git");
157 command
158 .args(&args)
159 .env("GIT_SSH_COMMAND", &key.command)
160 .env("GIT_ALLOW_PROTOCOL", ONLY_SSH);
161 Ok(run_ls_remote(&mut command, &[]).await?)
162}
163
164async fn ls_remote_https(
165 url: &str,
166 flags: &[&str],
167 pattern: &str,
168 credential: Option<&CredentialKind>,
169 follow_redirects: bool,
170) -> Result<String, LsRemoteError> {
171 if credential.is_none() && !has_http_scheme(url) {
173 return Err(HttpsAccessError::NotHttp.into());
174 }
175 let access = HttpsAccess::new(url, credential)?;
176 let mut args = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
177 args.extend(redirect_config(follow_redirects));
178 args.extend(ls_remote_args(access.url(), flags, pattern));
179
180 let mut command = access.git();
181 command.args(&args);
182 Ok(run_ls_remote(&mut command, access.secret().as_slice()).await?)
183}
184
185async fn ls_remote_codecommit(
186 url: &str,
187 flags: &[&str],
188 pattern: &str,
189 role: &AwsRole,
190 follow_redirects: bool,
191) -> Result<String, LsRemoteError> {
192 let creds = aws::assume_role(&role.arn, &role.external_id, url, role.via.as_deref()).await?;
193 Ok(ls_remote_with(url, flags, pattern, &creds, follow_redirects).await?)
194}
195
196async fn ls_remote_with(
197 url: &str,
198 flags: &[&str],
199 pattern: &str,
200 creds: &AwsTempCreds,
201 follow_redirects: bool,
202) -> Result<String, GitCallError> {
203 let args = codecommit_ls_remote_args(url, flags, pattern, follow_redirects);
204
205 let mut command = Command::new("git");
206 command
207 .args(&args)
208 .env("AWS_ACCESS_KEY_ID", &creds.access_key)
209 .env("AWS_SECRET_ACCESS_KEY", &creds.secret_key)
210 .env("AWS_SESSION_TOKEN", &creds.session_token)
211 .env("AWS_DEFAULT_REGION", &creds.region);
212 let secrets = [creds.secret_key.as_str(), creds.session_token.as_str()];
213 run_ls_remote(&mut command, &secrets).await
214}
215
216fn codecommit_ls_remote_args(
219 url: &str,
220 flags: &[&str],
221 pattern: &str,
222 follow_redirects: bool,
223) -> Vec<String> {
224 let mut args = redirect_config(follow_redirects).to_vec();
225 args.extend(ls_remote_args(url, flags, pattern));
226 args
227}
228
229fn commit_of(listing: &str, branch: &str) -> String {
231 let listed = |wanted: &str| {
232 listing.lines().find_map(|line| {
233 let (commit, name) = line.split_once('\t')?;
234 (name == wanted).then_some(commit)
235 })
236 };
237 branch_refs(branch)
238 .iter()
239 .find_map(|wanted| listed(wanted))
240 .unwrap_or_default()
241 .to_owned()
242}
243
244fn head_of(listing: &str) -> Option<String> {
246 listing.lines().find_map(head_branch).map(str::to_owned)
247}
248
249fn head_branch(line: &str) -> Option<&str> {
252 let (target, name) = line.strip_prefix("ref: ")?.split_once('\t')?;
253 if name != "HEAD" {
254 return None;
255 }
256 target
257 .strip_prefix("refs/heads/")
258 .filter(|branch| !branch.is_empty())
259}
260
261#[cfg(test)]
262mod tests {
263 use std::io::{BufRead, BufReader, Write};
264 use std::net::{TcpListener, TcpStream};
265 use std::sync::{Arc, Mutex};
266
267 use base64::Engine;
268
269 use super::{
270 codecommit_ls_remote_args, commit_of, head_of, ls_remote, ls_remote_args, ls_remote_with,
271 redirect_config, remote_head, run_ls_remote, AwsTempCreds,
272 };
273 use crate::clone::https::HttpsAccess;
274 use crate::errors::{GitCallError, HttpsAccessError, LsRemoteError};
275 use crate::types::{CredentialKind, Credentials};
276 use tokio::process::Command;
277
278 fn read_request(stream: &TcpStream) -> (String, Option<String>) {
280 let mut path = String::new();
281 let mut authorization = None;
282 let mut reader = BufReader::new(stream);
283 let mut line = String::new();
284 while reader.read_line(&mut line).is_ok_and(|read| read > 0) {
285 let header = line.trim_end();
286 if header.is_empty() {
287 break;
288 }
289 if path.is_empty() {
290 header
291 .split(' ')
292 .nth(1)
293 .unwrap_or_default()
294 .clone_into(&mut path);
295 }
296 if let Some(value) = header.strip_prefix("Authorization: ") {
297 authorization = Some(value.to_owned());
298 }
299 line.clear();
300 }
301 (path, authorization)
302 }
303
304 fn answer(redirect_to: Option<&str>, path: &str) -> String {
305 redirect_to.map_or_else(
306 || {
307 "HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"x\"\r\n\
308 Content-Length: 0\r\nConnection: close\r\n\r\n"
309 .to_owned()
310 },
311 |host| {
312 format!(
313 "HTTP/1.1 302 Found\r\nLocation: {host}{path}\r\n\
314 Content-Length: 0\r\nConnection: close\r\n\r\n"
315 )
316 },
317 )
318 }
319
320 fn serve(redirect_to: Option<String>) -> (String, Arc<Mutex<Vec<Option<String>>>>) {
323 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
324 let port = listener.local_addr().expect("addr").port();
325 let seen = Arc::new(Mutex::new(Vec::new()));
326 let record = Arc::clone(&seen);
327 std::thread::spawn(move || {
328 for stream in listener.incoming().flatten() {
329 let (path, authorization) = read_request(&stream);
330 record.lock().expect("lock").push(authorization);
331 let answer = answer(redirect_to.as_deref(), &path);
332 let _ = (&stream).write_all(answer.as_bytes());
333 }
334 });
335 (format!("http://127.0.0.1:{port}"), seen)
336 }
337
338 fn token(token: &str) -> Credentials {
339 Credentials {
340 kind: CredentialKind::Token {
341 token: token.to_owned(),
342 is_pat: false,
343 oauth_type: String::new(),
344 },
345 }
346 }
347
348 fn pat(token: &str) -> Credentials {
349 Credentials {
350 kind: CredentialKind::Token {
351 token: token.to_owned(),
352 is_pat: true,
353 oauth_type: String::new(),
354 },
355 }
356 }
357
358 fn refusing(stderr: &str, code: i32) -> Command {
360 let mut command = Command::new("sh");
361 command.args([
362 "-c",
363 &format!("printf '%s' \"$0\" >&2; exit {code}"),
364 stderr,
365 ]);
366 command
367 }
368
369 #[test]
370 fn redirects_are_forbidden_explicitly_and_not_left_to_the_git_default() {
371 assert_eq!(
372 redirect_config(false),
373 ["-c".to_owned(), "http.followRedirects=false".to_owned()]
374 );
375 assert_eq!(
376 redirect_config(true),
377 ["-c".to_owned(), "http.followRedirects=true".to_owned()]
378 );
379 }
380
381 #[test]
382 fn a_branch_missing_from_the_remote_is_an_empty_commit_and_not_a_missing_one() {
383 assert_eq!(commit_of("", "main"), "");
384 }
385
386 #[test]
387 fn a_branch_is_read_by_its_full_name_and_before_a_tag_of_the_same_name() {
388 for (said, commit) in [
389 (
390 "aaa\trefs/heads/feature/main\nbbb\trefs/heads/main\n",
391 "bbb",
392 ),
393 ("ttt\trefs/tags/main\nbbb\trefs/heads/main\n", "bbb"),
394 ("ttt\trefs/tags/main\n", "ttt"),
395 ("aaa\trefs/heads/feature/main\n", ""),
396 ] {
397 assert_eq!(commit_of(said, "main"), commit, "{said:?}");
398 }
399 }
400
401 #[test]
402 fn head_is_read_only_from_the_line_git_lists_as_head() {
403 for (said, commit) in [
404 ("aaa\tHEAD\nbbb\trefs/heads/HEAD\n", "aaa"),
405 ("bbb\trefs/heads/HEAD\nccc\trefs/remotes/origin/HEAD\n", ""),
406 ] {
407 assert_eq!(commit_of(said, "HEAD"), commit, "{said:?}");
408 }
409 }
410
411 #[tokio::test]
412 async fn a_refusal_keeps_the_whole_stderr_with_the_credential_hidden() {
413 let said = "remote: token tok123 is not valid\nfatal: Authentication failed\n";
414
415 let refused = run_ls_remote(&mut refusing(said, 128), &["tok123"]).await;
416
417 let Err(GitCallError::Refused { stderr, .. }) = refused else {
418 panic!("{refused:?}");
419 };
420 assert_eq!(
421 stderr,
422 "remote: token [REDACTED] is not valid\nfatal: Authentication failed\n"
423 );
424 }
425
426 #[tokio::test]
427 async fn a_token_reaches_its_host_and_stays_out_of_the_error() {
428 for (credentials, login) in [
429 (token("Zm9v+YmFy"), "Zm9v+YmFy:"),
430 (pat("Zm9v+YmFy"), "pat:Zm9v+YmFy"),
431 ] {
432 let (host, seen) = serve(None);
433 let result = ls_remote(
434 &format!("{host}/org/repo.git"),
435 "main",
436 Some(&credentials),
437 false,
438 )
439 .await
440 .unwrap();
441
442 let sent = base64::engine::general_purpose::STANDARD.encode(login);
443 assert!(seen
444 .lock()
445 .unwrap()
446 .contains(&Some(format!("Basic {sent}"))));
447 let error = result.error.unwrap();
448 assert!(error.contains("Authentication failed"), "{error}");
449 assert!(!error.contains("Zm9v"), "{error}");
450 }
451 }
452
453 #[tokio::test]
454 async fn a_redirect_to_another_host_carries_no_credential() {
455 for credentials in [token("tok123"), pat("tok123")] {
456 let (other, other_seen) = serve(None);
457 let other = other.replace("127.0.0.1", "localhost");
458 let (host, _) = serve(Some(other));
459 let result = ls_remote(
460 &format!("{host}/org/repo.git"),
461 "main",
462 Some(&credentials),
463 true,
464 )
465 .await
466 .unwrap();
467
468 assert!(result.error.is_some());
469 let other_seen = other_seen.lock().unwrap().clone();
470 assert!(!other_seen.is_empty());
471 assert!(other_seen.iter().all(Option::is_none), "{other_seen:?}");
472 }
473 }
474
475 #[tokio::test]
476 async fn a_helper_the_caller_configured_never_sees_the_credential() {
477 let (host, _) = serve(None);
478 let calls = tempfile::NamedTempFile::new().unwrap();
479 let access =
480 HttpsAccess::new(&format!("{host}/org/repo.git"), Some(&token("tok123").kind)).unwrap();
481 let mut command = access.git();
482 command
483 .env(
484 "GIT_CONFIG_PARAMETERS",
485 format!(
486 "'credential.helper'='!f() {{ echo $1 >> {}; }}; f'",
487 calls.path().display()
488 ),
489 )
490 .args(["ls-remote", "--", access.url(), "main"]);
491 let refused = run_ls_remote(&mut command, access.secret().as_slice()).await;
492
493 assert!(
494 matches!(refused, Err(GitCallError::Refused { .. })),
495 "{refused:?}"
496 );
497
498 assert_eq!(std::fs::read_to_string(calls.path()).unwrap(), "");
499 }
500
501 #[test]
502 fn a_codecommit_url_lets_git_check_the_certificate() {
503 for flags in [&[][..], &["--symref"]] {
504 let args =
505 codecommit_ls_remote_args("codecommit::us-east-1://repo", flags, "main", false);
506 assert!(!args.contains(&"http.sslVerify=false".to_owned()));
507 }
508 }
509
510 #[tokio::test]
511 async fn a_codecommit_ls_remote_keeps_the_session_out_of_what_git_says() {
512 let tmp = tempfile::tempdir().unwrap();
513 let url = format!("{}/a-secret-key/a-token", tmp.path().display());
515 let creds = AwsTempCreds {
516 access_key: "an-access-key".to_owned(),
517 secret_key: "a-secret-key".to_owned(),
518 session_token: "a-token".to_owned(),
519 region: "us-east-1".to_owned(),
520 };
521
522 let answer = ls_remote_with(&url, &[], "main", &creds, false).await;
523
524 let Err(GitCallError::Refused { stderr, .. }) = answer else {
525 panic!("{answer:?}");
526 };
527 assert!(stderr.contains("[REDACTED]/[REDACTED]"), "{stderr}");
528 }
529
530 #[test]
531 fn symbolic_refs_are_asked_for_only_when_wanted_and_before_the_url() {
532 assert_eq!(
533 ls_remote_args("https://h/r", &["--symref"], "HEAD"),
534 ["ls-remote", "--symref", "--", "https://h/r", "HEAD"]
535 );
536 assert_eq!(
537 ls_remote_args("https://h/r", &[], "main"),
538 ["ls-remote", "--", "https://h/r", "main"]
539 );
540 }
541
542 fn repository(branch: &str, commits: usize) -> tempfile::TempDir {
545 let dir = tempfile::tempdir().unwrap();
546 let git = |args: &[&str]| {
547 let status = std::process::Command::new("git")
548 .arg("-C")
549 .arg(dir.path())
550 .args(["-c", "user.name=t", "-c", "user.email=t@example.com"])
551 .args(args)
552 .output()
553 .unwrap()
554 .status;
555 assert!(status.success(), "git {args:?}");
556 };
557 git(&["init", "--quiet", "--initial-branch", branch]);
558 for _ in 0..commits {
559 git(&["commit", "--quiet", "--allow-empty", "--message", "c"]);
560 }
561 dir
562 }
563
564 fn file_answer(git_dir: &std::path::Path, path: &str) -> Vec<u8> {
566 let file = path.split('?').next().unwrap_or_default();
567 let Ok(body) = std::fs::read(git_dir.join(file.trim_start_matches('/'))) else {
568 return b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
569 .to_vec();
570 };
571 let head = format!(
572 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
573 body.len()
574 );
575 [head.into_bytes(), body].concat()
576 }
577
578 fn serve_repository(repository: &std::path::Path) -> String {
581 let git_dir = repository.join(".git");
582 let updated = std::process::Command::new("git")
583 .arg("-C")
584 .arg(&git_dir)
585 .arg("update-server-info")
586 .status()
587 .unwrap();
588 assert!(updated.success());
589 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
590 let port = listener.local_addr().expect("addr").port();
591 std::thread::spawn(move || {
592 for stream in listener.incoming().flatten() {
593 let (path, _) = read_request(&stream);
594 let _ = (&stream).write_all(&file_answer(&git_dir, &path));
595 }
596 });
597 format!("http://127.0.0.1:{port}/")
598 }
599
600 #[test]
601 fn the_branch_head_names_is_read_without_its_prefix() {
602 assert_eq!(
603 head_of("ref: refs/heads/release/2.0\tHEAD\nabc123\tHEAD\n").as_deref(),
604 Some("release/2.0")
605 );
606 }
607
608 #[test]
609 fn a_head_that_names_no_branch_is_no_branch() {
610 for said in [
611 "",
612 "abc123\tHEAD\n",
613 "ref: refs/tags/v1\tHEAD\n",
614 "ref: refs/heads/\tHEAD\n",
615 "ref: refs/heads/main\trefs/remotes/origin/HEAD\n",
616 ] {
617 assert!(head_of(said).is_none(), "{said:?}");
618 }
619 }
620
621 #[tokio::test]
622 async fn a_remote_answers_the_branch_its_head_names() {
623 let remote = repository("trunk", 1);
624 let url = serve_repository(remote.path());
625
626 let result = remote_head(&url, None, false).await.unwrap();
627
628 assert_eq!(result.branch.as_deref(), Some("trunk"));
629 assert!(result.error.is_none());
630 }
631
632 #[tokio::test]
633 async fn an_empty_remote_names_no_branch() {
634 let remote = repository("trunk", 0);
635 let url = serve_repository(remote.path());
636
637 let result = remote_head(&url, None, false).await.unwrap();
638
639 assert!(result.branch.is_none());
640 assert!(result.error.is_none());
641 }
642
643 #[tokio::test]
644 async fn a_branch_is_not_found_by_a_ref_that_only_ends_in_its_name() {
645 let remote = repository("trunk", 1);
646 let moved = std::process::Command::new("git")
647 .arg("-C")
648 .arg(remote.path())
649 .args(["update-ref", "refs/heads/feature/dev", "trunk"])
650 .status()
651 .unwrap();
652 assert!(moved.success());
653 let url = serve_repository(remote.path());
654
655 let found = ls_remote(&url, "trunk", None, false).await.unwrap();
656 let missing = ls_remote(&url, "dev", None, false).await.unwrap();
657
658 assert_eq!(found.commit.unwrap().len(), 40);
659 assert_eq!(missing.commit.as_deref(), Some(""));
660 }
661
662 #[tokio::test]
663 async fn without_a_credential_a_url_that_is_not_http_never_runs_git() {
664 let remote = repository("trunk", 1);
665 let path = remote.path().to_str().unwrap();
666
667 for url in [
668 path,
669 "httpx://127.0.0.1/repo.git",
670 "http:127.0.0.1/repo.git",
671 ] {
672 let answer = ls_remote(url, "trunk", None, false).await;
673
674 assert!(
675 matches!(
676 answer,
677 Err(LsRemoteError::Access(HttpsAccessError::NotHttp))
678 ),
679 "{url}: {answer:?}"
680 );
681 }
682 }
683
684 #[tokio::test]
685 async fn a_head_read_that_cannot_ask_says_why_as_before() {
686 let error = remote_head("git@example.com:org/repo.git", None, false)
688 .await
689 .unwrap_err();
690
691 assert_eq!(error.to_string(), "expected an http or https url");
692 }
693
694 #[tokio::test]
695 async fn an_ssh_ls_remote_with_a_nul_in_the_branch_never_runs_git() {
696 let key = Credentials {
697 kind: CredentialKind::Ssh {
698 key: base64::engine::general_purpose::STANDARD.encode("not a real key"),
699 },
700 };
701
702 let nul = ls_remote("git@example.com:org/repo.git", "ma\0in", Some(&key), false).await;
703
704 assert!(matches!(
705 nul,
706 Err(LsRemoteError::Git(GitCallError::NullByte {
707 command: "ls-remote"
708 }))
709 ));
710 }
711
712 #[tokio::test]
713 async fn a_key_reads_no_head_from_a_url_that_is_not_ssh() {
714 let remote = repository("trunk", 1);
715 let url = remote.path().to_str().unwrap();
716 let key = Credentials {
717 kind: CredentialKind::Ssh {
718 key: base64::engine::general_purpose::STANDARD.encode("not a real key"),
719 },
720 };
721
722 let result = remote_head(url, Some(&key), false).await.unwrap();
723
724 assert!(result.branch.is_none());
725 assert!(result.error.is_some());
726 }
727
728 #[tokio::test]
729 async fn a_head_read_takes_the_token_to_its_host_and_out_of_the_error() {
730 for (credentials, login) in [
731 (token("Zm9v+YmFy"), "Zm9v+YmFy:"),
732 (pat("Zm9v+YmFy"), "pat:Zm9v+YmFy"),
733 ] {
734 let (host, seen) = serve(None);
735 let result = remote_head(&format!("{host}/org/repo.git"), Some(&credentials), false)
736 .await
737 .unwrap();
738
739 let sent = base64::engine::general_purpose::STANDARD.encode(login);
740 assert!(seen
741 .lock()
742 .unwrap()
743 .contains(&Some(format!("Basic {sent}"))));
744 assert!(result.branch.is_none());
745 let error = result.error.unwrap();
746 assert!(error.contains("Authentication failed"), "{error}");
747 assert!(!error.contains("Zm9v"), "{error}");
748 }
749 }
750
751 #[tokio::test]
752 async fn a_head_read_redirected_to_another_host_carries_no_credential() {
753 for credentials in [token("tok123"), pat("tok123")] {
754 let (other, other_seen) = serve(None);
755 let other = other.replace("127.0.0.1", "localhost");
756 let (host, _) = serve(Some(other));
757 let result = remote_head(&format!("{host}/org/repo.git"), Some(&credentials), true)
758 .await
759 .unwrap();
760
761 assert!(result.error.is_some());
762 let other_seen = other_seen.lock().unwrap().clone();
763 assert!(!other_seen.is_empty());
764 assert!(other_seen.iter().all(Option::is_none), "{other_seen:?}");
765 }
766 }
767}