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 mut args = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
194 args.extend(redirect_config(follow_redirects));
195 args.extend([
196 "ls-remote".to_owned(),
197 "--".to_owned(),
198 url.to_owned(),
199 branch.to_owned(),
200 ]);
201
202 let mut command = Command::new("git");
203 command
204 .args(&args)
205 .env("AWS_ACCESS_KEY_ID", &creds.access_key)
206 .env("AWS_SECRET_ACCESS_KEY", &creds.secret_key)
207 .env("AWS_SESSION_TOKEN", &creds.session_token)
208 .env("AWS_DEFAULT_REGION", &creds.region);
209 run_ls_remote(&mut command, &[]).await
210}
211
212fn parse_ls_remote_output(output: &std::process::Output, secrets: &[&str]) -> LsRemoteResult {
213 if !output.status.success() {
214 let stderr = String::from_utf8_lossy(&output.stderr);
215 return LsRemoteResult {
216 commit: None,
217 error: Some(hide(&stderr, secrets)),
218 };
219 }
220
221 let stdout = String::from_utf8_lossy(&output.stdout);
222 let commit = stdout
223 .lines()
224 .next()
225 .and_then(|line| line.split('\t').next())
226 .unwrap_or_default()
227 .trim()
228 .to_owned();
229
230 LsRemoteResult {
231 commit: Some(commit),
232 error: None,
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::{https_access, parse_ls_remote_output, redirect_config};
239 use crate::types::CredentialKind;
240
241 fn output(code: i32, stdout: &str, stderr: &str) -> std::process::Output {
242 use std::os::unix::process::ExitStatusExt;
243 std::process::Output {
244 status: std::process::ExitStatus::from_raw(code << 8),
245 stdout: stdout.as_bytes().to_vec(),
246 stderr: stderr.as_bytes().to_vec(),
247 }
248 }
249
250 #[test]
251 fn redirects_are_forbidden_explicitly_and_not_left_to_the_git_default() {
252 assert_eq!(
253 redirect_config(false),
254 ["-c".to_owned(), "http.followRedirects=false".to_owned()]
255 );
256 assert_eq!(
257 redirect_config(true),
258 ["-c".to_owned(), "http.followRedirects=true".to_owned()]
259 );
260 }
261
262 #[test]
263 fn a_branch_missing_from_the_remote_is_an_empty_commit_and_not_a_missing_one() {
264 let result = parse_ls_remote_output(&output(0, "", ""), &[]);
265 assert_eq!(result.commit, Some(String::new()));
266 assert!(result.error.is_none());
267 }
268
269 #[test]
270 fn the_first_ref_is_the_commit() {
271 let result = parse_ls_remote_output(
272 &output(0, "abc123\trefs/heads/main\ndef456\trefs/heads/other\n", ""),
273 &[],
274 );
275 assert_eq!(result.commit.as_deref(), Some("abc123"));
276 }
277
278 #[test]
279 fn a_failure_reports_the_stderr_and_no_commit() {
280 let result = parse_ls_remote_output(&output(128, "", "fatal: repository not found"), &[]);
281 assert!(result.commit.is_none());
282 assert_eq!(result.error.as_deref(), Some("fatal: repository not found"));
283 }
284
285 #[test]
286 fn a_failure_hides_the_credential_as_the_url_encoded_it() {
287 let kind = CredentialKind::Token {
288 token: "ab=c/d".to_owned(),
289 is_pat: false,
290 oauth_type: String::new(),
291 };
292 let access = https_access("https://github.com/org/repo.git", Some(&kind)).unwrap();
293 let secrets: Vec<&str> = access.secrets.iter().map(String::as_str).collect();
294 let said = "fatal: could not read Password for 'https://ab%3Dc%2Fd@github.com': \
295 terminal prompts disabled\n";
296 let result = parse_ls_remote_output(&output(128, "", said), &secrets);
297 assert_eq!(
298 result.error.as_deref(),
299 Some(
300 "fatal: could not read Password for 'https://[REDACTED]@github.com': \
301 terminal prompts disabled\n"
302 )
303 );
304 }
305
306 #[test]
307 fn a_failure_hides_the_credential_as_git_encoded_it() {
308 let kind = CredentialKind::Token {
309 token: "Zm9v+YmFy".to_owned(),
310 is_pat: false,
311 oauth_type: String::new(),
312 };
313 let access = https_access("https://github.com/org/repo.git", Some(&kind)).unwrap();
314 let secrets: Vec<&str> = access.secrets.iter().map(String::as_str).collect();
315 let said = "fatal: could not read Password for 'https://Zm9v%2BYmFy@github.com': \
316 terminal prompts disabled\n";
317 let result = parse_ls_remote_output(&output(128, "", said), &secrets);
318 assert_eq!(
319 result.error.as_deref(),
320 Some(
321 "fatal: could not read Password for 'https://[REDACTED]@github.com': \
322 terminal prompts disabled\n"
323 )
324 );
325 }
326
327 #[test]
330 fn a_failure_hides_the_credential_git_repeats() {
331 let said = "fatal: could not read Password for 'https://tok123@github.com': \
332 terminal prompts disabled\n";
333 let result = parse_ls_remote_output(&output(128, "", said), &["tok123"]);
334 assert_eq!(
335 result.error.as_deref(),
336 Some(
337 "fatal: could not read Password for 'https://[REDACTED]@github.com': \
338 terminal prompts disabled\n"
339 )
340 );
341 }
342}