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