diff --git a/Cargo.lock b/Cargo.lock index 9f673d64..492255a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1192,7 +1192,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1272,7 +1272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1377,6 +1377,7 @@ dependencies = [ name = "ferron-common" version = "2.4.1" dependencies = [ + "anyhow", "async-channel", "async-trait", "bytes", @@ -1387,6 +1388,7 @@ dependencies = [ "http-body-util", "hyper", "monoio", + "regex", "regorus", "rustls", "rustls-pki-types", @@ -3275,7 +3277,7 @@ dependencies = [ "once_cell", "socket2 0.6.1", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3485,10 +3487,12 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ + "aho-corasick", + "memchr", "regex-automata", "regex-syntax", ] @@ -3663,7 +3667,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3721,7 +3725,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4833,7 +4837,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/build-prepare/Cargo.lock b/build-prepare/Cargo.lock index 02605ba9..61d4e16b 100644 --- a/build-prepare/Cargo.lock +++ b/build-prepare/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 4 +version = 3 [[package]] name = "arraydeque" diff --git a/ferron-common/Cargo.toml b/ferron-common/Cargo.toml index 050076e4..1af05322 100644 --- a/ferron-common/Cargo.toml +++ b/ferron-common/Cargo.toml @@ -4,7 +4,9 @@ version = "2.4.1" edition = "2021" [dependencies] +anyhow = "1.0.98" fancy-regex = "0.17.0" +regex = "1.12.3" hyper = { version = "1.6.0", features = ["full"] } cidr = "0.3.1" async-trait = "0.1.88" diff --git a/ferron-common/src/util/config_placeholders.rs b/ferron-common/src/util/config_placeholders.rs new file mode 100644 index 00000000..ae95a6d4 --- /dev/null +++ b/ferron-common/src/util/config_placeholders.rs @@ -0,0 +1,214 @@ +use std::env; +use anyhow::{bail, Result}; + +fn resolve_placeholder(kind: &str, value: &str) -> Result> { + match kind { + "env" => match env::var(value) { + Ok(val) => Ok(Some(val)), + Err(e) => bail!("couldn't find ENV-Key >>{}<< {}", value, e), + }, + _ => Ok(None), + } +} + +pub fn replace_placeholders(input: &str) -> Result { + let mut output = String::new(); + let mut cursor = 0; + + while cursor < input.len() { + let next = input[cursor..].find('{'); + + let start_rel = match next { + Some(pos) => pos, + None => { + output.push_str(&input[cursor..]); + break; + } + }; + + let start = cursor + start_rel; + + // Check if escaped: "\{" + if start > 0 && input.as_bytes()[start - 1] == b'\\' { + // push everything before the backslash + output.push_str(&input[cursor..start - 1]); + // push literal '{' + output.push('{'); + + cursor = start + 1; + continue; + } + + let end_rel = match input[start + 1..].find('}') { + Some(pos) => pos, + None => bail!("No closing '}}' found for '{{' at position {}", start), + }; + + let end = start + 1 + end_rel; + + // Push preceding text + output.push_str(&input[cursor..start]); + + let placeholder = &input[start + 1..end]; + + if let Some((kind, value)) = placeholder.split_once(':') { + match resolve_placeholder(kind, value)? { + Some(resolved) => output.push_str(&resolved), + None => { + output.push('{'); + output.push_str(placeholder); + output.push('}'); + } + } + } else { + output.push('{'); + output.push_str(placeholder); + output.push('}'); + } + + cursor = end + 1; + } + + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + #[test] + fn env_var_missing() { + let result = resolve_placeholder("env", "LALA_SHOULD_NOT_EXIST"); + assert!(result.is_err()); + } + + #[test] + fn env_var_exists() { + env::set_var("TEST_ENV_EXISTS", "value"); + + let result = resolve_placeholder("env", "TEST_ENV_EXISTS"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Some("value".to_string())); + } + + #[test] + fn passthrough_no_placeholders() { + let input = "LALA"; + let result = replace_placeholders(input); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), input); + } + + #[test] + fn single_env_placeholder() { + env::set_var("TEST_HOME", "/home/test"); + + let result = replace_placeholders("{env:TEST_HOME}"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "/home/test"); + } + + #[test] + fn unknown_kind_passthrough() { + let result = replace_placeholders("{envA:HOME}"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "{envA:HOME}"); + } + + #[test] + fn interpolate_env_with_suffix() { + env::set_var("TEST_HOME", "/home/test"); + + let result = replace_placeholders("{env:TEST_HOME}/src/modules"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "/home/test/src/modules"); + } + + #[test] + fn interpolate_multiple_env_values() { + env::set_var("TEST_HOME", "/home/test"); + env::set_var("TEST_USER", "user"); + + let input = "prefix_{env:TEST_HOME}_middle_{env:TEST_USER}_suffix"; + let result = replace_placeholders(input).unwrap(); + + let expected = "prefix_/home/test_middle_user_suffix"; + assert_eq!(result, expected); + } + + #[test] + fn plain_string_passthrough() { + let input = "plain_string_without_env"; + let result = replace_placeholders(input); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), input); + } + + #[test] + fn missing_closing_brace() { + let result = replace_placeholders("{env:TEST_HOME"); + assert!(result.is_err()); + } + + #[test] + fn nonexistent_env_var() { + let result = + replace_placeholders("{env:THIS_SHOULD_NOT_EXIST_123}"); + assert!(result.is_err()); + } + + #[test] + fn nonexistent_env_var_in_interpolation() { + let result = + replace_placeholders("prefix_{env:THIS_SHOULD_NOT_EXIST_456}_suffix"); + assert!(result.is_err()); + } + + #[test] + fn placeholder_without_colon_passthrough() { + let result = replace_placeholders("{justtext}"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "{justtext}"); + } + + #[test] + fn escaped_open_brace() { + // No env needed — should NOT resolve + let result = replace_placeholders(r"\{env:TEST_HOME}").unwrap(); + assert_eq!(result, "{env:TEST_HOME}"); + } + + #[test] + fn escaped_brace_with_text() { + let result = replace_placeholders(r"prefix_\{env:TEST_HOME}_suffix").unwrap(); + assert_eq!(result, "prefix_{env:TEST_HOME}_suffix"); + } + + #[test] + fn escaped_and_real_placeholder() { + std::env::set_var("TEST_HOME_ESC", "/home/test"); + + let input = r"\{env:TEST_HOME_ESC}_{env:TEST_HOME_ESC}"; + let result = replace_placeholders(input).unwrap(); + + assert_eq!(result, "{env:TEST_HOME_ESC}_/home/test"); + } + + #[test] + fn double_escape_sequence() { + std::env::set_var("TEST_HOME_ESC2", "/home/test"); + + // "\\{" → literal "\" + "{" + let result = replace_placeholders(r"\\{env:TEST_HOME_ESC2}").unwrap(); + + // First "\" is literal, then placeholder is evaluated + assert_eq!(result, r"\/home/test"); + } + + #[test] + fn escaped_non_placeholder() { + let result = replace_placeholders(r"\{justtext}").unwrap(); + assert_eq!(result, "{justtext}"); + } +} diff --git a/ferron-common/src/util/mod.rs b/ferron-common/src/util/mod.rs index a361b2eb..6f0c1366 100644 --- a/ferron-common/src/util/mod.rs +++ b/ferron-common/src/util/mod.rs @@ -1,6 +1,7 @@ mod anti_xss; mod config_macros; mod default_html_page; +mod config_placeholders; mod header_placeholders; mod ip_blocklist; mod is_localhost; @@ -22,6 +23,7 @@ pub use anti_xss::*; pub use header_placeholders::*; pub use ip_blocklist::*; pub use is_localhost::*; +pub use config_placeholders::*; pub use match_hostname::*; pub use match_location::*; pub use module_cache::*; diff --git a/ferron/src/config/adapters/kdl.rs b/ferron/src/config/adapters/kdl.rs index 362000be..de924e75 100644 --- a/ferron/src/config/adapters/kdl.rs +++ b/ferron/src/config/adapters/kdl.rs @@ -7,7 +7,7 @@ use std::{ str::FromStr, }; -use ferron_common::observability::ObservabilityBackendChannels; +use ferron_common::{observability::ObservabilityBackendChannels, util::replace_placeholders}; use glob::glob; use kdl::{KdlDocument, KdlNode, KdlValue}; @@ -23,7 +23,10 @@ fn kdl_node_to_configuration_entry(kdl_node: &KdlNode) -> ServerConfigurationEnt let mut props = HashMap::new(); for kdl_entry in kdl_node.iter() { let value = match kdl_entry.value().to_owned() { - KdlValue::String(value) => ServerConfigurationValue::String(value), + KdlValue::String(value) => { + let resolved_value = replace_placeholders(&value).expect("Failed to resolve environment variable in configuration"); + ServerConfigurationValue::String(resolved_value) + } KdlValue::Integer(value) => ServerConfigurationValue::Integer(value), KdlValue::Float(value) => ServerConfigurationValue::Float(value), KdlValue::Bool(value) => ServerConfigurationValue::Bool(value),