fluidattacks-core 0.1.5

Fluid Attacks Core Library
Documentation
use anyhow::Context;
use base64::Engine;
use std::path::Path;
use tokio::process::Command;

use super::{build_clone_args, CloneOpts};

pub async fn clone_ssh(opts: &CloneOpts, dest: &Path) -> anyhow::Result<()> {
    let crate::git::types::CredentialKind::Ssh { key } = &opts
        .credentials
        .as_ref()
        .context("missing credentials")?
        .kind
    else {
        anyhow::bail!("expected SSH credentials");
    };

    let key_data = base64::engine::general_purpose::STANDARD
        .decode(key)
        .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 repo_url = normalize_ssh_url(&opts.repo_url);
    let args = build_clone_args(opts, &repo_url, dest, &[]);

    let git_ssh_command = setup_ssh_env(key_file.path());

    let status = Command::new("git")
        .args(&args)
        .env("GIT_SSH_COMMAND", git_ssh_command)
        .status()
        .await
        .context("running git clone (SSH)")?;

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

    Ok(())
}

pub fn setup_ssh_env(key_file_path: &Path) -> String {
    format!(
        "ssh -i {} -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -o HostkeyAlgorithms=+ssh-rsa -o PubkeyAcceptedAlgorithms=+ssh-rsa",
        key_file_path.display()
    )
}

pub fn normalize_ssh_url(raw_url: &str) -> String {
    // Special cases that should not be transformed
    if raw_url.contains("source.developers.google") || raw_url.starts_with("ssh://FLUID") {
        return raw_url.to_string();
    }

    // Add ssh:// scheme if missing
    let with_scheme = if raw_url.starts_with("ssh://") {
        raw_url.to_string()
    } else if let Some(colon_idx) = raw_url.find(':') {
        let before_colon = &raw_url[..colon_idx];
        if !before_colon.contains('/') && !raw_url.contains("://") {
            format!("ssh://{}/{}", before_colon, &raw_url[colon_idx + 1..])
        } else {
            raw_url.to_string()
        }
    } else {
        raw_url.to_string()
    };

    // If URL has ssh:// scheme but no explicit port, ensure the path
    // starts with '/' so the part after the host isn't parsed as a port.
    // e.g. ssh://git@host:v3/org/repo → ssh://git@host:/v3/org/repo
    if with_scheme.starts_with("ssh://") && !url_has_port(&with_scheme) {
        let parts: Vec<&str> = with_scheme.splitn(3, ':').collect();
        if parts.len() == 3 && !parts[2].starts_with('/') {
            return format!("{}:{}:/{}", parts[0], parts[1], parts[2]);
        }
    }

    with_scheme
}

pub fn url_has_port(url: &str) -> bool {
    // Parse as URL and check if port is a valid integer
    url::Url::parse(url).ok().and_then(|u| u.port()).is_some()
}