fluidattacks-core-git 0.41.0

Fluid Attacks Core Git
Documentation
use anyhow::{Context, Result};
use base64::Engine;
use std::path::Path;
use tokio::process::Command;
use url::Url;

use super::{build_clone_args, reason, CloneOpts};
use crate::types::CredentialKind;

const USER_VAR: &str = "FLUIDATTACKS_GIT_USER";
const PASSWORD_VAR: &str = "FLUIDATTACKS_GIT_PASSWORD";

// Answers only `get`, so git has nowhere to store or erase the credential.
fn env_helper() -> String {
    format!(
        "!f() {{ if [ \"$1\" = get ]; then \
         printf 'username=%s\\npassword=%s\\n' \"${USER_VAR}\" \"${PASSWORD_VAR}\"; fi; }}; f"
    )
}

/// How git reaches a repo over https, with the credential kept off its command
/// line and out of the url, so that git has none of it to print back.
///
/// `git` hands the credential to the repo's own host and to no other, and
/// never lets git prompt. `secret` is what is still worth taking out of an
/// error, because a server may echo what it was sent.
pub(crate) struct HttpsAccess {
    pub url: String,
    secret: Option<String>,
    config: Vec<(String, String)>,
    env: Vec<(String, String)>,
}

impl HttpsAccess {
    pub fn new(url: &str, credential: Option<&CredentialKind>) -> Result<Self> {
        match credential {
            None => Ok(Self::public(url)),
            Some(CredentialKind::Https { user, password }) => Self::basic(url, user, password),
            Some(CredentialKind::Token {
                token,
                is_pat,
                oauth_type,
            }) => {
                let (user, password) = token_login(token, oauth_type, *is_pat);
                Ok(Self {
                    secret: Some(token.clone()),
                    ..Self::basic(url, user, password)?
                })
            }
            Some(CredentialKind::Ssh { .. } | CredentialKind::AwsRole { .. }) => {
                anyhow::bail!("expected an https credential")
            }
        }
    }

    fn public(url: &str) -> Self {
        Self {
            url: url.to_owned(),
            secret: None,
            config: Vec::new(),
            env: Vec::new(),
        }
    }

    fn basic(raw_url: &str, user: &str, password: &str) -> Result<Self> {
        if [user, password]
            .iter()
            .any(|value| value.contains(['\n', '\r', '\0']))
        {
            anyhow::bail!("Invalid credentials: a line break or a nul byte");
        }
        let url = bare_url(raw_url)?;
        Ok(Self {
            secret: Some(password.to_owned()),
            config: vec![(
                format!("credential.{}.helper", url.origin().ascii_serialization()),
                env_helper(),
            )],
            env: vec![
                (USER_VAR.to_owned(), user.to_owned()),
                (PASSWORD_VAR.to_owned(), password.to_owned()),
            ],
            url: url.to_string(),
        })
    }

    /// A git command for this repo, still waiting for its subcommand.
    pub fn git(&self) -> Command {
        let mut command = Command::new("git");
        // The empty helper drops whatever helpers the machine has configured,
        // so the credential is neither looked up in nor saved to them. Going
        // on the command line, it comes after any config git inherits.
        command.args(["-c", "credential.helper="]);
        for (key, value) in &self.config {
            command.arg("-c").arg(format!("{key}={value}"));
        }
        command
            .env("GIT_TERMINAL_PROMPT", "0")
            // Unset, git keeps credentials out of its own traces.
            .env_remove("GIT_TRACE_REDACT")
            // Its data dump prints what a server sends back, the Basic header included.
            .env_remove("GIT_TRACE_CURL")
            .envs(self.env.iter().cloned());
        command
    }

    pub fn secret(&self) -> Option<&str> {
        self.secret.as_deref()
    }
}

