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