car-ffi-common 0.28.0

Shared logic for FFI bindings (NAPI, PyO3) — JSON wrappers for verify, multi-agent, scheduler
//! Shared JSON wrapper for `car-secrets` operations, consumed by the CLI,
//! NAPI + PyO3 bindings, and the WebSocket server.
//!
//! Each typed `SecretError` variant maps to a stable machine-readable
//! `code` so callers can branch programmatically instead of parsing
//! message strings. The error JSON shape is:
//!
//! ```json
//! {
//!   "code": "not_found | unavailable | backend | invalid_json",
//!   "message": "...",
//!   "context": {"service": "...", "key": "..."}
//! }
//! ```
//!
//! FFI surfaces (NAPI, PyO3, JSON-RPC) surface this as the error message
//! body — consumers parse the JSON to read `code` programmatically.

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)
}

// ---- Secret name index ------------------------------------------------------
//
// The OS keychain backend is stateless round-trips with no portable
// enumeration (macOS `security` can't list a service's keys without
// prompt/value exposure, and the `keyring` crate has no cross-platform list).
// To give CarHost's Secrets pane (car#366) a "List" without keychain
// enumeration, every secret written THROUGH this module records its
// `(service, key)` — NAMES ONLY, never the value — in `~/.car/secret_index.json`,
// and `delete` forgets it. `list` reads the index and joins a live existence
// check so a name whose keychain item was removed out-of-band is flagged.
//
// Deliberately scoped to secrets written through the FFI/CLI/WS surface: CAR's
// own internal secrets (connector OAuth tokens, browser session refs) go
// straight to `car_secrets::SecretStore`, bypass this module, and are correctly
// absent from the user-facing Secrets pane.

/// File name under `~/.car/`. Holds secret NAMES only — no values ever touch it.
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,
}

/// `~/.car/secret_index.json`. `None` when no home directory resolves (the
/// index is then simply unavailable — put/delete still succeed against the
/// keychain, list just can't enumerate).
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(), // missing/unreadable → empty
    }
}

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;
    };
    // Write to a sibling temp file then atomically rename, so a concurrent
    // `list`/`load_index` never observes a half-written or truncated file (the
    // index is best-effort, but a torn read would transiently blank the pane).
    // Temp is in the same directory to keep the rename on one filesystem.
    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); // don't leave a stray temp behind
        }
    }
}

/// Record a `(service, key)` in the index (idempotent — no duplicates).
/// Best-effort: index failures must never fail the keychain write itself.
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);
    }
}

/// Forget a `(service, key)` from the index. Best-effort.
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);
    }
}

/// Stable error codes the FFI surface exposes.
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)?;
    // Record the NAME (never the value) so the Secrets pane can list it (#366).
    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}))
}

/// List the names of secrets stored through this surface — `(service, key)`
/// plus a live `exists` flag, NEVER the values (#366). Backed by the
/// `~/.car/secret_index.json` name index, since the OS keychain has no portable
/// enumeration. Each indexed name is joined with a `status` existence check so a
/// secret removed out-of-band (e.g. via `security delete-generic-password`)
/// surfaces as `exists: false` rather than a phantom entry. Sorted by
/// `(service, key)` for a stable display order.
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| {
            // Existence is best-effort: a backend error (e.g. locked keychain)
            // is reported as `unknown` rather than a false `exists: false`.
            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}))
}

/// Internal accessor used by other car-ffi-common modules (e.g. the browser
/// session_ref path). Returns the raw string value.
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();
        // Missing file → empty.
        assert!(load_index_at(&path).entries.is_empty());

        index_record_at(&path, "car", "FRED_API_KEY");
        index_record_at(&path, "car", "FRED_API_KEY"); // idempotent
        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");

        // Forgetting an absent entry is a no-op (no panic, no spurious write).
        index_forget_at(&path, "car", "nope");
        assert_eq!(load_index_at(&path).entries.len(), 1);
    }

    #[test]
    fn index_never_holds_values() {
        // The index type has no value field; serializing it can't leak a secret
        // value even if a caller mistakenly passed one as a key.
        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");
        // A subsequent record overwrites the corrupt file cleanly.
        index_record_at(&path, "car", "K");
        assert_eq!(load_index_at(&path).entries.len(), 1);
    }
}