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