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::HttpsAccess;
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
23fn redirect_config(follow_redirects: bool) -> [String; 2] {
24 [
25 "-c".to_owned(),
26 format!("http.followRedirects={follow_redirects}"),
27 ]
28}
29
30async fn run_ls_remote(command: &mut Command, secrets: &[&str]) -> Result<LsRemoteResult> {
31 let Ok(output) = tokio::time::timeout(
32 Duration::from_secs(LS_REMOTE_TIMEOUT_SECS),
33 command.kill_on_drop(true).output(),
34 )
35 .await
36 else {
37 return Ok(LsRemoteResult {
38 commit: None,
39 error: Some(LS_REMOTE_TIMEOUT_MESSAGE.to_owned()),
40 });
41 };
42
43 Ok(parse_ls_remote_output(
44 &output.context("running git ls-remote")?,
45 secrets,
46 ))
47}
48
49pub async fn ls_remote(
50 url: &str,
51 branch: &str,
52 credentials: Option<&Credentials>,
53 follow_redirects: bool,
54) -> Result<LsRemoteResult> {
55 match credentials.map(|c| &c.kind) {
56 Some(CredentialKind::Ssh { key }) => ls_remote_ssh(url, branch, key).await,
57 Some(CredentialKind::AwsRole {
58 arn,
59 external_id,
60 via,
61 }) => {
62 let role = AwsRole {
63 arn: arn.clone(),
64 external_id: external_id.clone(),
65 via: via.clone(),
66 };
67 ls_remote_codecommit(url, branch, &role, follow_redirects).await
68 }
69 kind => {
70 let access = HttpsAccess::new(url, kind)?;
71 ls_remote_https(&access, branch, follow_redirects).await
72 }
73 }
74}
75
76async fn ls_remote_ssh(url: &str, branch: &str, key_b64: &str) -> Result<LsRemoteResult> {
77 let key_data = base64::engine::general_purpose::STANDARD
78 .decode(key_b64)
79 .context("decoding SSH key")?;
80
81 let key_file = tempfile::Builder::new()
82 .prefix("melts-ssh-key-")
83 .tempfile()
84 .context("creating SSH key file")?;
85
86 std::fs::write(key_file.path(), &key_data).context("writing SSH key")?;
87
88 #[cfg(unix)]
89 {
90 use std::os::unix::fs::PermissionsExt;
91 std::fs::set_permissions(key_file.path(), std::fs::Permissions::from_mode(0o400))
92 .context("setting SSH key permissions")?;
93 }
94
95 let git_ssh_command = setup_ssh_env(key_file.path());
96 let normalized_url = normalize_ssh_url(url);
97
98 let args = [
99 "ls-remote".to_owned(),
100 "--".to_owned(),
101 normalized_url,
102 branch.to_owned(),
103 ];
104
105 let mut command = Command::new("git");
106 command.args(&args).env("GIT_SSH_COMMAND", &git_ssh_command);
107 run_ls_remote(&mut command, &[]).await
108}
109
110async fn ls_remote_https(
111 access: &HttpsAccess,
112 branch: &str,
113 follow_redirects: bool,
114) -> Result<LsRemoteResult> {
115 let mut args = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
116 args.extend(redirect_config(follow_redirects));
117 args.extend([
118 "ls-remote".to_owned(),
119 "--".to_owned(),
120 access.url.clone(),
121 branch.to_owned(),
122 ]);
123
124 let mut command = access.git();
125 command.args(&args);
126 run_ls_remote(&mut command, access.secret().as_slice()).await
127}
128
129async fn ls_remote_codecommit(
130 url: &str,
131 branch: &str,
132 role: &AwsRole,
133 follow_redirects: bool,
134) -> Result<LsRemoteResult> {
135 let creds = aws::assume_role(&role.arn, &role.external_id, url, role.via.as_deref()).await?;
136
137 let args = codecommit_ls_remote_args(url, branch, follow_redirects);
138
139 let mut command = Command::new("git");
140 command
141 .args(&args)
142 .env("AWS_ACCESS_KEY_ID", &creds.access_key)
143 .env("AWS_SECRET_ACCESS_KEY", &creds.secret_key)
144 .env("AWS_SESSION_TOKEN", &creds.session_token)
145 .env("AWS_DEFAULT_REGION", &creds.region);
146 run_ls_remote(&mut command, &[]).await
147}
148
149fn codecommit_ls_remote_args(url: &str, branch: &str, follow_redirects: bool) -> Vec<String> {
152 let mut args = redirect_config(follow_redirects).to_vec();
153 args.extend([
154 "ls-remote".to_owned(),
155 "--".to_owned(),
156 url.to_owned(),
157 branch.to_owned(),
158 ]);
159 args
160}
161
162fn parse_ls_remote_output(output: &std::process::Output, secrets: &[&str]) -> LsRemoteResult {
163 if !output.status.success() {
164 let stderr = String::from_utf8_lossy(&output.stderr);
165 return LsRemoteResult {
166 commit: None,
167 error: Some(hide(&stderr, secrets)),
168 };
169 }
170
171 let stdout = String::from_utf8_lossy(&output.stdout);
172 let commit = stdout
173 .lines()
174 .next()
175 .and_then(|line| line.split('\t').next())
176 .unwrap_or_default()
177 .trim()
178 .to_owned();
179
180 LsRemoteResult {
181 commit: Some(commit),
182 error: None,
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use std::io::{BufRead, BufReader, Write};
189 use std::net::{TcpListener, TcpStream};
190 use std::sync::{Arc, Mutex};
191
192 use base64::Engine;
193
194 use super::{
195 codecommit_ls_remote_args, ls_remote, parse_ls_remote_output, redirect_config,
196 run_ls_remote,
197 };
198 use crate::clone::https::HttpsAccess;
199 use crate::types::{CredentialKind, Credentials};
200
201 fn read_request(stream: &TcpStream) -> (String, Option<String>) {
203 let mut path = String::new();
204 let mut authorization = None;
205 let mut reader = BufReader::new(stream);
206 let mut line = String::new();
207 while reader.read_line(&mut line).is_ok_and(|read| read > 0) {
208 let header = line.trim_end();
209 if header.is_empty() {
210 break;
211 }
212 if path.is_empty() {
213 header
214 .split(' ')
215 .nth(1)
216 .unwrap_or_default()
217 .clone_into(&mut path);
218 }
219 if let Some(value) = header.strip_prefix("Authorization: ") {
220 authorization = Some(value.to_owned());
221 }
222 line.clear();
223 }
224 (path, authorization)
225 }
226
227 fn answer(redirect_to: Option<&str>, path: &str) -> String {
228 redirect_to.map_or_else(
229 || {
230 "HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"x\"\r\n\
231 Content-Length: 0\r\nConnection: close\r\n\r\n"
232 .to_owned()
233 },
234 |host| {
235 format!(
236 "HTTP/1.1 302 Found\r\nLocation: {host}{path}\r\n\
237 Content-Length: 0\r\nConnection: close\r\n\r\n"
238 )
239 },
240 )
241 }
242
243 fn serve(redirect_to: Option<String>) -> (String, Arc<Mutex<Vec<Option<String>>>>) {
246 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
247 let port = listener.local_addr().expect("addr").port();
248 let seen = Arc::new(Mutex::new(Vec::new()));
249 let record = Arc::clone(&seen);
250 std::thread::spawn(move || {
251 for stream in listener.incoming().flatten() {
252 let (path, authorization) = read_request(&stream);
253 record.lock().expect("lock").push(authorization);
254 let answer = answer(redirect_to.as_deref(), &path);
255 let _ = (&stream).write_all(answer.as_bytes());
256 }
257 });
258 (format!("http://127.0.0.1:{port}"), seen)
259 }
260
261 fn token(token: &str) -> Credentials {
262 Credentials {
263 kind: CredentialKind::Token {
264 token: token.to_owned(),
265 is_pat: false,
266 oauth_type: String::new(),
267 },
268 }
269 }
270
271 fn pat(token: &str) -> Credentials {
272 Credentials {
273 kind: CredentialKind::Token {
274 token: token.to_owned(),
275 is_pat: true,
276 oauth_type: String::new(),
277 },
278 }
279 }
280
281 fn output(code: i32, stdout: &str, stderr: &str) -> std::process::Output {
282 use std::os::unix::process::ExitStatusExt;
283 std::process::Output {
284 status: std::process::ExitStatus::from_raw(code << 8),
285 stdout: stdout.as_bytes().to_vec(),
286 stderr: stderr.as_bytes().to_vec(),
287 }
288 }
289
290 #[test]
291 fn redirects_are_forbidden_explicitly_and_not_left_to_the_git_default() {
292 assert_eq!(
293 redirect_config(false),
294 ["-c".to_owned(), "http.followRedirects=false".to_owned()]
295 );
296 assert_eq!(
297 redirect_config(true),
298 ["-c".to_owned(), "http.followRedirects=true".to_owned()]
299 );
300 }
301
302 #[test]
303 fn a_branch_missing_from_the_remote_is_an_empty_commit_and_not_a_missing_one() {
304 let result = parse_ls_remote_output(&output(0, "", ""), &[]);
305 assert_eq!(result.commit, Some(String::new()));
306 assert!(result.error.is_none());
307 }
308
309 #[test]
310 fn the_first_ref_is_the_commit() {
311 let result = parse_ls_remote_output(
312 &output(0, "abc123\trefs/heads/main\ndef456\trefs/heads/other\n", ""),
313 &[],
314 );
315 assert_eq!(result.commit.as_deref(), Some("abc123"));
316 }
317
318 #[test]
319 fn a_failure_reports_the_stderr_and_no_commit() {
320 let result = parse_ls_remote_output(&output(128, "", "fatal: repository not found"), &[]);
321 assert!(result.commit.is_none());
322 assert_eq!(result.error.as_deref(), Some("fatal: repository not found"));
323 }
324
325 #[tokio::test]
326 async fn a_token_reaches_its_host_and_stays_out_of_the_error() {
327 for (credentials, login) in [
328 (token("Zm9v+YmFy"), "Zm9v+YmFy:"),
329 (pat("Zm9v+YmFy"), "pat:Zm9v+YmFy"),
330 ] {
331 let (host, seen) = serve(None);
332 let result = ls_remote(
333 &format!("{host}/org/repo.git"),
334 "main",
335 Some(&credentials),
336 false,
337 )
338 .await
339 .unwrap();
340
341 let sent = base64::engine::general_purpose::STANDARD.encode(login);
342 assert!(seen
343 .lock()
344 .unwrap()
345 .contains(&Some(format!("Basic {sent}"))));
346 let error = result.error.unwrap();
347 assert!(error.contains("Authentication failed"), "{error}");
348 assert!(!error.contains("Zm9v"), "{error}");
349 }
350 }
351
352 #[tokio::test]
353 async fn a_redirect_to_another_host_carries_no_credential() {
354 for credentials in [token("tok123"), pat("tok123")] {
355 let (other, other_seen) = serve(None);
356 let other = other.replace("127.0.0.1", "localhost");
357 let (host, _) = serve(Some(other));
358 let result = ls_remote(
359 &format!("{host}/org/repo.git"),
360 "main",
361 Some(&credentials),
362 true,
363 )
364 .await
365 .unwrap();
366
367 assert!(result.error.is_some());
368 let other_seen = other_seen.lock().unwrap().clone();
369 assert!(!other_seen.is_empty());
370 assert!(other_seen.iter().all(Option::is_none), "{other_seen:?}");
371 }
372 }
373
374 #[tokio::test]
375 async fn a_helper_the_caller_configured_never_sees_the_credential() {
376 let (host, _) = serve(None);
377 let calls = tempfile::NamedTempFile::new().unwrap();
378 let access =
379 HttpsAccess::new(&format!("{host}/org/repo.git"), Some(&token("tok123").kind)).unwrap();
380 let mut command = access.git();
381 command
382 .env(
383 "GIT_CONFIG_PARAMETERS",
384 format!(
385 "'credential.helper'='!f() {{ echo $1 >> {}; }}; f'",
386 calls.path().display()
387 ),
388 )
389 .args(["ls-remote", "--", &access.url, "main"]);
390 run_ls_remote(&mut command, access.secret().as_slice())
391 .await
392 .unwrap();
393
394 assert_eq!(std::fs::read_to_string(calls.path()).unwrap(), "");
395 }
396
397 #[test]
398 fn a_failure_hides_the_credential_a_server_echoes() {
399 let said = "remote: token tok123 is not valid\nfatal: Authentication failed\n";
400 let result = parse_ls_remote_output(&output(128, "", said), &["tok123"]);
401 assert_eq!(
402 result.error.as_deref(),
403 Some("remote: token [REDACTED] is not valid\nfatal: Authentication failed\n")
404 );
405 }
406
407 #[test]
408 fn a_codecommit_url_lets_git_check_the_certificate() {
409 let args = codecommit_ls_remote_args("codecommit::us-east-1://repo", "main", false);
410 assert!(!args.contains(&"http.sslVerify=false".to_owned()));
411 }
412}