fluidattacks-core 0.1.5

Fluid Attacks Core Library
Documentation
pub mod codecommit;
pub mod https;
pub mod ssh;

pub use https::build_https_config_args;
pub use https::embed_credentials_in_url;
pub use https::format_https_url;
pub use ssh::normalize_ssh_url;
pub use ssh::setup_ssh_env;
pub use ssh::url_has_port;

use anyhow::{bail, Context, Result};
use serde_json::json;
use std::fs;
use std::path::{Path, PathBuf};
use tokio::process::Command;
use tracing::{info, warn};

use crate::git::types::{CredentialKind, Credentials, GitRoot};

pub struct CloneOpts {
    pub group_name: String,
    pub nickname: String,
    pub repo_url: String,
    pub branch: String,
    pub credentials: Option<Credentials>,
    pub follow_redirects: bool,
    pub skip_existing: bool,
    pub mirror: bool,
}

impl CloneOpts {
    pub fn from_root(
        group: &str,
        root: &GitRoot,
        credentials: Option<Credentials>,
        skip_existing: bool,
        mirror: bool,
    ) -> Self {
        Self {
            group_name: group.to_string(),
            nickname: root.nickname.clone(),
            repo_url: root.url.clone(),
            branch: root.branch.clone(),
            credentials,
            follow_redirects: root.follow_redirects(),
            skip_existing,
            mirror,
        }
    }
}

pub async fn clone_root(opts: &CloneOpts) -> Result<()> {
    let repo_dir = PathBuf::from("groups")
        .join(&opts.group_name)
        .join(&opts.nickname);

    if opts.skip_existing {
        if let Ok(entries) = fs::read_dir(&repo_dir) {
            if entries.count() > 0 {
                info!(nickname = opts.nickname, "skipping existing repo");
                return Ok(());
            }
        }
    }

    // Use tempdir_in targeting repo_dir's parent to avoid cross-filesystem rename (EXDEV)
    let tmp_parent = repo_dir.parent().unwrap_or(Path::new("."));
    fs::create_dir_all(tmp_parent)
        .with_context(|| format!("creating parent dir {}", tmp_parent.display()))?;
    let tmp_dir = tempfile::tempdir_in(tmp_parent).context("creating temp dir")?;
    let clone_dest = tmp_dir.path().join(&opts.nickname);

    let result = match &opts.credentials {
        Some(Credentials {
            kind: CredentialKind::Ssh { .. },
        }) => ssh::clone_ssh(opts, &clone_dest).await,
        Some(Credentials {
            kind: CredentialKind::Https { .. },
        }) => https::clone_https(opts, &clone_dest).await,
        Some(Credentials {
            kind: CredentialKind::Token { .. },
        }) => https::clone_https_token(opts, &clone_dest).await,
        Some(Credentials {
            kind: CredentialKind::AwsRole { .. },
        }) => codecommit::clone_codecommit(opts, &clone_dest).await,
        None if opts.repo_url.starts_with("http") => {
            https::clone_https_public(opts, &clone_dest).await
        }
        _ => bail!("no suitable credentials found for root {:?}", opts.nickname,),
    };

    result.with_context(|| format!("cloning {:?}", opts.nickname))?;

    if opts.mirror {
        convert_bare_to_worktree(&clone_dest)
            .await
            .with_context(|| format!("converting mirror to worktree for {:?}", opts.nickname))?;
    }

    if repo_dir.exists() {
        fs::remove_dir_all(&repo_dir)
            .with_context(|| format!("removing old repo dir {}", repo_dir.display()))?;
    }
    fs::rename(&clone_dest, &repo_dir)
        .with_context(|| format!("moving cloned repo to {}", repo_dir.display()))?;

    log_clone_result(&repo_dir, opts).await;

    if opts.mirror {
        if let Err(e) = write_mirror_info(&repo_dir, opts) {
            warn!(error = %e, "failed to write .info.json");
        }
    }

    Ok(())
}

async fn convert_bare_to_worktree(clone_dest: &Path) -> Result<()> {
    let bare_tmp = clone_dest.with_extension("bare");
    fs::rename(clone_dest, &bare_tmp)
        .with_context(|| format!("renaming bare repo to {}", bare_tmp.display()))?;
    fs::create_dir_all(clone_dest)
        .with_context(|| format!("creating worktree dir {}", clone_dest.display()))?;
    let git_dir = clone_dest.join(".git");
    fs::rename(&bare_tmp, &git_dir)
        .with_context(|| format!("moving bare contents to {}", git_dir.display()))?;

    let dest_str = clone_dest.to_string_lossy().to_string();

    let config_out = Command::new("git")
        .args(["-C", &dest_str, "config", "core.bare", "false"])
        .output()
        .await
        .context("running git config core.bare false")?;
    if !config_out.status.success() {
        bail!(
            "git config core.bare false failed: {}",
            String::from_utf8_lossy(&config_out.stderr)
        );
    }

    let reset_out = Command::new("git")
        .args(["-C", &dest_str, "reset", "--hard", "HEAD"])
        .output()
        .await
        .context("running git reset --hard HEAD")?;
    if !reset_out.status.success() {
        bail!(
            "git reset --hard HEAD failed: {}",
            String::from_utf8_lossy(&reset_out.stderr)
        );
    }

    Ok(())
}

