camel_config/
properties.rs1use std::collections::HashMap;
2use std::env;
3use std::sync::RwLock;
4
5fn sanitize_env_value(val: &str) -> String {
16 val.chars()
17 .map(|c| {
18 if matches!(c, '\n' | '\r' | '\0' | '\u{2028}' | '\u{2029}') {
19 ' '
20 } else {
21 c
22 }
23 })
24 .collect()
25}
26
27pub struct PropertiesResolver {
28 properties: RwLock<HashMap<String, String>>,
34}
35
36#[derive(Debug, PartialEq, thiserror::Error)]
40pub enum ResolveError {
41 #[error("missing property key: {key}")]
42 MissingKey { key: String },
43}
44
45impl Default for PropertiesResolver {
46 fn default() -> Self {
47 Self::new()
48 }
49}
50
51impl PropertiesResolver {
52 pub fn new() -> Self {
53 Self {
54 properties: RwLock::new(HashMap::new()),
55 }
56 }
57
58 pub fn set(&self, key: &str, value: &str) {
59 self.properties
60 .write()
61 .unwrap_or_else(|poisoned| poisoned.into_inner())
62 .insert(key.to_string(), value.to_string());
63 }
64
65 fn lookup(&self, key: &str) -> Option<String> {
66 if let Some(rest) = key.strip_prefix("env:") {
67 let env_key = rest.trim();
68 if env_key.is_empty() {
69 return None;
70 }
71 return env::var(env_key).ok();
72 }
73 let props = self
74 .properties
75 .read()
76 .unwrap_or_else(|poisoned| poisoned.into_inner());
77 props.get(key).cloned()
78 }
79
80 pub fn resolve(&self, input: &str) -> Result<String, ResolveError> {
81 let mut result = input.to_string();
82 let mut start = 0;
83
84 while let Some(open) = result[start..].find("{{") {
85 let open_abs = start + open;
86 if let Some(close) = result[open_abs..].find("}}") {
87 let close_abs = open_abs + close + 2;
88 let inner = &result[open_abs + 2..open_abs + close];
89
90 let (key, default) = if let Some(rest) = inner.strip_prefix("env:") {
91 let env_key = rest.trim();
92 if let Some(colon) = env_key.find(':') {
93 (&inner[..4 + colon], Some(&env_key[colon + 1..]))
94 } else {
95 (inner, None)
96 }
97 } else if let Some(colon) = inner.find(':') {
98 (&inner[..colon], Some(&inner[colon + 1..]))
99 } else {
100 (inner, None)
101 };
102
103 if key.trim().is_empty() {
104 return Err(ResolveError::MissingKey {
105 key: key.to_string(),
106 });
107 }
108
109 let value = match (self.lookup(key), default) {
110 (Some(v), _) => sanitize_env_value(&v),
111 (None, Some(d)) => sanitize_env_value(d),
112 (None, None) => {
113 return Err(ResolveError::MissingKey {
114 key: key.to_string(),
115 });
116 }
117 };
118
119 result.replace_range(open_abs..close_abs, &value);
120 start = open_abs + value.len();
121 } else {
122 break;
123 }
124 }
125
126 Ok(result)
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn test_resolve_simple_placeholder() {
136 let resolver = PropertiesResolver::new();
137 resolver.set("host", "localhost");
138 resolver.set("port", "6379");
139 assert_eq!(
140 resolver.resolve("redis://{{host}}:{{port}}"),
141 Ok("redis://localhost:6379".to_string())
142 );
143 }
144
145 #[test]
146 fn test_resolve_missing_key_returns_error() {
147 let resolver = PropertiesResolver::new();
148 let result = resolver.resolve("redis://{{host}}:6379");
149 assert!(result.is_err());
150 }
151
152 #[test]
153 fn test_resolve_no_placeholders() {
154 let resolver = PropertiesResolver::new();
155 assert_eq!(
156 resolver.resolve("redis://localhost:6379"),
157 Ok("redis://localhost:6379".to_string())
158 );
159 }
160
161 #[test]
162 fn test_resolve_default_value() {
163 let resolver = PropertiesResolver::new();
164 assert_eq!(
165 resolver.resolve("redis://{{host:localhost}}:6379"),
166 Ok("redis://localhost:6379".to_string())
167 );
168 }
169
170 #[test]
171 fn test_resolve_multiple_same_key() {
172 let resolver = PropertiesResolver::new();
173 resolver.set("host", "redis.example.com");
174 assert_eq!(
175 resolver.resolve("{{host}}:{{host}}"),
176 Ok("redis.example.com:redis.example.com".to_string())
177 );
178 }
179
180 #[test]
181 fn test_resolve_unclosed_placeholder_passthrough() {
182 let resolver = PropertiesResolver::new();
183 assert_eq!(
184 resolver.resolve("redis://{{host:localhost}:6379"),
185 Ok("redis://{{host:localhost}:6379".to_string())
186 );
187 }
188
189 #[test]
190 fn test_resolve_env_placeholder() {
191 unsafe {
192 env::set_var("RUST_CAMEL_TEST_HOST", "redis.prod.example.com");
193 }
194 let resolver = PropertiesResolver::new();
195 assert_eq!(
196 resolver.resolve("redis://{{env:RUST_CAMEL_TEST_HOST}}:6379"),
197 Ok("redis://redis.prod.example.com:6379".to_string())
198 );
199 unsafe {
200 env::remove_var("RUST_CAMEL_TEST_HOST");
201 }
202 }
203
204 #[test]
205 fn test_resolve_env_missing_without_default_returns_error() {
206 unsafe {
207 env::remove_var("RUST_CAMEL_TEST_DEFINITELY_MISSING");
208 }
209 let resolver = PropertiesResolver::new();
210 let result = resolver.resolve("{{env:RUST_CAMEL_TEST_DEFINITELY_MISSING}}");
211 assert!(result.is_err());
212 }
213
214 #[test]
215 fn test_resolve_env_with_default_uses_default_when_missing() {
216 unsafe {
217 env::remove_var("RUST_CAMEL_TEST_MISSING_HOST");
218 }
219 let resolver = PropertiesResolver::new();
220 assert_eq!(
221 resolver.resolve("redis://{{env:RUST_CAMEL_TEST_MISSING_HOST:localhost}}:6379"),
222 Ok("redis://localhost:6379".to_string())
223 );
224 }
225
226 #[test]
227 fn test_resolve_env_with_default_uses_env_when_present() {
228 unsafe {
229 env::set_var("RUST_CAMEL_TEST_DB_HOST", "db.prod.example.com");
230 }
231 let resolver = PropertiesResolver::new();
232 assert_eq!(
233 resolver.resolve("{{env:RUST_CAMEL_TEST_DB_HOST:localhost}}"),
234 Ok("db.prod.example.com".to_string())
235 );
236 unsafe {
237 env::remove_var("RUST_CAMEL_TEST_DB_HOST");
238 }
239 }
240
241 #[test]
242 fn test_resolve_mixed_env_and_property() {
243 unsafe {
244 env::set_var("RUST_CAMEL_TEST_PORT", "6380");
245 }
246 let resolver = PropertiesResolver::new();
247 resolver.set("host", "localhost");
248 assert_eq!(
249 resolver.resolve("redis://{{host}}:{{env:RUST_CAMEL_TEST_PORT}}"),
250 Ok("redis://localhost:6380".to_string())
251 );
252 unsafe {
253 env::remove_var("RUST_CAMEL_TEST_PORT");
254 }
255 }
256
257 #[test]
258 fn test_resolve_env_empty_key_returns_error() {
259 let resolver = PropertiesResolver::new();
260 let result = resolver.resolve("{{env:}}");
261 assert!(result.is_err());
262 }
263
264 #[test]
265 fn resolves_env_var_replacing_newlines() {
266 unsafe {
267 env::set_var(
268 "TEST_CONFIG_INJECTION",
269 "safe_value\ninjected_key: malicious",
270 );
271 }
272 let resolver = PropertiesResolver::new();
273 let result = resolver.resolve("{{env:TEST_CONFIG_INJECTION}}").unwrap();
274 unsafe {
275 env::remove_var("TEST_CONFIG_INJECTION");
276 }
277 assert!(
278 !result.contains('\n'),
279 "interpolated value must not contain newlines"
280 );
281 assert!(
283 result.contains("safe_value injected_key"),
284 "newline must be replaced with space, not removed"
285 );
286 }
287
288 #[test]
289 fn resolves_env_var_replacing_all_control_chars() {
290 let input = "a\rb\nb\0c\u{2028}d\u{2029}e";
293 let sanitized = sanitize_env_value(input);
294 assert!(!sanitized.contains('\r'), "CR must be replaced");
295 assert!(!sanitized.contains('\n'), "LF must be replaced");
296 assert!(!sanitized.contains('\0'), "NUL must be replaced");
297 assert!(!sanitized.contains('\u{2028}'), "LS must be replaced");
298 assert!(!sanitized.contains('\u{2029}'), "PS must be replaced");
299 assert!(
300 sanitized.contains("a b b c d e"),
301 "all control chars must be replaced with space"
302 );
303 }
304
305 #[test]
306 fn test_resolve_env_value_with_colons() {
307 unsafe {
308 env::set_var("RUST_CAMEL_TEST_CONN", "user:pass@host:5432");
309 }
310 let resolver = PropertiesResolver::new();
311 assert_eq!(
312 resolver.resolve("{{env:RUST_CAMEL_TEST_CONN}}"),
313 Ok("user:pass@host:5432".to_string())
314 );
315 unsafe {
316 env::remove_var("RUST_CAMEL_TEST_CONN");
317 }
318 }
319}