tsafe-cli 1.0.26

Secrets runtime for developers — inject credentials into processes via exec, never into shell history or .env files
//! Vault sync command handler.
//!
//! Implements `tsafe sync` — a three-way merge of the local vault against a
//! remote git branch, followed by commit and push with optional PAT injection.

use anyhow::{Context, Result};
use colored::Colorize;
use tsafe_core::{audit::AuditEntry, profile};

use crate::helpers::*;

pub(crate) fn cmd_sync(
    profile: &str,
    remote: &str,
    branch: &str,
    vault_file_override: Option<&str>,
    dry_run: bool,
) -> Result<()> {
    use std::process::Command;
    use tsafe_core::sync::three_way_merge;
    use tsafe_core::vault::VaultFile;

    // 1. Find git repo root.
    let repo_root = String::from_utf8(
        Command::new("git")
            .args(["rev-parse", "--show-toplevel"])
            .output()
            .context("failed to run git — is this a git repository?")?
            .stdout,
    )?
    .trim()
    .to_string();

    // 2. Determine vault file path relative to repo root.
    let vault_path = profile::vault_path(profile);
    let rel_path = vault_file_override.map(String::from).unwrap_or_else(|| {
        vault_path
            .strip_prefix(&repo_root)
            .or_else(|_| vault_path.strip_prefix("/"))
            .unwrap_or(&vault_path)
            .to_string_lossy()
            .replace('\\', "/")
    });

    println!("Syncing vault: {rel_path} ({remote}/{branch})");

    // 3. Snapshot current vault as safety net.
    if vault_path.exists() {
        let _ = tsafe_core::snapshot::take(
            &vault_path,
            profile,
            tsafe_core::snapshot::DEFAULT_SNAPSHOT_KEEP,
        );
    }

    // 4. Git fetch.
    let fetch = Command::new("git")
        .args(["fetch", remote, branch])
        .status()
        .context("git fetch failed")?;
    if !fetch.success() {
        anyhow::bail!("git fetch {remote} {branch} failed");
    }

    // 5. Find merge base.
    let remote_ref = format!("{remote}/{branch}");
    let merge_base_output = Command::new("git")
        .args(["merge-base", "HEAD", &remote_ref])
        .output()
        .context("git merge-base failed")?;
    let has_base = merge_base_output.status.success();
    let merge_base = String::from_utf8_lossy(&merge_base_output.stdout)
        .trim()
        .to_string();

    // 6. Read base vault (empty if no common ancestor).
    let base: VaultFile = if has_base {
        let base_json = Command::new("git")
            .args(["show", &format!("{merge_base}:{rel_path}")])
            .output()
            .ok()
            .filter(|o| o.status.success())
            .map(|o| String::from_utf8_lossy(&o.stdout).to_string());
        match base_json {
            Some(json) => serde_json::from_str(&json)
                .context("failed to parse base vault from git history")?,
            None => serde_json::from_str("{}").unwrap_or_else(|_| empty_vault_file()),
        }
    } else {
        empty_vault_file()
    };

    // 7. Read theirs (remote HEAD).
    let theirs_json = Command::new("git")
        .args(["show", &format!("{remote_ref}:{rel_path}")])
        .output()
        .ok()
        .filter(|o| o.status.success())
        .map(|o| String::from_utf8_lossy(&o.stdout).to_string());
    let theirs: VaultFile = match theirs_json {
        Some(json) => serde_json::from_str(&json).context("failed to parse remote vault")?,
        None => {
            println!("  Vault file not found on remote — will push local vault.");
            empty_vault_file()
        }
    };

    // 8. Read ours (local file).
    let ours: VaultFile = if vault_path.exists() {
        let json = std::fs::read_to_string(&vault_path)?;
        serde_json::from_str(&json).context("failed to parse local vault")?
    } else {
        println!("  Vault file not found locally — will pull from remote.");
        empty_vault_file()
    };

    // 9. Three-way merge.
    let result = three_way_merge(&base, &ours, &theirs)?;

    // 10. Report.
    if result.is_noop {
        println!("{} Already in sync — nothing to do.", "".green());
        return Ok(());
    }

    println!();
    if !result.added_from_theirs.is_empty() {
        println!(
            "  {} keys added from remote: {}",
            result.added_from_theirs.len(),
            result.added_from_theirs.join(", ")
        );
    }
    if !result.updated_from_theirs.is_empty() {
        println!(
            "  {} keys updated from remote: {}",
            result.updated_from_theirs.len(),
            result.updated_from_theirs.join(", ")
        );
    }
    if !result.added_from_ours.is_empty() {
        println!(
            "  {} keys added from local: {}",
            result.added_from_ours.len(),
            result.added_from_ours.join(", ")
        );
    }
    if !result.deleted.is_empty() {
        println!(
            "  {} keys deleted: {}",
            result.deleted.len(),
            result.deleted.join(", ")
        );
    }
    if !result.conflicts.is_empty() {
        println!(
            "  {} {} (resolved by last-write-wins): {}",
            "".yellow(),
            format!("{} conflicts", result.conflicts.len()).yellow(),
            result.conflicts.join(", ")
        );
    }

    if dry_run {
        println!("\n{} Dry run — no changes made.", "i".blue());
        return Ok(());
    }

    // 11. Write merged vault.
    let json = serde_json::to_string_pretty(&result.merged)?;
    let tmp = vault_path.with_extension("vault.tmp");
    std::fs::write(&tmp, &json)?;
    std::fs::rename(&tmp, &vault_path)?;

    // 12. Git add + commit + push.
    let add_status = Command::new("git")
        .args(["add", &vault_path.to_string_lossy()])
        .status()
        .context("git add failed")?;
    if !add_status.success() {
        anyhow::bail!("git add failed");
    }

    let total_changes = result.added_from_theirs.len()
        + result.updated_from_theirs.len()
        + result.added_from_ours.len()
        + result.deleted.len()
        + result.conflicts.len();
    let commit_msg = format!(
        "tsafe sync: {} changes ({} added, {} updated, {} deleted, {} conflicts)",
        total_changes,
        result.added_from_theirs.len() + result.added_from_ours.len(),
        result.updated_from_theirs.len(),
        result.deleted.len(),
        result.conflicts.len(),
    );

    let commit_status = Command::new("git")
        .args(["commit", "-m", &commit_msg])
        .status()
        .context("git commit failed")?;
    if !commit_status.success() {
        // Commit may fail if nothing changed (already in sync after merge).
        eprintln!("  git commit reported no changes — vault may already be committed.");
    }

    // Push with injected credentials (reuse cmd_git's PAT pattern).
    let vault = open_vault(profile).ok();
    let mut push_cmd = Command::new("git");
    push_cmd.args(["push", remote, branch]);
    if let Some(ref v) = vault {
        let pat_key = std::env::var("TSAFE_GIT_PAT_KEY").unwrap_or_else(|_| "ADO_PAT".to_string());
        if let Ok(pat) = v.get(&pat_key) {
            use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
            let encoded = B64.encode(format!(":{}", &*pat));
            push_cmd.env("GIT_CONFIG_COUNT", "1");
            push_cmd.env("GIT_CONFIG_KEY_0", "http.extraHeader");
            push_cmd.env(
                "GIT_CONFIG_VALUE_0",
                format!("Authorization: Basic {encoded}"),
            );
        }
    }
    drop(vault);

    let push_status = push_cmd.status().context("git push failed")?;
    if !push_status.success() {
        anyhow::bail!("git push failed — you may need to pull first or resolve upstream conflicts");
    }

    audit(profile)
        .append(&AuditEntry::success(profile, "sync", None))
        .ok();

    println!("\n{} Sync complete.", "".green());
    Ok(())
}

/// Create a minimal empty VaultFile for use as a merge base when no common ancestor exists.
fn empty_vault_file() -> tsafe_core::vault::VaultFile {
    use tsafe_core::vault::{KdfParams, VaultChallenge, VaultFile};
    VaultFile {
        schema: "tsafe/vault/v1".into(),
        kdf: KdfParams {
            algorithm: "argon2id".into(),
            m_cost: 65536,
            t_cost: 3,
            p_cost: 4,
            salt: String::new(),
        },
        cipher: "xchacha20poly1305".into(),
        vault_challenge: VaultChallenge {
            nonce: String::new(),
            ciphertext: String::new(),
        },
        created_at: chrono::Utc::now(),
        updated_at: chrono::Utc::now(),
        secrets: std::collections::HashMap::new(),
        age_recipients: Vec::new(),
        wrapped_dek: None,
    }
}