Skip to main content

reddb_server/utils/
env_secret.rs

1//! Env-var-with-`_FILE`-companion helper (PLAN.md Phase 6.4).
2//!
3//! Centralises the pattern that ~5 modules independently reinvented:
4//!   1. Read `VAR`. If non-empty/non-whitespace, return it.
5//!   2. Otherwise read `VAR_FILE`. If set, treat its value as a path
6//!      to a file containing the secret.
7//!   3. `read_to_string` the file, trim trailing `\n`/`\r` (kubectl
8//!      create secret + echo > file both append a newline), and
9//!      return the contents if non-empty.
10//!   4. On read failure, log a warning and return `None` — boot
11//!      fails closed downstream when the secret was required.
12
13/// Read `name` from the env. If unset/empty, fall back to
14/// `<name>_FILE`. Returns `None` when neither produces a usable
15/// value. Trims trailing whitespace from file contents.
16pub fn env_with_file_fallback(name: &str) -> Option<String> {
17    if let Ok(value) = std::env::var(name) {
18        if !value.trim().is_empty() {
19            return Some(value);
20        }
21    }
22    let file_var = format!("{name}_FILE");
23    let path = std::env::var(&file_var).ok()?;
24    let trimmed_path = path.trim();
25    if trimmed_path.is_empty() {
26        return None;
27    }
28    match std::fs::read_to_string(trimmed_path) {
29        Ok(contents) => {
30            let value = contents.trim_end_matches(['\n', '\r']).to_string();
31            if value.is_empty() {
32                None
33            } else {
34                Some(value)
35            }
36        }
37        Err(err) => {
38            tracing::warn!(
39                target: "reddb::secrets",
40                env = %file_var,
41                path = %trimmed_path,
42                error = %err,
43                "secret file referenced by {file_var} could not be read; falling back to None"
44            );
45            None
46        }
47    }
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53    use std::sync::Mutex;
54
55    // The env namespace is process-global, so serialise the tests
56    // that mutate it; otherwise running in parallel would create
57    // false negatives.
58    fn env_lock() -> &'static Mutex<()> {
59        static LOCK: std::sync::OnceLock<Mutex<()>> = std::sync::OnceLock::new();
60        LOCK.get_or_init(|| Mutex::new(()))
61    }
62
63    #[test]
64    fn returns_inline_when_set() {
65        let _g = env_lock().lock();
66        unsafe {
67            std::env::set_var("REDDB_TEST_INLINE", "value-from-env");
68            std::env::remove_var("REDDB_TEST_INLINE_FILE");
69        }
70        assert_eq!(
71            env_with_file_fallback("REDDB_TEST_INLINE"),
72            Some("value-from-env".to_string())
73        );
74        unsafe {
75            std::env::remove_var("REDDB_TEST_INLINE");
76        }
77    }
78
79    #[test]
80    fn falls_back_to_file_when_inline_empty() {
81        let _g = env_lock().lock();
82        let dir =
83            std::env::temp_dir().join(format!("reddb-env-secret-test-{}", std::process::id()));
84        let _ = std::fs::create_dir_all(&dir);
85        let path = dir.join("token");
86        std::fs::write(&path, "value-from-file\n").unwrap();
87        unsafe {
88            std::env::remove_var("REDDB_TEST_FALLBACK");
89            std::env::set_var("REDDB_TEST_FALLBACK_FILE", &path);
90        }
91        assert_eq!(
92            env_with_file_fallback("REDDB_TEST_FALLBACK"),
93            Some("value-from-file".to_string()) // trailing \n trimmed
94        );
95        unsafe {
96            std::env::remove_var("REDDB_TEST_FALLBACK_FILE");
97        }
98        let _ = std::fs::remove_dir_all(&dir);
99    }
100
101    #[test]
102    fn inline_wins_over_file() {
103        let _g = env_lock().lock();
104        let dir = std::env::temp_dir().join(format!("reddb-env-precedence-{}", std::process::id()));
105        let _ = std::fs::create_dir_all(&dir);
106        let path = dir.join("token");
107        std::fs::write(&path, "from-file").unwrap();
108        unsafe {
109            std::env::set_var("REDDB_TEST_PRIORITY", "from-env");
110            std::env::set_var("REDDB_TEST_PRIORITY_FILE", &path);
111        }
112        assert_eq!(
113            env_with_file_fallback("REDDB_TEST_PRIORITY"),
114            Some("from-env".to_string())
115        );
116        unsafe {
117            std::env::remove_var("REDDB_TEST_PRIORITY");
118            std::env::remove_var("REDDB_TEST_PRIORITY_FILE");
119        }
120        let _ = std::fs::remove_dir_all(&dir);
121    }
122
123    #[test]
124    fn returns_none_when_neither_set() {
125        let _g = env_lock().lock();
126        unsafe {
127            std::env::remove_var("REDDB_TEST_NONE");
128            std::env::remove_var("REDDB_TEST_NONE_FILE");
129        }
130        assert_eq!(env_with_file_fallback("REDDB_TEST_NONE"), None);
131    }
132
133    #[test]
134    fn read_failure_returns_none() {
135        let _g = env_lock().lock();
136        unsafe {
137            std::env::remove_var("REDDB_TEST_BAD");
138            std::env::set_var("REDDB_TEST_BAD_FILE", "/nonexistent/path/zzz");
139        }
140        assert_eq!(env_with_file_fallback("REDDB_TEST_BAD"), None);
141        unsafe {
142            std::env::remove_var("REDDB_TEST_BAD_FILE");
143        }
144    }
145}