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::rc::Rc;
24use std::sync::Arc;
25
26use harn_vm::VmValue;
27
28use crate::error::HostlibError;
29use crate::registry::{BuiltinRegistry, HostlibCapability, RegisteredBuiltin, SyncHandler};
30use crate::tools::args::{build_dict, dict_arg, require_string, str_value};
31
32mod file;
33#[cfg(any(target_os = "macos", target_os = "ios"))]
34mod keychain;
35#[cfg(target_os = "windows")]
36mod wincred;
37
38const GET_BUILTIN: &str = "hostlib_secret_store_get";
39const SET_BUILTIN: &str = "hostlib_secret_store_set";
40const DELETE_BUILTIN: &str = "hostlib_secret_store_delete";
41const LIST_BUILTIN: &str = "hostlib_secret_store_list";
42
43/// Backend-selection override. When set to `"file"` the file backend is
44/// used unconditionally. Other values fall through to the OS default.
45const BACKEND_ENV: &str = "HARN_SECRET_STORE_BACKEND";
46
47/// Minimal backend contract every implementation honors.
48trait Backend {
49    fn name(&self) -> &'static str;
50    fn get(&self, account: &str, key: &str) -> io::Result<Option<String>>;
51    fn set(&self, account: &str, key: &str, value: &str) -> io::Result<()>;
52    fn delete(&self, account: &str, key: &str) -> io::Result<bool>;
53    fn list(&self, account: &str) -> io::Result<Vec<String>>;
54}
55
56/// Secret-store capability. Stateless: backend selection is resolved on
57/// every call so environment-variable overrides (`HARN_SECRET_STORE_BACKEND`)
58/// take effect without requiring process restart.
59#[derive(Default)]
60pub struct SecretStoreCapability;
61
62impl HostlibCapability for SecretStoreCapability {
63    fn module_name(&self) -> &'static str {
64        "secret_store"
65    }
66
67    fn register_builtins(&self, registry: &mut BuiltinRegistry) {
68        register(registry, GET_BUILTIN, "get", handle_get);
69        register(registry, SET_BUILTIN, "set", handle_set);
70        register(registry, DELETE_BUILTIN, "delete", handle_delete);
71        register(registry, LIST_BUILTIN, "list", handle_list);
72    }
73}
74
75fn register(
76    registry: &mut BuiltinRegistry,
77    name: &'static str,
78    method: &'static str,
79    runner: fn(&[VmValue]) -> Result<VmValue, HostlibError>,
80) {
81    let handler: SyncHandler = Arc::new(runner);
82    registry.register(RegisteredBuiltin {
83        name,
84        module: "secret_store",
85        method,
86        handler,
87    });
88}
89
90fn handle_get(args: &[VmValue]) -> Result<VmValue, HostlibError> {
91    let dict = dict_arg(GET_BUILTIN, args)?;
92    let account = require_nonempty_string(GET_BUILTIN, &dict, "account")?;
93    let key = require_nonempty_string(GET_BUILTIN, &dict, "key")?;
94
95    let backend = select_backend();
96    let value = backend
97        .get(&account, &key)
98        .map_err(|err| backend_err(GET_BUILTIN, err))?;
99
100    Ok(build_dict([
101        ("account".to_string(), str_value(&account)),
102        ("key".to_string(), str_value(&key)),
103        (
104            "value".to_string(),
105            value.as_deref().map(str_value).unwrap_or(VmValue::Nil),
106        ),
107        ("backend".to_string(), str_value(backend.name())),
108    ]))
109}
110
111fn handle_set(args: &[VmValue]) -> Result<VmValue, HostlibError> {
112    let dict = dict_arg(SET_BUILTIN, args)?;
113    let account = require_nonempty_string(SET_BUILTIN, &dict, "account")?;
114    let key = require_nonempty_string(SET_BUILTIN, &dict, "key")?;
115    let value = require_string(SET_BUILTIN, &dict, "value")?;
116
117    let backend = select_backend();
118    backend
119        .set(&account, &key, &value)
120        .map_err(|err| backend_err(SET_BUILTIN, err))?;
121
122    Ok(build_dict([
123        ("account".to_string(), str_value(&account)),
124        ("key".to_string(), str_value(&key)),
125        ("backend".to_string(), str_value(backend.name())),
126    ]))
127}
128
129fn handle_delete(args: &[VmValue]) -> Result<VmValue, HostlibError> {
130    let dict = dict_arg(DELETE_BUILTIN, args)?;
131    let account = require_nonempty_string(DELETE_BUILTIN, &dict, "account")?;
132    let key = require_nonempty_string(DELETE_BUILTIN, &dict, "key")?;
133
134    let backend = select_backend();
135    let deleted = backend
136        .delete(&account, &key)
137        .map_err(|err| backend_err(DELETE_BUILTIN, err))?;
138
139    Ok(build_dict([
140        ("account".to_string(), str_value(&account)),
141        ("key".to_string(), str_value(&key)),
142        ("deleted".to_string(), VmValue::Bool(deleted)),
143        ("backend".to_string(), str_value(backend.name())),
144    ]))
145}
146
147fn handle_list(args: &[VmValue]) -> Result<VmValue, HostlibError> {
148    let dict = dict_arg(LIST_BUILTIN, args)?;
149    let account = require_nonempty_string(LIST_BUILTIN, &dict, "account")?;
150
151    let backend = select_backend();
152    let keys = backend
153        .list(&account)
154        .map_err(|err| backend_err(LIST_BUILTIN, err))?;
155
156    let items: Vec<VmValue> = keys.into_iter().map(|k| str_value(&k)).collect();
157    Ok(build_dict([
158        ("account".to_string(), str_value(&account)),
159        ("keys".to_string(), VmValue::List(Rc::new(items))),
160        ("backend".to_string(), str_value(backend.name())),
161    ]))
162}
163
164fn require_nonempty_string(
165    builtin: &'static str,
166    dict: &std::collections::BTreeMap<String, VmValue>,
167    key: &'static str,
168) -> Result<String, HostlibError> {
169    let value = require_string(builtin, dict, key)?;
170    if value.is_empty() {
171        Err(HostlibError::InvalidParameter {
172            builtin,
173            param: key,
174            message: "must be a non-empty string".to_string(),
175        })
176    } else {
177        Ok(value)
178    }
179}
180
181fn backend_err(builtin: &'static str, err: io::Error) -> HostlibError {
182    HostlibError::Backend {
183        builtin,
184        message: err.to_string(),
185    }
186}
187
188fn select_backend() -> Box<dyn Backend> {
189    if matches!(std::env::var(BACKEND_ENV).as_deref(), Ok("file")) {
190        return Box::new(file::FileStore::new());
191    }
192
193    #[cfg(any(target_os = "macos", target_os = "ios"))]
194    {
195        Box::new(keychain::KeychainStore::new())
196    }
197    #[cfg(target_os = "windows")]
198    {
199        Box::new(wincred::WinCredStore::new())
200    }
201    #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "windows")))]
202    {
203        Box::new(file::FileStore::new())
204    }
205}