use anyhow::{Context, Result};
#[cfg(target_os = "macos")]
use base64::Engine;
use clap::{Parser, Subcommand};
use log::{debug, info};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::time::Duration;
#[cfg(target_os = "macos")]
use rand_core::RngCore;
use crate::app::{InjectVarConfig, OpLoadConfig, TemplatedFile};
#[cfg(target_os = "macos")]
use crate::cache::cache_file_for_account;
use crate::cache::{
CacheKind, CacheRemoval, cache_dir, ensure_cache_dir, lock_path_for_account,
remove_cache_for_account,
};
#[cfg(target_os = "macos")]
use crate::keychain::{assert_keychain_available, delete_key, get_or_create_key};
#[derive(Debug, Default, Serialize, Deserialize)]
struct LegacyOpLoadConfig {
#[serde(default)]
inject_vars: std::collections::HashMap<String, String>,
#[serde(default)]
default_account_id: Option<String>,
#[serde(default)]
default_vault_per_account: std::collections::HashMap<String, String>,
#[serde(default)]
templated_files: std::collections::HashMap<String, TemplatedFile>,
}
#[derive(Parser)]
#[command(version)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
#[command(flatten)]
pub verbosity: clap_verbosity_flag::Verbosity,
}
#[derive(Subcommand)]
pub enum Command {
Config {
#[command(subcommand)]
action: ConfigAction,
},
Env {
#[command(subcommand)]
action: EnvAction,
},
Cache {
#[command(subcommand)]
action: CacheAction,
},
Template {
#[command(subcommand)]
action: TemplateAction,
},
}
#[derive(Subcommand, Debug)]
pub enum EnvAction {
Inject {
#[arg(long, value_name = "DURATION")]
cache_ttl: Option<String>,
#[arg(long, value_name = "DURATION", default_value = "5s")]
cache_lock_wait: String,
},
Unset,
}
#[derive(Subcommand, Debug)]
pub enum ConfigAction {
Get {
#[arg(short, long)]
key: String,
},
Path,
}
#[derive(Subcommand, Debug)]
pub enum TemplateAction {
Add {
path: String,
},
List,
Remove {
path: String,
},
Render,
}
#[derive(Subcommand, Debug)]
pub enum CacheAction {
Clear {
#[arg(long)]
account: Option<String>,
},
}
pub fn handle_config_action(action: ConfigAction) -> Result<()> {
handle_config_action_with_path(action, None)
}
fn handle_config_action_with_path(action: ConfigAction, config_path: Option<&Path>) -> Result<()> {
debug!("Handling config action: {action:?}");
match action {
ConfigAction::Get { key } => {
info!("Getting config key: {key}");
let config: OpLoadConfig = if let Some(path) = config_path {
confy::load_path(path).context("Failed to load configuration")?
} else {
confy::load("op_loader", None).context("Failed to load configuration")?
};
debug!("Config loaded successfully");
match key.as_str() {
"default_account_id" => match &config.default_account_id {
Some(preferred_account) => println!("{preferred_account}"),
None => println!("(not set)"),
},
_ => anyhow::bail!("Unknown config key: '{key}'."),
}
Ok(())
}
ConfigAction::Path => {
info!("Getting config path");
if let Some(path) = config_path {
debug!("Config path (provided): {}", path.display());
println!("{}", path.display());
} else {
let resolved_path = confy::get_configuration_file_path("op_loader", None)
.context("Failed to get config path")?
.display()
.to_string();
debug!("Config path resolved to: {resolved_path}");
println!("{resolved_path}");
}
Ok(())
}
}
}
pub fn handle_env_action(action: EnvAction) -> Result<()> {
match action {
EnvAction::Inject {
cache_ttl,
cache_lock_wait,
} => handle_env_injection(cache_ttl.as_deref(), Some(cache_lock_wait.as_str())),
EnvAction::Unset => handle_env_unset(),
}
}
pub fn handle_env_unset() -> Result<()> {
info!("Unsetting managed environment variables");
let config: OpLoadConfig =
confy::load("op_loader", None).context("Failed to load configuration")?;
debug!("Config loaded successfully");
if config.inject_vars.is_empty() {
info!("No managed environment variables configured");
return Ok(());
}
info!(
"Found {} managed environment variables",
config.inject_vars.len()
);
let keys: Vec<&String> = config.inject_vars.keys().collect();
let output = format_unsets(keys);
print!("{output}");
info!("Finished unsetting env var mappings");
Ok(())
}
fn format_unsets(keys: Vec<&String>) -> String {
let mut output = String::new();
for key in keys {
output.push_str("unset ");
output.push_str(key);
output.push('\n');
}
output
}
pub fn handle_env_injection(cache_ttl: Option<&str>, cache_lock_wait: Option<&str>) -> Result<()> {
info!("Loading environment variable mappings");
let mut config: OpLoadConfig =
confy::load("op_loader", None).context("Failed to load configuration")?;
debug!("Config loaded successfully");
if config.inject_vars.is_empty() {
let legacy: LegacyOpLoadConfig =
confy::load("op_loader", None).context("Failed to load configuration")?;
if legacy.inject_vars.is_empty() {
info!("No environment variables configured");
eprintln!("No environment variables configured. Use the TUI to add mappings.");
return Ok(());
}
eprintln!(
"Warning: Legacy inject_vars format detected. Please re-add your environment variable mappings in the TUI."
);
config.inject_vars.clear();
confy::store("op_loader", None, &config).context("Failed to save configuration")?;
}
if config.inject_vars.is_empty() {
return Ok(());
}
info!("Processing {} env var mappings", config.inject_vars.len());
let vars_by_account = group_vars_by_account(&config.inject_vars);
#[cfg(not(target_os = "macos"))]
if cache_ttl.is_some() {
anyhow::bail!("Cache is only supported on macOS.");
}
let cache_ttl = cache_ttl.map(parse_duration).transpose()?.unwrap_or(None);
let cache_lock_wait =
parse_duration(cache_lock_wait.unwrap_or("5s"))?.unwrap_or_else(|| Duration::from_secs(5));
let account_inputs: Vec<(&str, String)> = vars_by_account
.into_iter()
.map(|(account_id, vars)| {
let mut input = String::new();
for (env_var_name, var_config) in vars {
use std::fmt::Write;
writeln!(input, "{env_var_name}: {}", var_config.op_reference)
.expect("write to String cannot fail");
}
(account_id, input)
})
.collect();
let results: Vec<(String, Result<std::collections::HashMap<String, String>>)> =
std::thread::scope(|s| {
let handles: Vec<_> = account_inputs
.iter()
.map(|(account_id, input)| {
let account_id = *account_id;
s.spawn(move || {
let result =
load_resolved_vars(account_id, input, cache_ttl, cache_lock_wait);
(account_id.to_string(), result)
})
})
.collect();
handles
.into_iter()
.map(|h| h.join().expect("account resolver thread panicked"))
.collect()
});
let mut combined_output = String::new();
let mut resolved_vars_by_account: std::collections::HashMap<
String,
std::collections::HashMap<String, String>,
> = std::collections::HashMap::new();
for (account_id, result) in results {
match result {
Ok(resolved) => {
combined_output.push_str(&format_exports(&resolved));
resolved_vars_by_account.insert(account_id, resolved);
}
Err(err) => {
eprintln!("# Warning: Failed to inject secrets for account {account_id}: {err}");
}
}
}
print!("{combined_output}");
info!("Finished processing env var mappings");
if !config.templated_files.is_empty() {
info!("Rendering {} template files", config.templated_files.len());
render_templates(&config, &resolved_vars_by_account)?;
}
Ok(())
}
fn run_op_inject(account_id: &str, input: &str) -> Result<String> {
use std::process::{Command, Stdio};
let mut child = Command::new("op")
.args(["inject", "--account", account_id])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("Failed to run `op inject --account {account_id}`"))?;
if let Some(mut stdin) = child.stdin.take() {
use std::io::Write;
stdin
.write_all(input.as_bytes())
.with_context(|| "Failed to write to op inject stdin")?;
}
let output = child
.wait_with_output()
.with_context(|| "Failed to read op inject output")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("op inject failed: {stderr}");
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn parse_duration(input: &str) -> Result<Option<Duration>> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Ok(None);
}
if trimmed.len() < 2 {
anyhow::bail!("Invalid duration '{input}'. Use a number followed by s, m, h, or d.");
}
let (value, unit) = trimmed.split_at(trimmed.len().saturating_sub(1));
let amount: u64 = value
.parse()
.with_context(|| format!("Invalid duration value: {input}"))?;
let seconds = match unit {
"s" => amount,
"m" => amount.saturating_mul(60),
"h" => amount.saturating_mul(60 * 60),
"d" => amount.saturating_mul(60 * 60 * 24),
_ => anyhow::bail!("Invalid duration unit in '{input}'. Use s, m, h, or d."),
};
Ok(Some(Duration::from_secs(seconds)))
}
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
enum CacheReadOutcome {
Hit(String),
Miss,
Expired,
}
#[cfg(not(target_os = "macos"))]
fn read_cached_output(
_account_id: &str,
_kind: CacheKind,
_ttl: Duration,
) -> Result<CacheReadOutcome> {
anyhow::bail!("Cache is only supported on macOS.");
}
#[cfg(target_os = "macos")]
fn read_cached_output(
account_id: &str,
kind: CacheKind,
ttl: Duration,
) -> Result<CacheReadOutcome> {
read_cached_output_macos(account_id, kind, ttl)
}
#[cfg(target_os = "macos")]
fn read_cached_output_macos(
account_id: &str,
kind: CacheKind,
ttl: Duration,
) -> Result<CacheReadOutcome> {
let path = cache_file_for_account(account_id, kind)?;
let metadata = match std::fs::metadata(&path) {
Ok(meta) => meta,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(CacheReadOutcome::Miss);
}
Err(err) => {
return Err(err)
.with_context(|| format!("Failed to read cache metadata: {}", path.display()));
}
};
let modified = metadata
.modified()
.with_context(|| format!("Failed to read cache mtime: {}", path.display()))?;
let age = modified
.elapsed()
.unwrap_or_else(|_| Duration::from_secs(0));
if age > ttl {
return Ok(CacheReadOutcome::Expired);
}
let contents = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read cache file: {}", path.display()))?;
match decrypt_cache(&contents) {
Ok(decrypted) => {
let rendered = String::from_utf8_lossy(&decrypted).to_string();
Ok(CacheReadOutcome::Hit(rendered))
}
Err(err) => {
eprintln!("# Warning: Failed to decrypt cache for account {account_id}: {err}");
if let Err(remove_err) = std::fs::remove_file(&path) {
eprintln!(
"# Warning: Failed to remove corrupt cache file {}: {remove_err}",
path.display()
);
}
Ok(CacheReadOutcome::Miss)
}
}
}
fn read_cached_output_if_fresh(
account_id: &str,
kind: CacheKind,
ttl: Duration,
) -> Result<Option<String>> {
match read_cached_output(account_id, kind, ttl)? {
CacheReadOutcome::Hit(cached) => Ok(Some(cached)),
CacheReadOutcome::Expired | CacheReadOutcome::Miss => Ok(None),
}
}
fn try_log_cache_state(account_id: &str, kind: CacheKind, ttl: Duration) {
let prefix = match kind {
CacheKind::ResolvedVars => "Cache",
};
match read_cached_output(account_id, kind, ttl) {
Ok(CacheReadOutcome::Hit(_)) => info!("{prefix} hit for account {account_id}"),
Ok(CacheReadOutcome::Expired) => info!("{prefix} expired for account {account_id}"),
Ok(CacheReadOutcome::Miss) => info!("{prefix} miss for account {account_id}"),
Err(err) => eprintln!("# Warning: Failed to read cache for account {account_id}: {err}"),
}
}
#[cfg(target_os = "macos")]
fn encrypt_cache(plaintext: &[u8]) -> Result<String> {
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Key, Nonce};
assert_keychain_available()?;
let key = get_or_create_key()?;
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key));
let mut nonce_bytes = [0u8; 12];
rand_core::OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext)
.map_err(|err| anyhow::anyhow!("Failed to encrypt cache: {err}"))?;
let mut payload = Vec::with_capacity(1 + nonce_bytes.len() + ciphertext.len());
payload.push(1u8);
payload.extend_from_slice(&nonce_bytes);
payload.extend_from_slice(&ciphertext);
Ok(base64::engine::general_purpose::STANDARD.encode(payload))
}
#[cfg(target_os = "macos")]
fn decrypt_cache(encoded: &str) -> Result<Vec<u8>> {
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Key, Nonce};
assert_keychain_available()?;
let key = get_or_create_key()?;
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key));
let payload = base64::engine::general_purpose::STANDARD
.decode(encoded)
.context("Failed to decode cache base64")?;
if payload.len() < 1 + 12 {
anyhow::bail!("Invalid cache payload length");
}
if payload[0] != 1u8 {
anyhow::bail!("Unsupported cache payload version");
}
let nonce = Nonce::from_slice(&payload[1..13]);
let ciphertext = &payload[13..];
cipher
.decrypt(nonce, ciphertext)
.map_err(|err| anyhow::anyhow!("Failed to decrypt cache: {err}"))
}
#[cfg(not(target_os = "macos"))]
fn write_cached_output(_account_id: &str, _kind: CacheKind, _output: &str) -> Result<()> {
anyhow::bail!("Cache is only supported on macOS.");
}
#[cfg(target_os = "macos")]
fn write_cached_output(account_id: &str, kind: CacheKind, output: &str) -> Result<()> {
write_cached_output_macos(account_id, kind, output)
}
fn load_resolved_vars(
account_id: &str,
input: &str,
cache_ttl: Option<Duration>,
cache_lock_wait: Duration,
) -> Result<std::collections::HashMap<String, String>> {
if let Some(ttl) = cache_ttl {
if let Ok(Some(cached)) =
read_cached_output_if_fresh(account_id, CacheKind::ResolvedVars, ttl)
{
info!("Cache hit for account {account_id}");
return parse_cached_vars(&cached);
}
try_log_cache_state(account_id, CacheKind::ResolvedVars, ttl);
let lock_file = open_lock_file_for_account(account_id)?;
let acquired = lock_exclusive_with_timeout(&lock_file, cache_lock_wait)?;
if !acquired {
anyhow::bail!(
"Cache lock for account {account_id} not acquired within {}s",
cache_lock_wait.as_secs()
);
}
if let Ok(Some(cached)) =
read_cached_output_if_fresh(account_id, CacheKind::ResolvedVars, ttl)
{
info!("Cache hit (after lock) for account {account_id}");
let _ = lock_file.unlock();
return parse_cached_vars(&cached);
}
let resolved_json = resolve_vars_json(account_id, input)?;
if let Err(err) = write_cached_output(account_id, CacheKind::ResolvedVars, &resolved_json) {
eprintln!("# Warning: Failed to write cache for account {account_id}: {err}");
}
let _ = lock_file.unlock();
return parse_cached_vars(&resolved_json);
}
let resolved_json = resolve_vars_json(account_id, input)?;
parse_cached_vars(&resolved_json)
}
fn lock_exclusive_with_timeout(file: &std::fs::File, timeout: Duration) -> Result<bool> {
use fs2::FileExt;
use std::sync::mpsc;
if file.try_lock_exclusive().is_ok() {
return Ok(true);
}
info!("Lock contended, waiting up to {}s", timeout.as_secs());
let file_dup = file.try_clone().context("Failed to duplicate lock fd")?;
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let result = file_dup.lock_exclusive();
if tx.send(result).is_err() {
let _ = file_dup.unlock();
}
});
match rx.recv_timeout(timeout) {
Ok(Ok(())) => Ok(true),
Ok(Err(err)) => Err(err).context("Failed to acquire exclusive lock"),
Err(mpsc::RecvTimeoutError::Timeout) => Ok(false),
Err(mpsc::RecvTimeoutError::Disconnected) => {
anyhow::bail!("Lock thread terminated unexpectedly")
}
}
}
fn resolve_vars_json(account_id: &str, input: &str) -> Result<String> {
let output = run_op_inject(account_id, input)?;
let mut vars = std::collections::HashMap::new();
for line in output.lines() {
if let Some((var_name, value)) = line.split_once(": ") {
vars.insert(var_name.to_string(), value.to_string());
}
}
serde_json::to_string(&vars).context("Failed to serialize resolved vars")
}
fn parse_cached_vars(cached_json: &str) -> Result<std::collections::HashMap<String, String>> {
serde_json::from_str(cached_json).context("Failed to parse cached vars")
}
fn format_exports(vars: &std::collections::HashMap<String, String>) -> String {
let mut lines: Vec<(&String, &String)> = vars.iter().collect();
lines.sort_by(|a, b| a.0.cmp(b.0));
let mut output = String::new();
for (key, value) in lines {
let escaped = escape_shell_single_quotes(value);
output.push_str("export ");
output.push_str(key);
output.push_str("='");
output.push_str(&escaped);
output.push_str("'\n");
}
output
}
fn escape_shell_single_quotes(value: &str) -> String {
value.replace('\'', "'\\''")
}
#[cfg(target_os = "macos")]
fn write_cached_output_macos(account_id: &str, kind: CacheKind, output: &str) -> Result<()> {
use std::fs::OpenOptions;
use std::io::Write;
ensure_cache_dir()?;
let path = cache_file_for_account(account_id, kind)?;
let tmp_path = path.with_extension("cache.tmp");
let encrypted = encrypt_cache(output.as_bytes())?;
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&tmp_path)
.with_context(|| {
format!(
"Failed to open temp cache file for writing: {}",
tmp_path.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = file.metadata()?.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(&tmp_path, perms).with_context(|| {
format!(
"Failed to set cache file permissions: {}",
tmp_path.display()
)
})?;
}
file.write_all(encrypted.as_bytes())
.with_context(|| format!("Failed to write temp cache file: {}", tmp_path.display()))?;
file.sync_all()
.with_context(|| format!("Failed to sync temp cache file: {}", tmp_path.display()))?;
drop(file);
std::fs::rename(&tmp_path, &path)
.with_context(|| format!("Failed to rename temp cache to {}", path.display()))?;
Ok(())
}
fn open_lock_file_for_account(account_id: &str) -> Result<std::fs::File> {
use std::fs::OpenOptions;
ensure_cache_dir()?;
let lock_path = lock_path_for_account(account_id)?;
let lock_file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&lock_path)
.with_context(|| format!("Failed to open cache lock: {}", lock_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = lock_file.metadata()?.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(&lock_path, perms).with_context(|| {
format!(
"Failed to set lock file permissions: {}",
lock_path.display()
)
})?;
}
Ok(lock_file)
}
fn get_templates_dir() -> Result<PathBuf> {
let config_path = confy::get_configuration_file_path("op_loader", None)
.context("Failed to get config path")?;
let config_dir = config_path
.parent()
.context("Config path has no parent directory")?;
Ok(config_dir.join("templates"))
}
fn expand_path(path: &str) -> Result<PathBuf> {
let expanded = if let Some(suffix) = path.strip_prefix("~/") {
let home = std::env::var("HOME").context("HOME environment variable not set")?;
PathBuf::from(home).join(suffix)
} else {
PathBuf::from(path)
};
if expanded.exists() {
expanded
.canonicalize()
.with_context(|| format!("Failed to canonicalize path: {}", expanded.display()))
} else {
Ok(expanded)
}
}
fn path_to_template_name(path: &Path) -> String {
let filename = path.file_name().map_or_else(
|| "template".to_string(),
|s| s.to_string_lossy().to_string(),
);
format!("{filename}.tmpl")
}
pub fn handle_template_action(action: TemplateAction) -> Result<()> {
debug!("Handling template action: {action:?}");
match action {
TemplateAction::Add { path } => template_add(&path),
TemplateAction::List => template_list(),
TemplateAction::Remove { path } => template_remove(&path),
TemplateAction::Render => {
let config: OpLoadConfig =
confy::load("op_loader", None).context("Failed to load configuration")?;
let resolved_vars_by_account = std::collections::HashMap::new();
render_templates(&config, &resolved_vars_by_account)
}
}
}
pub fn handle_cache_action(action: CacheAction) -> Result<()> {
debug!("Handling cache action: {action:?}");
match action {
CacheAction::Clear { account } => {
if let Some(account_id) = account {
match remove_cache_for_account(&account_id) {
Ok(CacheRemoval::Removed) => {
println!("Cleared cache for account {account_id}");
}
Ok(CacheRemoval::NotFound) => {
println!("No cache found for account {account_id}");
}
Err(err) => {
eprintln!("Warning: Failed to clear cache for account {account_id}: {err}");
}
}
} else {
clear_all_caches()?;
#[cfg(target_os = "macos")]
{
if let Err(err) = delete_key() {
eprintln!("Warning: Failed to delete cache key from Keychain: {err}");
}
}
}
}
}
Ok(())
}
fn clear_all_caches() -> Result<()> {
let dir = cache_dir()?;
if !dir.exists() {
println!("No cache directory found.");
return Ok(());
}
let mut removed = 0usize;
let mut failed = 0usize;
let mut saw_file = false;
for entry in std::fs::read_dir(&dir)
.with_context(|| format!("Failed to read cache directory: {}", dir.display()))?
{
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
match std::fs::remove_file(&path) {
Ok(()) => removed += 1,
Err(err) => {
failed += 1;
eprintln!("Warning: Failed to remove {}: {err}", path.display());
}
}
saw_file = true;
}
if !saw_file {
println!("No cache files found.");
return Ok(());
}
println!(
"Cleared {removed} cache file(s).{suffix}",
suffix = if failed > 0 { " (some failures)" } else { "" }
);
Ok(())
}
fn template_add(path: &str) -> Result<()> {
info!("Adding template for: {path}");
let target_path = expand_path(path)?;
let target_key = target_path.to_string_lossy().to_string();
if !target_path.exists() {
anyhow::bail!("File does not exist: {}", target_path.display());
}
let mut config: OpLoadConfig =
confy::load("op_loader", None).context("Failed to load configuration")?;
if config.templated_files.contains_key(&target_key) {
anyhow::bail!(
"File is already managed as a template: {}",
target_path.display()
);
}
let templates_dir = get_templates_dir()?;
std::fs::create_dir_all(&templates_dir).with_context(|| {
format!(
"Failed to create templates directory: {}",
templates_dir.display()
)
})?;
let template_name = path_to_template_name(&target_path);
let template_path = templates_dir.join(&template_name);
let original_content =
std::fs::read_to_string(&target_path).context("Failed to read source file")?;
let var_names: Vec<String> = config
.inject_vars
.keys()
.map(|k| format!("{{{{{k}}}}}"))
.collect();
let vars_comment = if var_names.is_empty() {
"# op-loader: No variables configured yet. Use the TUI to add variables.\n".to_string()
} else {
format!(
"# op-loader: Available variables: {}\n",
var_names.join(", ")
)
};
let template_content = format!("{vars_comment}{original_content}");
std::fs::write(&template_path, &template_content)
.with_context(|| format!("Failed to write template to {}", template_path.display()))?;
config
.templated_files
.insert(target_key, TemplatedFile { template_name });
confy::store("op_loader", None, &config).context("Failed to save configuration")?;
println!("Added template for: {}", target_path.display());
println!("Template stored at: {}", template_path.display());
println!("\nAdd {{VAR_NAME}} placeholders to the template file.");
println!("Use `op-loader template list` to see configured variables.");
Ok(())
}
fn template_list() -> Result<()> {
info!("Listing templates");
let config: OpLoadConfig =
confy::load("op_loader", None).context("Failed to load configuration")?;
if config.templated_files.is_empty() {
println!("No template files configured.");
println!("\nAdd a template with: op-loader template add <path>");
return Ok(());
}
let templates_dir = get_templates_dir()?;
println!("Managed template files:\n");
for (target_path, template_config) in &config.templated_files {
let template_path = templates_dir.join(&template_config.template_name);
let status = if template_path.exists() {
"✓"
} else {
"✗ (missing)"
};
println!(" {status} {target_path}");
println!(" └─ {}", template_path.display());
}
Ok(())
}
fn template_remove(path: &str) -> Result<()> {
info!("Removing template for: {path}");
let target_path = expand_path(path)?;
let target_key = target_path.to_string_lossy().to_string();
let mut config: OpLoadConfig =
confy::load("op_loader", None).context("Failed to load configuration")?;
let template_config = config
.templated_files
.remove(&target_key)
.with_context(|| {
format!(
"File is not managed as a template: {}",
target_path.display()
)
})?;
let templates_dir = get_templates_dir()?;
let template_path = templates_dir.join(&template_config.template_name);
if template_path.exists() {
std::fs::remove_file(&template_path)
.with_context(|| format!("Failed to delete template: {}", template_path.display()))?;
println!("Removed template: {}", template_path.display());
} else {
println!(
"Removed config for: {} (template file was already missing)",
target_path.display()
);
}
confy::store("op_loader", None, &config).context("Failed to save configuration")?;
Ok(())
}
fn render_templates(
config: &OpLoadConfig,
resolved_vars_by_account: &std::collections::HashMap<
String,
std::collections::HashMap<String, String>,
>,
) -> Result<()> {
let templates_dir = get_templates_dir()?;
let resolved_vars: std::collections::HashMap<String, String> = resolved_vars_by_account
.values()
.flat_map(|vars| vars.iter().map(|(k, v)| (k.clone(), v.clone())))
.collect();
for (target_path, template_config) in &config.templated_files {
let template_path = templates_dir.join(&template_config.template_name);
if !template_path.exists() {
eprintln!(
"# Warning: Template file not found for {}: {}",
target_path,
template_path.display()
);
continue;
}
debug!(
"Rendering template: {} -> {}",
template_path.display(),
target_path
);
let template_content =
std::fs::read_to_string(&template_path).context("Failed to read template file")?;
let mut rendered: String = template_content
.lines()
.filter(|line| !line.starts_with("# op-loader:"))
.collect::<Vec<_>>()
.join("\n");
if template_content.ends_with('\n') && !rendered.ends_with('\n') {
rendered.push('\n');
}
for (var_name, value) in &resolved_vars {
let placeholder = format!("{{{{{var_name}}}}}");
rendered = rendered.replace(&placeholder, value);
}
let target = PathBuf::from(target_path);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
std::fs::write(&target, &rendered)
.with_context(|| format!("Failed to write to {target_path}"))?;
info!("Rendered template: {target_path}");
}
Ok(())
}
fn group_vars_by_account<'a>(
inject_vars: &'a std::collections::HashMap<String, InjectVarConfig>,
) -> std::collections::BTreeMap<&'a str, Vec<(&'a str, &'a InjectVarConfig)>> {
let mut vars_by_account: std::collections::BTreeMap<
&'a str,
Vec<(&'a str, &'a InjectVarConfig)>,
> = std::collections::BTreeMap::new();
for (var_name, var_config) in inject_vars {
vars_by_account
.entry(var_config.account_id.as_str())
.or_default()
.push((var_name.as_str(), var_config));
}
vars_by_account
}
#[cfg(all(test, target_os = "macos"))]
mod cache_tests {
use super::*;
use crate::cache::cache_path_for_account;
use assert_fs::TempDir;
use filetime::FileTime;
#[cfg(target_os = "macos")]
fn write_cached_output_at(
cache_root: &std::path::Path,
account_id: &str,
kind: CacheKind,
output: &str,
) -> Result<()> {
use std::fs::OpenOptions;
use std::io::Write;
std::fs::create_dir_all(cache_root).with_context(|| {
format!("Failed to create cache directory: {}", cache_root.display())
})?;
let path = cache_path_for_account(cache_root, account_id, kind);
let encrypted = super::encrypt_cache(output.as_bytes())?;
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&path)
.with_context(|| {
format!("Failed to open cache file for writing: {}", path.display())
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = file.metadata()?.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(&path, perms).with_context(|| {
format!("Failed to set cache file permissions: {}", path.display())
})?;
}
file.write_all(encrypted.as_bytes())
.with_context(|| format!("Failed to write cache file: {}", path.display()))?;
Ok(())
}
#[cfg(target_os = "macos")]
fn read_cached_output_at(
cache_root: &std::path::Path,
account_id: &str,
kind: CacheKind,
ttl: Duration,
) -> Result<CacheReadOutcome> {
let path = cache_path_for_account(cache_root, account_id, kind);
let metadata = match std::fs::metadata(&path) {
Ok(meta) => meta,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(CacheReadOutcome::Miss);
}
Err(err) => {
return Err(err)
.with_context(|| format!("Failed to read cache metadata: {}", path.display()));
}
};
let modified = metadata
.modified()
.with_context(|| format!("Failed to read cache mtime: {}", path.display()))?;
let age = modified
.elapsed()
.unwrap_or_else(|_| Duration::from_secs(0));
if age > ttl {
return Ok(CacheReadOutcome::Expired);
}
let contents = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read cache file: {}", path.display()))?;
let decrypted = super::decrypt_cache(&contents)?;
let rendered = String::from_utf8_lossy(&decrypted).to_string();
Ok(CacheReadOutcome::Hit(rendered))
}
#[cfg(target_os = "macos")]
fn clear_all_caches_at(cache_root: &std::path::Path) -> Result<()> {
if !cache_root.exists() {
return Ok(());
}
for entry in std::fs::read_dir(cache_root)
.with_context(|| format!("Failed to read cache directory: {}", cache_root.display()))?
{
let entry = entry?;
let path = entry.path();
if path.is_file() {
std::fs::remove_file(&path)
.with_context(|| format!("Failed to remove cache file: {}", path.display()))?;
}
}
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn cache_write_and_read_hit() {
let temp_dir = TempDir::new().unwrap();
let cache_root = temp_dir.path().join("op_loader");
let output = "{\"FOO\":\"bar\"}";
write_cached_output_at(&cache_root, "account-1", CacheKind::ResolvedVars, output).unwrap();
let result = read_cached_output_at(
&cache_root,
"account-1",
CacheKind::ResolvedVars,
Duration::from_secs(60),
)
.unwrap();
match result {
CacheReadOutcome::Hit(contents) => assert_eq!(contents, output),
_ => panic!("Expected cache hit"),
}
}
#[cfg(target_os = "macos")]
#[test]
fn cache_read_expired_returns_expired() {
let temp_dir = TempDir::new().unwrap();
let cache_root = temp_dir.path().join("op_loader");
write_cached_output_at(
&cache_root,
"account-2",
CacheKind::ResolvedVars,
"{\"TOKEN\":\"old\"}",
)
.unwrap();
let cache_path = cache_path_for_account(&cache_root, "account-2", CacheKind::ResolvedVars);
let past = std::time::SystemTime::now() - Duration::from_secs(120);
filetime::set_file_mtime(&cache_path, FileTime::from_system_time(past)).unwrap();
let result = read_cached_output_at(
&cache_root,
"account-2",
CacheKind::ResolvedVars,
Duration::from_secs(60),
)
.unwrap();
assert!(matches!(result, CacheReadOutcome::Expired));
}
#[cfg(target_os = "macos")]
#[test]
fn cache_read_missing_returns_miss() {
let temp_dir = TempDir::new().unwrap();
let cache_root = temp_dir.path().join("op_loader");
let result = read_cached_output_at(
&cache_root,
"missing-account",
CacheKind::ResolvedVars,
Duration::from_secs(60),
)
.unwrap();
assert!(matches!(result, CacheReadOutcome::Miss));
}
#[cfg(target_os = "macos")]
#[test]
fn cache_clear_removes_all_files() {
let temp_dir = TempDir::new().unwrap();
let cache_root = temp_dir.path().join("op_loader");
write_cached_output_at(
&cache_root,
"account-a",
CacheKind::ResolvedVars,
"{\"A\":\"1\"}",
)
.unwrap();
std::fs::write(cache_root.join("extra-file.txt"), "extra").unwrap();
std::fs::create_dir_all(cache_root.join("nested")).unwrap();
clear_all_caches_at(&cache_root).unwrap();
let remaining_files = std::fs::read_dir(cache_root)
.unwrap()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().is_file())
.count();
assert_eq!(remaining_files, 0);
}
}
#[cfg(test)]
mod config_tests {
use super::*;
use assert_fs::TempDir;
#[test]
fn config_get_default_account_id() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let config = OpLoadConfig {
default_account_id: Some("test-account-123".to_string()),
..Default::default()
};
confy::store_path(&config_path, &config).unwrap();
let result = handle_config_action_with_path(
ConfigAction::Get {
key: "default_account_id".to_string(),
},
Some(&config_path),
);
assert!(result.is_ok());
}
#[test]
fn config_get_unknown_key() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let result = handle_config_action_with_path(
ConfigAction::Get {
key: "nonexistent_key".to_string(),
},
Some(&config_path),
);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Unknown config key")
);
}
#[test]
fn config_path_shows_custom_path() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("config.toml");
let result = handle_config_action_with_path(ConfigAction::Path, Some(&config_path));
assert!(result.is_ok());
}
#[test]
fn config_get_when_file_does_not_exist_returns_not_set() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("nonexistent.toml");
let result = handle_config_action_with_path(
ConfigAction::Get {
key: "default_account_id".to_string(),
},
Some(&config_path),
);
assert!(result.is_ok());
}
}
#[cfg(test)]
mod resolved_vars_tests {
use super::*;
#[test]
fn parses_resolved_vars_json() {
let json = r#"{"API_KEY":"abc123","URL":"https://example.com"}"#;
let parsed = parse_cached_vars(json).unwrap();
assert_eq!(parsed.get("API_KEY"), Some(&"abc123".to_string()));
assert_eq!(parsed.get("URL"), Some(&"https://example.com".to_string()));
}
#[test]
fn format_exports_escapes_single_quotes() {
let mut vars = std::collections::HashMap::new();
vars.insert("TOKEN".to_string(), "a'b".to_string());
let output = format_exports(&vars);
assert_eq!(output, "export TOKEN='a'\\''b'\n");
}
#[test]
fn format_exports_preserves_colons_and_newlines() {
let mut vars = std::collections::HashMap::new();
vars.insert("CONFIG".to_string(), "line1:ok\nline2".to_string());
let output = format_exports(&vars);
assert_eq!(output, "export CONFIG='line1:ok\nline2'\n");
}
}
#[cfg(test)]
mod unset_tests {
use super::*;
#[test]
fn format_unsets_empty_returns_empty_string() {
let keys: Vec<&String> = Vec::new();
let output = format_unsets(keys);
assert_eq!(output, "");
}
#[test]
fn format_unsets_emits_unset_lines_in_order() {
let var_a = "API_TOKEN".to_string();
let var_b = "USER".to_string();
let keys = vec![&var_a, &var_b];
let output = format_unsets(keys);
assert_eq!(output, "unset API_TOKEN\nunset USER\n");
}
}
#[cfg(test)]
mod template_tests {
use super::*;
mod path_to_template_name {
use super::*;
#[test]
fn extracts_filename_from_path() {
let path = Path::new("/Users/foo/.npmrc");
let result = path_to_template_name(path);
assert_eq!(result, ".npmrc.tmpl");
}
#[test]
fn handles_simple_filename() {
let path = Path::new("myfile.txt");
let result = path_to_template_name(path);
assert_eq!(result, "myfile.txt.tmpl");
}
#[test]
fn handles_nested_path() {
let path = Path::new("/home/user/.config/app/settings.json");
let result = path_to_template_name(path);
assert_eq!(result, "settings.json.tmpl");
}
}
mod expand_path {
use super::*;
use std::env;
#[test]
fn expands_tilde_to_home() {
let home = env::var("HOME").unwrap();
let result = expand_path("~/.npmrc").unwrap();
assert_eq!(result, PathBuf::from(format!("{}/.npmrc", home)));
}
#[test]
fn preserves_absolute_path() {
let result = expand_path("/some/absolute/path").unwrap();
assert_eq!(result, PathBuf::from("/some/absolute/path"));
}
#[test]
fn handles_relative_path() {
let result = expand_path("relative/path").unwrap();
assert_eq!(result, PathBuf::from("relative/path"));
}
}
mod render_template_content {
fn render_content(
template: &str,
vars: &std::collections::HashMap<String, String>,
) -> String {
let mut rendered: String = template
.lines()
.filter(|line| !line.starts_with("# op-loader:"))
.collect::<Vec<_>>()
.join("\n");
if template.ends_with('\n') && !rendered.ends_with('\n') {
rendered.push('\n');
}
for (var_name, value) in vars {
let placeholder = format!("{{{{{}}}}}", var_name);
rendered = rendered.replace(&placeholder, value);
}
rendered
}
#[test]
fn substitutes_single_variable() {
let template = "token={{MY_TOKEN}}\n";
let mut vars = std::collections::HashMap::new();
vars.insert("MY_TOKEN".to_string(), "secret123".to_string());
let result = render_content(template, &vars);
assert_eq!(result, "token=secret123\n");
}
#[test]
fn substitutes_multiple_variables() {
let template = "user={{USER}}\npass={{PASS}}\n";
let mut vars = std::collections::HashMap::new();
vars.insert("USER".to_string(), "admin".to_string());
vars.insert("PASS".to_string(), "secret".to_string());
let result = render_content(template, &vars);
assert_eq!(result, "user=admin\npass=secret\n");
}
#[test]
fn strips_op_loader_comments() {
let template = "# op-loader: Available variables: {{TOKEN}}\n# op-loader: This line too\ntoken={{TOKEN}}\n";
let mut vars = std::collections::HashMap::new();
vars.insert("TOKEN".to_string(), "abc".to_string());
let result = render_content(template, &vars);
assert_eq!(result, "token=abc\n");
}
#[test]
fn preserves_other_comments() {
let template = "# This is a regular comment\ntoken={{TOKEN}}\n";
let mut vars = std::collections::HashMap::new();
vars.insert("TOKEN".to_string(), "xyz".to_string());
let result = render_content(template, &vars);
assert_eq!(result, "# This is a regular comment\ntoken=xyz\n");
}
#[test]
fn handles_same_var_multiple_times() {
let template = "first={{VAR}} second={{VAR}}\n";
let mut vars = std::collections::HashMap::new();
vars.insert("VAR".to_string(), "value".to_string());
let result = render_content(template, &vars);
assert_eq!(result, "first=value second=value\n");
}
#[test]
fn leaves_unmatched_placeholders() {
let template = "token={{UNKNOWN}}\n";
let vars = std::collections::HashMap::new();
let result = render_content(template, &vars);
assert_eq!(result, "token={{UNKNOWN}}\n");
}
#[test]
fn preserves_trailing_newline() {
let template = "content\n";
let vars = std::collections::HashMap::new();
let result = render_content(template, &vars);
assert!(result.ends_with('\n'));
}
#[test]
fn handles_empty_template() {
let template = "";
let vars = std::collections::HashMap::new();
let result = render_content(template, &vars);
assert_eq!(result, "");
}
}
}
#[cfg(test)]
mod lock_tests {
use super::*;
use assert_fs::TempDir;
use std::fs::OpenOptions;
use std::time::Duration;
fn open_temp_lock(dir: &std::path::Path, name: &str) -> std::fs::File {
OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(dir.join(name))
.unwrap()
}
#[test]
fn uncontended_lock_succeeds_immediately() {
let dir = TempDir::new().unwrap();
let lock = open_temp_lock(dir.path(), "test.lock");
let result = lock_exclusive_with_timeout(&lock, Duration::from_secs(1)).unwrap();
assert!(result, "should acquire uncontended lock");
}
#[test]
fn lock_times_out_when_held_by_another_thread() {
use fs2::FileExt;
let dir = TempDir::new().unwrap();
let lock_path = dir.path().join("contended.lock");
let holder = open_temp_lock(dir.path(), "contended.lock");
holder.lock_exclusive().unwrap();
let contender = OpenOptions::new()
.read(true)
.write(true)
.open(&lock_path)
.unwrap();
let result = lock_exclusive_with_timeout(&contender, Duration::from_millis(200)).unwrap();
assert!(!result, "should time out while lock is held");
let _ = holder.unlock();
}
#[test]
fn lock_acquired_after_holder_releases() {
use fs2::FileExt;
let dir = TempDir::new().unwrap();
let lock_path = dir.path().join("delayed.lock");
let holder = open_temp_lock(dir.path(), "delayed.lock");
holder.lock_exclusive().unwrap();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(100));
let _ = holder.unlock();
});
let contender = OpenOptions::new()
.read(true)
.write(true)
.open(&lock_path)
.unwrap();
let result = lock_exclusive_with_timeout(&contender, Duration::from_secs(5)).unwrap();
assert!(result, "should acquire lock after holder releases");
}
#[test]
fn per_account_locks_are_independent() {
use fs2::FileExt;
let dir = TempDir::new().unwrap();
let lock_a = open_temp_lock(dir.path(), "account_a.lock");
lock_a.lock_exclusive().unwrap();
let lock_b = open_temp_lock(dir.path(), "account_b.lock");
let result = lock_exclusive_with_timeout(&lock_b, Duration::from_millis(200)).unwrap();
assert!(result, "account B lock should not be blocked by account A");
let _ = lock_a.unlock();
}
}
#[cfg(all(test, target_os = "macos"))]
mod concurrency_tests {
use super::*;
use crate::cache::cache_path_for_account;
use assert_fs::TempDir;
use std::time::Duration;
fn write_test_cache(
cache_root: &std::path::Path,
account_id: &str,
kind: CacheKind,
output: &str,
) {
use std::fs::OpenOptions;
use std::io::Write;
std::fs::create_dir_all(cache_root).unwrap();
let path = cache_path_for_account(cache_root, account_id, kind);
let encrypted = encrypt_cache(output.as_bytes()).unwrap();
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&path)
.unwrap();
file.write_all(encrypted.as_bytes()).unwrap();
}
fn read_test_cache(
cache_root: &std::path::Path,
account_id: &str,
kind: CacheKind,
ttl: Duration,
) -> Option<String> {
let path = cache_path_for_account(cache_root, account_id, kind);
let metadata = match std::fs::metadata(&path) {
Ok(m) => m,
Err(_) => return None,
};
let modified = metadata.modified().ok()?;
let age = modified.elapsed().unwrap_or_default();
if age > ttl {
return None;
}
let contents = std::fs::read_to_string(&path).ok()?;
let decrypted = decrypt_cache(&contents).ok()?;
Some(String::from_utf8_lossy(&decrypted).to_string())
}
#[test]
fn atomic_write_not_visible_as_partial_data() {
let dir = TempDir::new().unwrap();
let cache_root = dir.path().join("cache");
let account = "test-atomic";
let original = r#"{"KEY":"original"}"#;
write_test_cache(&cache_root, account, CacheKind::ResolvedVars, original);
let cache_root_clone = cache_root.clone();
let reader_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let reader_done_clone = reader_done.clone();
let reader = std::thread::spawn(move || {
let mut saw_values = Vec::new();
let start = std::time::Instant::now();
while start.elapsed() < Duration::from_millis(500) {
if let Some(data) = read_test_cache(
&cache_root_clone,
account,
CacheKind::ResolvedVars,
Duration::from_secs(60),
) {
assert!(
serde_json::from_str::<std::collections::HashMap<String, String>>(&data)
.is_ok(),
"Reader saw invalid JSON: {data}"
);
saw_values.push(data);
}
std::thread::sleep(Duration::from_millis(1));
}
reader_done_clone.store(true, std::sync::atomic::Ordering::Release);
saw_values
});
let updated = r#"{"KEY":"updated"}"#;
let path = cache_path_for_account(&cache_root, account, CacheKind::ResolvedVars);
let tmp_path = path.with_extension("cache.tmp");
let encrypted = encrypt_cache(updated.as_bytes()).unwrap();
std::fs::write(&tmp_path, &encrypted).unwrap();
std::fs::rename(&tmp_path, &path).unwrap();
let saw = reader.join().unwrap();
assert!(
!saw.is_empty(),
"reader should have seen at least one cache value"
);
for val in &saw {
assert!(
val == original || val == updated,
"unexpected cache content: {val}"
);
}
}
#[test]
fn double_check_prevents_redundant_resolve() {
use fs2::FileExt;
let dir = TempDir::new().unwrap();
let cache_root = dir.path().join("cache");
let lock_path = dir.path().join("account.lock");
let account = "double-check-test";
let expected = r#"{"SECRET":"from_process_1"}"#;
let lock_file = std::fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&lock_path)
.unwrap();
lock_file.lock_exclusive().unwrap();
let cache_root_clone = cache_root.clone();
let lock_path_clone = lock_path.clone();
let process2 = std::thread::spawn(move || {
let lock = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&lock_path_clone)
.unwrap();
lock.lock_exclusive().unwrap();
let cached = read_test_cache(
&cache_root_clone,
account,
CacheKind::ResolvedVars,
Duration::from_secs(60),
);
let _ = lock.unlock();
cached
});
std::thread::sleep(Duration::from_millis(50));
write_test_cache(&cache_root, account, CacheKind::ResolvedVars, expected);
let _ = lock_file.unlock();
let result = process2.join().unwrap();
assert_eq!(
result.as_deref(),
Some(expected),
"process 2 should see cache populated by process 1"
);
}
}