fn bare_url(raw_url: &str) -> Result<Url> {
    let mut url = Url::parse(raw_url).context("parsing URL")?;
    if !matches!(url.scheme(), "http" | "https") {
        anyhow::bail!("expected an http or https url");
    }
    url.set_username("")
        .map_err(|()| anyhow::anyhow!("failed to drop the user"))?;
    url.set_password(None)
        .map_err(|()| anyhow::anyhow!("failed to drop the password"))?;
    Ok(url)
}

// A token with no provider is the user itself, with no password. A PAT takes
// any user but an empty one, since git sends no credential without a user.
const fn token_login<'a>(token: &'a str, provider: &str, is_pat: bool) -> (&'a str, &'a str) {
    if is_pat {
        ("pat", token)
    } else if provider.eq_ignore_ascii_case("BITBUCKET") {
        ("x-token-auth", token)
    } else if provider.is_empty() {
        (token, "")
    } else {
        ("oauth2", token)
    }
}

pub async fn clone_https(opts: &CloneOpts, dest: &Path) -> Result<()> {
    let kind = &opts
        .credentials
        .as_ref()
        .context("missing credentials")?
        .kind;
    if !matches!(kind, CredentialKind::Https { .. }) {
        anyhow::bail!("expected HTTPS credentials");
    }
    run_git_clone(opts, dest, &HttpsAccess::new(&opts.repo_url, Some(kind))?).await
}

pub async fn clone_https_token(opts: &CloneOpts, dest: &Path) -> Result<()> {
    let kind = &opts
        .credentials
        .as_ref()
        .context("missing credentials")?
        .kind;
    if !matches!(kind, CredentialKind::Token { .. }) {
        anyhow::bail!("expected Token credentials");
    }
    run_git_clone(opts, dest, &HttpsAccess::new(&opts.repo_url, Some(kind))?).await
}

pub async fn clone_https_public(opts: &CloneOpts, dest: &Path) -> Result<()> {
    run_git_clone(opts, dest, &HttpsAccess::public(&opts.repo_url)).await
}

/// Build git config args for HTTPS operations (equivalent to Python's `get_https_git_config_args`).
#[must_use]
pub fn build_https_config_args(
    follow_redirects: bool,
    is_pat: bool,
    token: Option<&str>,
    disable_credential_prompt: bool,
) -> Vec<String> {
    let mut args = vec![
        "-c".to_owned(),
        "http.sslVerify=false".to_owned(),
        "-c".to_owned(),
        format!("http.followRedirects={follow_redirects}"),
    ];
    if is_pat {
        if let Some(t) = token {
            let encoded = base64::engine::general_purpose::STANDARD.encode(format!(":{t}"));
            args.extend([
                "-c".to_owned(),
                format!("http.extraHeader=Authorization: Basic {encoded}"),
            ]);
        }
    }
    if disable_credential_prompt {
        args.extend(["-c".to_owned(), "credential.helper=".to_owned()]);
    }
    args
}

/// Format URL with credentials embedded (equivalent to Python's `format_url`).
#[allow(clippy::too_many_arguments)]
pub fn format_https_url(
    repo_url: &str,
    user: Option<&str>,
    password: Option<&str>,
    token: Option<&str>,
    provider: Option<&str>,
    is_pat: bool,
) -> Result<String> {
    let mut parsed = Url::parse(repo_url).context("parsing URL")?;
    // Re-quote the path
    let path = parsed.path().to_owned();
    parsed.set_path(&path);

    if is_pat {
        return Ok(parsed.to_string());
    }

    if let Some(t) = token {
        let user_part = match provider {
            Some(p) if p.eq_ignore_ascii_case("BITBUCKET") => "x-token-auth",
            Some("") | None => "",
            Some(_) => "oauth2",
        };
        if user_part.is_empty() {
            // No provider: use token as username, no password
            parsed
                .set_username(t)
                .map_err(|()| anyhow::anyhow!("set username"))?;
            parsed
                .set_password(None)
                .map_err(|()| anyhow::anyhow!("set password"))?;
        } else {
            // Use username/password separately to avoid percent-encoding the colon
            parsed
                .set_username(user_part)
                .map_err(|()| anyhow::anyhow!("set username"))?;
            parsed
                .set_password(Some(t))
                .map_err(|()| anyhow::anyhow!("set password"))?;
        }
        return Ok(parsed.to_string());
    }

    if let (Some(u), Some(p)) = (user, password) {
        parsed
            .set_username(u)
            .map_err(|()| anyhow::anyhow!("set username"))?;
        parsed
            .set_password(Some(p))
            .map_err(|()| anyhow::anyhow!("set password"))?;
        return Ok(parsed.to_string());
    }

    Ok(parsed.to_string())
}

