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