use std::collections::HashMap;
use std::ops::{Deref, DerefMut};
use std::path::Path;
use crate::providers::index::ProviderIndex;
#[derive(Debug, Clone, Default)]
pub struct SecretVault(HashMap<String, String>);
impl SecretVault {
pub fn empty() -> Self {
Self(HashMap::new())
}
pub fn load_from(path: impl AsRef<Path>) -> anyhow::Result<Self> {
let raw = match std::fs::read(path.as_ref()) {
Ok(d) => d,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::empty()),
Err(e) => return Err(e.into()),
};
Self::guard_not_symlink(path.as_ref())?;
raw.split(|&b| b == b'\n')
.enumerate()
.filter(|(_, line)| {
let text = std::str::from_utf8(line).unwrap_or("");
let trimmed = text.trim();
!trimmed.is_empty() && !trimmed.starts_with('#')
})
.try_fold(Self::empty(), |mut acc, (i, line)| {
let text = std::str::from_utf8(line)?.trim();
let (key, raw_val) = text
.split_once('=')
.ok_or_else(|| anyhow::anyhow!("invalid secrets file line {}", i + 1))?;
anyhow::ensure!(is_valid_env_key(key), "invalid secrets file line {}", i + 1);
acc.0.insert(key.to_owned(), decode_shell_value(raw_val)?);
Ok(acc)
})
}
pub fn persist_to(&self, path: impl AsRef<Path>) -> anyhow::Result<()> {
let p = path.as_ref();
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent)?;
}
let body = self
.0
.keys()
.filter(|k| is_valid_env_key(k))
.collect::<std::collections::BTreeSet<_>>()
.iter()
.map(|k| format!("{}={}\n", k, encode_for_shell(&self.0[*k])))
.collect::<String>();
super::atomic::write_atomic(&p.to_string_lossy(), body.as_bytes(), 0o600)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
pub fn prune_outdated(&mut self, catalog: &ProviderIndex) {
let builtin_keys = catalog.builtin_secret_keys();
let stale: Vec<String> = self
.0
.iter()
.filter(|(k, v)| is_stale_legacy_entry(k, v, &builtin_keys))
.map(|(k, _)| k.clone())
.collect();
for k in stale {
self.0.remove(&k);
}
}
fn guard_not_symlink(path: &Path) -> anyhow::Result<()> {
let meta = std::fs::symlink_metadata(path)?;
if meta.file_type().is_symlink() {
anyhow::bail!("secrets file is a symlink: {}", path.display());
}
Ok(())
}
}
impl Deref for SecretVault {
type Target = HashMap<String, String>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for SecretVault {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl From<HashMap<String, String>> for SecretVault {
fn from(map: HashMap<String, String>) -> Self {
Self(map)
}
}
impl IntoIterator for SecretVault {
type Item = (String, String);
type IntoIter = std::collections::hash_map::IntoIter<String, String>;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
impl<'a> IntoIterator for &'a SecretVault {
type Item = (&'a String, &'a String);
type IntoIter = std::collections::hash_map::Iter<'a, String, String>;
fn into_iter(self) -> Self::IntoIter {
self.0.iter()
}
}
pub fn load_vault(path: &str) -> anyhow::Result<SecretVault> {
SecretVault::load_from(path)
}
pub fn persist_vault(path: &str, secrets: &SecretVault) -> anyhow::Result<()> {
secrets.persist_to(path)
}
pub fn prune_outdated_entries(secrets: &mut SecretVault, catalog: &ProviderIndex) {
secrets.prune_outdated(catalog);
}
pub fn redact(value: &str) -> String {
redact_credential(value)
}
pub fn redact_credential(value: &str) -> String {
match value.len() {
0 => String::new(),
1..=8 => "****".to_owned(),
_ => format!("{}****{}", &value[..4], &value[value.len() - 4..]),
}
}
fn is_stale_legacy_entry(
key: &str,
val: &str,
builtin_keys: &std::collections::HashSet<String>,
) -> bool {
if let Some(suffix) = key.strip_prefix("OPENROUTER_MODEL_") {
let alias = crate::config::registry::normalize_openrouter_name(
&suffix.to_lowercase().replace('_', "-"),
);
return alias.is_empty() || crate::config::registry::is_launcher_placeholder(val);
}
if let Some(rest) = key
.strip_prefix("CLAUDY_")
.and_then(|r| r.strip_suffix("_BASE_URL"))
{
return builtin_keys.contains(rest);
}
false
}
fn is_valid_env_key(key: &str) -> bool {
let first = key.as_bytes().first();
first.is_some_and(|&b| {
(b.is_ascii_uppercase() || b == b'_')
&& key.as_bytes()[1..]
.iter()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || *b == b'_')
})
}
fn decode_shell_value(value: &str) -> anyhow::Result<String> {
let b = value.as_bytes();
match b.first() {
Some(b'\'') if b.last() == Some(&b'\'') && b.len() >= 2 => {
Ok(value[1..value.len() - 1].to_owned())
}
Some(b'$') if b.get(1) == Some(&b'\'') && b.last() == Some(&b'\'') => {
unescape_ansi(&value[2..value.len() - 1])
}
Some(b'"') if b.last() == Some(&b'"') && b.len() >= 2 => {
Ok(value[1..value.len() - 1].replace("\\\"", "\""))
}
_ => Ok(value.to_owned()),
}
}
fn unescape_ansi(s: &str) -> anyhow::Result<String> {
let mut out = String::with_capacity(s.len());
let mut chars = s.as_bytes().iter().copied().peekable();
while let Some(b) = chars.next() {
if b != b'\\' {
out.push(b as char);
continue;
}
match chars.next() {
None => anyhow::bail!("unterminated escape"),
Some(b'n') => out.push('\n'),
Some(b't') => out.push('\t'),
Some(b'\\') => out.push('\\'),
Some(b'\'') => out.push('\''),
Some(other) => out.push(other as char),
}
}
Ok(out)
}
fn encode_for_shell(value: &str) -> String {
if value.is_empty() {
return "''".to_owned();
}
let needs_quoting = value
.as_bytes()
.iter()
.any(|b| matches!(b, b'\n' | b'\t' | b'\'' | b'\\' | b' '));
if !needs_quoting {
return value.to_owned();
}
let escaped = value
.replace('\\', "\\\\")
.replace('\n', "\\n")
.replace('\t', "\\t")
.replace('\'', "\\'");
format!("$'{}'", escaped)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::index as providers;
fn load_catalog() -> providers::ProviderIndex {
providers::load_index().expect("catalog should load")
}
#[test]
fn test_prune_outdated_drops_invalid_entries() {
let catalog = load_catalog();
let mut secrets = SecretVault::from(HashMap::from([
(
"OPENROUTER_MODEL_CLAUDY_OR_KIMI_K25".to_string(),
"claudy-or-kimi-k25".to_string(),
),
(
"OPENROUTER_MODEL_KIMI_K25".to_string(),
"moonshotai/kimi-k2.5".to_string(),
),
(
"CLAUDY_ALIBABA_API_KEY_BASE_URL".to_string(),
"https://example.com/unused".to_string(),
),
("ALIBABA_API_KEY".to_string(), "secret".to_string()),
]));
secrets.prune_outdated(&catalog);
assert!(
!secrets.contains_key("OPENROUTER_MODEL_CLAUDY_OR_KIMI_K25"),
"expected invalid OpenRouter launcher-shaped entry to be removed"
);
assert!(
!secrets.contains_key("CLAUDY_ALIBABA_API_KEY_BASE_URL"),
"expected builtin provider legacy base URL to be removed"
);
assert_eq!(
secrets.get("OPENROUTER_MODEL_KIMI_K25").map(|s| s.as_str()),
Some("moonshotai/kimi-k2.5"),
);
}
#[test]
fn test_redact_short() {
assert_eq!(redact_credential("ab"), "****");
}
#[test]
fn test_redact_empty() {
assert_eq!(redact_credential(""), "");
}
#[test]
fn test_redact_long() {
assert_eq!(redact_credential("abcdefghijklmnop"), "abcd****mnop");
}
#[test]
fn test_decode_single_quotes() {
assert_eq!(decode_shell_value("'hello world'").unwrap(), "hello world");
}
#[test]
fn test_decode_ansi_dollar_quotes() {
assert_eq!(
decode_shell_value("$'hello\\nworld'").unwrap(),
"hello\nworld"
);
assert_eq!(decode_shell_value("$'tab\\there'").unwrap(), "tab\there");
assert_eq!(
decode_shell_value("$'back\\\\slash'").unwrap(),
"back\\slash"
);
assert_eq!(decode_shell_value("$'quo\\'te'").unwrap(), "quo'te");
}
#[test]
fn test_decode_double_quotes() {
assert_eq!(
decode_shell_value("\"hello \\\"world\\\"\"").unwrap(),
"hello \"world\""
);
}
#[test]
fn test_decode_bare() {
assert_eq!(decode_shell_value("simple123").unwrap(), "simple123");
}
#[test]
fn test_encode_simple() {
assert_eq!(encode_for_shell("hello"), "hello");
}
#[test]
fn test_encode_empty() {
assert_eq!(encode_for_shell(""), "''");
}
#[test]
fn test_encode_special() {
let quoted = encode_for_shell("hello world");
assert!(
quoted.starts_with("$'"),
"expected $'...' for space, got {}",
quoted
);
}
#[test]
fn test_is_valid_env_key() {
assert!(is_valid_env_key("VALID_KEY"));
assert!(is_valid_env_key("_LEADING"));
assert!(!is_valid_env_key(""));
assert!(!is_valid_env_key("lowercase"));
assert!(!is_valid_env_key("1STARTS_NUM"));
assert!(is_valid_env_key("HAS_123"));
assert!(!is_valid_env_key("HAS-DASH"));
}
#[test]
fn test_roundtrip_via_impl_methods() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("secrets.env");
let secrets = SecretVault::from(HashMap::from([
("MY_KEY".to_string(), "my-value".to_string()),
("OTHER_KEY".to_string(), "other".to_string()),
]));
secrets.persist_to(&path).expect("persist");
let loaded = SecretVault::load_from(&path).expect("load");
assert_eq!(loaded.get("MY_KEY").map(|s| s.as_str()), Some("my-value"));
assert_eq!(loaded.get("OTHER_KEY").map(|s| s.as_str()), Some("other"));
}
#[test]
fn test_load_missing_returns_empty() {
let secrets =
SecretVault::load_from("/nonexistent/path/secrets.env").expect("load missing");
assert!(secrets.is_empty());
}
#[test]
fn test_roundtrip_with_special_chars() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("secrets.env");
let secrets = SecretVault::from(HashMap::from([(
"MY_KEY".to_string(),
"value with spaces\nand newlines".to_string(),
)]));
secrets.persist_to(&path).expect("persist");
let loaded = SecretVault::load_from(&path).expect("load");
assert_eq!(
loaded.get("MY_KEY").map(|s| s.as_str()),
Some("value with spaces\nand newlines")
);
}
}