use std::io::{self, Read, Write};
use std::path::PathBuf;
use clap::{Args, Parser, Subcommand as ClapSubcommand};
use ring::digest::{digest, SHA256};
use serde_json::json;
use zeroize::Zeroizing;
use crate::dotenv::parse_dotenv;
use crate::error::SecretshError;
use crate::redact::Redactor;
use crate::spawn::{spawn_child, SpawnConfig};
use crate::tokenizer::tokenize;
use crate::vault::{Vault, VaultConfig};
pub fn default_vault_path() -> PathBuf {
#[cfg(target_os = "macos")]
{
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_owned());
PathBuf::from(home)
.join("Library")
.join("Application Support")
.join("secretsh")
.join("vault.bin")
}
#[cfg(not(target_os = "macos"))]
{
let base = std::env::var("XDG_DATA_HOME")
.ok()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_owned());
PathBuf::from(home).join(".local").join("share")
});
base.join("secretsh").join("vault.bin")
}
}
pub fn emit_audit(op: &str, key_count: usize, extra: &serde_json::Value) {
let ts = chrono::Utc::now().to_rfc3339();
let mut entry = json!({
"ts": ts,
"op": op,
"key_count": key_count,
});
if let (Some(obj), Some(extra_obj)) = (entry.as_object_mut(), extra.as_object()) {
for (k, v) in extra_obj {
obj.insert(k.clone(), v.clone());
}
}
let _ = writeln!(io::stderr(), "{}", entry);
}
#[derive(Args, Debug)]
pub struct VaultArgs {
#[arg(long, value_name = "PATH")]
pub vault: Option<PathBuf>,
#[arg(long, value_name = "ENV_VAR", default_value = "SECRETSH_KEY")]
pub master_key_env: String,
}
impl VaultArgs {
pub fn vault_path(&self) -> PathBuf {
self.vault.clone().unwrap_or_else(default_vault_path)
}
pub fn to_vault_config(&self) -> VaultConfig {
VaultConfig {
vault_path: self.vault_path(),
master_key_env: self.master_key_env.clone(),
allow_insecure_permissions: false,
kdf_memory: None,
}
}
}
#[derive(Parser, Debug)]
#[command(
name = "secretsh",
version,
about = "Inject secrets from an encrypted vault into subprocess arguments",
long_about = None,
)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(ClapSubcommand, Debug)]
pub enum Command {
Init(InitArgs),
Set(SetArgs),
Delete(DeleteArgs),
List(ListArgs),
Run(RunArgs),
Export(ExportArgs),
Import(ImportArgs),
ImportEnv(ImportEnvArgs),
}
#[derive(Args, Debug)]
pub struct InitArgs {
#[arg(long, value_name = "PATH")]
pub vault: Option<PathBuf>,
#[arg(long, value_name = "ENV_VAR", default_value = "SECRETSH_KEY")]
pub master_key_env: String,
#[arg(long, value_name = "KiB", default_value_t = 131_072, value_parser = clap::value_parser!(u32).range(65_536..))]
pub kdf_memory: u32,
#[arg(long)]
pub no_passphrase_check: bool,
#[arg(long)]
pub force: bool,
}
impl InitArgs {
fn vault_path(&self) -> PathBuf {
self.vault.clone().unwrap_or_else(default_vault_path)
}
}
#[derive(Args, Debug)]
pub struct SetArgs {
pub key_name: String,
#[command(flatten)]
pub vault: VaultArgs,
}
#[derive(Args, Debug)]
pub struct DeleteArgs {
pub key_name: String,
#[command(flatten)]
pub vault: VaultArgs,
}
#[derive(Args, Debug)]
pub struct ListArgs {
#[command(flatten)]
pub vault: VaultArgs,
}
#[derive(Args, Debug)]
pub struct RunArgs {
#[command(flatten)]
pub vault: VaultArgs,
#[arg(long, value_name = "SECONDS", default_value_t = 300)]
pub timeout: u64,
#[arg(long, value_name = "BYTES", default_value_t = 52_428_800)]
pub max_output: usize,
#[arg(long, value_name = "BYTES", default_value_t = 1_048_576)]
pub max_stderr: usize,
#[arg(long)]
pub quiet: bool,
#[arg(long)]
pub verbose: bool,
#[arg(last = true, required = true, value_name = "COMMAND")]
pub command: Vec<String>,
}
#[derive(Args, Debug)]
pub struct ExportArgs {
#[command(flatten)]
pub vault: VaultArgs,
#[arg(long, value_name = "PATH", required = true)]
pub out: PathBuf,
}
#[derive(Args, Debug)]
pub struct ImportArgs {
#[command(flatten)]
pub vault: VaultArgs,
#[arg(long = "in", value_name = "PATH", required = true)]
pub input: PathBuf,
#[arg(long)]
pub overwrite: bool,
#[arg(long, value_name = "ENV_VAR")]
pub import_key_env: Option<String>,
}
#[derive(Args, Debug)]
pub struct ImportEnvArgs {
#[command(flatten)]
pub vault: VaultArgs,
#[arg(long = "file", short = 'f', value_name = "PATH", required = true)]
pub file: PathBuf,
#[arg(long)]
pub overwrite: bool,
}
pub fn run_init(args: &InitArgs) -> Result<(), SecretshError> {
let vault_path = args.vault_path();
if vault_path.exists() {
if args.force {
std::fs::remove_file(&vault_path)
.map_err(|e| SecretshError::Io(crate::error::IoError(e)))?;
} else {
return Err(SecretshError::Io(crate::error::IoError(
std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!(
"vault already exists at {} — use --force to overwrite",
vault_path.display()
),
),
)));
}
}
let config = VaultConfig {
vault_path: vault_path.clone(),
master_key_env: args.master_key_env.clone(),
allow_insecure_permissions: false,
kdf_memory: Some(args.kdf_memory),
};
if args.no_passphrase_check {
Vault::init_no_passphrase_check(&config)?;
} else {
Vault::init(&config)?;
}
eprintln!("secretsh: vault initialised at {}", vault_path.display());
Ok(())
}
pub fn run_set(args: &SetArgs) -> Result<(), SecretshError> {
let config = args.vault.to_vault_config();
let mut vault = Vault::open(&config)?;
let value: Zeroizing<Vec<u8>> = {
let stdin = io::stdin();
let mut buf = Vec::new();
stdin
.lock()
.read_to_end(&mut buf)
.map_err(|e| SecretshError::Io(crate::error::IoError(e)))?;
if buf.ends_with(b"\n") {
buf.pop();
}
Zeroizing::new(buf)
};
vault.set(&args.key_name, &value)?;
let key_count = vault.list_keys().len();
emit_audit("set", key_count, &json!({}));
Ok(())
}
pub fn run_delete(args: &DeleteArgs) -> Result<(), SecretshError> {
let config = args.vault.to_vault_config();
let mut vault = Vault::open(&config)?;
let removed = vault.delete(&args.key_name)?;
if !removed {
eprintln!(
"secretsh: key {:?} not found in vault — nothing to delete",
args.key_name
);
}
let key_count = vault.list_keys().len();
emit_audit("delete", key_count, &json!({ "removed": removed }));
Ok(())
}
pub fn run_list(args: &ListArgs) -> Result<(), SecretshError> {
let config = args.vault.to_vault_config();
let vault = Vault::open(&config)?;
let keys = vault.list_keys();
let stdout = io::stdout();
let mut out = stdout.lock();
for key in &keys {
writeln!(out, "{key}").map_err(|e| SecretshError::Io(crate::error::IoError(e)))?;
}
Ok(())
}
pub fn run_run(args: &RunArgs) -> Result<i32, SecretshError> {
let config = args.vault.to_vault_config();
let vault = Vault::open(&config)?;
let command_str = args.command.join(" ");
if args.verbose {
eprintln!("[secretsh] tokenizing command: {command_str:?}");
}
let token_result = tokenize(&command_str)?;
if args.verbose {
eprintln!("[secretsh] tokens: {:?}", token_result.tokens);
}
let mut argv: Vec<Zeroizing<Vec<u8>>> = Vec::with_capacity(token_result.tokens.len());
for token in &token_result.tokens {
let mut resolved: Vec<u8> = token.value.as_bytes().to_vec();
for placeholder in token.placeholders.iter().rev() {
let secret = vault.resolve_placeholder(&placeholder.key).ok_or_else(|| {
crate::error::PlaceholderError::UnresolvedKey {
key: placeholder.key.clone(),
}
})?;
let mut new_resolved = Vec::with_capacity(
resolved.len() - (placeholder.end - placeholder.start) + secret.len(),
);
new_resolved.extend_from_slice(&resolved[..placeholder.start]);
new_resolved.extend_from_slice(secret);
new_resolved.extend_from_slice(&resolved[placeholder.end..]);
resolved = new_resolved;
}
resolved.push(0u8);
argv.push(Zeroizing::new(resolved));
}
let secrets: Vec<(&str, &[u8])> = vault.all_secret_values();
let redactor = Redactor::new(&secrets)?;
let cmd_resolved_hash = {
let mut combined: Vec<u8> = Vec::new();
for arg in &argv {
let without_nul = &arg[..arg.len().saturating_sub(1)];
combined.extend_from_slice(without_nul);
combined.push(0u8);
}
let h = digest(&SHA256, &combined);
format!("sha256:{}", hex::encode(h.as_ref()))
};
let spawn_config = SpawnConfig {
timeout_secs: args.timeout,
max_output_bytes: args.max_output,
max_stderr_bytes: args.max_stderr,
};
let result = spawn_child(argv, &redactor, &spawn_config)?;
{
let stdout = io::stdout();
let mut out = stdout.lock();
out.write_all(result.stdout.as_bytes())
.map_err(|e| SecretshError::Io(crate::error::IoError(e)))?;
}
{
let stderr = io::stderr();
let mut err = stderr.lock();
err.write_all(result.stderr.as_bytes())
.map_err(|e| SecretshError::Io(crate::error::IoError(e)))?;
}
if !args.quiet {
let key_count = vault.list_keys().len();
let cmd_template_hash = {
let h = digest(&SHA256, command_str.as_bytes());
format!("sha256:{}", hex::encode(h.as_ref()))
};
emit_audit(
"run",
key_count,
&json!({
"exit_code": result.exit_code,
"timed_out": result.timed_out,
"cmd_template_hash": cmd_template_hash,
"cmd_resolved_hash": cmd_resolved_hash,
}),
);
}
Ok(result.exit_code)
}
pub fn run_export(args: &ExportArgs) -> Result<(), SecretshError> {
let config = args.vault.to_vault_config();
let vault = Vault::open(&config)?;
vault.export(&args.out)?;
let key_count = vault.list_keys().len();
emit_audit("export", key_count, &json!({}));
eprintln!("secretsh: vault exported to {}", args.out.display());
Ok(())
}
pub fn run_import(args: &ImportArgs) -> Result<(), SecretshError> {
let config = args.vault.to_vault_config();
let mut vault = Vault::open(&config)?;
let (added, skipped, replaced) =
vault.import(&args.input, args.import_key_env.as_deref(), args.overwrite)?;
let key_count = vault.list_keys().len();
emit_audit(
"import",
key_count,
&json!({
"added": added,
"skipped": skipped,
"replaced": replaced,
}),
);
eprintln!(
"secretsh: imported {} entries ({} skipped, {} replaced) from {}",
added,
skipped,
replaced,
args.input.display()
);
Ok(())
}
pub fn run_import_env(args: &ImportEnvArgs) -> Result<(), SecretshError> {
let config = args.vault.to_vault_config();
let mut vault = Vault::open(&config)?;
let entries = parse_dotenv(&args.file)?;
if entries.is_empty() {
eprintln!("secretsh: no entries found in {}", args.file.display());
return Ok(());
}
let existing_keys: std::collections::HashSet<String> = vault.list_keys().into_iter().collect();
let mut added: usize = 0;
let mut skipped: usize = 0;
let mut replaced: usize = 0;
for entry in &entries {
let exists = existing_keys.contains(&entry.key);
if exists && !args.overwrite {
eprintln!(
"secretsh: skipping {:?} (already exists, use --overwrite)",
entry.key
);
skipped += 1;
continue;
}
vault.set(&entry.key, &entry.value)?;
if exists {
replaced += 1;
} else {
added += 1;
}
}
let key_count = vault.list_keys().len();
emit_audit(
"import-env",
key_count,
&json!({
"added": added,
"skipped": skipped,
"replaced": replaced,
"source": args.file.display().to_string(),
}),
);
eprintln!(
"secretsh: imported {} entries ({} skipped, {} replaced) from {}",
added,
skipped,
replaced,
args.file.display()
);
Ok(())
}