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