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::ssh::{normalize_ssh_url, setup_ssh_env};
10
11const LS_REMOTE_TIMEOUT_SECS: u64 = 20;
12
13/// The message `roots/validations.py` matches on to raise `GitTimeoutError`.
14const 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}