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, RegisteredBuiltin, SyncHandler};
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        register(registry, GET_BUILTIN, "get", handle_get);
68        register(registry, SET_BUILTIN, "set", handle_set);
69        register(registry, DELETE_BUILTIN, "delete", handle_delete);
70        register(registry, LIST_BUILTIN, "list", handle_list);
71    }
72}
73
74fn register(
75    registry: &mut BuiltinRegistry,
76    name: &'static str,
77    method: &'static str,
78    runner: fn(&[VmValue]) -> Result<VmValue, HostlibError>,
79) {
80    let handler: SyncHandler = Arc::new(runner);
81    registry.register(RegisteredBuiltin {
82        name,
83        module: "secret_store",
84        method,
85        handler,
86    });
87}
88
89fn handle_get(args: &[VmValue]) -> Result<VmValue, HostlibError> {
90    let dict = dict_arg(GET_BUILTIN, args)?;
91    let account = require_nonempty_string(GET_BUILTIN, &dict, "account")?;
92    let key = require_nonempty_string(GET_BUILTIN, &dict, "key")?;
93
94    let backend = select_backend();
95    let value = backend
96        .get(&account, &key)
97        .map_err(|err| backend_err(GET_BUILTIN, err))?;
98
99    Ok(build_dict([
100        ("account".to_string(), str_value(&account)),
101        ("key".to_string(), str_value(&key)),
102        (
103            "value".to_string(),
104            value.as_deref().map(str_value).unwrap_or(VmValue::Nil),
105        ),
106        ("backend".to_string(), str_value(backend.name())),
107    ]))
108}
109
110fn handle_set(args: &[VmValue]) -> Result<VmValue, HostlibError> {
111    let dict = dict_arg(SET_BUILTIN, args)?;
112    let account = require_nonempty_string(SET_BUILTIN, &dict, "account")?;
113    let key = require_nonempty_string(SET_BUILTIN, &dict, "key")?;
114    let value = require_string(SET_BUILTIN, &dict, "value")?;
115
116    let backend = select_backend();
117    backend
118        .set(&account, &key, &value)
119        .map_err(|err| backend_err(SET_BUILTIN, err))?;
120
121    Ok(build_dict([
122        ("account".to_string(), str_value(&account)),
123        ("key".to_string(), str_value(&key)),
124        ("backend".to_string(), str_value(backend.name())),
125    ]))
126}
127
128fn handle_delete(args: &[VmValue]) -> Result<VmValue, HostlibError> {
129    let dict = dict_arg(DELETE_BUILTIN, args)?;
130    let account = require_nonempty_string(DELETE_BUILTIN, &dict, "account")?;
131    let key = require_nonempty_string(DELETE_BUILTIN, &dict, "key")?;
132
133    let backend = select_backend();
134    let deleted = backend
135        .delete(&account, &key)
136        .map_err(|err| backend_err(DELETE_BUILTIN, err))?;
137
138    Ok(build_dict([
139        ("account".to_string(), str_value(&account)),
140        ("key".to_string(), str_value(&key)),
141        ("deleted".to_string(), VmValue::Bool(deleted)),
142        ("backend".to_string(), str_value(backend.name())),
143    ]))
144}
145
146fn handle_list(args: &[VmValue]) -> Result<VmValue, HostlibError> {
147    let dict = dict_arg(LIST_BUILTIN, args)?;
148    let account = require_nonempty_string(LIST_BUILTIN, &dict, "account")?;
149
150    let backend = select_backend();
151    let keys = backend
152        .list(&account)
153        .map_err(|err| backend_err(LIST_BUILTIN, err))?;
154
155    let items: Vec<VmValue> = keys.into_iter().map(|k| str_value(&k)).collect();
156    Ok(build_dict([
157        ("account".to_string(), str_value(&account)),
158        ("keys".to_string(), VmValue::List(Arc::new(items))),
159        ("backend".to_string(), str_value(backend.name())),
160    ]))
161}
162
163fn require_nonempty_string(
164    builtin: &'static str,
165    dict: &std::collections::BTreeMap<String, VmValue>,
166    key: &'static str,
167) -> Result<String, HostlibError> {
168    let value = require_string(builtin, dict, key)?;
169    if value.is_empty() {
170        Err(HostlibError::InvalidParameter {
171            builtin,
172            param: key,
173            message: "must be a non-empty string".to_string(),
174        })
175    } else {
176        Ok(value)
177    }
178}
179
180fn backend_err(builtin: &'static str, err: io::Error) -> HostlibError {
181    HostlibError::Backend {
182        builtin,
183        message: err.to_string(),
184    }
185}
186
187fn select_backend() -> Box<dyn Backend> {
188    if matches!(std::env::var(BACKEND_ENV).as_deref(), Ok("file")) {
189        return Box::new(file::FileStore::new());
190    }
191
192    #[cfg(any(target_os = "macos", target_os = "ios"))]
193    {
194        Box::new(keychain::KeychainStore::new())
195    }
196    #[cfg(target_os = "windows")]
197    {
198        Box::new(wincred::WinCredStore::new())
199    }
200    #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "windows")))]
201    {
202        Box::new(file::FileStore::new())
203    }
204}