async fn log_clone_result(repo_dir: &Path, opts: &CloneOpts) {
    let label = if opts.mirror {
        "cloned (mirror)"
    } else {
        "cloned"
    };
    let repo_dir_str = repo_dir.to_string_lossy().to_string();
    match Command::new("git")
        .args(["-C", &repo_dir_str, "log", "-1", "--format=%H %ai"])
        .output()
        .await
    {
        Ok(output) if output.status.success() => {
            let head = String::from_utf8_lossy(&output.stdout).trim().to_string();
            info!(nickname = opts.nickname, head = head, label);
        }
        _ => {
            info!(nickname = opts.nickname, dir = %repo_dir.display(), label);
        }
    }
}

fn write_mirror_info(repo_dir: &Path, opts: &CloneOpts) -> Result<()> {
    let info = json!({
        "fluid_branch": opts.branch,
        "repo": opts.repo_url,
    });
    let data = serde_json::to_string_pretty(&info)?;
    fs::write(repo_dir.join(".info.json"), data)?;
    Ok(())
}

/// Clones a repo directly into `temp_dir/{uuid}` and returns
/// `(Some(path), None)` on success or `(None, Some(error))` on failure.
#[allow(clippy::too_many_arguments)]
pub async fn clone_to_temp(
    repo_url: &str,
    repo_branch: &str,
    temp_dir: &str,
    credentials: Option<Credentials>,
    follow_redirects: bool,
    mirror: bool,
) -> (Option<String>, Option<String>) {
    let nickname = uuid::Uuid::new_v4().to_string();
    let dest = PathBuf::from(temp_dir).join(&nickname);

    if let Err(e) = fs::create_dir_all(temp_dir) {
        return (None, Some(e.to_string()));
    }

    let opts = CloneOpts {
        group_name: String::new(),
        nickname: nickname.clone(),
        repo_url: repo_url.to_string(),
        branch: repo_branch.to_string(),
        credentials,
        follow_redirects,
        skip_existing: false,
        mirror,
    };

    let result = match &opts.credentials {
        Some(Credentials {
            kind: CredentialKind::Ssh { .. },
        }) => ssh::clone_ssh(&opts, &dest).await,
        Some(Credentials {
            kind: CredentialKind::Https { .. },
        }) => https::clone_https(&opts, &dest).await,
        Some(Credentials {
            kind: CredentialKind::Token { .. },
        }) => https::clone_https_token(&opts, &dest).await,
        Some(Credentials {
            kind: CredentialKind::AwsRole { .. },
        }) => codecommit::clone_codecommit(&opts, &dest).await,
        None if opts.repo_url.starts_with("http") => https::clone_https_public(&opts, &dest).await,
        _ => Err(anyhow::anyhow!("no suitable credentials")),
    };

    match result {
        Ok(()) => {
            if opts.mirror {
                if let Err(e) = convert_bare_to_worktree(&dest).await {
                    let _ = fs::remove_dir_all(&dest);
                    return (None, Some(e.to_string()));
                }
            }
            log_clone_result(&dest, &opts).await;
            if opts.mirror {
                if let Err(e) = write_mirror_info(&dest, &opts) {
                    warn!(error = %e, "failed to write .info.json");
                }
            }
            (Some(dest.to_string_lossy().to_string()), None)
        }
        Err(e) => {
            let _ = fs::remove_dir_all(&dest);
            (None, Some(e.to_string()))
        }
    }
}

/// Builds the common clone arguments: config flags, clone subcommand, mirror/branch, URL, dest.
pub fn build_clone_args(
    opts: &CloneOpts,
    repo_url: &str,
    dest: &Path,
    extra_config: &[String],
) -> Vec<String> {
    let mut args = Vec::new();

    if opts.follow_redirects {
        args.extend(["-c".to_string(), "http.followRedirects=true".to_string()]);
    }

    args.extend(extra_config.iter().cloned());

    args.push("clone".to_string());

    if opts.mirror {
        args.push("--mirror".to_string());
    } else if !opts.branch.is_empty() {
        args.extend([
            "--branch".to_string(),
            opts.branch.clone(),
            "--single-branch".to_string(),
        ]);
    }

    args.extend([
        "--".to_string(),
        repo_url.to_string(),
        dest.to_string_lossy().to_string(),
    ]);

    args
}