fluidattacks-core 0.1.5

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

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

pub async fn clone_https(opts: &CloneOpts, dest: &Path) -> Result<()> {
    let CredentialKind::Https { user, password } = &opts
        .credentials
        .as_ref()
        .context("missing credentials")?
        .kind
    else {
        anyhow::bail!("expected HTTPS credentials");
    };
    let repo_url = embed_credentials_in_url(&opts.repo_url, user, password)?;

    let extra_config = vec!["-c".to_string(), "http.sslVerify=false".to_string()];
    let args = build_clone_args(opts, &repo_url, dest, &extra_config);
    run_git_clone(&args).await
}

pub async fn clone_https_token(opts: &CloneOpts, dest: &Path) -> Result<()> {
    let CredentialKind::Token {
        token,
        is_pat,
        oauth_type: _,
    } = &opts
        .credentials
        .as_ref()
        .context("missing credentials")?
        .kind
    else {
        anyhow::bail!("expected Token credentials");
    };

    if *is_pat {
        let encoded = base64::engine::general_purpose::STANDARD.encode(format!(":{}", token));
        let extra_config = vec![
            "-c".to_string(),
            "http.sslVerify=false".to_string(),
            "-c".to_string(),
            format!("http.extraHeader=Authorization: Basic {encoded}"),
        ];
        let args = build_clone_args(opts, &opts.repo_url, dest, &extra_config);
        return run_git_clone(&args).await;
    }

    let user = if opts.repo_url.to_lowercase().contains("bitbucket") {
        "x-token-auth"
    } else {
        "oauth2"
    };

    let repo_url = embed_credentials_in_url(&opts.repo_url, user, token)?;
    let extra_config = vec!["-c".to_string(), "http.sslVerify=false".to_string()];
    let args = build_clone_args(opts, &repo_url, dest, &extra_config);
    run_git_clone(&args).await
}

pub async fn clone_https_public(opts: &CloneOpts, dest: &Path) -> Result<()> {
    let extra_config = vec!["-c".to_string(), "http.sslVerify=false".to_string()];
    let args = build_clone_args(opts, &opts.repo_url, dest, &extra_config);
    run_git_clone(&args).await
}

pub fn embed_credentials_in_url(raw_url: &str, user: &str, password: &str) -> Result<String> {
    let mut url = Url::parse(raw_url).context("parsing URL")?;
    url.set_username(user)
        .map_err(|_| anyhow::anyhow!("failed to set username"))?;
    url.set_password(Some(password))
        .map_err(|_| anyhow::anyhow!("failed to set password"))?;
    Ok(url.to_string())
}

/// Build git config args for HTTPS operations (equivalent to Python's get_https_git_config_args).
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_string(),
        "http.sslVerify=false".to_string(),
        "-c".to_string(),
        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_string(),
                format!("http.extraHeader=Authorization: Basic {encoded}"),
            ]);
        }
    }
    if disable_credential_prompt {
        args.extend(["-c".to_string(), "credential.helper=".to_string()]);
    }
    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_string();
    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(_) => "oauth2",
            None => "",
        };
        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(args: &[String]) -> Result<()> {
    let status = Command::new("git")
        .args(args)
        .status()
        .await
        .context("running git clone")?;

    if !status.success() {
        anyhow::bail!("git clone failed with status {status}");
    }

    Ok(())
}