use std::collections::HashMap;
use std::env;
use std::fs;
use std::io::Write as _;
use std::process::Command;
use clap::Args;
use indexmap::IndexMap;
use tempfile::NamedTempFile;
use toml_edit::{DocumentMut, Table, Value};
use crate::commands::Cli;
use crate::config::{Config, SecretConfig};
use crate::error::{FnoxError, Result};
use crate::providers::{ProviderCapability, get_provider_resolved};
use crate::secret_resolver;
const TEMP_FILE_HEADER: &str = "\
# FNOX EDIT - Decrypted Secrets
# This is a temporary file with decrypted secret values.
# Secrets marked as READ-ONLY cannot be modified (from 1Password, Bitwarden, etc.)
# After you save and close this file, fnox will re-encrypt changed secrets.
# DO NOT share this file as it contains plaintext secrets!
";
#[derive(Debug, Args)]
pub struct EditCommand;
#[derive(Debug, Clone)]
struct SecretEntry {
profile: String,
key: String,
original_config: SecretConfig,
plaintext_value: Option<String>,
is_read_only: bool,
provider_name: Option<String>,
}
impl EditCommand {
pub async fn run(&self, cli: &Cli, config: Config) -> Result<()> {
let profile = Config::get_profile(cli.profile.as_deref());
tracing::debug!("Starting enhanced edit with profile: {}", profile);
let toml_content =
fs::read_to_string(&cli.config).map_err(|source| FnoxError::ConfigReadFailed {
path: cli.config.clone(),
source,
})?;
let doc = toml_content
.parse::<DocumentMut>()
.map_err(|e| FnoxError::Config(format!("Failed to parse TOML: {}", e)))?;
let mut all_secrets = Vec::new();
if !config.secrets.is_empty() {
self.collect_secrets(&config, "default", &config.secrets, &mut all_secrets)
.await?;
}
for (profile_name, profile_config) in &config.profiles {
if !profile_config.secrets.is_empty() {
self.collect_secrets(
&config,
profile_name,
&profile_config.secrets,
&mut all_secrets,
)
.await?;
}
}
tracing::debug!("Decrypting {} secrets", all_secrets.len());
let mut secrets_by_profile: IndexMap<String, IndexMap<String, SecretConfig>> =
IndexMap::new();
for secret_entry in &all_secrets {
let mut edit_secret_config = secret_entry.original_config.clone();
edit_secret_config.if_missing = Some(crate::config::IfMissing::Error);
secrets_by_profile
.entry(secret_entry.profile.clone())
.or_default()
.insert(secret_entry.key.clone(), edit_secret_config);
}
let mut resolved_by_profile: IndexMap<String, IndexMap<String, Option<String>>> =
IndexMap::new();
for (profile_name, secrets) in secrets_by_profile {
let resolved =
secret_resolver::resolve_secrets_batch(&config, &profile_name, &secrets).await?;
resolved_by_profile.insert(profile_name, resolved);
}
for secret_entry in &mut all_secrets {
secret_entry.plaintext_value = resolved_by_profile
.get(&secret_entry.profile)
.and_then(|resolved| resolved.get(&secret_entry.key))
.cloned()
.flatten();
}
let temp_file = self.create_decrypted_temp_file(&doc, &all_secrets)?;
let temp_path = temp_file.path().to_path_buf();
tracing::debug!("Opening editor on temporary file");
let editor = env::var("EDITOR")
.or_else(|_| env::var("VISUAL"))
.unwrap_or_else(|_| {
if cfg!(target_os = "windows") {
"notepad".to_string()
} else {
"vi".to_string()
}
});
#[cfg(windows)]
let editor_path = which::which(&editor).unwrap_or_else(|_| editor.clone().into());
#[cfg(not(windows))]
let editor_path = &editor;
let status = Command::new(editor_path)
.arg(&temp_path)
.status()
.map_err(|e| FnoxError::EditorLaunchFailed {
editor: editor.clone(),
source: e,
})?;
if !status.success()
&& let Some(code) = status.code()
{
return Err(FnoxError::EditorExitFailed {
editor: editor.clone(),
status: code,
});
}
tracing::debug!("Reading modified temporary file");
let modified_content = fs::read_to_string(&temp_path)
.map_err(|e| FnoxError::Config(format!("Failed to read temporary file: {}", e)))?;
let mut modified_doc = modified_content
.parse::<DocumentMut>()
.map_err(|e| FnoxError::Config(format!("Invalid TOML after edit: {}", e)))?;
tracing::debug!("Re-encrypting secrets in modified document");
let modified_toml = Self::strip_temp_header(&modified_content);
let modified_config: Config = toml_edit::de::from_str(&modified_toml)
.map_err(|e| FnoxError::Config(format!("Invalid configuration after edit: {}", e)))?;
self.reencrypt_secrets(&modified_config, &mut modified_doc, &all_secrets)
.await?;
let output = Self::strip_temp_header(&modified_doc.to_string());
fs::write(&cli.config, output).map_err(|source| FnoxError::ConfigWriteFailed {
path: cli.config.clone(),
source,
})?;
let check = console::style("✓").green();
let styled_config = console::style(cli.config.display()).cyan();
println!("{check} Configuration file {styled_config} updated with re-encrypted secrets");
Ok(())
}
async fn collect_secrets(
&self,
config: &Config,
profile: &str,
secrets: &IndexMap<String, SecretConfig>,
all_secrets: &mut Vec<SecretEntry>,
) -> Result<()> {
for (key, secret_config) in secrets {
let provider_name = if let Some(prov) = secret_config.provider() {
Some(prov.to_string())
} else {
config.get_default_provider(profile)?
};
let (is_read_only, resolved_provider_name) = if let Some(ref prov_name) = provider_name
{
let providers = config.get_providers(profile);
if let Some(provider_config) = providers.get(prov_name) {
let provider =
get_provider_resolved(config, profile, prov_name, provider_config).await?;
let capabilities = provider.capabilities();
let is_read_only = capabilities.contains(&ProviderCapability::RemoteRead)
&& !capabilities.contains(&ProviderCapability::Encryption)
&& !capabilities.contains(&ProviderCapability::RemoteStorage);
(is_read_only, Some(prov_name.clone()))
} else {
(false, provider_name)
}
} else {
(false, None)
};
all_secrets.push(SecretEntry {
profile: profile.to_string(),
key: key.clone(),
original_config: secret_config.clone(),
plaintext_value: None,
is_read_only,
provider_name: resolved_provider_name,
});
}
Ok(())
}
fn create_decrypted_temp_file(
&self,
doc: &DocumentMut,
all_secrets: &[SecretEntry],
) -> Result<NamedTempFile> {
let mut temp_file = tempfile::Builder::new()
.suffix(".toml")
.tempfile()
.map_err(|e| FnoxError::Config(format!("Failed to create temporary file: {}", e)))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = temp_file
.as_file()
.metadata()
.map_err(|e| FnoxError::Config(format!("Failed to get file metadata: {}", e)))?
.permissions();
perms.set_mode(0o600);
temp_file
.as_file()
.set_permissions(perms)
.map_err(|e| FnoxError::Config(format!("Failed to set file permissions: {}", e)))?;
}
let mut decrypted_doc = doc.clone();
let secrets_map: HashMap<_, _> = all_secrets
.iter()
.map(|s| ((s.profile.clone(), s.key.clone()), s))
.collect();
if let Some(secrets_table) = decrypted_doc
.get_mut("secrets")
.and_then(|item| item.as_table_mut())
{
self.replace_secrets_in_table(secrets_table, "default", &secrets_map)?;
}
if let Some(profiles_table) = decrypted_doc
.get_mut("profiles")
.and_then(|item| item.as_table_mut())
{
for (profile_name, profile_item) in profiles_table.iter_mut() {
let profile_name_str = profile_name.to_string();
if let Some(profile_table) = profile_item.as_table_mut()
&& let Some(secrets_table) = profile_table
.get_mut("secrets")
.and_then(|item| item.as_table_mut())
{
self.replace_secrets_in_table(secrets_table, &profile_name_str, &secrets_map)?;
}
}
}
let header = format!("{}{}", TEMP_FILE_HEADER, decrypted_doc);
temp_file
.write_all(header.as_bytes())
.map_err(|e| FnoxError::Config(format!("Failed to write to temporary file: {}", e)))?;
temp_file
.flush()
.map_err(|e| FnoxError::Config(format!("Failed to flush temporary file: {}", e)))?;
Ok(temp_file)
}
fn replace_secrets_in_table(
&self,
secrets_table: &mut Table,
profile: &str,
secrets_map: &HashMap<(String, String), &SecretEntry>,
) -> Result<()> {
for (key, value) in secrets_table.iter_mut() {
let key_string = key.to_string();
let lookup_key = (profile.to_string(), key_string.clone());
if let Some(secret_entry) = secrets_map.get(&lookup_key) {
if let Some(inline_table) = value.as_inline_table_mut() {
if let Some(plaintext) = &secret_entry.plaintext_value {
inline_table.insert("value", Value::from(plaintext.as_str()));
}
} else if let Some(table) = value.as_table_mut() {
if let Some(plaintext) = &secret_entry.plaintext_value {
table.insert("value", toml_edit::value(plaintext.as_str()));
}
}
}
}
Ok(())
}
async fn reencrypt_secrets(
&self,
config: &Config,
modified_doc: &mut DocumentMut,
all_secrets: &[SecretEntry],
) -> Result<()> {
let secrets_map: HashMap<_, _> = all_secrets
.iter()
.map(|s| ((s.profile.clone(), s.key.clone()), s))
.collect();
if let Some(secrets_table) = modified_doc
.get_mut("secrets")
.and_then(|item| item.as_table_mut())
{
self.reencrypt_secrets_table(config, secrets_table, "default", &secrets_map)
.await?;
}
if let Some(profiles_table) = modified_doc
.get_mut("profiles")
.and_then(|item| item.as_table_mut())
{
let profile_names: Vec<_> = profiles_table.iter().map(|(k, _)| k.to_string()).collect();
for profile_name in profile_names {
if let Some(profile_item) = profiles_table.get_mut(&profile_name)
&& let Some(profile_table) = profile_item.as_table_mut()
&& let Some(secrets_table) = profile_table
.get_mut("secrets")
.and_then(|item| item.as_table_mut())
{
self.reencrypt_secrets_table(
config,
secrets_table,
&profile_name,
&secrets_map,
)
.await?;
}
}
}
Ok(())
}
async fn reencrypt_secrets_table(
&self,
config: &Config,
secrets_table: &mut Table,
secret_profile: &str,
secrets_map: &HashMap<(String, String), &SecretEntry>,
) -> Result<()> {
let keys: Vec<_> = secrets_table.iter().map(|(k, _)| k.to_string()).collect();
for key_str in keys {
let lookup_key = (secret_profile.to_string(), key_str.clone());
let Some(value) = secrets_table.get_mut(&key_str) else {
continue;
};
let (plaintext, explicit_provider) = if let Some(inline_table) = value.as_inline_table()
{
let plaintext = inline_table.get("value").and_then(|v| v.as_str());
let provider = inline_table.get("provider").and_then(|v| v.as_str());
(plaintext, provider.map(String::from))
} else if let Some(table) = value.as_table() {
let plaintext = table.get("value").and_then(|v| v.as_str());
let provider = table.get("provider").and_then(|v| v.as_str());
(plaintext, provider.map(String::from))
} else {
continue;
};
let Some(plaintext) = plaintext else {
continue;
};
if let Some(secret_entry) = secrets_map.get(&lookup_key) {
if secret_entry.is_read_only {
if Some(plaintext) != secret_entry.plaintext_value.as_deref() {
return Err(FnoxError::Config(format!(
"Cannot modify read-only secret '{}' from provider '{}'",
key_str,
secret_entry
.provider_name
.as_ref()
.unwrap_or(&"unknown".to_string())
)));
}
if let Some(original_value) = secret_entry.original_config.value() {
Self::set_secret_value(value, original_value);
}
continue;
}
let value_changed = Some(plaintext) != secret_entry.plaintext_value.as_deref();
let provider_changed =
explicit_provider.as_deref() != secret_entry.original_config.provider();
if !value_changed && !provider_changed {
if let Some(original_value) = secret_entry.original_config.value() {
Self::set_secret_value(value, original_value);
}
continue;
}
tracing::debug!("Secret '{}' changed, re-encrypting", key_str);
let provider_to_use = if let Some(ref prov) = explicit_provider {
Some(prov.clone())
} else {
config.get_default_provider(secret_profile)?
};
let encrypted_value = if let Some(provider_name) = provider_to_use {
let providers = config.get_providers(secret_profile);
if let Some(provider_config) = providers.get(&provider_name) {
let provider = get_provider_resolved(
config,
secret_profile,
&provider_name,
provider_config,
)
.await?;
provider.put_secret(&key_str, plaintext).await?
} else {
plaintext.to_string()
}
} else {
plaintext.to_string()
};
Self::set_secret_value(value, &encrypted_value);
} else {
tracing::debug!("New secret '{}' detected, encrypting", key_str);
let provider_name = if let Some(prov) = explicit_provider {
prov
} else if let Some(default_prov) = config.get_default_provider(secret_profile)? {
default_prov
} else {
tracing::warn!(
"No provider specified for new secret '{}', storing as plaintext",
key_str
);
continue;
};
let providers = config.get_providers(secret_profile);
let Some(provider_config) = providers.get(&provider_name) else {
return Err(FnoxError::Config(format!(
"Provider '{}' not found for new secret '{}'",
provider_name, key_str
)));
};
let provider =
get_provider_resolved(config, secret_profile, &provider_name, provider_config)
.await?;
let encrypted_value = provider.put_secret(&key_str, plaintext).await?;
Self::set_secret_value(value, &encrypted_value);
}
}
Ok(())
}
fn set_secret_value(item: &mut toml_edit::Item, value: &str) {
if let Some(inline_table) = item.as_inline_table_mut() {
inline_table.insert("value", Value::from(value));
} else if let Some(table) = item.as_table_mut() {
table.insert("value", toml_edit::value(value));
}
}
fn strip_temp_header(content: &str) -> String {
content
.strip_prefix(TEMP_FILE_HEADER)
.unwrap_or(content)
.to_string()
}
}