async fn run_git_clone(opts: &CloneOpts, dest: &Path, access: &HttpsAccess) -> Result<()> {
    let extra_config = vec!["-c".to_owned(), "http.sslVerify=false".to_owned()];
    let args = build_clone_args(opts, &access.url, dest, &extra_config);
    let mut command = access.git();
    command.args(&args);
    reason::run(&mut command, "HTTPS", access.secret().as_slice()).await
}

#[cfg(test)]
mod tests {
    use super::{format_https_url, token_login, HttpsAccess};
    use crate::types::CredentialKind;

    fn token(token: &str, provider: &str) -> CredentialKind {
        CredentialKind::Token {
            token: token.to_owned(),
            is_pat: false,
            oauth_type: provider.to_owned(),
        }
    }

    #[test]
    fn a_token_with_no_provider_logs_in_as_the_user_with_no_password() {
        assert_eq!(token_login("tok", "", false), ("tok", ""));
    }

    #[test]
    fn the_provider_names_the_user_even_off_its_own_domain() {
        assert_eq!(
            token_login("tok", "BITBUCKET", false),
            ("x-token-auth", "tok")
        );
        assert_eq!(token_login("tok", "GITLAB", false), ("oauth2", "tok"));
    }

    #[test]
    fn the_provider_is_matched_ignoring_the_case() {
        let url = "https://host/a/b.git";

        assert_eq!(
            format_https_url(url, None, None, Some("tok"), Some("Bitbucket"), false).unwrap(),
            format_https_url(url, None, None, Some("tok"), Some("BITBUCKET"), false).unwrap()
        );
        assert_eq!(
            token_login("tok", "Bitbucket", false),
            token_login("tok", "BITBUCKET", false)
        );
    }

    #[test]
    fn the_url_drops_whatever_credential_it_carried() {
        let access = HttpsAccess::new(
            "https://olduser:oldpass@host/a/b.git",
            Some(&token("tok", "")),
        )
        .unwrap();
        assert_eq!(access.url, "https://host/a/b.git");
    }

    #[test]
    fn a_pat_logs_in_as_a_placeholder_user_whatever_the_provider() {
        assert_eq!(token_login("tok", "", true), ("pat", "tok"));
        assert_eq!(token_login("tok", "BITBUCKET", true), ("pat", "tok"));
    }

    #[test]
    fn a_curl_trace_the_caller_set_is_dropped() {
        let access = HttpsAccess::new("https://host/a/b.git", Some(&token("tok", ""))).unwrap();
        assert!(access
            .git()
            .as_std()
            .get_envs()
            .any(|(name, value)| name == "GIT_TRACE_CURL" && value.is_none()));
    }

    #[test]
    fn a_credential_with_a_line_break_is_refused() {
        for tok in ["tok\nhost=x", "tok\rhost=x"] {
            assert!(HttpsAccess::new("https://host/a/b.git", Some(&token(tok, ""))).is_err());
        }
    }

    #[test]
    fn an_empty_provider_formats_the_url_like_no_provider_at_all() {
        let url = "https://gitlab.com/a/b.git";
        let no_provider = format_https_url(url, None, None, Some("tok"), None, false).unwrap();
        assert_eq!(no_provider, "https://tok@gitlab.com/a/b.git");
        assert_eq!(
            format_https_url(url, None, None, Some("tok"), Some(""), false).unwrap(),
            no_provider
        );
    }
}