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#[derive(Clone, Default)]
29pub struct SecretVault(HashMap<String, String>);
30
31impl Drop for SecretVault {
35 fn drop(&mut self) {
36 for value in self.0.values_mut() {
37 value.zeroize();
38 }
39 }
40}
41
42impl 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 pub fn empty() -> Self {
55 Self(HashMap::new())
56 }
57
58 pub fn load_from(path: impl AsRef<Path>) -> Result<Self> {
63 let path = path.as_ref();
64
65 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 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 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 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 let result = write_atomic(p, body.as_bytes(), 0o600);
138 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
162impl 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 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
202pub 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
220fn 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 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 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 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 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 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 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 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}