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::reason::hide;
10use crate::clone::ssh::{normalize_ssh_url, setup_ssh_env};
11
12const LS_REMOTE_TIMEOUT_SECS: u64 = 20;
13
14const LS_REMOTE_TIMEOUT_MESSAGE: &str = "git ls-remote time out";
16
17struct AwsRole {
18 arn: String,
19 external_id: String,
20 via: Option<String>,
21}
22
23struct HttpsAccess {
26 url: String,
27 extra_config: Vec<String>,
28 secrets: Vec<String>,
29}
30
31fn redirect_config(follow_redirects: bool) -> [String; 2] {
32 [
33 "-c".to_owned(),
34 format!("http.followRedirects={follow_redirects}"),
35 ]
36}
37
38async fn run_ls_remote(command: &mut Command, secrets: &[&str]) -> Result<LsRemoteResult> {
39 let Ok(output) = tokio::time::timeout(
40 Duration::from_secs(LS_REMOTE_TIMEOUT_SECS),
41 command.kill_on_drop(true).output(),
42 )
43 .await
44 else {
45 return Ok(LsRemoteResult {
46 commit: None,
47 error: Some(LS_REMOTE_TIMEOUT_MESSAGE.to_owned()),
48 });
49 };
50
51 Ok(parse_ls_remote_output(
52 &output.context("running git ls-remote")?,
53 secrets,
54 ))
55}
56
57pub async fn ls_remote(
58 url: &str,
59 branch: &str,
60 credentials: Option<&Credentials>,
61 follow_redirects: bool,
62) -> Result<LsRemoteResult> {
63 match credentials.map(|c| &c.kind) {
64 Some(CredentialKind::Ssh { key }) => ls_remote_ssh(url, branch, key).await,
65 Some(CredentialKind::AwsRole {
66 arn,
67 external_id,
68 via,
69 }) => {
70 let role = AwsRole {
71 arn: arn.clone(),
72 external_id: external_id.clone(),
73 via: via.clone(),
74 };
75 ls_remote_codecommit(url, branch, &role, follow_redirects).await
76 }
77 kind => {
78 let access = https_access(url, kind)?;
79 ls_remote_https(&access, branch, follow_redirects).await
80 }
81 }
82}
83
84fn https_access(url: &str, kind: Option<&CredentialKind>) -> Result<HttpsAccess> {
85 match kind {
86 None => Ok(HttpsAccess {
87 url: url.to_owned(),
88 extra_config: Vec::new(),
89 secrets: Vec::new(),
90 }),
91 Some(CredentialKind::Https { user, password }) => {
92 let auth = embed_credentials_in_url(url, user, password)?;
93 Ok(HttpsAccess {
94 url: auth.url,
95 extra_config: Vec::new(),
96 secrets: auth.secrets,
97 })
98 }
99 Some(CredentialKind::Token {
100 token,
101 is_pat: true,
102 ..
103 }) => {
104 let encoded = base64::engine::general_purpose::STANDARD.encode(format!(":{token}"));
105 Ok(HttpsAccess {
106 url: url.to_owned(),
107 extra_config: vec![
108 "-c".to_owned(),
109 format!("http.extraHeader=Authorization: Basic {encoded}"),
110 ],
111 secrets: vec![token.clone(), encoded],
112 })
113 }
114 Some(CredentialKind::Token {
115 token, oauth_type, ..
116 }) => {
117 let auth = embed_token_in_url(url, token, oauth_type)?;
118 Ok(HttpsAccess {
119 url: auth.url,
120 extra_config: Vec::new(),
121 secrets: auth.secrets,
122 })
123 }
124 Some(CredentialKind::Ssh { .. } | CredentialKind::AwsRole { .. }) => {
125 anyhow::bail!("expected an https credential")
126 }
127 }
128}
129
130async fn ls_remote_ssh(url: &str, branch: &str, key_b64: &str) -> Result<LsRemoteResult> {
131 let key_data = base64::engine::general_purpose::STANDARD
132 .decode(key_b64)
133 .context("decoding SSH key")?;
134
135 let key_file = tempfile::Builder::new()
136 .prefix("melts-ssh-key-")
137 .tempfile()
138 .context("creating SSH key file")?;
139
140 std::fs::write(key_file.path(), &key_data).context("writing SSH key")?;
141
142 #[cfg(unix)]
143 {
144 use std::os::unix::fs::PermissionsExt;
145 std::fs::set_permissions(key_file.path(), std::fs::Permissions::from_mode(0o400))
146 .context("setting SSH key permissions")?;
147 }
148
149 let git_ssh_command = setup_ssh_env(key_file.path());
150 let normalized_url = normalize_ssh_url(url);
151
152 let args = [
153 "ls-remote".to_owned(),
154 "--".to_owned(),
155 normalized_url,
156 branch.to_owned(),
157 ];
158
159 let mut command = Command::new("git");
160 command.args(&args).env("GIT_SSH_COMMAND", &git_ssh_command);
161 run_ls_remote(&mut command, &[]).await
162}
163
164async fn ls_remote_https(
165 access: &HttpsAccess,
166 branch: &str,
167 follow_redirects: bool,
168) -> Result<LsRemoteResult> {
169 let mut args = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
170 args.extend(redirect_config(follow_redirects));
171 args.extend(access.extra_config.iter().cloned());
172 args.extend([
173 "ls-remote".to_owned(),
174 "--".to_owned(),
175 access.url.clone(),
176 branch.to_owned(),
177 ]);
178
179 let secrets: Vec<&str> = access.secrets.iter().map(String::as_str).collect();
180 let mut command = Command::new("git");
181 command.args(&args);
182 run_ls_remote(&mut command, &secrets).await
183}
184
185async fn ls_remote_codecommit(
186 url: &str,
187 branch: &str,
188 role: &AwsRole,
189 follow_redirects: bool,
190) -> Result<LsRemoteResult> {
191 let creds = aws::assume_role(&role.arn, &role.external_id, url, role.via.as_deref()).await?;
192
193 let args = codecommit_ls_remote_args(url, branch, follow_redirects);
194
195 let mut command = Command::new("git");
196 command
197 .args(&args)
198 .env("AWS_ACCESS_KEY_ID", &creds.access_key)
199 .env("AWS_SECRET_ACCESS_KEY", &creds.secret_key)
200 .env("AWS_SESSION_TOKEN", &creds.session_token)
201 .env("AWS_DEFAULT_REGION", &creds.region);
202 run_ls_remote(&mut command, &[]).await
203}
204
205fn codecommit_ls_remote_args(url: &str, branch: &str, follow_redirects: bool) -> Vec<String> {
208 let mut args = redirect_config(follow_redirects).to_vec();
209 args.extend([
210 "ls-remote".to_owned(),
211 "--".to_owned(),
212 url.to_owned(),
213 branch.to_owned(),
214 ]);
215 args
216}
217
218fn parse_ls_remote_output(output: &std::process::Output, secrets: &[&str]) -> LsRemoteResult {
219 if !output.status.success() {
220 let stderr = String::from_utf8_lossy(&output.stderr);
221 return LsRemoteResult {
222 commit: None,
223 error: Some(hide(&stderr, secrets)),
224 };
225 }
226
227 let stdout = String::from_utf8_lossy(&output.stdout);
228 let commit = stdout
229 .lines()
230 .next()
231 .and_then(|line| line.split('\t').next())
232 .unwrap_or_default()
233 .trim()
234 .to_owned();
235
236 LsRemoteResult {
237 commit: Some(commit),
238 error: None,
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::{codecommit_ls_remote_args, https_access, parse_ls_remote_output, redirect_config};
245 use crate::types::CredentialKind;
246
247 fn output(code: i32, stdout: &str, stderr: &str) -> std::process::Output {
248 use std::os::unix::process::ExitStatusExt;
249 std::process::Output {
250 status: std::process::ExitStatus::from_raw(code << 8),
251 stdout: stdout.as_bytes().to_vec(),
252 stderr: stderr.as_bytes().to_vec(),
253 }
254 }
255
256 #[test]
257 fn redirects_are_forbidden_explicitly_and_not_left_to_the_git_default() {
258 assert_eq!(
259 redirect_config(false),
260 ["-c".to_owned(), "http.followRedirects=false".to_owned()]
261 );
262 assert_eq!(
263 redirect_config(true),
264 ["-c".to_owned(), "http.followRedirects=true".to_owned()]
265 );
266 }
267
268 #[test]
269 fn a_branch_missing_from_the_remote_is_an_empty_commit_and_not_a_missing_one() {
270 let result = parse_ls_remote_output(&output(0, "", ""), &[]);
271 assert_eq!(result.commit, Some(String::new()));
272 assert!(result.error.is_none());
273 }
274
275 #[test]
276 fn the_first_ref_is_the_commit() {
277 let result = parse_ls_remote_output(
278 &output(0, "abc123\trefs/heads/main\ndef456\trefs/heads/other\n", ""),
279 &[],
280 );
281 assert_eq!(result.commit.as_deref(), Some("abc123"));
282 }
283
284 #[test]
285 fn a_failure_reports_the_stderr_and_no_commit() {
286 let result = parse_ls_remote_output(&output(128, "", "fatal: repository not found"), &[]);
287 assert!(result.commit.is_none());
288 assert_eq!(result.error.as_deref(), Some("fatal: repository not found"));
289 }
290
291 #[test]
292 fn a_failure_hides_the_credential_as_the_url_encoded_it() {
293 let kind = CredentialKind::Token {
294 token: "ab=c/d".to_owned(),
295 is_pat: false,
296 oauth_type: String::new(),
297 };
298 let access = https_access("https://github.com/org/repo.git", Some(&kind)).unwrap();
299 let secrets: Vec<&str> = access.secrets.iter().map(String::as_str).collect();
300 let said = "fatal: could not read Password for 'https://ab%3Dc%2Fd@github.com': \
301 terminal prompts disabled\n";
302 let result = parse_ls_remote_output(&output(128, "", said), &secrets);
303 assert_eq!(
304 result.error.as_deref(),
305 Some(
306 "fatal: could not read Password for 'https://[REDACTED]@github.com': \
307 terminal prompts disabled\n"
308 )
309 );
310 }
311
312 #[test]
313 fn a_failure_hides_the_credential_as_git_encoded_it() {
314 let kind = CredentialKind::Token {
315 token: "Zm9v+YmFy".to_owned(),
316 is_pat: false,
317 oauth_type: String::new(),
318 };
319 let access = https_access("https://github.com/org/repo.git", Some(&kind)).unwrap();
320 let secrets: Vec<&str> = access.secrets.iter().map(String::as_str).collect();
321 let said = "fatal: could not read Password for 'https://Zm9v%2BYmFy@github.com': \
322 terminal prompts disabled\n";
323 let result = parse_ls_remote_output(&output(128, "", said), &secrets);
324 assert_eq!(
325 result.error.as_deref(),
326 Some(
327 "fatal: could not read Password for 'https://[REDACTED]@github.com': \
328 terminal prompts disabled\n"
329 )
330 );
331 }
332
333 #[test]
336 fn a_failure_hides_the_credential_git_repeats() {
337 let said = "fatal: could not read Password for 'https://tok123@github.com': \
338 terminal prompts disabled\n";
339 let result = parse_ls_remote_output(&output(128, "", said), &["tok123"]);
340 assert_eq!(
341 result.error.as_deref(),
342 Some(
343 "fatal: could not read Password for 'https://[REDACTED]@github.com': \
344 terminal prompts disabled\n"
345 )
346 );
347 }
348
349 #[test]
350 fn a_codecommit_url_lets_git_check_the_certificate() {
351 let args = codecommit_ls_remote_args("codecommit::us-east-1://repo", "main", false);
352 assert!(!args.contains(&"http.sslVerify=false".to_owned()));
353 }
354}