Skip to main content

fluidattacks_core_git/
remote.rs

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
14/// The message `roots/validations.py` matches on to raise `GitTimeoutError`.
15const 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
23/// What reaching a repo over https takes: the url to ask, the config that
24/// carries a credential outside it, and the secrets to keep out of errors.
25struct 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
205/// The `git ls-remote` arguments for a `codecommit::` url. git checks the
206/// certificate, since the helper always reaches AWS's own endpoint.
207fn 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    // with the token as the user there is no password, so git asks for one
334    // and names the user in the prompt
335    #[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}