Skip to main content

camel_config/
properties.rs

1use std::collections::HashMap;
2use std::env;
3use std::sync::RwLock;
4
5/// Replace line/paragraph-break chars with SPACE to prevent YAML
6/// structural injection when values are spliced into raw YAML before parse.
7///
8/// Covers: `\n`, `\r`, `\0`, `\u{2028}`, `\u{2029}`.
9///
10/// Does NOT strip flow indicators (`[ ] { } ,`), plain-scalar colon
11/// (`: `), comment truncation (` #`), or tag/anchor chars (`! & * %`).
12/// These can alter YAML within a single line but cannot inject new
13/// keys/structure (that requires newlines). Values are inserted into
14/// already-trusted template positions, not arbitrary escaping.
15fn 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    // TODO(CONFIG-015): Property value caching not implemented.
29    // Values are re-read from source on every access.
30    // TODO(CONFIG-017): Multi-source property chaining partially implemented.
31    // Currently: file + env vars. Missing: explicit priority ordering,
32    // per-source encryption, dynamic re-evaluation.
33    properties: RwLock<HashMap<String, String>>,
34}
35
36// TODO(CONFIG-016): Property value encoding/decoding not supported.
37// All values are treated as UTF-8 strings.
38
39#[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        // Newline replaced with SPACE (not deleted)
282        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        // \0 cannot be set via env::set_var (C string terminator).
291        // Test sanitize_env_value directly for all 5 chars including \0.
292        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}