lmn_core/config/
secret.rs1use crate::config::error::ConfigError;
2
3#[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
40pub 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 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 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 output.push_str("${");
99 remaining = after_open;
100 }
101 }
102 }
103
104 output.push_str(remaining);
105 Ok(output)
106}
107
108#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[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 #[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 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 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 unsafe { std::env::set_var("LUMEN_TEST_RECURSIVE", "${LUMEN_TEST_INNER}") };
233 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 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 #[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}