mod identity;
mod keychain;
mod recipients;
mod registry;
mod session;
mod vault;
pub use self::identity::IdentitySource;
pub use self::registry::{infer_env_name, is_valid_env_name, is_valid_secret_name};
pub use self::vault::VaultStatus;
use crate::paths;
use anyhow::{bail, Result};
use std::collections::HashMap;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
#[derive(Debug)]
pub struct SecretsStatus {
pub global: VaultStatus,
pub project: Option<VaultStatus>,
pub identity_source: Option<IdentitySource>,
pub recipient_key: Option<String>,
}
pub fn check_status(project_root: Option<&Path>) -> Result<SecretsStatus> {
let global_vault = paths::secrets::vault_path();
let global_recipients = paths::secrets::recipient_path();
let global_registry = paths::secrets::registry_path();
let global = vault::check_status(&global_vault, &global_recipients, &global_registry);
let project = project_root.map(|root| {
let project_vault = paths::secrets::project_vault_path(root);
let project_recipients = paths::secrets::project_recipients_path(root);
let project_registry = paths::secrets::project_registry_path(root);
vault::check_status(&project_vault, &project_recipients, &project_registry)
});
let identity_source = identity::get_identity_source();
let recipient_key = if identity::has_identity() {
identity::get_recipient().ok()
} else {
None
};
Ok(SecretsStatus {
global,
project,
identity_source,
recipient_key,
})
}
pub fn add_secret(
name: &str,
value: &str,
env: Option<&str>,
global: bool,
project_root: Option<&Path>,
) -> Result<()> {
if !registry::is_valid_secret_name(name) {
bail!(
"Invalid secret name '{}'. Use lowercase letters, digits, and hyphens (e.g., 'github-token')",
name
);
}
let env_var = env
.map(|e| e.to_string())
.unwrap_or_else(|| registry::infer_env_name(name));
if !registry::is_valid_env_name(&env_var) {
bail!(
"Invalid env name '{}'. Use uppercase letters, digits, and underscores (e.g., 'GITHUB_TOKEN')",
env_var
);
}
let (vault_path, recipients_path, registry_path) = if global {
(
paths::secrets::vault_path(),
paths::secrets::recipient_path(),
paths::secrets::registry_path(),
)
} else if let Some(root) = project_root {
(
paths::secrets::project_vault_path(root),
paths::secrets::project_recipients_path(root),
paths::secrets::project_registry_path(root),
)
} else {
(
paths::secrets::vault_path(),
paths::secrets::recipient_path(),
paths::secrets::registry_path(),
)
};
if !vault_path.exists() {
println!("Vault not found. Creating...");
let recipient = vault::init_vault(&vault_path, &recipients_path)?;
println!("✓ Saved public key: {}", recipient);
}
let mut vault_data = vault::decrypt_vault(&vault_path)?;
vault_data.insert(name, value);
vault::encrypt_vault(&vault_data, &vault_path, &recipients_path)?;
let mut reg = registry::SecretsRegistry::load_from(®istry_path)?;
reg.insert(name, &env_var);
reg.save_to(®istry_path)?;
println!("✓ Added {} → {}", name, env_var);
Ok(())
}
pub fn remove_secret(name: &str, global: bool, project_root: Option<&Path>) -> Result<()> {
let (vault_path, recipients_path, registry_path) = if global {
(
paths::secrets::vault_path(),
paths::secrets::recipient_path(),
paths::secrets::registry_path(),
)
} else if let Some(root) = project_root {
(
paths::secrets::project_vault_path(root),
paths::secrets::project_recipients_path(root),
paths::secrets::project_registry_path(root),
)
} else {
bail!("No project root provided and --global not specified");
};
if !vault_path.exists() {
bail!("Vault not found");
}
let mut vault_data = vault::decrypt_vault(&vault_path)?;
if vault_data.remove(name).is_none() {
bail!("Secret '{}' not found in vault", name);
}
vault::encrypt_vault(&vault_data, &vault_path, &recipients_path)?;
let mut reg = registry::SecretsRegistry::load_from(®istry_path)?;
reg.remove(name);
reg.save_to(®istry_path)?;
println!("✓ Removed {}", name);
Ok(())
}
pub fn run_with_secrets(project_root: Option<&Path>, command: &[String]) -> Result<i32> {
if command.is_empty() {
bail!("No command provided");
}
let secrets = session::get_secrets_with_cache(|| load_all_secrets(project_root))?;
if secrets.is_empty() {
println!("No secrets to inject.");
} else {
let env_map = load_env_mappings(project_root)?;
println!("✓ Injecting {} secrets", secrets.len());
let mut cmd = Command::new(&command[0]);
cmd.args(&command[1..]);
cmd.envs(std::env::vars());
for (name, value) in &secrets {
if let Some(env_var) = env_map.get(name) {
cmd.env(env_var, value);
}
}
cmd.stdin(Stdio::inherit());
cmd.stdout(Stdio::inherit());
cmd.stderr(Stdio::inherit());
let status = cmd.status()?;
return Ok(status.code().unwrap_or(1));
}
let status = Command::new(&command[0]).args(&command[1..]).status()?;
Ok(status.code().unwrap_or(1))
}
pub fn run_with_secrets_ssh(
project_root: Option<&Path>,
host: &str,
command: &[String],
) -> Result<i32> {
if command.is_empty() {
bail!("No command provided");
}
let secrets = session::get_secrets_with_cache(|| load_all_secrets(project_root))?;
let env_map = load_env_mappings(project_root)?;
let mut stdin_script = String::new();
for (name, value) in &secrets {
if let Some(env_var) = env_map.get(name) {
let escaped_value = value.replace('\'', "'\\''");
stdin_script.push_str(&format!("export {}='{}'\n", env_var, escaped_value));
}
}
stdin_script.push_str(&format!("exec {}\n", shell_join(command)));
println!("✓ Injecting {} secrets via SSH (stdin)", secrets.len());
let mut child = Command::new("ssh")
.arg(host)
.arg("bash")
.arg("-s")
.stdin(Stdio::piped())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(stdin_script.as_bytes())?;
}
let status = child.wait()?;
Ok(status.code().unwrap_or(1))
}
fn shell_join(args: &[String]) -> String {
args.iter()
.map(|arg| {
if arg.is_empty()
|| arg
.contains(|c: char| c.is_whitespace() || "\"'\\$`!#&|;(){}[]<>?*~".contains(c))
{
format!("'{}'", arg.replace('\'', "'\\''"))
} else {
arg.clone()
}
})
.collect::<Vec<_>>()
.join(" ")
}
pub fn lock_session() -> Result<()> {
if session::clear_cache()? {
println!("✓ Session cache cleared");
} else {
println!("Session cache not active (serve not running)");
}
Ok(())
}
pub fn export_identity() -> Result<zeroize::Zeroizing<String>> {
identity::export_identity()
}
pub fn import_identity(identity_str: &str) -> Result<String> {
identity::import_identity(identity_str)
}
pub fn reset_identity() -> Result<()> {
keychain::delete_identity()
}
pub fn add_recipient(project_root: &Path, recipient_key: &str) -> Result<()> {
if !recipients::is_valid_age_recipient(recipient_key) {
bail!("Invalid age recipient. Expected age1...");
}
let recipients_path = paths::secrets::project_recipients_path(project_root);
let vault_path = paths::secrets::project_vault_path(project_root);
if !vault_path.exists() {
bail!("Project vault not found. Add a secret first.");
}
let mut recipient_list = recipients::load_recipients(&recipients_path)?;
if recipient_list.contains(&recipient_key.to_string()) {
bail!("Recipient already exists");
}
recipient_list.push(recipient_key.to_string());
recipients::save_recipients(&recipients_path, &recipient_list)?;
let vault_data = vault::decrypt_vault(&vault_path)?;
vault::encrypt_vault(&vault_data, &vault_path, &recipients_path)?;
println!(
"✓ Re-encrypted vault for {} recipients",
recipient_list.len()
);
Ok(())
}
pub fn remove_recipient(project_root: &Path, recipient_key: &str) -> Result<()> {
let recipients_path = paths::secrets::project_recipients_path(project_root);
let vault_path = paths::secrets::project_vault_path(project_root);
if !vault_path.exists() {
bail!("Project vault not found");
}
let mut recipient_list = recipients::load_recipients(&recipients_path)?;
let original_len = recipient_list.len();
recipient_list.retain(|r| r != recipient_key);
if recipient_list.len() == original_len {
bail!("Recipient not found");
}
if recipient_list.is_empty() {
bail!("Cannot remove last recipient");
}
recipients::save_recipients(&recipients_path, &recipient_list)?;
let vault_data = vault::decrypt_vault(&vault_path)?;
vault::encrypt_vault(&vault_data, &vault_path, &recipients_path)?;
println!(
"✓ Re-encrypted vault for {} recipients",
recipient_list.len()
);
Ok(())
}
pub fn list_recipients(project_root: &Path) -> Result<Vec<String>> {
let recipients_path = paths::secrets::project_recipients_path(project_root);
recipients::load_recipients(&recipients_path)
}
fn load_all_secrets(project_root: Option<&Path>) -> Result<HashMap<String, String>> {
let global_path = paths::secrets::vault_path();
let project_path = project_root.map(paths::secrets::project_vault_path);
vault::load_merged_secrets(
if global_path.exists() {
Some(&global_path)
} else {
None
},
project_path
.as_ref()
.filter(|p| p.exists())
.map(|p| p.as_path()),
)
}
fn load_env_mappings(project_root: Option<&Path>) -> Result<HashMap<String, String>> {
let mut mappings = HashMap::new();
let global_registry_path = paths::secrets::registry_path();
if global_registry_path.exists() {
let reg = registry::SecretsRegistry::load_from(&global_registry_path)?;
for (name, env) in reg.iter() {
mappings.insert(name.to_string(), env.to_string());
}
}
if let Some(root) = project_root {
let project_registry_path = paths::secrets::project_registry_path(root);
if project_registry_path.exists() {
let reg = registry::SecretsRegistry::load_from(&project_registry_path)?;
for (name, env) in reg.iter() {
mappings.insert(name.to_string(), env.to_string());
}
}
}
Ok(mappings)
}
pub fn prompt_for_value(name: &str) -> Result<String> {
let term = console::Term::stderr();
let value = term
.read_secure_line()
.map_err(|e| anyhow::anyhow!("Failed to read secret for '{}': {}", name, e))?;
Ok(value.trim().to_string())
}
#[cfg(test)]
mod tests {
use crate::paths;
#[test]
fn test_registry_path() {
let path = paths::secrets::registry_path();
assert!(path.to_string_lossy().ends_with("secrets.toml"));
assert!(path.to_string_lossy().contains(".patina"));
}
}