use std::collections::HashMap;
use std::env;
use std::sync::RwLock;
fn sanitize_env_value(val: &str) -> String {
val.chars()
.map(|c| {
if matches!(c, '\n' | '\r' | '\0' | '\u{2028}' | '\u{2029}') {
' '
} else {
c
}
})
.collect()
}
pub struct PropertiesResolver {
properties: RwLock<HashMap<String, String>>,
}
#[derive(Debug, PartialEq, thiserror::Error)]
pub enum ResolveError {
#[error("missing property key: {key}")]
MissingKey { key: String },
}
impl Default for PropertiesResolver {
fn default() -> Self {
Self::new()
}
}
impl PropertiesResolver {
pub fn new() -> Self {
Self {
properties: RwLock::new(HashMap::new()),
}
}
pub fn set(&self, key: &str, value: &str) {
self.properties
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(key.to_string(), value.to_string());
}
fn lookup(&self, key: &str) -> Option<String> {
if let Some(rest) = key.strip_prefix("env:") {
let env_key = rest.trim();
if env_key.is_empty() {
return None;
}
return env::var(env_key).ok();
}
let props = self
.properties
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner());
props.get(key).cloned()
}
pub fn resolve(&self, input: &str) -> Result<String, ResolveError> {
let mut result = input.to_string();
let mut start = 0;
while let Some(open) = result[start..].find("{{") {
let open_abs = start + open;
if let Some(close) = result[open_abs..].find("}}") {
let close_abs = open_abs + close + 2;
let inner = &result[open_abs + 2..open_abs + close];
let (key, default) = if let Some(rest) = inner.strip_prefix("env:") {
let env_key = rest.trim();
if let Some(colon) = env_key.find(':') {
(&inner[..4 + colon], Some(&env_key[colon + 1..]))
} else {
(inner, None)
}
} else if let Some(colon) = inner.find(':') {
(&inner[..colon], Some(&inner[colon + 1..]))
} else {
(inner, None)
};
if key.trim().is_empty() {
return Err(ResolveError::MissingKey {
key: key.to_string(),
});
}
let value = match (self.lookup(key), default) {
(Some(v), _) => sanitize_env_value(&v),
(None, Some(d)) => sanitize_env_value(d),
(None, None) => {
return Err(ResolveError::MissingKey {
key: key.to_string(),
});
}
};
result.replace_range(open_abs..close_abs, &value);
start = open_abs + value.len();
} else {
break;
}
}
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_simple_placeholder() {
let resolver = PropertiesResolver::new();
resolver.set("host", "localhost");
resolver.set("port", "6379");
assert_eq!(
resolver.resolve("redis://{{host}}:{{port}}"),
Ok("redis://localhost:6379".to_string())
);
}
#[test]
fn test_resolve_missing_key_returns_error() {
let resolver = PropertiesResolver::new();
let result = resolver.resolve("redis://{{host}}:6379");
assert!(result.is_err());
}
#[test]
fn test_resolve_no_placeholders() {
let resolver = PropertiesResolver::new();
assert_eq!(
resolver.resolve("redis://localhost:6379"),
Ok("redis://localhost:6379".to_string())
);
}
#[test]
fn test_resolve_default_value() {
let resolver = PropertiesResolver::new();
assert_eq!(
resolver.resolve("redis://{{host:localhost}}:6379"),
Ok("redis://localhost:6379".to_string())
);
}
#[test]
fn test_resolve_multiple_same_key() {
let resolver = PropertiesResolver::new();
resolver.set("host", "redis.example.com");
assert_eq!(
resolver.resolve("{{host}}:{{host}}"),
Ok("redis.example.com:redis.example.com".to_string())
);
}
#[test]
fn test_resolve_unclosed_placeholder_passthrough() {
let resolver = PropertiesResolver::new();
assert_eq!(
resolver.resolve("redis://{{host:localhost}:6379"),
Ok("redis://{{host:localhost}:6379".to_string())
);
}
#[test]
fn test_resolve_env_placeholder() {
unsafe {
env::set_var("RUST_CAMEL_TEST_HOST", "redis.prod.example.com");
}
let resolver = PropertiesResolver::new();
assert_eq!(
resolver.resolve("redis://{{env:RUST_CAMEL_TEST_HOST}}:6379"),
Ok("redis://redis.prod.example.com:6379".to_string())
);
unsafe {
env::remove_var("RUST_CAMEL_TEST_HOST");
}
}
#[test]
fn test_resolve_env_missing_without_default_returns_error() {
unsafe {
env::remove_var("RUST_CAMEL_TEST_DEFINITELY_MISSING");
}
let resolver = PropertiesResolver::new();
let result = resolver.resolve("{{env:RUST_CAMEL_TEST_DEFINITELY_MISSING}}");
assert!(result.is_err());
}
#[test]
fn test_resolve_env_with_default_uses_default_when_missing() {
unsafe {
env::remove_var("RUST_CAMEL_TEST_MISSING_HOST");
}
let resolver = PropertiesResolver::new();
assert_eq!(
resolver.resolve("redis://{{env:RUST_CAMEL_TEST_MISSING_HOST:localhost}}:6379"),
Ok("redis://localhost:6379".to_string())
);
}
#[test]
fn test_resolve_env_with_default_uses_env_when_present() {
unsafe {
env::set_var("RUST_CAMEL_TEST_DB_HOST", "db.prod.example.com");
}
let resolver = PropertiesResolver::new();
assert_eq!(
resolver.resolve("{{env:RUST_CAMEL_TEST_DB_HOST:localhost}}"),
Ok("db.prod.example.com".to_string())
);
unsafe {
env::remove_var("RUST_CAMEL_TEST_DB_HOST");
}
}
#[test]
fn test_resolve_mixed_env_and_property() {
unsafe {
env::set_var("RUST_CAMEL_TEST_PORT", "6380");
}
let resolver = PropertiesResolver::new();
resolver.set("host", "localhost");
assert_eq!(
resolver.resolve("redis://{{host}}:{{env:RUST_CAMEL_TEST_PORT}}"),
Ok("redis://localhost:6380".to_string())
);
unsafe {
env::remove_var("RUST_CAMEL_TEST_PORT");
}
}
#[test]
fn test_resolve_env_empty_key_returns_error() {
let resolver = PropertiesResolver::new();
let result = resolver.resolve("{{env:}}");
assert!(result.is_err());
}
#[test]
fn resolves_env_var_replacing_newlines() {
unsafe {
env::set_var(
"TEST_CONFIG_INJECTION",
"safe_value\ninjected_key: malicious",
);
}
let resolver = PropertiesResolver::new();
let result = resolver.resolve("{{env:TEST_CONFIG_INJECTION}}").unwrap();
unsafe {
env::remove_var("TEST_CONFIG_INJECTION");
}
assert!(
!result.contains('\n'),
"interpolated value must not contain newlines"
);
assert!(
result.contains("safe_value injected_key"),
"newline must be replaced with space, not removed"
);
}
#[test]
fn resolves_env_var_replacing_all_control_chars() {
let input = "a\rb\nb\0c\u{2028}d\u{2029}e";
let sanitized = sanitize_env_value(input);
assert!(!sanitized.contains('\r'), "CR must be replaced");
assert!(!sanitized.contains('\n'), "LF must be replaced");
assert!(!sanitized.contains('\0'), "NUL must be replaced");
assert!(!sanitized.contains('\u{2028}'), "LS must be replaced");
assert!(!sanitized.contains('\u{2029}'), "PS must be replaced");
assert!(
sanitized.contains("a b b c d e"),
"all control chars must be replaced with space"
);
}
#[test]
fn test_resolve_env_value_with_colons() {
unsafe {
env::set_var("RUST_CAMEL_TEST_CONN", "user:pass@host:5432");
}
let resolver = PropertiesResolver::new();
assert_eq!(
resolver.resolve("{{env:RUST_CAMEL_TEST_CONN}}"),
Ok("user:pass@host:5432".to_string())
);
unsafe {
env::remove_var("RUST_CAMEL_TEST_CONN");
}
}
}