sshenv 0.0.1-alpha.1

SSH-key-backed encrypted vault for environment variables
use std::collections::{BTreeMap, BTreeSet, HashSet};

use anyhow::{Result, bail};
use sshenv_cli_models::RotateKeyArgs;
use sshenv_vault::recipient::fingerprint_from_recipient_descriptor;
use sshenv_vault::{DataKey, Vault};

use crate::commands::Context as CmdContext;
#[cfg(feature = "rekey")]
use crate::commands::{load_ciphertext_and_fps, save_vault, unlock_ciphertext};
use crate::identity::discover_public_key_paths;
use crate::pubkey::load_public_key;

#[cfg(feature = "rekey")]
pub fn rotate_key(ctx: &CmdContext, args: RotateKeyArgs) -> Result<()> {
    let (ciphertext, recipients) = load_ciphertext_and_fps(&ctx.vault_path)?;
    let (mut vault, _old_key) = unlock_ciphertext(ciphertext, &recipients)?;
    let new_key = rotate_unlocked_vault(&mut vault, &args.recipient_keys)?;
    save_vault(ctx, &mut vault, &new_key)?;
    crate::security_state::clear_rotation_recommended(&ctx.vault_path)?;
    eprintln!(
        "Rotated vault data key for {} recipient(s).",
        vault.recipients.len()
    );
    Ok(())
}

#[cfg(not(feature = "rekey"))]
pub fn rotate_key(_ctx: &CmdContext, _args: RotateKeyArgs) -> Result<()> {
    bail!("this sshenv build was compiled without rekey support")
}

#[cfg(feature = "rekey")]
pub fn rotate_unlocked_vault(vault: &mut Vault, explicit_keys: &[String]) -> Result<DataKey> {
    let public_key_lines = resolve_current_recipient_public_key_lines(vault, explicit_keys)?;
    vault.rotate_data_key(&public_key_lines)
}

#[cfg(not(feature = "rekey"))]
pub fn rotate_unlocked_vault(_vault: &mut Vault, _explicit_keys: &[String]) -> Result<DataKey> {
    bail!("this sshenv build was compiled without rekey support")
}

pub fn resolve_current_recipient_public_key_lines(
    vault: &Vault,
    explicit_keys: &[String],
) -> Result<Vec<String>> {
    let expected_fingerprints: HashSet<String> = vault
        .recipients
        .iter()
        .map(|recipient| recipient.fingerprint.clone())
        .collect();
    let mut public_keys_by_fingerprint = BTreeMap::new();

    for recipient in &vault.recipients {
        if !recipient.public_key_line.is_empty() {
            public_keys_by_fingerprint
                .entry(recipient.fingerprint.clone())
                .or_insert_with(|| recipient.public_key_line.clone());
        }
    }

    for key in explicit_keys {
        let line = load_public_key(key)?;
        let fingerprint = fingerprint_from_recipient_descriptor(&line)?;
        if !expected_fingerprints.contains(&fingerprint) {
            bail!(
                "provided recipient key {fingerprint} is not a current vault recipient; \
                 use add-recipient/remove-recipient to change recipients before rotating"
            );
        }
        public_keys_by_fingerprint
            .entry(fingerprint)
            .or_insert(line);
    }

    for path in discover_public_key_paths() {
        let Ok(line) = load_public_key(&path.display().to_string()) else {
            continue;
        };
        let Ok(fingerprint) = fingerprint_from_recipient_descriptor(&line) else {
            continue;
        };
        if expected_fingerprints.contains(&fingerprint) {
            public_keys_by_fingerprint
                .entry(fingerprint)
                .or_insert(line);
        }
    }

    let expected_sorted: BTreeSet<String> = expected_fingerprints.iter().cloned().collect();
    let provided_sorted: BTreeSet<String> = public_keys_by_fingerprint.keys().cloned().collect();
    if provided_sorted != expected_sorted {
        let missing: Vec<&String> = expected_sorted.difference(&provided_sorted).collect();
        bail!(
            "cannot rotate data key because public keys are missing for recipient(s): {}. \
             Re-run with `--recipient-key <path-or-public-key-line>` for each missing recipient.",
            format_fingerprint_list(&missing)
        );
    }

    Ok(expected_sorted
        .iter()
        .filter_map(|fingerprint| public_keys_by_fingerprint.get(fingerprint).cloned())
        .collect())
}

fn format_fingerprint_list(values: &[&String]) -> String {
    if values.is_empty() {
        "(none)".to_string()
    } else {
        values
            .iter()
            .map(|value| value.as_str())
            .collect::<Vec<_>>()
            .join(", ")
    }
}