1use anyhow::{Context, Result};
2use base64::Engine;
3use std::time::Duration;
4use tokio::process::Command;
5
6use super::aws;
7use super::types::{CredentialKind, Credentials, LsRemoteResult};
8use crate::clone::https::{embed_credentials_in_url, embed_token_in_url};
9use crate::clone::ssh::{normalize_ssh_url, setup_ssh_env};
10
11const LS_REMOTE_TIMEOUT_SECS: u64 = 20;
12
13const LS_REMOTE_TIMEOUT_MESSAGE: &str = "git ls-remote time out";
15
16struct AwsRole {
17 arn: String,
18 external_id: String,
19 via: Option<String>,
20}
21
22fn redirect_config(follow_redirects: bool) -> [String; 2] {
23 [
24 "-c".to_owned(),
25 format!("http.followRedirects={follow_redirects}"),
26 ]
27}
28
29async fn run_ls_remote(command: &mut Command) -> Result<LsRemoteResult> {
30 let Ok(output) = tokio::time::timeout(
31 Duration::from_secs(LS_REMOTE_TIMEOUT_SECS),
32 command.kill_on_drop(true).output(),
33 )
34 .await
35 else {
36 return Ok(LsRemoteResult {
37 commit: None,
38 error: Some(LS_REMOTE_TIMEOUT_MESSAGE.to_owned()),
39 });
40 };
41
42 Ok(parse_ls_remote_output(
43 &output.context("running git ls-remote")?,
44 ))
45}
46
47pub async fn ls_remote(
48 url: &str,
49 branch: &str,
50 credentials: Option<&Credentials>,
51 follow_redirects: bool,
52) -> Result<LsRemoteResult> {
53 match credentials.map(|c| &c.kind) {
54 Some(CredentialKind::Ssh { key }) => ls_remote_ssh(url, branch, key).await,
55 Some(CredentialKind::AwsRole {
56 arn,
57 external_id,
58 via,
59 }) => {
60 let role = AwsRole {
61 arn: arn.clone(),
62 external_id: external_id.clone(),
63 via: via.clone(),
64 };
65 ls_remote_codecommit(url, branch, &role, follow_redirects).await
66 }
67 Some(CredentialKind::Https { user, password }) => {
68 let repo_url = embed_credentials_in_url(url, user, password)?;
69 ls_remote_https(&repo_url, branch, follow_redirects, &[]).await
70 }
71 Some(CredentialKind::Token {
72 token,
73 is_pat,
74 oauth_type,
75 }) => {
76 if *is_pat {
77 let encoded = base64::engine::general_purpose::STANDARD.encode(format!(":{token}"));
78 let extra_config = vec![
79 "-c".to_owned(),
80 format!("http.extraHeader=Authorization: Basic {encoded}"),
81 ];
82 ls_remote_https(url, branch, follow_redirects, &extra_config).await
83 } else {
84 let repo_url = embed_token_in_url(url, token, oauth_type)?;
85 ls_remote_https(&repo_url, branch, follow_redirects, &[]).await
86 }
87 }
88 None => ls_remote_https(url, branch, follow_redirects, &[]).await,
89 }
90}
91
92async fn ls_remote_ssh(url: &str, branch: &str, key_b64: &str) -> Result<LsRemoteResult> {
93 let key_data = base64::engine::general_purpose::STANDARD
94 .decode(key_b64)
95 .context("decoding SSH key")?;
96
97 let key_file = tempfile::Builder::new()
98 .prefix("melts-ssh-key-")
99 .tempfile()
100 .context("creating SSH key file")?;
101
102 std::fs::write(key_file.path(), &key_data).context("writing SSH key")?;
103
104 #[cfg(unix)]
105 {
106 use std::os::unix::fs::PermissionsExt;
107 std::fs::set_permissions(key_file.path(), std::fs::Permissions::from_mode(0o400))
108 .context("setting SSH key permissions")?;
109 }
110
111 let git_ssh_command = setup_ssh_env(key_file.path());
112 let normalized_url = normalize_ssh_url(url);
113
114 let args = [
115 "ls-remote".to_owned(),
116 "--".to_owned(),
117 normalized_url,
118 branch.to_owned(),
119 ];
120
121 let mut command = Command::new("git");
122 command.args(&args).env("GIT_SSH_COMMAND", &git_ssh_command);
123 run_ls_remote(&mut command).await
124}
125
126async fn ls_remote_https(
127 url: &str,
128 branch: &str,
129 follow_redirects: bool,
130 extra_config: &[String],
131) -> Result<LsRemoteResult> {
132 let mut args = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
133 args.extend(redirect_config(follow_redirects));
134 args.extend(extra_config.iter().cloned());
135 args.extend([
136 "ls-remote".to_owned(),
137 "--".to_owned(),
138 url.to_owned(),
139 branch.to_owned(),
140 ]);
141
142 let mut command = Command::new("git");
143 command.args(&args);
144 run_ls_remote(&mut command).await
145}
146
147async fn ls_remote_codecommit(
148 url: &str,
149 branch: &str,
150 role: &AwsRole,
151 follow_redirects: bool,
152) -> Result<LsRemoteResult> {
153 let creds = aws::assume_role(&role.arn, &role.external_id, url, role.via.as_deref()).await?;
154
155 let mut args = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
156 args.extend(redirect_config(follow_redirects));
157 args.extend([
158 "ls-remote".to_owned(),
159 "--".to_owned(),
160 url.to_owned(),
161 branch.to_owned(),
162 ]);
163
164 let mut command = Command::new("git");
165 command
166 .args(&args)
167 .env("AWS_ACCESS_KEY_ID", &creds.access_key)
168 .env("AWS_SECRET_ACCESS_KEY", &creds.secret_key)
169 .env("AWS_SESSION_TOKEN", &creds.session_token)
170 .env("AWS_DEFAULT_REGION", &creds.region);
171 run_ls_remote(&mut command).await
172}
173
174fn parse_ls_remote_output(output: &std::process::Output) -> LsRemoteResult {
175 if !output.status.success() {
176 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
177 return LsRemoteResult {
178 commit: None,
179 error: Some(stderr),
180 };
181 }
182
183 let stdout = String::from_utf8_lossy(&output.stdout);
184 let commit = stdout
185 .lines()
186 .next()
187 .and_then(|line| line.split('\t').next())
188 .unwrap_or_default()
189 .trim()
190 .to_owned();
191
192 LsRemoteResult {
193 commit: Some(commit),
194 error: None,
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::{parse_ls_remote_output, redirect_config};
201
202 fn output(code: i32, stdout: &str, stderr: &str) -> std::process::Output {
203 use std::os::unix::process::ExitStatusExt;
204 std::process::Output {
205 status: std::process::ExitStatus::from_raw(code << 8),
206 stdout: stdout.as_bytes().to_vec(),
207 stderr: stderr.as_bytes().to_vec(),
208 }
209 }
210
211 #[test]
212 fn redirects_are_forbidden_explicitly_and_not_left_to_the_git_default() {
213 assert_eq!(
214 redirect_config(false),
215 ["-c".to_owned(), "http.followRedirects=false".to_owned()]
216 );
217 assert_eq!(
218 redirect_config(true),
219 ["-c".to_owned(), "http.followRedirects=true".to_owned()]
220 );
221 }
222
223 #[test]
224 fn a_branch_missing_from_the_remote_is_an_empty_commit_and_not_a_missing_one() {
225 let result = parse_ls_remote_output(&output(0, "", ""));
226 assert_eq!(result.commit, Some(String::new()));
227 assert!(result.error.is_none());
228 }
229
230 #[test]
231 fn the_first_ref_is_the_commit() {
232 let result = parse_ls_remote_output(&output(
233 0,
234 "abc123\trefs/heads/main\ndef456\trefs/heads/other\n",
235 "",
236 ));
237 assert_eq!(result.commit.as_deref(), Some("abc123"));
238 }
239
240 #[test]
241 fn a_failure_reports_the_stderr_and_no_commit() {
242 let result = parse_ls_remote_output(&output(128, "", "fatal: repository not found"));
243 assert!(result.commit.is_none());
244 assert_eq!(result.error.as_deref(), Some("fatal: repository not found"));
245 }
246}