Skip to main content

llm_kernel/secrets/
vault.rs

1use std::collections::HashMap;
2use std::ops::{Deref, DerefMut};
3use std::path::Path;
4
5use zeroize::Zeroize;
6
7use crate::error::{KernelError, Result};
8
9use super::atomic::write_atomic;
10
11/// Credential store backed by a dotenv-style file.
12///
13/// Wraps a `HashMap<String, String>` with typed methods for load/save/normalize,
14/// keeping the ergonomics of a map via `Deref`/`DerefMut`.
15///
16/// # Security model
17///
18/// Values are stored **in plaintext**, in a file written `0o600` (owner-only)
19/// via an atomic temp-file rename. This protects against other local users
20/// and against torn writes; it does **not** protect against an attacker who
21/// already runs as this user, nor against disk forensics. It is not an OS
22/// keychain — do not describe it as one. For stronger guarantees, hold the
23/// key in an OS keychain and pass it in rather than persisting it here.
24///
25/// Values are zeroized on drop and after the serialized body is written, so
26/// they do not linger in freed heap pages. This is best-effort: `DerefMut`
27/// and `IntoIterator` let copies escape, and those are the caller's to wipe.
28#[derive(Clone, Default)]
29pub struct SecretVault(HashMap<String, String>);
30
31/// Wipe every value when the vault goes away, so credentials do not linger
32/// in freed heap pages (core dumps, swap). Best-effort: `DerefMut` lets a
33/// caller clone a value out, and that copy is theirs to manage.
34impl Drop for SecretVault {
35    fn drop(&mut self) {
36        for value in self.0.values_mut() {
37            value.zeroize();
38        }
39    }
40}
41
42/// Deriving `Debug` would print every secret verbatim into logs and panic
43/// messages — show only the key names.
44impl std::fmt::Debug for SecretVault {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        let mut keys: Vec<&str> = self.0.keys().map(String::as_str).collect();
47        keys.sort_unstable();
48        f.debug_tuple("SecretVault").field(&keys).finish()
49    }
50}
51
52impl SecretVault {
53    /// Create an empty vault with no credentials loaded.
54    pub fn empty() -> Self {
55        Self(HashMap::new())
56    }
57
58    /// Load a vault from a dotenv-style file at `path`.
59    ///
60    /// Returns an empty vault if the file does not exist.
61    /// Errors if the file is a symlink, has invalid UTF-8, or contains malformed lines.
62    pub fn load_from(path: impl AsRef<Path>) -> Result<Self> {
63        let path = path.as_ref();
64
65        // Symlink check BEFORE read to prevent TOCTOU race.
66        if path.exists() {
67            Self::guard_not_symlink(path)?;
68        }
69
70        let raw = match std::fs::read(path) {
71            Ok(d) => d,
72            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::empty()),
73            Err(e) => return Err(e.into()),
74        };
75
76        raw.split(|&b| b == b'\n')
77            .enumerate()
78            .filter(|(_, line)| {
79                // Invalid UTF-8 must NOT be filtered out here: treating it as
80                // an empty line would silently drop the entry, and the next
81                // persist_to would erase it from disk for good. Let it reach
82                // the fold and error there.
83                match std::str::from_utf8(line) {
84                    Ok(text) => {
85                        let trimmed = text.trim();
86                        !trimmed.is_empty() && !trimmed.starts_with('#')
87                    }
88                    Err(_) => true,
89                }
90            })
91            .try_fold(Self::empty(), |mut acc, (i, line)| {
92                let text = std::str::from_utf8(line)
93                    .map_err(|e| {
94                        KernelError::Vault(format!("invalid UTF-8 on line {}: {}", i + 1, e))
95                    })?
96                    .trim();
97                let (key, raw_val) = text.split_once('=').ok_or_else(|| {
98                    KernelError::Vault(format!("invalid secrets file line {}", i + 1))
99                })?;
100                if !is_valid_env_key(key) {
101                    return Err(KernelError::Vault(format!(
102                        "invalid secrets file line {}",
103                        i + 1
104                    )));
105                }
106                acc.0.insert(key.to_owned(), decode_shell_value(raw_val)?);
107                Ok(acc)
108            })
109    }
110
111    /// Persist the vault to a dotenv-style file at `path` using an atomic write.
112    pub fn persist_to(&self, path: impl AsRef<Path>) -> Result<()> {
113        let p = path.as_ref();
114        if let Some(parent) = p.parent() {
115            std::fs::create_dir_all(parent)?;
116        }
117
118        // A key that cannot be written must fail loudly: silently skipping it
119        // makes `insert(...); persist_to(...)` report success while the
120        // credential never reaches disk.
121        if let Some(bad) = self.0.keys().find(|k| !is_valid_env_key(k)) {
122            return Err(KernelError::Vault(format!(
123                "cannot persist invalid secret key {bad:?} (expected [A-Z_][A-Z0-9_]*)"
124            )));
125        }
126
127        let mut body = self
128            .0
129            .keys()
130            .collect::<std::collections::BTreeSet<_>>()
131            .iter()
132            .map(|k| format!("{}={}\n", k, encode_for_shell(&self.0[*k])))
133            .collect::<String>();
134
135        // Pass the Path itself — a lossy string conversion would silently
136        // write to a DIFFERENT file on a non-UTF-8 path.
137        let result = write_atomic(p, body.as_bytes(), 0o600);
138        // `body` is a full plaintext copy of every secret; wipe it before the
139        // allocation goes back to the heap (readable in a core dump or swap).
140        Zeroize::zeroize(&mut body);
141        result?;
142        #[cfg(unix)]
143        {
144            use std::os::unix::fs::PermissionsExt;
145            std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o600))?;
146        }
147        Ok(())
148    }
149
150    fn guard_not_symlink(path: &Path) -> Result<()> {
151        let meta = std::fs::symlink_metadata(path)?;
152        if meta.file_type().is_symlink() {
153            return Err(KernelError::Vault(format!(
154                "secrets file is a symlink: {}",
155                path.display()
156            )));
157        }
158        Ok(())
159    }
160}
161
162// --- Deref/DerefMut so callers can use `.get()`, `.iter()`, etc. ---
163
164impl Deref for SecretVault {
165    type Target = HashMap<String, String>;
166    fn deref(&self) -> &Self::Target {
167        &self.0
168    }
169}
170
171impl DerefMut for SecretVault {
172    fn deref_mut(&mut self) -> &mut Self::Target {
173        &mut self.0
174    }
175}
176
177impl From<HashMap<String, String>> for SecretVault {
178    fn from(map: HashMap<String, String>) -> Self {
179        Self(map)
180    }
181}
182
183impl IntoIterator for SecretVault {
184    type Item = (String, String);
185    type IntoIter = std::collections::hash_map::IntoIter<String, String>;
186    /// Takes the map out so the `Drop` impl (which zeroizes) can still run —
187    /// moving a field out of a `Drop` type is not allowed. The values handed
188    /// to the caller are theirs to wipe.
189    fn into_iter(mut self) -> Self::IntoIter {
190        std::mem::take(&mut self.0).into_iter()
191    }
192}
193
194impl<'a> IntoIterator for &'a SecretVault {
195    type Item = (&'a String, &'a String);
196    type IntoIter = std::collections::hash_map::Iter<'a, String, String>;
197    fn into_iter(self) -> Self::IntoIter {
198        self.0.iter()
199    }
200}
201
202/// Mask a credential for display, showing only first/last 4 characters.
203///
204/// Counts characters, not bytes: byte slicing at fixed offsets panics on any
205/// multi-byte credential, and this runs on error/log paths where a panic is
206/// the worst possible outcome.
207pub fn redact_credential(value: &str) -> String {
208    let count = value.chars().count();
209    match count {
210        0 => String::new(),
211        1..=8 => "****".to_owned(),
212        _ => {
213            let head: String = value.chars().take(4).collect();
214            let tail: String = value.chars().skip(count - 4).collect();
215            format!("{head}****{tail}")
216        }
217    }
218}
219
220// --- Internal helpers ---
221
222fn is_valid_env_key(key: &str) -> bool {
223    let first = key.as_bytes().first();
224    first.is_some_and(|&b| {
225        (b.is_ascii_uppercase() || b == b'_')
226            && key.as_bytes()[1..]
227                .iter()
228                .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || *b == b'_')
229    })
230}
231
232fn decode_shell_value(value: &str) -> Result<String> {
233    let b = value.as_bytes();
234    match b.first() {
235        Some(b'\'') if b.last() == Some(&b'\'') && b.len() >= 2 => {
236            Ok(value[1..value.len() - 1].to_owned())
237        }
238        // len >= 3 so the opening `$'` and the closing `'` are distinct bytes
239        // — the bare string `$'` would otherwise slice [2..1] and panic.
240        Some(b'$') if b.len() >= 3 && b.get(1) == Some(&b'\'') && b.last() == Some(&b'\'') => {
241            unescape_ansi(&value[2..value.len() - 1])
242        }
243        Some(b'"') if b.last() == Some(&b'"') && b.len() >= 2 => {
244            Ok(value[1..value.len() - 1].replace("\\\"", "\""))
245        }
246        _ => Ok(value.to_owned()),
247    }
248}
249
250fn unescape_ansi(s: &str) -> Result<String> {
251    // Iterate CHARS, not bytes — `b as char` reinterprets each UTF-8 byte as
252    // Latin-1, silently corrupting any non-ASCII secret on load.
253    let mut out = String::with_capacity(s.len());
254    let mut chars = s.chars();
255    while let Some(c) = chars.next() {
256        if c != '\\' {
257            out.push(c);
258            continue;
259        }
260        match chars.next() {
261            None => return Err(KernelError::Vault("unterminated escape".into())),
262            Some('n') => out.push('\n'),
263            Some('t') => out.push('\t'),
264            Some('\\') => out.push('\\'),
265            Some('\'') => out.push('\''),
266            Some(other) => out.push(other),
267        }
268    }
269    Ok(out)
270}
271
272fn encode_for_shell(value: &str) -> String {
273    if value.is_empty() {
274        return "''".to_owned();
275    }
276    // A value written bare must decode back to itself. `decode_shell_value`
277    // strips a surrounding pair of `'` or `"` and treats a leading `$'` as an
278    // ANSI-C string, so any value that could be mistaken for one of those
279    // forms has to be explicitly quoted or the round-trip silently mangles it.
280    let needs_quoting = value
281        .as_bytes()
282        .iter()
283        .any(|b| matches!(b, b'\n' | b'\t' | b'\'' | b'"' | b'\\' | b' '))
284        || value.starts_with('$');
285    if !needs_quoting {
286        return value.to_owned();
287    }
288    let escaped = value
289        .replace('\\', "\\\\")
290        .replace('\n', "\\n")
291        .replace('\t', "\\t")
292        .replace('\'', "\\'");
293    format!("$'{}'", escaped)
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn test_redact_short() {
302        assert_eq!(redact_credential("ab"), "****");
303    }
304
305    #[test]
306    fn test_redact_empty() {
307        assert_eq!(redact_credential(""), "");
308    }
309
310    #[test]
311    fn test_redact_long() {
312        assert_eq!(redact_credential("abcdefghijklmnop"), "abcd****mnop");
313    }
314
315    #[test]
316    fn test_redact_multibyte_does_not_panic() {
317        // Byte slicing at fixed offsets would panic mid-codepoint here.
318        // 8 chars or fewer are fully masked; 9+ show first/last 4.
319        assert_eq!(redact_credential("한국어키값입니다"), "****");
320        assert_eq!(
321            redact_credential("한국어키값입니다요"),
322            "한국어키****입니다요"
323        );
324        assert_eq!(redact_credential(&"é".repeat(12)).chars().count(), 12);
325    }
326
327    #[test]
328    fn test_decode_single_quotes() {
329        assert_eq!(decode_shell_value("'hello world'").unwrap(), "hello world");
330    }
331
332    #[test]
333    fn test_decode_ansi_dollar_quotes() {
334        assert_eq!(
335            decode_shell_value("$'hello\\nworld'").unwrap(),
336            "hello\nworld"
337        );
338        assert_eq!(decode_shell_value("$'tab\\there'").unwrap(), "tab\there");
339        assert_eq!(
340            decode_shell_value("$'back\\\\slash'").unwrap(),
341            "back\\slash"
342        );
343        assert_eq!(decode_shell_value("$'quo\\'te'").unwrap(), "quo'te");
344    }
345
346    #[test]
347    fn test_decode_double_quotes() {
348        assert_eq!(
349            decode_shell_value("\"hello \\\"world\\\"\"").unwrap(),
350            "hello \"world\""
351        );
352    }
353
354    #[test]
355    fn test_decode_bare() {
356        assert_eq!(decode_shell_value("simple123").unwrap(), "simple123");
357    }
358
359    #[test]
360    fn test_encode_simple() {
361        assert_eq!(encode_for_shell("hello"), "hello");
362    }
363
364    #[test]
365    fn test_encode_empty() {
366        assert_eq!(encode_for_shell(""), "''");
367    }
368
369    #[test]
370    fn test_encode_special() {
371        let quoted = encode_for_shell("hello world");
372        assert!(
373            quoted.starts_with("$'"),
374            "expected $'...' for space, got {}",
375            quoted
376        );
377    }
378
379    #[test]
380    fn test_is_valid_env_key() {
381        assert!(is_valid_env_key("VALID_KEY"));
382        assert!(is_valid_env_key("_LEADING"));
383        assert!(!is_valid_env_key(""));
384        assert!(!is_valid_env_key("lowercase"));
385        assert!(!is_valid_env_key("1STARTS_NUM"));
386        assert!(is_valid_env_key("HAS_123"));
387        assert!(!is_valid_env_key("HAS-DASH"));
388    }
389
390    #[test]
391    fn test_roundtrip_via_impl_methods() {
392        let dir = tempfile::tempdir().expect("tempdir");
393        let path = dir.path().join("secrets.env");
394
395        let secrets = SecretVault::from(HashMap::from([
396            ("MY_KEY".to_string(), "my-value".to_string()),
397            ("OTHER_KEY".to_string(), "other".to_string()),
398        ]));
399
400        secrets.persist_to(&path).expect("persist");
401        let loaded = SecretVault::load_from(&path).expect("load");
402
403        assert_eq!(loaded.get("MY_KEY").map(|s| s.as_str()), Some("my-value"));
404        assert_eq!(loaded.get("OTHER_KEY").map(|s| s.as_str()), Some("other"));
405    }
406
407    #[test]
408    fn test_roundtrip_non_ascii_with_quoting_trigger() {
409        // Space forces $'...' encoding; the decoder must not corrupt UTF-8.
410        let dir = tempfile::tempdir().expect("tempdir");
411        let path = dir.path().join("secrets.env");
412        let secrets = SecretVault::from(HashMap::from([(
413            "MY_KEY".to_string(),
414            "한국어 키 값".to_string(),
415        )]));
416        secrets.persist_to(&path).expect("persist");
417        let loaded = SecretVault::load_from(&path).expect("load");
418        assert_eq!(
419            loaded.get("MY_KEY").map(|s| s.as_str()),
420            Some("한국어 키 값")
421        );
422    }
423
424    #[test]
425    fn test_roundtrip_values_that_look_like_quoting() {
426        // Every form decode_shell_value would strip must survive persist+load.
427        let dir = tempfile::tempdir().expect("tempdir");
428        let path = dir.path().join("secrets.env");
429        for val in ["\"quoted\"", "'single'", "$'ansi'", "$plain", "sk-normal"] {
430            let v = SecretVault::from(HashMap::from([("K".to_string(), val.to_string())]));
431            v.persist_to(&path).expect("persist");
432            let loaded = SecretVault::load_from(&path).expect("load");
433            assert_eq!(
434                loaded.get("K").map(|s| s.as_str()),
435                Some(val),
436                "value {val:?}"
437            );
438        }
439    }
440
441    #[test]
442    fn test_persist_rejects_invalid_key_instead_of_dropping_it() {
443        let dir = tempfile::tempdir().expect("tempdir");
444        let path = dir.path().join("secrets.env");
445        let v = SecretVault::from(HashMap::from([("lowercase".to_string(), "v".to_string())]));
446        assert!(v.persist_to(&path).is_err(), "silent drop is data loss");
447    }
448
449    #[test]
450    fn test_invalid_utf8_line_errors_instead_of_vanishing() {
451        let dir = tempfile::tempdir().expect("tempdir");
452        let path = dir.path().join("secrets.env");
453        std::fs::write(&path, b"GOOD=1\nBAD=\xff\xfe\n").expect("write");
454        assert!(SecretVault::load_from(&path).is_err());
455    }
456
457    #[test]
458    fn test_decode_bare_dollar_quote_does_not_panic() {
459        // A value of exactly `$'` must not slice out of bounds.
460        assert_eq!(decode_shell_value("$'").unwrap(), "$'");
461    }
462
463    #[test]
464    fn test_debug_never_prints_secret_values() {
465        let vault = SecretVault::from(HashMap::from([(
466            "API_KEY".to_string(),
467            "sk-super-secret".to_string(),
468        )]));
469        let dbg = format!("{vault:?}");
470        assert!(dbg.contains("API_KEY"));
471        assert!(!dbg.contains("sk-super-secret"), "{dbg}");
472    }
473
474    #[test]
475    fn test_load_missing_returns_empty() {
476        let secrets =
477            SecretVault::load_from("/nonexistent/path/secrets.env").expect("load missing");
478        assert!(secrets.is_empty());
479    }
480
481    #[test]
482    fn test_roundtrip_with_special_chars() {
483        let dir = tempfile::tempdir().expect("tempdir");
484        let path = dir.path().join("secrets.env");
485
486        let secrets = SecretVault::from(HashMap::from([(
487            "MY_KEY".to_string(),
488            "value with spaces\nand newlines".to_string(),
489        )]));
490
491        secrets.persist_to(&path).expect("persist");
492        let loaded = SecretVault::load_from(&path).expect("load");
493
494        assert_eq!(
495            loaded.get("MY_KEY").map(|s| s.as_str()),
496            Some("value with spaces\nand newlines")
497        );
498    }
499}