use car_secrets::{SecretError, SecretRef, SecretStatus, SecretStore, DEFAULT_SERVICE};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::PathBuf;
fn make_ref(service: Option<&str>, key: &str) -> SecretRef {
SecretRef::new(service.unwrap_or(DEFAULT_SERVICE), key)
}
const INDEX_FILE: &str = "secret_index.json";
#[derive(Debug, Default, Serialize, Deserialize)]
struct SecretIndex {
#[serde(default)]
entries: Vec<IndexEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct IndexEntry {
service: String,
key: String,
}
fn index_path() -> Option<PathBuf> {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(PathBuf::from(home).join(".car").join(INDEX_FILE))
}
fn load_index() -> SecretIndex {
match index_path() {
Some(path) => load_index_at(&path),
None => SecretIndex::default(),
}
}
fn load_index_at(path: &std::path::Path) -> SecretIndex {
match std::fs::read_to_string(path) {
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
Err(_) => SecretIndex::default(), }
}
fn save_index_at(path: &std::path::Path, idx: &SecretIndex) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let Ok(s) = serde_json::to_string_pretty(idx) else {
return;
};
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, s).is_ok() {
if std::fs::rename(&tmp, path).is_err() {
let _ = std::fs::remove_file(&tmp); }
}
}
fn index_record(service: &str, key: &str) {
if let Some(path) = index_path() {
index_record_at(&path, service, key);
}
}
fn index_record_at(path: &std::path::Path, service: &str, key: &str) {
let mut idx = load_index_at(path);
if !idx
.entries
.iter()
.any(|e| e.service == service && e.key == key)
{
idx.entries.push(IndexEntry {
service: service.to_string(),
key: key.to_string(),
});
save_index_at(path, &idx);
}
}
fn index_forget(service: &str, key: &str) {
if let Some(path) = index_path() {
index_forget_at(&path, service, key);
}
}
fn index_forget_at(path: &std::path::Path, service: &str, key: &str) {
let mut idx = load_index_at(path);
let before = idx.entries.len();
idx.entries
.retain(|e| !(e.service == service && e.key == key));
if idx.entries.len() != before {
save_index_at(path, &idx);
}
}
fn code_for(e: &SecretError) -> &'static str {
match e {
SecretError::NotFound { .. } => "not_found",
SecretError::Unavailable(_) => "unavailable",
SecretError::Backend(_) => "backend",
SecretError::InvalidJson(_) => "invalid_json",
}
}
fn json_err(e: SecretError) -> String {
let mut body = json!({
"code": code_for(&e),
"message": e.to_string(),
});
if let SecretError::NotFound { service, key } = &e {
body["context"] = json!({"service": service, "key": key});
}
body.to_string()
}
pub fn put(service: Option<&str>, key: &str, value: &str) -> Result<Value, String> {
let store = SecretStore::new();
let svc = service.unwrap_or(DEFAULT_SERVICE);
store
.put(&make_ref(service, key), value)
.map_err(json_err)?;
index_record(svc, key);
Ok(json!({"service": svc, "key": key, "stored": true}))
}
pub fn get(service: Option<&str>, key: &str) -> Result<Value, String> {
let store = SecretStore::new();
let v = store.get(&make_ref(service, key)).map_err(json_err)?;
Ok(json!({"service": service.unwrap_or(DEFAULT_SERVICE), "key": key, "value": v}))
}
pub fn delete(service: Option<&str>, key: &str) -> Result<Value, String> {
let store = SecretStore::new();
let svc = service.unwrap_or(DEFAULT_SERVICE);
store.delete(&make_ref(service, key)).map_err(json_err)?;
index_forget(svc, key);
Ok(json!({"service": svc, "key": key, "deleted": true}))
}
pub fn list() -> Result<Value, String> {
let store = SecretStore::new();
let mut idx = load_index();
idx.entries.sort_by(|a, b| {
a.service
.cmp(&b.service)
.then_with(|| a.key.cmp(&b.key))
});
let secrets: Vec<Value> = idx
.entries
.iter()
.map(|e| {
let exists = store
.status(&SecretRef::new(e.service.clone(), e.key.clone()))
.map(|st| Value::Bool(st.exists))
.unwrap_or(Value::Null);
json!({"service": e.service, "key": e.key, "exists": exists})
})
.collect();
Ok(json!({"secrets": secrets}))
}
pub fn status(service: Option<&str>, key: &str) -> Result<Value, String> {
let store = SecretStore::new();
let st: SecretStatus = store.status(&make_ref(service, key)).map_err(json_err)?;
serde_json::to_value(&st)
.map_err(|e| json!({"code": "backend", "message": e.to_string()}).to_string())
}
pub fn is_available() -> Value {
let check = SecretStore::new().availability();
serde_json::to_value(&check).unwrap_or_else(|_| json!({"available": check.available}))
}
pub fn read_raw(service: Option<&str>, key: &str) -> Result<String, String> {
SecretStore::new()
.get(&make_ref(service, key))
.map_err(json_err)
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_index() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(".car").join(INDEX_FILE);
(dir, path)
}
#[test]
fn index_records_dedups_and_forgets() {
let (_d, path) = tmp_index();
assert!(load_index_at(&path).entries.is_empty());
index_record_at(&path, "car", "FRED_API_KEY");
index_record_at(&path, "car", "FRED_API_KEY"); index_record_at(&path, "providers", "OPENAI_API_KEY");
let idx = load_index_at(&path);
assert_eq!(idx.entries.len(), 2, "dedup on (service,key)");
index_forget_at(&path, "car", "FRED_API_KEY");
let idx = load_index_at(&path);
assert_eq!(idx.entries.len(), 1);
assert_eq!(idx.entries[0].key, "OPENAI_API_KEY");
index_forget_at(&path, "car", "nope");
assert_eq!(load_index_at(&path).entries.len(), 1);
}
#[test]
fn index_never_holds_values() {
let (_d, path) = tmp_index();
index_record_at(&path, "car", "API_KEY");
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("API_KEY"));
assert!(raw.contains("service"));
assert!(!raw.contains("value"), "index must never serialize a value field");
}
#[test]
fn corrupt_index_degrades_to_empty() {
let (_d, path) = tmp_index();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "not json {{{").unwrap();
assert!(load_index_at(&path).entries.is_empty(), "corrupt → empty, no panic");
index_record_at(&path, "car", "K");
assert_eq!(load_index_at(&path).entries.len(), 1);
}
}