fluidattacks-core-git 0.33.0

Fluid Attacks Core Git
Documentation
use anyhow::{Context, Result};
use base64::Engine;
use std::time::Duration;
use tokio::process::Command;

use super::aws;
use super::types::{CredentialKind, Credentials, LsRemoteResult};
use crate::clone::https::{embed_credentials_in_url, embed_token_in_url};
use crate::clone::ssh::{normalize_ssh_url, setup_ssh_env};

const LS_REMOTE_TIMEOUT_SECS: u64 = 20;

/// The message `roots/validations.py` matches on to raise `GitTimeoutError`.
const LS_REMOTE_TIMEOUT_MESSAGE: &str = "git ls-remote time out";

struct AwsRole<'a> {
    arn: &'a str,
    external_id: &'a str,
    via: Option<&'a str>,
}

fn redirect_config(follow_redirects: bool) -> [String; 2] {
    [
        "-c".to_owned(),
        format!("http.followRedirects={follow_redirects}"),
    ]
}

async fn run_ls_remote(command: &mut Command) -> Result<LsRemoteResult> {
    let Ok(output) = tokio::time::timeout(
        Duration::from_secs(LS_REMOTE_TIMEOUT_SECS),
        command.kill_on_drop(true).output(),
    )
    .await
    else {
        return Ok(LsRemoteResult {
            commit: None,
            error: Some(LS_REMOTE_TIMEOUT_MESSAGE.to_owned()),
        });
    };

    Ok(parse_ls_remote_output(
        &output.context("running git ls-remote")?,
    ))
}

pub async fn ls_remote(
    url: &str,
    branch: &str,
    credentials: Option<&Credentials>,
    follow_redirects: bool,
) -> Result<LsRemoteResult> {
    match credentials.map(|c| &c.kind) {
        Some(CredentialKind::Ssh { key }) => ls_remote_ssh(url, branch, key).await,
        Some(CredentialKind::AwsRole {
            arn,
            external_id,
            via,
        }) => {
            let role = AwsRole {
                arn,
                external_id,
                via: via.as_deref(),
            };
            ls_remote_codecommit(url, branch, &role, follow_redirects).await
        }
        Some(CredentialKind::Https { user, password }) => {
            let repo_url = embed_credentials_in_url(url, user, password)?;
            ls_remote_https(&repo_url, branch, follow_redirects, &[]).await
        }
        Some(CredentialKind::Token {
            token,
            is_pat,
            oauth_type,
        }) => {
            if *is_pat {
                let encoded = base64::engine::general_purpose::STANDARD.encode(format!(":{token}"));
                let extra_config = vec![
                    "-c".to_owned(),
                    format!("http.extraHeader=Authorization: Basic {encoded}"),
                ];
                ls_remote_https(url, branch, follow_redirects, &extra_config).await
            } else {
                let repo_url = embed_token_in_url(url, token, oauth_type)?;
                ls_remote_https(&repo_url, branch, follow_redirects, &[]).await
            }
        }
        None => ls_remote_https(url, branch, follow_redirects, &[]).await,
    }
}

async fn ls_remote_ssh(url: &str, branch: &str, key_b64: &str) -> Result<LsRemoteResult> {
    let key_data = base64::engine::general_purpose::STANDARD
        .decode(key_b64)
        .context("decoding SSH key")?;

    let key_file = tempfile::Builder::new()
        .prefix("melts-ssh-key-")
        .tempfile()
        .context("creating SSH key file")?;

    std::fs::write(key_file.path(), &key_data).context("writing SSH key")?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(key_file.path(), std::fs::Permissions::from_mode(0o400))
            .context("setting SSH key permissions")?;
    }

    let git_ssh_command = setup_ssh_env(key_file.path());
    let normalized_url = normalize_ssh_url(url);

    let args = [
        "ls-remote".to_owned(),
        "--".to_owned(),
        normalized_url,
        branch.to_owned(),
    ];

    let mut command = Command::new("git");
    command.args(&args).env("GIT_SSH_COMMAND", &git_ssh_command);
    run_ls_remote(&mut command).await
}

async fn ls_remote_https(
    url: &str,
    branch: &str,
    follow_redirects: bool,
    extra_config: &[String],
) -> Result<LsRemoteResult> {
    let mut args = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
    args.extend(redirect_config(follow_redirects));
    args.extend(extra_config.iter().cloned());
    args.extend([
        "ls-remote".to_owned(),
        "--".to_owned(),
        url.to_owned(),
        branch.to_owned(),
    ]);

    let mut command = Command::new("git");
    command.args(&args);
    run_ls_remote(&mut command).await
}

async fn ls_remote_codecommit(
    url: &str,
    branch: &str,
    role: &AwsRole<'_>,
    follow_redirects: bool,
) -> Result<LsRemoteResult> {
    let creds = aws::assume_role(role.arn, role.external_id, url, role.via).await?;

    let mut args = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
    args.extend(redirect_config(follow_redirects));
    args.extend([
        "ls-remote".to_owned(),
        "--".to_owned(),
        url.to_owned(),
        branch.to_owned(),
    ]);

    let mut command = Command::new("git");
    command
        .args(&args)
        .env("AWS_ACCESS_KEY_ID", &creds.access_key)
        .env("AWS_SECRET_ACCESS_KEY", &creds.secret_key)
        .env("AWS_SESSION_TOKEN", &creds.session_token)
        .env("AWS_DEFAULT_REGION", &creds.region);
    run_ls_remote(&mut command).await
}

fn parse_ls_remote_output(output: &std::process::Output) -> LsRemoteResult {
    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
        return LsRemoteResult {
            commit: None,
            error: Some(stderr),
        };
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let commit = stdout
        .lines()
        .next()
        .and_then(|line| line.split('\t').next())
        .unwrap_or_default()
        .trim()
        .to_owned();

    LsRemoteResult {
        commit: Some(commit),
        error: None,
    }
}

#[cfg(test)]
mod tests {
    use super::{parse_ls_remote_output, redirect_config};

    fn output(code: i32, stdout: &str, stderr: &str) -> std::process::Output {
        use std::os::unix::process::ExitStatusExt;
        std::process::Output {
            status: std::process::ExitStatus::from_raw(code << 8),
            stdout: stdout.as_bytes().to_vec(),
            stderr: stderr.as_bytes().to_vec(),
        }
    }

    #[test]
    fn redirects_are_forbidden_explicitly_and_not_left_to_the_git_default() {
        assert_eq!(
            redirect_config(false),
            ["-c".to_owned(), "http.followRedirects=false".to_owned()]
        );
        assert_eq!(
            redirect_config(true),
            ["-c".to_owned(), "http.followRedirects=true".to_owned()]
        );
    }

    #[test]
    fn a_branch_missing_from_the_remote_is_an_empty_commit_and_not_a_missing_one() {
        let result = parse_ls_remote_output(&output(0, "", ""));
        assert_eq!(result.commit, Some(String::new()));
        assert!(result.error.is_none());
    }

    #[test]
    fn the_first_ref_is_the_commit() {
        let result = parse_ls_remote_output(&output(
            0,
            "abc123\trefs/heads/main\ndef456\trefs/heads/other\n",
            "",
        ));
        assert_eq!(result.commit.as_deref(), Some("abc123"));
    }

    #[test]
    fn a_failure_reports_the_stderr_and_no_commit() {
        let result = parse_ls_remote_output(&output(128, "", "fatal: repository not found"));
        assert!(result.commit.is_none());
        assert_eq!(result.error.as_deref(), Some("fatal: repository not found"));
    }
}