Skip to main content

lmn_core/config/
secret.rs

1use crate::config::error::ConfigError;
2
3// ── SensitiveString ───────────────────────────────────────────────────────────
4
5/// A string wrapper that redacts its value in `Debug` output to prevent
6/// accidental logging of secrets.
7///
8/// Use `Display` (or `.as_str()`) to access the actual value when needed.
9#[derive(Clone, PartialEq)]
10pub struct SensitiveString(String);
11
12impl SensitiveString {
13    pub fn new(value: String) -> Self {
14        Self(value)
15    }
16
17    pub fn as_str(&self) -> &str {
18        &self.0
19    }
20}
21
22impl std::fmt::Debug for SensitiveString {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        write!(f, "[REDACTED]")
25    }
26}
27
28impl std::fmt::Display for SensitiveString {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        write!(f, "{}", self.0)
31    }
32}
33
34impl serde::Serialize for SensitiveString {
35    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
36        serializer.serialize_str("[REDACTED]")
37    }
38}
39
40// ── resolve_env_placeholders ──────────────────────────────────────────────────
41
42/// Resolves `${VAR_NAME}` placeholders in `input` by substituting the
43/// corresponding environment variable values.
44///
45/// # Rules
46/// - VAR_NAME must contain only uppercase ASCII letters, digits, or underscores
47///   (`[A-Z0-9_]`). Any other character inside `${...}` is a validation error.
48/// - If a referenced variable is not set in the environment, returns a
49///   [`ConfigError::ValidationError`] (fail-closed).
50/// - Single-pass only — the substituted values are NOT scanned again for
51///   `${...}` patterns (no recursive expansion).
52/// - Literal `${` with no closing `}` is left as-is (not an error).
53pub fn resolve_env_placeholders(input: &str) -> Result<String, ConfigError> {
54    if !input.contains("${") {
55        return Ok(input.to_string());
56    }
57
58    let mut output = String::with_capacity(input.len());
59    let mut remaining = input;
60
61    while let Some(open) = remaining.find("${") {
62        output.push_str(&remaining[..open]);
63        let after_open = &remaining[open + 2..];
64
65        match after_open.find('}') {
66            Some(close) => {
67                let var_name = &after_open[..close];
68                // Validate charset: only A-Z, 0-9, _ allowed
69                if var_name.is_empty() {
70                    return Err(ConfigError::ValidationError(
71                        "env var placeholder '${...}' must not be empty".to_string(),
72                    ));
73                }
74                if !var_name
75                    .chars()
76                    .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
77                {
78                    // Find the first invalid character for a precise error message
79                    let bad = var_name
80                        .chars()
81                        .find(|c| !c.is_ascii_uppercase() && !c.is_ascii_digit() && *c != '_')
82                        .unwrap();
83                    return Err(ConfigError::ValidationError(format!(
84                        "env var name '{var_name}' contains invalid character '{bad}' — \
85                         only uppercase letters, digits, and underscores are allowed"
86                    )));
87                }
88                let value = std::env::var(var_name).map_err(|_| {
89                    ConfigError::ValidationError(format!(
90                        "environment variable '{var_name}' is not set"
91                    ))
92                })?;
93                output.push_str(&value);
94                remaining = &after_open[close + 1..];
95            }
96            None => {
97                // No closing `}` — push `${` literally and continue
98                output.push_str("${");
99                remaining = after_open;
100            }
101        }
102    }
103
104    output.push_str(remaining);
105    Ok(output)
106}
107
108// ── Tests ─────────────────────────────────────────────────────────────────────
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    // ── SensitiveString ───────────────────────────────────────────────────────
115
116    #[test]
117    fn sensitive_string_debug_is_redacted() {
118        let s = SensitiveString::new("super-secret-token".to_string());
119        assert_eq!(format!("{s:?}"), "[REDACTED]");
120    }
121
122    #[test]
123    fn sensitive_string_display_shows_value() {
124        let s = SensitiveString::new("my-actual-value".to_string());
125        assert_eq!(format!("{s}"), "my-actual-value");
126    }
127
128    #[test]
129    fn sensitive_string_as_str_returns_value() {
130        let s = SensitiveString::new("hello".to_string());
131        assert_eq!(s.as_str(), "hello");
132    }
133
134    #[test]
135    fn sensitive_string_clone_equals_original() {
136        let s = SensitiveString::new("abc".to_string());
137        let cloned = s.clone();
138        assert_eq!(s, cloned);
139    }
140
141    #[test]
142    fn sensitive_string_partial_eq() {
143        let a = SensitiveString::new("x".to_string());
144        let b = SensitiveString::new("x".to_string());
145        let c = SensitiveString::new("y".to_string());
146        assert_eq!(a, b);
147        assert_ne!(a, c);
148    }
149
150    // ── resolve_env_placeholders ──────────────────────────────────────────────
151
152    #[test]
153    fn resolves_single_placeholder() {
154        unsafe { std::env::set_var("LUMEN_TEST_TOKEN", "abc123") };
155        let result = resolve_env_placeholders("Bearer ${LUMEN_TEST_TOKEN}").unwrap();
156        assert_eq!(result, "Bearer abc123");
157    }
158
159    #[test]
160    fn resolves_multiple_placeholders() {
161        unsafe {
162            std::env::set_var("LUMEN_TEST_USER", "alice");
163            std::env::set_var("LUMEN_TEST_PASS", "s3cr3t");
164        }
165        let result = resolve_env_placeholders("${LUMEN_TEST_USER}:${LUMEN_TEST_PASS}").unwrap();
166        assert_eq!(result, "alice:s3cr3t");
167    }
168
169    #[test]
170    fn no_placeholders_returns_input_unchanged() {
171        let result = resolve_env_placeholders("plain-value").unwrap();
172        assert_eq!(result, "plain-value");
173    }
174
175    #[test]
176    fn missing_env_var_returns_validation_error() {
177        // Ensure the variable is definitely not set
178        unsafe { std::env::remove_var("LUMEN_TEST_DEFINITELY_NOT_SET_XYZ") };
179        let result = resolve_env_placeholders("Bearer ${LUMEN_TEST_DEFINITELY_NOT_SET_XYZ}");
180        assert!(matches!(result, Err(ConfigError::ValidationError(_))));
181        let msg = result.err().unwrap().to_string();
182        assert!(
183            msg.contains("LUMEN_TEST_DEFINITELY_NOT_SET_XYZ"),
184            "error should name the var: {msg}"
185        );
186    }
187
188    #[test]
189    fn invalid_charset_in_var_name_returns_error() {
190        let result = resolve_env_placeholders("${lower_case}");
191        assert!(matches!(result, Err(ConfigError::ValidationError(_))));
192        let msg = result.err().unwrap().to_string();
193        assert!(
194            msg.contains("invalid character"),
195            "expected charset error, got: {msg}"
196        );
197    }
198
199    #[test]
200    fn empty_placeholder_returns_error() {
201        let result = resolve_env_placeholders("${  }");
202        // space is invalid char — will trigger charset error
203        assert!(matches!(result, Err(ConfigError::ValidationError(_))));
204    }
205
206    #[test]
207    fn empty_braces_returns_error() {
208        let result = resolve_env_placeholders("${}");
209        assert!(matches!(result, Err(ConfigError::ValidationError(_))));
210        let msg = result.err().unwrap().to_string();
211        assert!(
212            msg.contains("must not be empty"),
213            "expected empty error, got: {msg}"
214        );
215    }
216
217    #[test]
218    fn unclosed_brace_is_kept_literally() {
219        let result = resolve_env_placeholders("${NO_CLOSE").unwrap();
220        assert_eq!(result, "${NO_CLOSE");
221    }
222
223    #[test]
224    fn dollar_without_brace_is_kept_literally() {
225        let result = resolve_env_placeholders("$VAR").unwrap();
226        assert_eq!(result, "$VAR");
227    }
228
229    #[test]
230    fn no_recursive_expansion() {
231        // Set a var whose value itself looks like another placeholder
232        unsafe { std::env::set_var("LUMEN_TEST_RECURSIVE", "${LUMEN_TEST_INNER}") };
233        // LUMEN_TEST_INNER is NOT set — if recursive expansion happened this would error
234        let result = resolve_env_placeholders("${LUMEN_TEST_RECURSIVE}").unwrap();
235        assert_eq!(result, "${LUMEN_TEST_INNER}");
236    }
237
238    #[test]
239    fn mixed_literals_and_placeholders() {
240        unsafe { std::env::set_var("LUMEN_TEST_API_KEY", "key-xyz") };
241        let result = resolve_env_placeholders("prefix_${LUMEN_TEST_API_KEY}_suffix").unwrap();
242        assert_eq!(result, "prefix_key-xyz_suffix");
243    }
244
245    #[test]
246    fn digits_and_underscores_in_var_name_are_valid() {
247        unsafe { std::env::set_var("LUMEN_TEST_A1_B2", "ok") };
248        let result = resolve_env_placeholders("${LUMEN_TEST_A1_B2}").unwrap();
249        assert_eq!(result, "ok");
250    }
251
252    #[test]
253    fn resolve_env_placeholders_with_utf8_value() {
254        // Env var value contains multi-byte UTF-8; must come back intact with no corruption.
255        unsafe { std::env::set_var("LUMEN_TEST_UTF8_VAL", "café") };
256        let result = resolve_env_placeholders("prefix_${LUMEN_TEST_UTF8_VAL}_suffix").unwrap();
257        assert_eq!(result, "prefix_café_suffix");
258    }
259
260    // ── SensitiveString::Serialize ────────────────────────────────────────────
261
262    #[test]
263    fn sensitive_string_serializes_as_redacted() {
264        let s = SensitiveString::new("super-secret".to_string());
265        let serialized = serde_json::to_string(&s).expect("serialization must not fail");
266        assert_eq!(serialized, r#""[REDACTED]""#);
267    }
268}