1use std::process::Output;
2use std::time::Duration;
3use tokio::process::Command;
4
5use super::aws::{self, AwsTempCreds};
6use super::types::{CredentialKind, Credentials, LsRemoteResult, RemoteHead};
7use crate::call::not_run;
8use crate::clone::https::HttpsAccess;
9use crate::clone::reason::hide;
10use crate::clone::ssh::{drop_scp_scheme, SshKey, ONLY_SSH};
11use crate::clone::{has_http_scheme, redirect_config};
12use crate::errors::{
13 CodecommitLsRemoteError, GitCallError, HttpsAccessError, HttpsLsRemoteError, LsRemoteError,
14};
15
16const LS_REMOTE_TIMEOUT_SECS: u64 = 20;
17
18const LS_REMOTE_TIMEOUT_MESSAGE: &str = "git ls-remote time out";
20
21struct AwsRole {
22 arn: String,
23 external_id: String,
24 via: Option<String>,
25}
26
27trait Answer {
32 const SYMREF: bool;
33
34 fn timed_out() -> Self;
35 fn read(output: &Output, named: &str, secrets: &[&str]) -> Self;
36}
37
38impl Answer for LsRemoteResult {
39 const SYMREF: bool = false;
40
41 fn timed_out() -> Self {
42 Self {
43 commit: None,
44 error: Some(LS_REMOTE_TIMEOUT_MESSAGE.to_owned()),
45 }
46 }
47
48 fn read(output: &Output, branch: &str, secrets: &[&str]) -> Self {
49 parse_ls_remote_output(output, branch, secrets)
50 }
51}
52
53impl Answer for RemoteHead {
54 const SYMREF: bool = true;
55
56 fn timed_out() -> Self {
57 Self {
58 branch: None,
59 error: Some(LS_REMOTE_TIMEOUT_MESSAGE.to_owned()),
60 }
61 }
62
63 fn read(output: &Output, _named: &str, secrets: &[&str]) -> Self {
64 parse_symref_output(output, secrets)
65 }
66}
67
68fn branch_refs(branch: &str) -> Vec<String> {
71 if branch == "HEAD" {
72 return vec!["HEAD".to_owned()];
73 }
74 vec![
75 format!("refs/heads/{branch}"),
76 format!("refs/tags/{branch}"),
77 ]
78}
79
80fn ls_remote_args(url: &str, pattern: &str, symref: bool) -> Vec<String> {
81 let mut args = vec!["ls-remote".to_owned()];
82 if symref {
83 args.push("--symref".to_owned());
84 }
85 args.extend(["--".to_owned(), url.to_owned(), pattern.to_owned()]);
86 args
87}
88
89async fn run_ls_remote<A: Answer>(
90 command: &mut Command,
91 named: &str,
92 secrets: &[&str],
93) -> Result<A, GitCallError> {
94 let Ok(output) = tokio::time::timeout(
95 Duration::from_secs(LS_REMOTE_TIMEOUT_SECS),
96 command.kill_on_drop(true).output(),
97 )
98 .await
99 else {
100 return Ok(A::timed_out());
101 };
102
103 let output = output.map_err(|source| not_run("ls-remote", source))?;
104 Ok(A::read(&output, named, secrets))
105}
106
107pub async fn ls_remote(
108 url: &str,
109 branch: &str,
110 credentials: Option<&Credentials>,
111 follow_redirects: bool,
112) -> anyhow::Result<LsRemoteResult> {
113 ask(url, branch, credentials, follow_redirects).await
114}
115
116pub async fn ssh_ls_remote(
117 url: &str,
118 branch: &str,
119 key: &str,
120) -> Result<LsRemoteResult, LsRemoteError> {
121 ls_remote_ssh(url, branch, key).await
122}
123
124pub async fn codecommit_ls_remote(
125 url: &str,
126 branch: &str,
127 arn: &str,
128 external_id: &str,
129 follow_redirects: bool,
130) -> Result<LsRemoteResult, CodecommitLsRemoteError> {
131 let role = AwsRole {
132 arn: arn.to_owned(),
133 external_id: external_id.to_owned(),
134 via: None,
135 };
136 ls_remote_codecommit(url, branch, &role, follow_redirects).await
137}
138
139pub async fn https_ls_remote(
140 url: &str,
141 branch: &str,
142 credential: Option<&CredentialKind>,
143 follow_redirects: bool,
144) -> Result<LsRemoteResult, HttpsLsRemoteError> {
145 ls_remote_https(url, branch, credential, follow_redirects).await
146}
147
148pub async fn remote_head(
158 url: &str,
159 credentials: Option<&Credentials>,
160 follow_redirects: bool,
161) -> anyhow::Result<RemoteHead> {
162 ask(url, "HEAD", credentials, follow_redirects).await
163}
164
165async fn ask<A: Answer>(
166 url: &str,
167 pattern: &str,
168 credentials: Option<&Credentials>,
169 follow_redirects: bool,
170) -> anyhow::Result<A> {
171 match credentials.map(|c| &c.kind) {
172 Some(CredentialKind::Ssh { key }) => Ok(ls_remote_ssh(url, pattern, key).await?),
173 Some(CredentialKind::AwsRole {
174 arn,
175 external_id,
176 via,
177 }) => {
178 let role = AwsRole {
179 arn: arn.clone(),
180 external_id: external_id.clone(),
181 via: via.clone(),
182 };
183 Ok(ls_remote_codecommit(url, pattern, &role, follow_redirects).await?)
184 }
185 kind => Ok(ls_remote_https(url, pattern, kind, follow_redirects).await?),
186 }
187}
188
189async fn ls_remote_ssh<A: Answer>(
190 url: &str,
191 pattern: &str,
192 key_b64: &str,
193) -> Result<A, LsRemoteError> {
194 let key = SshKey::write(key_b64)?;
195 let args = ls_remote_args(drop_scp_scheme(url), pattern, A::SYMREF);
196
197 let mut command = Command::new("git");
198 command
199 .args(&args)
200 .env("GIT_SSH_COMMAND", &key.command)
201 .env("GIT_ALLOW_PROTOCOL", ONLY_SSH);
202 Ok(run_ls_remote(&mut command, pattern, &[]).await?)
203}
204
205async fn ls_remote_https<A: Answer>(
206 url: &str,
207 pattern: &str,
208 credential: Option<&CredentialKind>,
209 follow_redirects: bool,
210) -> Result<A, HttpsLsRemoteError> {
211 if credential.is_none() && !has_http_scheme(url) {
213 return Err(HttpsAccessError::NotHttp.into());
214 }
215 let access = HttpsAccess::new(url, credential)?;
216 let mut args = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
217 args.extend(redirect_config(follow_redirects));
218 args.extend(ls_remote_args(access.url(), pattern, A::SYMREF));
219
220 let mut command = access.git();
221 command.args(&args);
222 Ok(run_ls_remote(&mut command, pattern, access.secret().as_slice()).await?)
223}
224
225async fn ls_remote_codecommit<A: Answer>(
226 url: &str,
227 pattern: &str,
228 role: &AwsRole,
229 follow_redirects: bool,
230) -> Result<A, CodecommitLsRemoteError> {
231 let creds = aws::assume_role(&role.arn, &role.external_id, url, role.via.as_deref()).await?;
232 Ok(ls_remote_with(url, pattern, &creds, follow_redirects).await?)
233}
234
235async fn ls_remote_with<A: Answer>(
236 url: &str,
237 pattern: &str,
238 creds: &AwsTempCreds,
239 follow_redirects: bool,
240) -> Result<A, GitCallError> {
241 let args = codecommit_ls_remote_args(url, pattern, A::SYMREF, follow_redirects);
242
243 let mut command = Command::new("git");
244 command
245 .args(&args)
246 .env("AWS_ACCESS_KEY_ID", &creds.access_key)
247 .env("AWS_SECRET_ACCESS_KEY", &creds.secret_key)
248 .env("AWS_SESSION_TOKEN", &creds.session_token)
249 .env("AWS_DEFAULT_REGION", &creds.region);
250 let secrets = [creds.secret_key.as_str(), creds.session_token.as_str()];
251 run_ls_remote(&mut command, pattern, &secrets).await
252}
253
254fn codecommit_ls_remote_args(
257 url: &str,
258 pattern: &str,
259 symref: bool,
260 follow_redirects: bool,
261) -> Vec<String> {
262 let mut args = redirect_config(follow_redirects).to_vec();
263 args.extend(ls_remote_args(url, pattern, symref));
264 args
265}
266
267fn parse_ls_remote_output(output: &Output, branch: &str, secrets: &[&str]) -> LsRemoteResult {
268 if !output.status.success() {
269 let stderr = String::from_utf8_lossy(&output.stderr);
270 return LsRemoteResult {
271 commit: None,
272 error: Some(hide(&stderr, secrets)),
273 };
274 }
275
276 let stdout = String::from_utf8_lossy(&output.stdout);
277 let commit_of = |wanted: &str| {
278 stdout.lines().find_map(|line| {
279 let (commit, name) = line.split_once('\t')?;
280 (name == wanted).then_some(commit)
281 })
282 };
283 let commit = branch_refs(branch)
284 .iter()
285 .find_map(|wanted| commit_of(wanted))
286 .unwrap_or_default()
287 .to_owned();
288
289 LsRemoteResult {
290 commit: Some(commit),
291 error: None,
292 }
293}
294
295fn parse_symref_output(output: &Output, secrets: &[&str]) -> RemoteHead {
296 if !output.status.success() {
297 let stderr = String::from_utf8_lossy(&output.stderr);
298 return RemoteHead {
299 branch: None,
300 error: Some(hide(&stderr, secrets)),
301 };
302 }
303
304 let stdout = String::from_utf8_lossy(&output.stdout);
305 RemoteHead {
306 branch: stdout.lines().find_map(head_branch).map(str::to_owned),
307 error: None,
308 }
309}
310
311fn head_branch(line: &str) -> Option<&str> {
314 let (target, name) = line.strip_prefix("ref: ")?.split_once('\t')?;
315 if name != "HEAD" {
316 return None;
317 }
318 target
319 .strip_prefix("refs/heads/")
320 .filter(|branch| !branch.is_empty())
321}
322
323#[cfg(test)]
324mod tests {
325 use std::io::{BufRead, BufReader, Write};
326 use std::net::{TcpListener, TcpStream};
327 use std::sync::{Arc, Mutex};
328
329 use base64::Engine;
330
331 use super::{
332 codecommit_ls_remote_args, https_ls_remote, ls_remote, ls_remote_args, ls_remote_with,
333 parse_ls_remote_output, parse_symref_output, redirect_config, remote_head, run_ls_remote,
334 ssh_ls_remote, AwsTempCreds,
335 };
336 use crate::clone::https::HttpsAccess;
337 use crate::errors::{GitCallError, HttpsAccessError, HttpsLsRemoteError, LsRemoteError};
338 use crate::types::{CredentialKind, Credentials, LsRemoteResult};
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 output(code: i32, stdout: &str, stderr: &str) -> std::process::Output {
421 use std::os::unix::process::ExitStatusExt;
422 std::process::Output {
423 status: std::process::ExitStatus::from_raw(code << 8),
424 stdout: stdout.as_bytes().to_vec(),
425 stderr: stderr.as_bytes().to_vec(),
426 }
427 }
428
429 #[test]
430 fn redirects_are_forbidden_explicitly_and_not_left_to_the_git_default() {
431 assert_eq!(
432 redirect_config(false),
433 ["-c".to_owned(), "http.followRedirects=false".to_owned()]
434 );
435 assert_eq!(
436 redirect_config(true),
437 ["-c".to_owned(), "http.followRedirects=true".to_owned()]
438 );
439 }
440
441 #[test]
442 fn a_branch_missing_from_the_remote_is_an_empty_commit_and_not_a_missing_one() {
443 let result = parse_ls_remote_output(&output(0, "", ""), "main", &[]);
444 assert_eq!(result.commit, Some(String::new()));
445 assert!(result.error.is_none());
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 let result = parse_ls_remote_output(&output(0, said, ""), "main", &[]);
460 assert_eq!(result.commit.as_deref(), Some(commit), "{said:?}");
461 }
462 }
463
464 #[test]
465 fn head_is_read_only_from_the_line_git_lists_as_head() {
466 for (said, commit) in [
467 ("aaa\tHEAD\nbbb\trefs/heads/HEAD\n", "aaa"),
468 ("bbb\trefs/heads/HEAD\nccc\trefs/remotes/origin/HEAD\n", ""),
469 ] {
470 let result = parse_ls_remote_output(&output(0, said, ""), "HEAD", &[]);
471 assert_eq!(result.commit.as_deref(), Some(commit), "{said:?}");
472 }
473 }
474
475 #[test]
476 fn a_failure_reports_the_stderr_and_no_commit() {
477 let result =
478 parse_ls_remote_output(&output(128, "", "fatal: repository not found"), "main", &[]);
479 assert!(result.commit.is_none());
480 assert_eq!(result.error.as_deref(), Some("fatal: repository not found"));
481 }
482
483 #[tokio::test]
484 async fn a_token_reaches_its_host_and_stays_out_of_the_error() {
485 for (credentials, login) in [
486 (token("Zm9v+YmFy"), "Zm9v+YmFy:"),
487 (pat("Zm9v+YmFy"), "pat:Zm9v+YmFy"),
488 ] {
489 let (host, seen) = serve(None);
490 let result = ls_remote(
491 &format!("{host}/org/repo.git"),
492 "main",
493 Some(&credentials),
494 false,
495 )
496 .await
497 .unwrap();
498
499 let sent = base64::engine::general_purpose::STANDARD.encode(login);
500 assert!(seen
501 .lock()
502 .unwrap()
503 .contains(&Some(format!("Basic {sent}"))));
504 let error = result.error.unwrap();
505 assert!(error.contains("Authentication failed"), "{error}");
506 assert!(!error.contains("Zm9v"), "{error}");
507 }
508 }
509
510 #[tokio::test]
511 async fn a_redirect_to_another_host_carries_no_credential() {
512 for credentials in [token("tok123"), pat("tok123")] {
513 let (other, other_seen) = serve(None);
514 let other = other.replace("127.0.0.1", "localhost");
515 let (host, _) = serve(Some(other));
516 let result = ls_remote(
517 &format!("{host}/org/repo.git"),
518 "main",
519 Some(&credentials),
520 true,
521 )
522 .await
523 .unwrap();
524
525 assert!(result.error.is_some());
526 let other_seen = other_seen.lock().unwrap().clone();
527 assert!(!other_seen.is_empty());
528 assert!(other_seen.iter().all(Option::is_none), "{other_seen:?}");
529 }
530 }
531
532 #[tokio::test]
533 async fn a_helper_the_caller_configured_never_sees_the_credential() {
534 let (host, _) = serve(None);
535 let calls = tempfile::NamedTempFile::new().unwrap();
536 let access =
537 HttpsAccess::new(&format!("{host}/org/repo.git"), Some(&token("tok123").kind)).unwrap();
538 let mut command = access.git();
539 command
540 .env(
541 "GIT_CONFIG_PARAMETERS",
542 format!(
543 "'credential.helper'='!f() {{ echo $1 >> {}; }}; f'",
544 calls.path().display()
545 ),
546 )
547 .args(["ls-remote", "--", access.url(), "main"]);
548 run_ls_remote::<LsRemoteResult>(&mut command, "main", access.secret().as_slice())
549 .await
550 .unwrap();
551
552 assert_eq!(std::fs::read_to_string(calls.path()).unwrap(), "");
553 }
554
555 #[test]
556 fn a_failure_hides_the_credential_a_server_echoes() {
557 let said = "remote: token tok123 is not valid\nfatal: Authentication failed\n";
558 let result = parse_ls_remote_output(&output(128, "", said), "main", &["tok123"]);
559 assert_eq!(
560 result.error.as_deref(),
561 Some("remote: token [REDACTED] is not valid\nfatal: Authentication failed\n")
562 );
563 }
564
565 #[test]
566 fn a_codecommit_url_lets_git_check_the_certificate() {
567 for symref in [false, true] {
568 let args =
569 codecommit_ls_remote_args("codecommit::us-east-1://repo", "main", symref, false);
570 assert!(!args.contains(&"http.sslVerify=false".to_owned()));
571 }
572 }
573
574 #[tokio::test]
575 async fn a_codecommit_ls_remote_keeps_the_session_out_of_what_git_says() {
576 let tmp = tempfile::tempdir().unwrap();
577 let url = format!("{}/a-secret-key/a-token", tmp.path().display());
579 let creds = AwsTempCreds {
580 access_key: "an-access-key".to_owned(),
581 secret_key: "a-secret-key".to_owned(),
582 session_token: "a-token".to_owned(),
583 region: "us-east-1".to_owned(),
584 };
585
586 let answer: LsRemoteResult = ls_remote_with(&url, "main", &creds, false)
587 .await
588 .expect("git ran");
589
590 let said = answer.error.expect("git refused");
591 assert!(said.contains("[REDACTED]/[REDACTED]"), "{said}");
592 }
593
594 #[test]
595 fn symbolic_refs_are_asked_for_only_when_wanted_and_before_the_url() {
596 assert_eq!(
597 ls_remote_args("https://h/r", "HEAD", true),
598 ["ls-remote", "--symref", "--", "https://h/r", "HEAD"]
599 );
600 assert_eq!(
601 ls_remote_args("https://h/r", "main", false),
602 ["ls-remote", "--", "https://h/r", "main"]
603 );
604 }
605
606 fn repository(branch: &str, commits: usize) -> tempfile::TempDir {
609 let dir = tempfile::tempdir().unwrap();
610 let git = |args: &[&str]| {
611 let status = std::process::Command::new("git")
612 .arg("-C")
613 .arg(dir.path())
614 .args(["-c", "user.name=t", "-c", "user.email=t@example.com"])
615 .args(args)
616 .output()
617 .unwrap()
618 .status;
619 assert!(status.success(), "git {args:?}");
620 };
621 git(&["init", "--quiet", "--initial-branch", branch]);
622 for _ in 0..commits {
623 git(&["commit", "--quiet", "--allow-empty", "--message", "c"]);
624 }
625 dir
626 }
627
628 fn file_answer(git_dir: &std::path::Path, path: &str) -> Vec<u8> {
630 let file = path.split('?').next().unwrap_or_default();
631 let Ok(body) = std::fs::read(git_dir.join(file.trim_start_matches('/'))) else {
632 return b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
633 .to_vec();
634 };
635 let head = format!(
636 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
637 body.len()
638 );
639 [head.into_bytes(), body].concat()
640 }
641
642 fn serve_repository(repository: &std::path::Path) -> String {
645 let git_dir = repository.join(".git");
646 let updated = std::process::Command::new("git")
647 .arg("-C")
648 .arg(&git_dir)
649 .arg("update-server-info")
650 .status()
651 .unwrap();
652 assert!(updated.success());
653 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
654 let port = listener.local_addr().expect("addr").port();
655 std::thread::spawn(move || {
656 for stream in listener.incoming().flatten() {
657 let (path, _) = read_request(&stream);
658 let _ = (&stream).write_all(&file_answer(&git_dir, &path));
659 }
660 });
661 format!("http://127.0.0.1:{port}/")
662 }
663
664 #[test]
665 fn the_branch_head_names_is_read_without_its_prefix() {
666 let result = parse_symref_output(
667 &output(0, "ref: refs/heads/release/2.0\tHEAD\nabc123\tHEAD\n", ""),
668 &[],
669 );
670 assert_eq!(result.branch.as_deref(), Some("release/2.0"));
671 assert!(result.error.is_none());
672 }
673
674 #[test]
675 fn a_head_that_names_no_branch_is_no_branch_and_no_error() {
676 for said in [
677 "",
678 "abc123\tHEAD\n",
679 "ref: refs/tags/v1\tHEAD\n",
680 "ref: refs/heads/\tHEAD\n",
681 "ref: refs/heads/main\trefs/remotes/origin/HEAD\n",
682 ] {
683 let result = parse_symref_output(&output(0, said, ""), &[]);
684 assert!(result.branch.is_none(), "{said:?}");
685 assert!(result.error.is_none(), "{said:?}");
686 }
687 }
688
689 #[test]
690 fn a_failed_head_read_reports_the_stderr_with_the_credential_hidden() {
691 let said = "remote: token tok123 is not valid\nfatal: Authentication failed\n";
692 let result = parse_symref_output(&output(128, "", said), &["tok123"]);
693 assert!(result.branch.is_none());
694 assert_eq!(
695 result.error.as_deref(),
696 Some("remote: token [REDACTED] is not valid\nfatal: Authentication failed\n")
697 );
698 }
699
700 #[tokio::test]
701 async fn a_remote_answers_the_branch_its_head_names() {
702 let remote = repository("trunk", 1);
703 let url = serve_repository(remote.path());
704
705 let result = remote_head(&url, None, false).await.unwrap();
706
707 assert_eq!(result.branch.as_deref(), Some("trunk"));
708 assert!(result.error.is_none());
709 }
710
711 #[tokio::test]
712 async fn an_empty_remote_names_no_branch() {
713 let remote = repository("trunk", 0);
714 let url = serve_repository(remote.path());
715
716 let result = remote_head(&url, None, false).await.unwrap();
717
718 assert!(result.branch.is_none());
719 assert!(result.error.is_none());
720 }
721
722 #[tokio::test]
723 async fn a_branch_is_not_found_by_a_ref_that_only_ends_in_its_name() {
724 let remote = repository("trunk", 1);
725 let moved = std::process::Command::new("git")
726 .arg("-C")
727 .arg(remote.path())
728 .args(["update-ref", "refs/heads/feature/dev", "trunk"])
729 .status()
730 .unwrap();
731 assert!(moved.success());
732 let url = serve_repository(remote.path());
733
734 let found = ls_remote(&url, "trunk", None, false).await.unwrap();
735 let missing = ls_remote(&url, "dev", None, false).await.unwrap();
736
737 assert_eq!(found.commit.unwrap().len(), 40);
738 assert_eq!(missing.commit.as_deref(), Some(""));
739 }
740
741 #[tokio::test]
742 async fn without_a_credential_a_url_that_is_not_http_never_runs_git() {
743 let remote = repository("trunk", 1);
744 let path = remote.path().to_str().unwrap();
745
746 for url in [
747 path,
748 "httpx://127.0.0.1/repo.git",
749 "http:127.0.0.1/repo.git",
750 ] {
751 let answer = https_ls_remote(url, "trunk", None, false).await;
752
753 assert!(
754 matches!(
755 answer,
756 Err(HttpsLsRemoteError::Access(HttpsAccessError::NotHttp))
757 ),
758 "{url}: {answer:?}"
759 );
760 }
761 }
762
763 #[tokio::test]
764 async fn an_ssh_ls_remote_with_a_nul_in_the_branch_never_runs_git() {
765 let key = base64::engine::general_purpose::STANDARD.encode("not a real key");
766
767 let nul = ssh_ls_remote("git@example.com:org/repo.git", "ma\0in", &key).await;
768
769 assert!(matches!(
770 nul,
771 Err(LsRemoteError::Git(GitCallError::NullByte {
772 command: "ls-remote"
773 }))
774 ));
775 }
776
777 #[tokio::test]
778 async fn a_key_reads_no_head_from_a_url_that_is_not_ssh() {
779 let remote = repository("trunk", 1);
780 let url = remote.path().to_str().unwrap();
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 result = remote_head(url, Some(&key), false).await.unwrap();
788
789 assert!(result.branch.is_none());
790 assert!(result.error.is_some());
791 }
792
793 #[tokio::test]
794 async fn a_head_read_takes_the_token_to_its_host_and_out_of_the_error() {
795 for (credentials, login) in [
796 (token("Zm9v+YmFy"), "Zm9v+YmFy:"),
797 (pat("Zm9v+YmFy"), "pat:Zm9v+YmFy"),
798 ] {
799 let (host, seen) = serve(None);
800 let result = remote_head(&format!("{host}/org/repo.git"), Some(&credentials), false)
801 .await
802 .unwrap();
803
804 let sent = base64::engine::general_purpose::STANDARD.encode(login);
805 assert!(seen
806 .lock()
807 .unwrap()
808 .contains(&Some(format!("Basic {sent}"))));
809 assert!(result.branch.is_none());
810 let error = result.error.unwrap();
811 assert!(error.contains("Authentication failed"), "{error}");
812 assert!(!error.contains("Zm9v"), "{error}");
813 }
814 }
815
816 #[tokio::test]
817 async fn a_head_read_redirected_to_another_host_carries_no_credential() {
818 for credentials in [token("tok123"), pat("tok123")] {
819 let (other, other_seen) = serve(None);
820 let other = other.replace("127.0.0.1", "localhost");
821 let (host, _) = serve(Some(other));
822 let result = remote_head(&format!("{host}/org/repo.git"), Some(&credentials), true)
823 .await
824 .unwrap();
825
826 assert!(result.error.is_some());
827 let other_seen = other_seen.lock().unwrap().clone();
828 assert!(!other_seen.is_empty());
829 assert!(other_seen.iter().all(Option::is_none), "{other_seen:?}");
830 }
831 }
832}