Skip to main content

harn_hostlib/secret_store/
mod.rs

1//! Per-OS secret-store host primitive.
2//!
3//! Stores credentials in a per-application namespace (`account`) keyed by
4//! a free-form `key`. The active backend is picked at call time:
5//!
6//! | OS               | Default backend                                              |
7//! |------------------|--------------------------------------------------------------|
8//! | macOS / iOS      | Apple Keychain (`security-framework`, generic password item) |
9//! | Windows          | Credential Manager (`CredRead`/`CredWrite`, generic type)    |
10//! | Linux / other    | File backend at `$XDG_CONFIG_HOME/<account>/credentials.json`|
11//!
12//! Setting `HARN_SECRET_STORE_BACKEND=file` forces the file backend on every
13//! OS — useful for sandboxed CI, eval harnesses, and anything that must not
14//! touch the user's keychain. Tests also set `HARN_SECRET_STORE_FILE_ROOT`
15//! to redirect the file backend's root config directory.
16//!
17//! The four registered builtins are intentionally minimal — they own
18//! "where the bytes live" and nothing else. Audit logging, schema
19//! validation beyond builtin signatures, env-vs-stored precedence, and
20//! migration logic belong in the `.harn` orchestration layer.
21
22use std::io;
23use std::sync::Arc;
24
25use harn_vm::VmValue;
26
27use crate::error::HostlibError;
28use crate::registry::{BuiltinRegistry, HostlibCapability};
29use crate::tools::args::{build_dict, dict_arg, require_string, str_value};
30
31mod file;
32#[cfg(any(target_os = "macos", target_os = "ios"))]
33mod keychain;
34#[cfg(target_os = "windows")]
35mod wincred;
36
37const GET_BUILTIN: &str = "hostlib_secret_store_get";
38const SET_BUILTIN: &str = "hostlib_secret_store_set";
39const DELETE_BUILTIN: &str = "hostlib_secret_store_delete";
40const LIST_BUILTIN: &str = "hostlib_secret_store_list";
41
42/// Backend-selection override. When set to `"file"` the file backend is
43/// used unconditionally. Other values fall through to the OS default.
44const BACKEND_ENV: &str = "HARN_SECRET_STORE_BACKEND";
45
46/// Minimal backend contract every implementation honors.
47trait Backend {
48    fn name(&self) -> &'static str;
49    fn get(&self, account: &str, key: &str) -> io::Result<Option<String>>;
50    fn set(&self, account: &str, key: &str, value: &str) -> io::Result<()>;
51    fn delete(&self, account: &str, key: &str) -> io::Result<bool>;
52    fn list(&self, account: &str) -> io::Result<Vec<String>>;
53}
54
55/// Secret-store capability. Stateless: backend selection is resolved on
56/// every call so environment-variable overrides (`HARN_SECRET_STORE_BACKEND`)
57/// take effect without requiring process restart.
58#[derive(Default)]
59pub struct SecretStoreCapability;
60
61impl HostlibCapability for SecretStoreCapability {
62    fn module_name(&self) -> &'static str {
63        "secret_store"
64    }
65
66    fn register_builtins(&self, registry: &mut BuiltinRegistry) {
67        registry.register_fn("secret_store", GET_BUILTIN, "get", handle_get);
68        registry.register_fn("secret_store", SET_BUILTIN, "set", handle_set);
69        registry.register_fn("secret_store", DELETE_BUILTIN, "delete", handle_delete);
70        registry.register_fn("secret_store", LIST_BUILTIN, "list", handle_list);
71    }
72}
73
74fn handle_get(args: &[VmValue]) -> Result<VmValue, HostlibError> {
75    let dict = dict_arg(GET_BUILTIN, args)?;
76    let account = require_nonempty_string(GET_BUILTIN, &dict, "account")?;
77    let key = require_nonempty_string(GET_BUILTIN, &dict, "key")?;
78
79    let backend = select_backend();
80    let value = backend
81        .get(&account, &key)
82        .map_err(|err| backend_err(GET_BUILTIN, err))?;
83
84    Ok(build_dict([
85        ("account".to_string(), str_value(&account)),
86        ("key".to_string(), str_value(&key)),
87        (
88            "value".to_string(),
89            value.as_deref().map(str_value).unwrap_or(VmValue::Nil),
90        ),
91        ("backend".to_string(), str_value(backend.name())),
92    ]))
93}
94
95fn handle_set(args: &[VmValue]) -> Result<VmValue, HostlibError> {
96    let dict = dict_arg(SET_BUILTIN, args)?;
97    let account = require_nonempty_string(SET_BUILTIN, &dict, "account")?;
98    let key = require_nonempty_string(SET_BUILTIN, &dict, "key")?;
99    let value = require_string(SET_BUILTIN, &dict, "value")?;
100
101    let backend = select_backend();
102    backend
103        .set(&account, &key, &value)
104        .map_err(|err| backend_err(SET_BUILTIN, err))?;
105
106    Ok(build_dict([
107        ("account".to_string(), str_value(&account)),
108        ("key".to_string(), str_value(&key)),
109        ("backend".to_string(), str_value(backend.name())),
110    ]))
111}
112
113fn handle_delete(args: &[VmValue]) -> Result<VmValue, HostlibError> {
114    let dict = dict_arg(DELETE_BUILTIN, args)?;
115    let account = require_nonempty_string(DELETE_BUILTIN, &dict, "account")?;
116    let key = require_nonempty_string(DELETE_BUILTIN, &dict, "key")?;
117
118    let backend = select_backend();
119    let deleted = backend
120        .delete(&account, &key)
121        .map_err(|err| backend_err(DELETE_BUILTIN, err))?;
122
123    Ok(build_dict([
124        ("account".to_string(), str_value(&account)),
125        ("key".to_string(), str_value(&key)),
126        ("deleted".to_string(), VmValue::Bool(deleted)),
127        ("backend".to_string(), str_value(backend.name())),
128    ]))
129}
130
131fn handle_list(args: &[VmValue]) -> Result<VmValue, HostlibError> {
132    let dict = dict_arg(LIST_BUILTIN, args)?;
133    let account = require_nonempty_string(LIST_BUILTIN, &dict, "account")?;
134
135    let backend = select_backend();
136    let keys = backend
137        .list(&account)
138        .map_err(|err| backend_err(LIST_BUILTIN, err))?;
139
140    let items: Vec<VmValue> = keys.into_iter().map(|k| str_value(&k)).collect();
141    Ok(build_dict([
142        ("account".to_string(), str_value(&account)),
143        ("keys".to_string(), VmValue::List(Arc::new(items))),
144        ("backend".to_string(), str_value(backend.name())),
145    ]))
146}
147
148fn require_nonempty_string(
149    builtin: &'static str,
150    dict: &harn_vm::value::DictMap,
151    key: &'static str,
152) -> Result<String, HostlibError> {
153    let value = require_string(builtin, dict, key)?;
154    if value.is_empty() {
155        Err(HostlibError::InvalidParameter {
156            builtin,
157            param: key,
158            message: "must be a non-empty string".to_string(),
159        })
160    } else {
161        Ok(value)
162    }
163}
164
165fn backend_err(builtin: &'static str, err: io::Error) -> HostlibError {
166    HostlibError::Backend {
167        builtin,
168        message: err.to_string(),
169    }
170}
171
172fn select_backend() -> Box<dyn Backend> {
173    if matches!(std::env::var(BACKEND_ENV).as_deref(), Ok("file")) {
174        return Box::new(file::FileStore::new());
175    }
176
177    #[cfg(any(target_os = "macos", target_os = "ios"))]
178    {
179        Box::new(keychain::KeychainStore::new())
180    }
181    #[cfg(target_os = "windows")]
182    {
183        Box::new(wincred::WinCredStore::new())
184    }
185    #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "windows")))]
186    {
187        Box::new(file::FileStore::new())
188    }
189}