fnox 1.25.1

A flexible secret management tool supporting multiple providers and encryption methods
Documentation
use crate::commands::Cli;
use crate::config::{Config, SecretConfig};
use crate::error::{FnoxError, Result};
use crate::secret_resolver::resolve_secrets_batch;
use clap::Args;
use console;
use indexmap::IndexMap;
use regex::Regex;
use std::collections::HashMap;
use std::io;
use std::path::PathBuf;

/// Re-encrypt secrets with current provider configuration
///
/// When you add or remove recipients from an encryption provider (e.g. age),
/// existing secrets remain encrypted with the old recipient set. This command
/// decrypts and re-encrypts all matching secrets with the current provider
/// configuration.
#[derive(Args)]
pub struct ReencryptCommand {
    /// Only re-encrypt these specific secret keys
    keys: Vec<String>,

    /// Skip confirmation prompt
    #[arg(short, long)]
    force: bool,

    /// Show what would be done without making changes
    #[arg(short = 'n', long)]
    dry_run: bool,

    /// Only re-encrypt secrets from this provider
    #[arg(short = 'p', long)]
    provider: Option<String>,

    /// Only re-encrypt matching secrets (regex pattern)
    #[arg(long)]
    filter: Option<String>,
}

impl ReencryptCommand {
    pub async fn run(&self, cli: &Cli, merged_config: Config) -> Result<()> {
        let profile = Config::get_profile(cli.profile.as_deref());
        tracing::debug!("Re-encrypting secrets for profile '{}'", profile);

        let providers = merged_config.get_providers(&profile);
        let all_secrets = merged_config.get_secrets(&profile)?;

        let filter_regex = if let Some(ref filter) = self.filter {
            Some(
                Regex::new(filter).map_err(|e| FnoxError::InvalidRegexFilter {
                    pattern: filter.clone(),
                    details: e.to_string(),
                })?,
            )
        } else {
            None
        };

        let keys_filter: std::collections::HashSet<_> = self.keys.iter().collect();

        // Resolve and cache encryption providers; track non-encryption providers
        // to avoid redundant resolution
        let mut provider_cache: HashMap<String, Box<dyn crate::providers::Provider>> =
            HashMap::new();
        let mut non_encryption_providers: std::collections::HashSet<String> =
            std::collections::HashSet::new();

        // Collect secrets that use encryption providers
        let mut secrets_to_reencrypt: IndexMap<String, (String, SecretConfig)> = IndexMap::new();

        let default_provider = merged_config.get_default_provider(&profile)?;

        for (key, secret_config) in &all_secrets {
            let provider_name = if let Some(p) = secret_config.provider() {
                p.to_string()
            } else if let Some(ref dp) = default_provider {
                dp.clone()
            } else {
                continue;
            };

            // Apply --provider filter
            if let Some(ref p) = self.provider
                && provider_name != *p
            {
                continue;
            }

            // Apply positional KEYS filter
            if !keys_filter.is_empty() && !keys_filter.contains(key) {
                continue;
            }

            // Apply --filter regex
            if let Some(ref regex) = filter_regex
                && !regex.is_match(key)
            {
                continue;
            }

            // Skip providers already known to lack Encryption capability
            if non_encryption_providers.contains(&provider_name) {
                continue;
            }

            // Check if provider has Encryption capability (resolve once and cache)
            let Some(provider_config) = providers.get(provider_name.as_str()) else {
                tracing::warn!(
                    "Skipping '{key}': provider '{provider_name}' not found in current config"
                );
                continue;
            };
            if !provider_cache.contains_key(&provider_name) {
                let provider = crate::providers::get_provider_resolved(
                    &merged_config,
                    &profile,
                    &provider_name,
                    provider_config,
                )
                .await?;
                if !provider
                    .capabilities()
                    .contains(&crate::providers::ProviderCapability::Encryption)
                {
                    non_encryption_providers.insert(provider_name.clone());
                    continue;
                }
                provider_cache.insert(provider_name.clone(), provider);
            }

            // Skip secrets with no stored ciphertext — nothing to re-encrypt
            if secret_config.value().is_none() {
                tracing::debug!("Skipping '{key}': no encrypted value stored");
                continue;
            }

            secrets_to_reencrypt.insert(key.clone(), (provider_name, secret_config.clone()));
        }

        // Error if explicitly-requested keys weren't found or eligible
        for key in &self.keys {
            if !secrets_to_reencrypt.contains_key(key) {
                return Err(FnoxError::SecretNotFound {
                    key: key.clone(),
                    profile: profile.to_string(),
                    config_path: None,
                    suggestion: Some(format!(
                        "The key was not found, does not use an encryption provider, or was excluded by filters{}{}",
                        self.provider
                            .as_ref()
                            .map(|p| format!(" (--provider {})", p))
                            .unwrap_or_default(),
                        self.filter
                            .as_ref()
                            .map(|f| format!(" (--filter {})", f))
                            .unwrap_or_default(),
                    )),
                });
            }
        }

        if secrets_to_reencrypt.is_empty() {
            println!("No secrets to re-encrypt");
            return Ok(());
        }

        // Dry-run mode
        if self.dry_run {
            let dry_run_label = console::style("[dry-run]").yellow().bold();
            let styled_profile = console::style(&profile).magenta();

            println!(
                "{dry_run_label} Would re-encrypt {} secrets in profile {styled_profile}:",
                secrets_to_reencrypt.len()
            );
            for (key, (provider_name, _)) in &secrets_to_reencrypt {
                println!(
                    "  {} ({})",
                    console::style(key).cyan(),
                    console::style(provider_name).green()
                );
            }
            return Ok(());
        }

        // Confirm unless forced
        if !self.force {
            println!(
                "\nReady to re-encrypt {} secrets in profile '{}':",
                secrets_to_reencrypt.len(),
                profile
            );
            for (key, (provider_name, _)) in secrets_to_reencrypt.iter().take(10) {
                println!("  {} ({})", key, provider_name);
            }
            if secrets_to_reencrypt.len() > 10 {
                println!("  ... and {} more", secrets_to_reencrypt.len() - 10);
            }

            println!("\nContinue? [y/N]");
            let mut response = String::new();
            io::stdin()
                .read_line(&mut response)
                .map_err(|e| FnoxError::StdinReadFailed { source: e })?;

            if !response.trim().to_lowercase().starts_with('y') {
                println!("Re-encryption cancelled");
                return Ok(());
            }
        }

        // Resolve raw values from the original provider:
        // - Cached sync values would reencrypt stale data instead of the latest.
        // - Post-processed values (e.g. from json_path) would cause future reads to fail.
        let secrets_for_resolve: IndexMap<String, SecretConfig> = secrets_to_reencrypt
            .iter()
            .map(|(key, (_, sc))| (key.clone(), sc.for_raw_resolve()))
            .collect();

        let resolved = resolve_secrets_batch(&merged_config, &profile, &secrets_for_resolve).await;

        // Scrub decrypted plaintext from the process environment regardless of
        // success/failure. resolve_secrets_batch calls set_var for each resolved
        // secret, which leaks plaintext here (visible via /proc and inherited by
        // child processes).
        for key in secrets_to_reencrypt.keys() {
            // SAFETY: reencrypt is single-threaded at this point; no other threads
            // are reading the environment concurrently.
            unsafe {
                std::env::remove_var(key);
            }
        }

        let resolved = resolved?;

        // Verify all secrets were resolved (catch silent drops from resolve_secrets_batch)
        for key in secrets_to_reencrypt.keys() {
            if !resolved.contains_key(key) {
                return Err(FnoxError::ReencryptDecryptFailed {
                    key: key.clone(),
                    details: "secret was not returned by the resolver".to_string(),
                });
            }
        }

        // Re-encrypt each secret and group by (source file, effective profile)
        let mut by_source: IndexMap<(PathBuf, String), IndexMap<String, SecretConfig>> =
            IndexMap::new();
        let mut reencrypted_count = 0;

        for (key, plaintext) in &resolved {
            let Some(plaintext) = plaintext else {
                return Err(FnoxError::ReencryptDecryptFailed {
                    key: key.clone(),
                    details: "resolver returned no value for this secret".to_string(),
                });
            };

            let (provider_name, secret_config) = &secrets_to_reencrypt[key];

            let provider = provider_cache.get(provider_name.as_str()).ok_or_else(|| {
                FnoxError::ProviderNotConfigured {
                    provider: provider_name.clone(),
                    profile: profile.to_string(),
                    config_path: None,
                    suggestion: None,
                }
            })?;

            match provider.encrypt(plaintext).await {
                Ok(encrypted) => {
                    let mut updated = secret_config.clone();
                    updated.set_value(Some(encrypted));
                    updated.sync = None; // Clear stale sync cache

                    let source_path =
                        secret_config.source_path.clone().ok_or_else(|| {
                            FnoxError::Config(format!(
                                "Secret '{}' has no known source file; cannot write back re-encrypted value",
                                key
                            ))
                        })?;

                    // Use source_is_profile to determine the correct TOML section.
                    // Secrets loaded from root [secrets] must be saved back there,
                    // not to [profiles.X.secrets].
                    let save_profile = if secret_config.source_is_profile {
                        profile.clone()
                    } else {
                        "default".to_string()
                    };

                    by_source
                        .entry((source_path, save_profile))
                        .or_default()
                        .insert(key.clone(), updated);
                    reencrypted_count += 1;
                }
                Err(e) => {
                    return Err(FnoxError::ReencryptEncryptionFailed {
                        key: key.clone(),
                        provider: provider_name.clone(),
                        details: e.to_string(),
                    });
                }
            }
        }

        // Save back to each source file under the correct TOML section
        for ((source_path, save_profile), secrets) in &by_source {
            Config::save_secrets_to_source(secrets, save_profile, source_path)?;
        }

        println!("Re-encrypted {} secrets", reencrypted_count);

        Ok(())
    }
}