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