From 4cefae6c96f458effa26b7db30cfd9d3565f45c0 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 25 Oct 2025 10:27:33 +0200 Subject: [PATCH 1/8] Add lookup_env_var to get environment Variable Signed-off-by: alex --- ferron-common/src/util/lookup_env_var.rs | 58 ++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 ferron-common/src/util/lookup_env_var.rs diff --git a/ferron-common/src/util/lookup_env_var.rs b/ferron-common/src/util/lookup_env_var.rs new file mode 100644 index 00000000..3dabb635 --- /dev/null +++ b/ferron-common/src/util/lookup_env_var.rs @@ -0,0 +1,58 @@ +use std::env; + +pub fn lookup_env_value(conf_string: String) -> String { + let index_lb = conf_string.find("{"); + if let Some(index_lb) = index_lb { + let index_rb_afterlb = conf_string.find("}"); + if let Some(index_rb_afterlb) = index_rb_afterlb { + // +1 for { + // +4 for env: + return get_env_val(conf_string[index_lb + 5..index_rb_afterlb].to_string()); + } else { + return conf_string; + } + } else { + return conf_string; + } +} + +pub fn get_env_val(conf_key: String) -> String { + match env::var(conf_key.clone()) { + Ok(val) => return val, + Err(e) => format!("couldn't find ENV-Key >>{conf_key}<< {e}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lala_get_env_var() { + let result = get_env_val("LALA".to_string()); + assert_eq!(result, "couldn't find ENV-Key >>LALA<< environment variable not found"); + } + + #[test] + fn home_get_env_var() { + let result = get_env_val("HOME".to_string()); + assert_ne!(result, ""); + } + + #[test] + fn missing_get_env_key() { + let result = lookup_env_value("LALA".to_string()); + assert_eq!(result, "LALA"); + } + + #[test] + fn success_lookup_env_value() { + let result = lookup_env_value("{env:HOME}".to_string()); + assert_ne!(result, ""); + } + #[test] + fn failed_lookup_env_value() { + let result = lookup_env_value("{envA:HOME}".to_string()); + assert_eq!(result, "couldn't find ENV-Key >>:HOME<< environment variable not found"); + } +} From 2b41f6d8edae7f61165b57312e1d668755d3e95a Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 30 Oct 2025 14:10:20 +0100 Subject: [PATCH 2/8] Add lookup_env_var to get environment Variable, after feedback Signed-off-by: Alex --- ferron-common/src/util/lookup_env_var.rs | 50 +++++++++++++++--------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/ferron-common/src/util/lookup_env_var.rs b/ferron-common/src/util/lookup_env_var.rs index 3dabb635..bd0ce2a4 100644 --- a/ferron-common/src/util/lookup_env_var.rs +++ b/ferron-common/src/util/lookup_env_var.rs @@ -1,25 +1,31 @@ use std::env; +use anyhow::bail; -pub fn lookup_env_value(conf_string: String) -> String { - let index_lb = conf_string.find("{"); - if let Some(index_lb) = index_lb { - let index_rb_afterlb = conf_string.find("}"); - if let Some(index_rb_afterlb) = index_rb_afterlb { - // +1 for { - // +4 for env: - return get_env_val(conf_string[index_lb + 5..index_rb_afterlb].to_string()); +pub fn lookup_env_value(conf_string: String) -> anyhow::Result { + if !conf_string.starts_with("{env:") { + bail!("String '{}' does not start with '{{env:'", conf_string); + } + + // Search Index of "{env:" + if let Some(index_lb) = conf_string.find("{env:") { + // Search for ending "}" + if let Some(index_rb_afterlb) = conf_string[index_lb + 5..].find("}") { + return get_env_val( + conf_string[index_lb + 5..index_lb + 5 + index_rb_afterlb].to_string(), + ); } else { - return conf_string; + bail!("No closing '}}' found in string '{}'", conf_string); } - } else { - return conf_string; } + + // If not an env prefix return the string + Ok(conf_string) } -pub fn get_env_val(conf_key: String) -> String { +pub fn get_env_val(conf_key: String) -> anyhow::Result { match env::var(conf_key.clone()) { - Ok(val) => return val, - Err(e) => format!("couldn't find ENV-Key >>{conf_key}<< {e}"), + Ok(val) => return Ok(val), + Err(e) => bail!("couldn't find ENV-Key >>{conf_key}<< {e}"), } } @@ -30,29 +36,35 @@ mod tests { #[test] fn lala_get_env_var() { let result = get_env_val("LALA".to_string()); - assert_eq!(result, "couldn't find ENV-Key >>LALA<< environment variable not found"); + assert!( + result.is_err(), + "couldn't find ENV-Key >>LALA<< environment variable not found" + ); } #[test] fn home_get_env_var() { let result = get_env_val("HOME".to_string()); - assert_ne!(result, ""); + assert!(result.is_ok(), "Found 'HOME'"); } #[test] fn missing_get_env_key() { let result = lookup_env_value("LALA".to_string()); - assert_eq!(result, "LALA"); + assert!(result.is_err(), "Not found LALA"); } #[test] fn success_lookup_env_value() { let result = lookup_env_value("{env:HOME}".to_string()); - assert_ne!(result, ""); + assert!(result.is_ok(), "Get successfully 'HOME' ENV"); } #[test] fn failed_lookup_env_value() { let result = lookup_env_value("{envA:HOME}".to_string()); - assert_eq!(result, "couldn't find ENV-Key >>:HOME<< environment variable not found"); + assert!( + result.is_err(), + "couldn't find ENV-Key >>:HOME<< environment variable not found" + ); } } From fabaeef1867dd28e57fd210c03142b8dbe726919 Mon Sep 17 00:00:00 2001 From: Geraldo Luiz Date: Fri, 9 Jan 2026 16:45:28 -0300 Subject: [PATCH 3/8] Env lookup working right now --- build-prepare/Cargo.lock | 18 ++-- ferron-common/Cargo.toml | 1 + ferron-common/src/util/lookup_env_var.rs | 102 +++++++++++------------ ferron-common/src/util/mod.rs | 2 + ferron/src/config/adapters/kdl.rs | 23 +++-- 5 files changed, 78 insertions(+), 68 deletions(-) diff --git a/build-prepare/Cargo.lock b/build-prepare/Cargo.lock index a37337b3..dfb155de 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" @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown", @@ -73,18 +73,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.104" +version = "1.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" dependencies = [ "proc-macro2", ] @@ -120,9 +120,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.112" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21f182278bf2d2bcb3c88b1b08a37df029d71ce3d3ae26168e3c653b213b99d4" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", diff --git a/ferron-common/Cargo.toml b/ferron-common/Cargo.toml index d120898c..1928a2d4 100644 --- a/ferron-common/Cargo.toml +++ b/ferron-common/Cargo.toml @@ -4,6 +4,7 @@ version = "2.3.2" edition = "2021" [dependencies] +anyhow = "1.0.98" fancy-regex = "0.17.0" hyper = { version = "1.6.0", features = ["full"] } cidr = "0.3.1" diff --git a/ferron-common/src/util/lookup_env_var.rs b/ferron-common/src/util/lookup_env_var.rs index bd0ce2a4..2b2a3f22 100644 --- a/ferron-common/src/util/lookup_env_var.rs +++ b/ferron-common/src/util/lookup_env_var.rs @@ -1,70 +1,68 @@ -use std::env; use anyhow::bail; +use std::env; pub fn lookup_env_value(conf_string: String) -> anyhow::Result { - if !conf_string.starts_with("{env:") { - bail!("String '{}' does not start with '{{env:'", conf_string); - } + if !conf_string.starts_with("{env:") { + bail!("String '{}' does not start with '{{env:'", conf_string); + } - // Search Index of "{env:" - if let Some(index_lb) = conf_string.find("{env:") { - // Search for ending "}" - if let Some(index_rb_afterlb) = conf_string[index_lb + 5..].find("}") { - return get_env_val( - conf_string[index_lb + 5..index_lb + 5 + index_rb_afterlb].to_string(), - ); - } else { - bail!("No closing '}}' found in string '{}'", conf_string); - } + // Search Index of "{env:" + if let Some(index_lb) = conf_string.find("{env:") { + // Search for ending "}" + if let Some(index_rb_afterlb) = conf_string[index_lb + 5..].find("}") { + return get_env_val(conf_string[index_lb + 5..index_lb + 5 + index_rb_afterlb].to_string()); + } else { + bail!("No closing '}}' found in string '{}'", conf_string); } + } - // If not an env prefix return the string - Ok(conf_string) + // If not an env prefix return the string + Ok(conf_string) } pub fn get_env_val(conf_key: String) -> anyhow::Result { - match env::var(conf_key.clone()) { - Ok(val) => return Ok(val), - Err(e) => bail!("couldn't find ENV-Key >>{conf_key}<< {e}"), - } + match env::var(conf_key.clone()) { + Ok(val) => Ok(val), + Err(e) => bail!("couldn't find ENV-Key >>{conf_key}<< {e}"), + } } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn lala_get_env_var() { - let result = get_env_val("LALA".to_string()); - assert!( - result.is_err(), - "couldn't find ENV-Key >>LALA<< environment variable not found" - ); - } + #[test] + fn lala_get_env_var() { + let result = get_env_val("LALA".to_string()); + assert!( + result.is_err(), + "couldn't find ENV-Key >>LALA<< environment variable not found" + ); + } - #[test] - fn home_get_env_var() { - let result = get_env_val("HOME".to_string()); - assert!(result.is_ok(), "Found 'HOME'"); - } + #[test] + fn home_get_env_var() { + let result = get_env_val("HOME".to_string()); + assert!(result.is_ok(), "Found 'HOME'"); + } - #[test] - fn missing_get_env_key() { - let result = lookup_env_value("LALA".to_string()); - assert!(result.is_err(), "Not found LALA"); - } + #[test] + fn missing_get_env_key() { + let result = lookup_env_value("LALA".to_string()); + assert!(result.is_err(), "Not found LALA"); + } - #[test] - fn success_lookup_env_value() { - let result = lookup_env_value("{env:HOME}".to_string()); - assert!(result.is_ok(), "Get successfully 'HOME' ENV"); - } - #[test] - fn failed_lookup_env_value() { - let result = lookup_env_value("{envA:HOME}".to_string()); - assert!( - result.is_err(), - "couldn't find ENV-Key >>:HOME<< environment variable not found" - ); - } + #[test] + fn success_lookup_env_value() { + let result = lookup_env_value("{env:HOME}".to_string()); + assert!(result.is_ok(), "Get successfully 'HOME' ENV"); + } + #[test] + fn failed_lookup_env_value() { + let result = lookup_env_value("{envA:HOME}".to_string()); + assert!( + result.is_err(), + "couldn't find ENV-Key >>:HOME<< environment variable not found" + ); + } } diff --git a/ferron-common/src/util/mod.rs b/ferron-common/src/util/mod.rs index a361b2eb..029ec3f6 100644 --- a/ferron-common/src/util/mod.rs +++ b/ferron-common/src/util/mod.rs @@ -4,6 +4,7 @@ mod default_html_page; mod header_placeholders; mod ip_blocklist; mod is_localhost; +mod lookup_env_var; mod match_hostname; mod match_location; mod module_cache; @@ -22,6 +23,7 @@ pub use anti_xss::*; pub use header_placeholders::*; pub use ip_blocklist::*; pub use is_localhost::*; +pub use lookup_env_var::*; 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..a02dde48 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::lookup_env_value}; use glob::glob; use kdl::{KdlDocument, KdlNode, KdlValue}; @@ -18,12 +18,21 @@ use crate::config::{ use super::ConfigurationAdapter; -fn kdl_node_to_configuration_entry(kdl_node: &KdlNode) -> ServerConfigurationEntry { +fn kdl_node_to_configuration_entry( + kdl_node: &KdlNode, +) -> Result> { let mut values = Vec::new(); 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 = if value.starts_with("{env:") { + lookup_env_value(value).map_err(|err| -> Box { err.into() })? + } else { + value + }; + ServerConfigurationValue::String(resolved_value) + } KdlValue::Integer(value) => ServerConfigurationValue::Integer(value), KdlValue::Float(value) => ServerConfigurationValue::Float(value), KdlValue::Bool(value) => ServerConfigurationValue::Bool(value), @@ -39,7 +48,7 @@ fn kdl_node_to_configuration_entry(kdl_node: &KdlNode) -> ServerConfigurationEnt // If KDL node doesn't have any arguments, add the "#true" KDL value values.push(ServerConfigurationValue::Bool(true)); } - ServerConfigurationEntry { values, props } + Ok(ServerConfigurationEntry { values, props }) } fn load_configuration_inner( @@ -315,7 +324,7 @@ fn load_configuration_inner( Err(anyhow::anyhow!("Invalid `use` statement"))?; } } - let value = kdl_node_to_configuration_entry(kdl_node); + let value = kdl_node_to_configuration_entry(kdl_node)?; conditions_data.push(match parse_conditional_data(name, value) { Ok(d) => d, Err(err) => Err(anyhow::anyhow!( @@ -533,7 +542,7 @@ fn load_configuration_inner( } else { for kdl_node in children.nodes() { let kdl_node_name = kdl_node.name().value(); - let value = kdl_node_to_configuration_entry(kdl_node); + let value = kdl_node_to_configuration_entry(kdl_node)?; if let Some(entries) = configuration_entries.get_mut(kdl_node_name) { entries.inner.push(value); } else { @@ -566,7 +575,7 @@ fn load_configuration_inner( ))? } } else { - let value = kdl_node_to_configuration_entry(kdl_node); + let value = kdl_node_to_configuration_entry(kdl_node)?; if let Some(entries) = configuration_entries.get_mut(kdl_node_name) { entries.inner.push(value); } else { From 351b987fe3d8290ea0534a4c581c242ed3db28ea Mon Sep 17 00:00:00 2001 From: Geraldo Luiz Date: Sun, 11 Jan 2026 19:07:28 -0300 Subject: [PATCH 4/8] env variable interpolation implemented --- Cargo.lock | 1 + ferron-common/src/util/lookup_env_var.rs | 89 ++++++++++++++++++++---- ferron/src/config/adapters/kdl.rs | 2 +- 3 files changed, 78 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a0e7aa72..987b01da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1376,6 +1376,7 @@ dependencies = [ name = "ferron-common" version = "2.3.2" dependencies = [ + "anyhow", "async-channel", "async-trait", "bytes", diff --git a/ferron-common/src/util/lookup_env_var.rs b/ferron-common/src/util/lookup_env_var.rs index 2b2a3f22..e2bd6a6a 100644 --- a/ferron-common/src/util/lookup_env_var.rs +++ b/ferron-common/src/util/lookup_env_var.rs @@ -2,22 +2,37 @@ use anyhow::bail; use std::env; pub fn lookup_env_value(conf_string: String) -> anyhow::Result { - if !conf_string.starts_with("{env:") { - bail!("String '{}' does not start with '{{env:'", conf_string); + if !conf_string.contains("{env:") { + return Ok(conf_string); } - // Search Index of "{env:" - if let Some(index_lb) = conf_string.find("{env:") { - // Search for ending "}" - if let Some(index_rb_afterlb) = conf_string[index_lb + 5..].find("}") { - return get_env_val(conf_string[index_lb + 5..index_lb + 5 + index_rb_afterlb].to_string()); + let mut result = String::new(); + let mut last_end = 0; + + for (start, _) in conf_string.match_indices("{env:") { + // Add everything before this match + result.push_str(&conf_string[last_end..start]); + + // Find the closing brace + let search_from = start + 5; + if let Some(end_offset) = conf_string[search_from..].find('}') { + let end = search_from + end_offset; + let env_var_name = &conf_string[search_from..end]; + + // Get and append the env value + let env_value = get_env_val(env_var_name.to_string())?; + result.push_str(&env_value); + + last_end = end + 1; } else { - bail!("No closing '}}' found in string '{}'", conf_string); + bail!("No closing '}}' found for '{{env:' at position {}", start); } } - // If not an env prefix return the string - Ok(conf_string) + // Add any remaining text + result.push_str(&conf_string[last_end..]); + + Ok(result) } pub fn get_env_val(conf_key: String) -> anyhow::Result { @@ -48,8 +63,10 @@ mod tests { #[test] fn missing_get_env_key() { + // Strings without {env:} are now passed through unchanged let result = lookup_env_value("LALA".to_string()); - assert!(result.is_err(), "Not found LALA"); + assert!(result.is_ok(), "Strings without env markers should pass through"); + assert_eq!(result.unwrap(), "LALA"); } #[test] @@ -59,10 +76,56 @@ mod tests { } #[test] fn failed_lookup_env_value() { + // {envA:HOME} doesn't contain {env: so it passes through unchanged let result = lookup_env_value("{envA:HOME}".to_string()); assert!( - result.is_err(), - "couldn't find ENV-Key >>:HOME<< environment variable not found" + result.is_ok(), + "Strings without proper {{env: markers should pass through" ); + assert_eq!(result.unwrap(), "{envA:HOME}"); + } + + #[test] + fn interpolate_env_value() { + let result = lookup_env_value("{env:HOME}/src/modules".to_string()); + assert!(result.is_ok(), "Successfully interpolated HOME with path suffix"); + let value = result.unwrap(); + assert!(value.ends_with("/src/modules"), "Should end with /src/modules"); + assert!(!value.contains("{env:"), "Should not contain {{env: marker"); + } + + #[test] + fn interpolate_multiple_env_values() { + let result = lookup_env_value("prefix_{env:HOME}_middle_{env:USER}_suffix".to_string()); + assert!(result.is_ok(), "Successfully interpolated multiple env vars"); + let value = result.unwrap(); + assert!(value.starts_with("prefix_"), "Should start with prefix_"); + assert!(value.ends_with("_suffix"), "Should end with _suffix"); + assert!(!value.contains("{env:"), "Should not contain {{env: marker"); + } + + #[test] + fn no_env_passthrough() { + let result = lookup_env_value("plain_string_without_env".to_string()); + assert!(result.is_ok(), "Plain strings should pass through"); + assert_eq!(result.unwrap(), "plain_string_without_env"); + } + + #[test] + fn missing_closing_brace() { + let result = lookup_env_value("{env:HOME".to_string()); + assert!(result.is_err(), "Should error on missing closing brace"); + } + + #[test] + fn nonexistent_env_var() { + let result = lookup_env_value("{env:NONEXISTENT_VAR_THAT_SHOULD_NOT_EXIST}".to_string()); + assert!(result.is_err(), "Should error when env var doesn't exist"); + } + + #[test] + fn nonexistent_env_var_in_interpolation() { + let result = lookup_env_value("prefix_{env:NONEXISTENT_VAR}_suffix".to_string()); + assert!(result.is_err(), "Should error when interpolated env var doesn't exist"); } } diff --git a/ferron/src/config/adapters/kdl.rs b/ferron/src/config/adapters/kdl.rs index a02dde48..ee72520e 100644 --- a/ferron/src/config/adapters/kdl.rs +++ b/ferron/src/config/adapters/kdl.rs @@ -26,7 +26,7 @@ fn kdl_node_to_configuration_entry( for kdl_entry in kdl_node.iter() { let value = match kdl_entry.value().to_owned() { KdlValue::String(value) => { - let resolved_value = if value.starts_with("{env:") { + let resolved_value = if value.contains("{env:") { lookup_env_value(value).map_err(|err| -> Box { err.into() })? } else { value From 48375a33f608b45b4319b23de6704303ead4babd Mon Sep 17 00:00:00 2001 From: Geraldo Luiz Date: Sat, 17 Jan 2026 17:00:17 -0300 Subject: [PATCH 5/8] Panicked on failure to parse env variable in ferron.kdl --- ferron/src/config/adapters/kdl.rs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/ferron/src/config/adapters/kdl.rs b/ferron/src/config/adapters/kdl.rs index ee72520e..861c004b 100644 --- a/ferron/src/config/adapters/kdl.rs +++ b/ferron/src/config/adapters/kdl.rs @@ -18,19 +18,13 @@ use crate::config::{ use super::ConfigurationAdapter; -fn kdl_node_to_configuration_entry( - kdl_node: &KdlNode, -) -> Result> { +fn kdl_node_to_configuration_entry(kdl_node: &KdlNode) -> ServerConfigurationEntry { let mut values = Vec::new(); let mut props = HashMap::new(); for kdl_entry in kdl_node.iter() { let value = match kdl_entry.value().to_owned() { KdlValue::String(value) => { - let resolved_value = if value.contains("{env:") { - lookup_env_value(value).map_err(|err| -> Box { err.into() })? - } else { - value - }; + let resolved_value = lookup_env_value(value).expect("Failed to resolve environment variable in configuration"); ServerConfigurationValue::String(resolved_value) } KdlValue::Integer(value) => ServerConfigurationValue::Integer(value), @@ -48,7 +42,7 @@ fn kdl_node_to_configuration_entry( // If KDL node doesn't have any arguments, add the "#true" KDL value values.push(ServerConfigurationValue::Bool(true)); } - Ok(ServerConfigurationEntry { values, props }) + ServerConfigurationEntry { values, props } } fn load_configuration_inner( @@ -324,7 +318,7 @@ fn load_configuration_inner( Err(anyhow::anyhow!("Invalid `use` statement"))?; } } - let value = kdl_node_to_configuration_entry(kdl_node)?; + let value = kdl_node_to_configuration_entry(kdl_node); conditions_data.push(match parse_conditional_data(name, value) { Ok(d) => d, Err(err) => Err(anyhow::anyhow!( @@ -542,7 +536,7 @@ fn load_configuration_inner( } else { for kdl_node in children.nodes() { let kdl_node_name = kdl_node.name().value(); - let value = kdl_node_to_configuration_entry(kdl_node)?; + let value = kdl_node_to_configuration_entry(kdl_node); if let Some(entries) = configuration_entries.get_mut(kdl_node_name) { entries.inner.push(value); } else { @@ -575,7 +569,7 @@ fn load_configuration_inner( ))? } } else { - let value = kdl_node_to_configuration_entry(kdl_node)?; + let value = kdl_node_to_configuration_entry(kdl_node); if let Some(entries) = configuration_entries.get_mut(kdl_node_name) { entries.inner.push(value); } else { From a208997552ec7c7a450ffdb1070684d54874f2ca Mon Sep 17 00:00:00 2001 From: Geraldo Luiz Date: Fri, 27 Mar 2026 09:13:04 -0300 Subject: [PATCH 6/8] Adopted more standard way of replacing placeholders based on headers_placeholders.rs and log_placeholders.rs --- ferron-common/Cargo.toml | 1 + ferron-common/src/util/config_placeholders.rs | 151 ++++++++++++++++++ ferron-common/src/util/lookup_env_var.rs | 131 --------------- ferron-common/src/util/mod.rs | 4 +- ferron/src/config/adapters/kdl.rs | 4 +- 5 files changed, 156 insertions(+), 135 deletions(-) create mode 100644 ferron-common/src/util/config_placeholders.rs delete mode 100644 ferron-common/src/util/lookup_env_var.rs diff --git a/ferron-common/Cargo.toml b/ferron-common/Cargo.toml index 2ac349d5..1af05322 100644 --- a/ferron-common/Cargo.toml +++ b/ferron-common/Cargo.toml @@ -6,6 +6,7 @@ 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..e16f287e --- /dev/null +++ b/ferron-common/src/util/config_placeholders.rs @@ -0,0 +1,151 @@ +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; + + loop { + let start_rel = match input[cursor..].find('{') { + Some(pos) => pos, + None => { + output.push_str(&input[cursor..]); + break; + } + }; + + let start = cursor + start_rel; + + 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]; + + // Split into kind:value + if let Some((kind, value)) = placeholder.split_once(':') { + match resolve_placeholder(kind, value)? { + Some(resolved) => output.push_str(&resolved), + None => { + // Unknown kind → keep original + output.push('{'); + output.push_str(placeholder); + output.push('}'); + } + } + } else { + // No ':' → not a structured placeholder, keep as-is + output.push('{'); + output.push_str(placeholder); + output.push('}'); + } + + cursor = end + 1; + } + + Ok(output) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lala_get_env_var() { + let result = get_env_val("LALA".to_string()); + assert!( + result.is_err(), + "couldn't find ENV-Key >>LALA<< environment variable not found" + ); + } + + #[test] + fn home_get_env_var() { + let result = get_env_val("HOME".to_string()); + assert!(result.is_ok(), "Found 'HOME'"); + } + + #[test] + fn missing_get_env_key() { + // Strings without {env:} are now passed through unchanged + let result = lookup_config_placeholders("LALA".to_string()); + assert!(result.is_ok(), "Strings without env markers should pass through"); + assert_eq!(result.unwrap(), "LALA"); + } + + #[test] + fn lookup_config_placeholders() { + let result = lookup_config_placeholders("{env:HOME}".to_string()); + assert!(result.is_ok(), "Get successfully 'HOME' ENV"); + } + #[test] + fn lookup_config_placeholders() { + // {envA:HOME} doesn't contain {env: so it passes through unchanged + let result = lookup_config_placeholders("{envA:HOME}".to_string()); + assert!( + result.is_ok(), + "Strings without proper {{env: markers should pass through" + ); + assert_eq!(result.unwrap(), "{envA:HOME}"); + } + + #[test] + fn interpolate_env_value() { + let result = lookup_config_placeholders("{env:HOME}/src/modules".to_string()); + assert!(result.is_ok(), "Successfully interpolated HOME with path suffix"); + let value = result.unwrap(); + assert!(value.ends_with("/src/modules"), "Should end with /src/modules"); + assert!(!value.contains("{env:"), "Should not contain {{env: marker"); + } + + #[test] + fn interpolate_multiple_env_values() { + let result = lookup_config_placeholders("prefix_{env:HOME}_middle_{env:USER}_suffix".to_string()); + assert!(result.is_ok(), "Successfully interpolated multiple env vars"); + let value = result.unwrap(); + assert!(value.starts_with("prefix_"), "Should start with prefix_"); + assert!(value.ends_with("_suffix"), "Should end with _suffix"); + assert!(!value.contains("{env:"), "Should not contain {{env: marker"); + } + + #[test] + fn no_env_passthrough() { + let result = lookup_config_placeholders("plain_string_without_env".to_string()); + assert!(result.is_ok(), "Plain strings should pass through"); + assert_eq!(result.unwrap(), "plain_string_without_env"); + } + + #[test] + fn missing_closing_brace() { + let result = lookup_config_placeholders("{env:HOME".to_string()); + assert!(result.is_err(), "Should error on missing closing brace"); + } + + #[test] + fn nonexistent_env_var() { + let result = lookup_config_placeholders("{env:NONEXISTENT_VAR_THAT_SHOULD_NOT_EXIST}".to_string()); + assert!(result.is_err(), "Should error when env var doesn't exist"); + } + + #[test] + fn nonexistent_env_var_in_interpolation() { + let result = lookup_config_placeholders("prefix_{env:NONEXISTENT_VAR}_suffix".to_string()); + assert!(result.is_err(), "Should error when interpolated env var doesn't exist"); + } +} diff --git a/ferron-common/src/util/lookup_env_var.rs b/ferron-common/src/util/lookup_env_var.rs deleted file mode 100644 index e2bd6a6a..00000000 --- a/ferron-common/src/util/lookup_env_var.rs +++ /dev/null @@ -1,131 +0,0 @@ -use anyhow::bail; -use std::env; - -pub fn lookup_env_value(conf_string: String) -> anyhow::Result { - if !conf_string.contains("{env:") { - return Ok(conf_string); - } - - let mut result = String::new(); - let mut last_end = 0; - - for (start, _) in conf_string.match_indices("{env:") { - // Add everything before this match - result.push_str(&conf_string[last_end..start]); - - // Find the closing brace - let search_from = start + 5; - if let Some(end_offset) = conf_string[search_from..].find('}') { - let end = search_from + end_offset; - let env_var_name = &conf_string[search_from..end]; - - // Get and append the env value - let env_value = get_env_val(env_var_name.to_string())?; - result.push_str(&env_value); - - last_end = end + 1; - } else { - bail!("No closing '}}' found for '{{env:' at position {}", start); - } - } - - // Add any remaining text - result.push_str(&conf_string[last_end..]); - - Ok(result) -} - -pub fn get_env_val(conf_key: String) -> anyhow::Result { - match env::var(conf_key.clone()) { - Ok(val) => Ok(val), - Err(e) => bail!("couldn't find ENV-Key >>{conf_key}<< {e}"), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn lala_get_env_var() { - let result = get_env_val("LALA".to_string()); - assert!( - result.is_err(), - "couldn't find ENV-Key >>LALA<< environment variable not found" - ); - } - - #[test] - fn home_get_env_var() { - let result = get_env_val("HOME".to_string()); - assert!(result.is_ok(), "Found 'HOME'"); - } - - #[test] - fn missing_get_env_key() { - // Strings without {env:} are now passed through unchanged - let result = lookup_env_value("LALA".to_string()); - assert!(result.is_ok(), "Strings without env markers should pass through"); - assert_eq!(result.unwrap(), "LALA"); - } - - #[test] - fn success_lookup_env_value() { - let result = lookup_env_value("{env:HOME}".to_string()); - assert!(result.is_ok(), "Get successfully 'HOME' ENV"); - } - #[test] - fn failed_lookup_env_value() { - // {envA:HOME} doesn't contain {env: so it passes through unchanged - let result = lookup_env_value("{envA:HOME}".to_string()); - assert!( - result.is_ok(), - "Strings without proper {{env: markers should pass through" - ); - assert_eq!(result.unwrap(), "{envA:HOME}"); - } - - #[test] - fn interpolate_env_value() { - let result = lookup_env_value("{env:HOME}/src/modules".to_string()); - assert!(result.is_ok(), "Successfully interpolated HOME with path suffix"); - let value = result.unwrap(); - assert!(value.ends_with("/src/modules"), "Should end with /src/modules"); - assert!(!value.contains("{env:"), "Should not contain {{env: marker"); - } - - #[test] - fn interpolate_multiple_env_values() { - let result = lookup_env_value("prefix_{env:HOME}_middle_{env:USER}_suffix".to_string()); - assert!(result.is_ok(), "Successfully interpolated multiple env vars"); - let value = result.unwrap(); - assert!(value.starts_with("prefix_"), "Should start with prefix_"); - assert!(value.ends_with("_suffix"), "Should end with _suffix"); - assert!(!value.contains("{env:"), "Should not contain {{env: marker"); - } - - #[test] - fn no_env_passthrough() { - let result = lookup_env_value("plain_string_without_env".to_string()); - assert!(result.is_ok(), "Plain strings should pass through"); - assert_eq!(result.unwrap(), "plain_string_without_env"); - } - - #[test] - fn missing_closing_brace() { - let result = lookup_env_value("{env:HOME".to_string()); - assert!(result.is_err(), "Should error on missing closing brace"); - } - - #[test] - fn nonexistent_env_var() { - let result = lookup_env_value("{env:NONEXISTENT_VAR_THAT_SHOULD_NOT_EXIST}".to_string()); - assert!(result.is_err(), "Should error when env var doesn't exist"); - } - - #[test] - fn nonexistent_env_var_in_interpolation() { - let result = lookup_env_value("prefix_{env:NONEXISTENT_VAR}_suffix".to_string()); - assert!(result.is_err(), "Should error when interpolated env var doesn't exist"); - } -} diff --git a/ferron-common/src/util/mod.rs b/ferron-common/src/util/mod.rs index 029ec3f6..6f0c1366 100644 --- a/ferron-common/src/util/mod.rs +++ b/ferron-common/src/util/mod.rs @@ -1,10 +1,10 @@ mod anti_xss; mod config_macros; mod default_html_page; +mod config_placeholders; mod header_placeholders; mod ip_blocklist; mod is_localhost; -mod lookup_env_var; mod match_hostname; mod match_location; mod module_cache; @@ -23,7 +23,7 @@ pub use anti_xss::*; pub use header_placeholders::*; pub use ip_blocklist::*; pub use is_localhost::*; -pub use lookup_env_var::*; +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 861c004b..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, util::lookup_env_value}; +use ferron_common::{observability::ObservabilityBackendChannels, util::replace_placeholders}; use glob::glob; use kdl::{KdlDocument, KdlNode, KdlValue}; @@ -24,7 +24,7 @@ fn kdl_node_to_configuration_entry(kdl_node: &KdlNode) -> ServerConfigurationEnt for kdl_entry in kdl_node.iter() { let value = match kdl_entry.value().to_owned() { KdlValue::String(value) => { - let resolved_value = lookup_env_value(value).expect("Failed to resolve environment variable in configuration"); + let resolved_value = replace_placeholders(&value).expect("Failed to resolve environment variable in configuration"); ServerConfigurationValue::String(resolved_value) } KdlValue::Integer(value) => ServerConfigurationValue::Integer(value), From 5bda26c455cef6ed9af62a20fa48b1ab1be29705 Mon Sep 17 00:00:00 2001 From: Geraldo Luiz Date: Fri, 27 Mar 2026 09:28:53 -0300 Subject: [PATCH 7/8] Adapted tests to work with new functions --- Cargo.lock | 19 +- ferron-common/src/util/config_placeholders.rs | 180 ++++++++++-------- 2 files changed, 107 insertions(+), 92 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 832ca5bd..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]] @@ -1388,6 +1388,7 @@ dependencies = [ "http-body-util", "hyper", "monoio", + "regex", "regorus", "rustls", "rustls-pki-types", @@ -3276,7 +3277,7 @@ dependencies = [ "once_cell", "socket2 0.6.1", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -3486,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", ] @@ -3664,7 +3667,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3722,7 +3725,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4834,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/ferron-common/src/util/config_placeholders.rs b/ferron-common/src/util/config_placeholders.rs index e16f287e..d85a6e17 100644 --- a/ferron-common/src/util/config_placeholders.rs +++ b/ferron-common/src/util/config_placeholders.rs @@ -64,88 +64,100 @@ pub fn replace_placeholders(input: &str) -> Result { #[cfg(test)] mod tests { - use super::*; - - #[test] - fn lala_get_env_var() { - let result = get_env_val("LALA".to_string()); - assert!( - result.is_err(), - "couldn't find ENV-Key >>LALA<< environment variable not found" - ); - } - - #[test] - fn home_get_env_var() { - let result = get_env_val("HOME".to_string()); - assert!(result.is_ok(), "Found 'HOME'"); - } - - #[test] - fn missing_get_env_key() { - // Strings without {env:} are now passed through unchanged - let result = lookup_config_placeholders("LALA".to_string()); - assert!(result.is_ok(), "Strings without env markers should pass through"); - assert_eq!(result.unwrap(), "LALA"); - } - - #[test] - fn lookup_config_placeholders() { - let result = lookup_config_placeholders("{env:HOME}".to_string()); - assert!(result.is_ok(), "Get successfully 'HOME' ENV"); - } - #[test] - fn lookup_config_placeholders() { - // {envA:HOME} doesn't contain {env: so it passes through unchanged - let result = lookup_config_placeholders("{envA:HOME}".to_string()); - assert!( - result.is_ok(), - "Strings without proper {{env: markers should pass through" - ); - assert_eq!(result.unwrap(), "{envA:HOME}"); - } - - #[test] - fn interpolate_env_value() { - let result = lookup_config_placeholders("{env:HOME}/src/modules".to_string()); - assert!(result.is_ok(), "Successfully interpolated HOME with path suffix"); - let value = result.unwrap(); - assert!(value.ends_with("/src/modules"), "Should end with /src/modules"); - assert!(!value.contains("{env:"), "Should not contain {{env: marker"); - } - - #[test] - fn interpolate_multiple_env_values() { - let result = lookup_config_placeholders("prefix_{env:HOME}_middle_{env:USER}_suffix".to_string()); - assert!(result.is_ok(), "Successfully interpolated multiple env vars"); - let value = result.unwrap(); - assert!(value.starts_with("prefix_"), "Should start with prefix_"); - assert!(value.ends_with("_suffix"), "Should end with _suffix"); - assert!(!value.contains("{env:"), "Should not contain {{env: marker"); - } - - #[test] - fn no_env_passthrough() { - let result = lookup_config_placeholders("plain_string_without_env".to_string()); - assert!(result.is_ok(), "Plain strings should pass through"); - assert_eq!(result.unwrap(), "plain_string_without_env"); - } - - #[test] - fn missing_closing_brace() { - let result = lookup_config_placeholders("{env:HOME".to_string()); - assert!(result.is_err(), "Should error on missing closing brace"); - } - - #[test] - fn nonexistent_env_var() { - let result = lookup_config_placeholders("{env:NONEXISTENT_VAR_THAT_SHOULD_NOT_EXIST}".to_string()); - assert!(result.is_err(), "Should error when env var doesn't exist"); - } - - #[test] - fn nonexistent_env_var_in_interpolation() { - let result = lookup_config_placeholders("prefix_{env:NONEXISTENT_VAR}_suffix".to_string()); - assert!(result.is_err(), "Should error when interpolated env var doesn't exist"); - } + 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() { + let result = resolve_placeholder("env", "HOME"); + assert!(result.is_ok()); + assert!(result.unwrap().is_some()); + } + + #[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() { + let result = replace_placeholders("{env:HOME}"); + assert!(result.is_ok()); + let value = result.unwrap(); + assert!(!value.contains("{env:")); + } + + #[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() { + let result = replace_placeholders("{env:HOME}/src/modules"); + assert!(result.is_ok()); + let value = result.unwrap(); + assert!(value.ends_with("/src/modules")); + assert!(!value.contains("{env:")); + } + + #[test] + fn interpolate_multiple_env_values() { + std::env::set_var("TEST_HOME", "/home/test"); + std::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:HOME"); + assert!(result.is_err()); + } + + #[test] + fn nonexistent_env_var() { + let result = + replace_placeholders("{env:NONEXISTENT_VAR_THAT_SHOULD_NOT_EXIST}"); + assert!(result.is_err()); + } + + #[test] + fn nonexistent_env_var_in_interpolation() { + let result = + replace_placeholders("prefix_{env:NONEXISTENT_VAR}_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}"); + } } From 9b88283c6bfb46200499ef20073e1403d089ed54 Mon Sep 17 00:00:00 2001 From: Geraldo Luiz Date: Fri, 27 Mar 2026 09:35:27 -0300 Subject: [PATCH 8/8] Improved tests --- ferron-common/src/util/config_placeholders.rs | 93 ++++++++++++++----- 1 file changed, 72 insertions(+), 21 deletions(-) diff --git a/ferron-common/src/util/config_placeholders.rs b/ferron-common/src/util/config_placeholders.rs index d85a6e17..ae95a6d4 100644 --- a/ferron-common/src/util/config_placeholders.rs +++ b/ferron-common/src/util/config_placeholders.rs @@ -15,8 +15,10 @@ pub fn replace_placeholders(input: &str) -> Result { let mut output = String::new(); let mut cursor = 0; - loop { - let start_rel = match input[cursor..].find('{') { + while cursor < input.len() { + let next = input[cursor..].find('{'); + + let start_rel = match next { Some(pos) => pos, None => { output.push_str(&input[cursor..]); @@ -26,6 +28,17 @@ pub fn replace_placeholders(input: &str) -> Result { 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), @@ -38,19 +51,16 @@ pub fn replace_placeholders(input: &str) -> Result { let placeholder = &input[start + 1..end]; - // Split into kind:value if let Some((kind, value)) = placeholder.split_once(':') { match resolve_placeholder(kind, value)? { Some(resolved) => output.push_str(&resolved), None => { - // Unknown kind → keep original output.push('{'); output.push_str(placeholder); output.push('}'); } } } else { - // No ':' → not a structured placeholder, keep as-is output.push('{'); output.push_str(placeholder); output.push('}'); @@ -75,9 +85,11 @@ mod tests { #[test] fn env_var_exists() { - let result = resolve_placeholder("env", "HOME"); + env::set_var("TEST_ENV_EXISTS", "value"); + + let result = resolve_placeholder("env", "TEST_ENV_EXISTS"); assert!(result.is_ok()); - assert!(result.unwrap().is_some()); + assert_eq!(result.unwrap(), Some("value".to_string())); } #[test] @@ -90,10 +102,11 @@ mod tests { #[test] fn single_env_placeholder() { - let result = replace_placeholders("{env:HOME}"); + env::set_var("TEST_HOME", "/home/test"); + + let result = replace_placeholders("{env:TEST_HOME}"); assert!(result.is_ok()); - let value = result.unwrap(); - assert!(!value.contains("{env:")); + assert_eq!(result.unwrap(), "/home/test"); } #[test] @@ -105,17 +118,17 @@ mod tests { #[test] fn interpolate_env_with_suffix() { - let result = replace_placeholders("{env:HOME}/src/modules"); + env::set_var("TEST_HOME", "/home/test"); + + let result = replace_placeholders("{env:TEST_HOME}/src/modules"); assert!(result.is_ok()); - let value = result.unwrap(); - assert!(value.ends_with("/src/modules")); - assert!(!value.contains("{env:")); + assert_eq!(result.unwrap(), "/home/test/src/modules"); } #[test] fn interpolate_multiple_env_values() { - std::env::set_var("TEST_HOME", "/home/test"); - std::env::set_var("TEST_USER", "user"); + 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(); @@ -124,8 +137,6 @@ mod tests { assert_eq!(result, expected); } - - #[test] fn plain_string_passthrough() { let input = "plain_string_without_env"; @@ -136,21 +147,21 @@ mod tests { #[test] fn missing_closing_brace() { - let result = replace_placeholders("{env:HOME"); + let result = replace_placeholders("{env:TEST_HOME"); assert!(result.is_err()); } #[test] fn nonexistent_env_var() { let result = - replace_placeholders("{env:NONEXISTENT_VAR_THAT_SHOULD_NOT_EXIST}"); + 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:NONEXISTENT_VAR}_suffix"); + replace_placeholders("prefix_{env:THIS_SHOULD_NOT_EXIST_456}_suffix"); assert!(result.is_err()); } @@ -160,4 +171,44 @@ mod tests { 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}"); + } }