Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion build-prepare/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions ferron-common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
165 changes: 165 additions & 0 deletions ferron-common/src/util/config_placeholders.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
use std::env;

fn resolve_placeholder(kind: &str, value: &str) -> Option<String> {
match kind {
"env" => env::var(value).ok(),
_ => None,
}
}

pub fn replace_placeholders(input: &str) -> String {
let mut output = String::new();
let mut cursor = 0;

while cursor < input.len() {
// Find the next opening brace
let Some(lb_offset) = input[cursor..].find('{') else {
// No more placeholders, push remaining text and break
output.push_str(&input[cursor..]);
break;
};
let lb = cursor + lb_offset;

// Look for closing brace after the opening brace
let Some(rb_offset) = input[lb + 1..].find('}') else {
// No closing brace found, push the rest of the string as-is
output.push_str(&input[cursor..]);
break;
};
let rb = lb + 1 + rb_offset;

// Push text before this placeholder
output.push_str(&input[cursor..lb]);

// Try to resolve the placeholder (no colon or unknown kind => None)
let placeholder = &input[lb + 1..rb];
let resolved = placeholder
.split_once(':')
.and_then(|(kind, value)| resolve_placeholder(kind, value));

match resolved {
Some(value) => output.push_str(&value),
// Keep original placeholder
None => output.push_str(&input[lb..=rb]),
}

cursor = rb + 1;
}

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_eq!(result, None);
}

#[test]
fn env_var_exists() {
env::set_var("TEST_ENV_EXISTS", "value");

let result = resolve_placeholder("env", "TEST_ENV_EXISTS");
assert_eq!(result, Some("value".to_string()));
}

#[test]
fn passthrough_no_placeholders() {
let input = "LALA";
let result = replace_placeholders(input);
assert_eq!(result, input);
}

#[test]
fn single_env_placeholder() {
env::set_var("TEST_HOME", "/home/test");

let result = replace_placeholders("{env:TEST_HOME}");
assert_eq!(result, "/home/test");
}

#[test]
fn unknown_kind_passthrough() {
let result = replace_placeholders("{envA:HOME}");
assert_eq!(result, "{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_eq!(result, "/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);

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_eq!(result, input);
}

#[test]
fn missing_closing_brace() {
let result = replace_placeholders("{env:TEST_HOME");
// Reference behavior: push all remaining text including the unmatched '{'
assert_eq!(result, "{env:TEST_HOME");
}

#[test]
fn missing_closing_brace_with_text_after() {
let result = replace_placeholders("{env:TEST_HOME and then more text");
// Should keep everything including the opening brace
assert_eq!(result, "{env:TEST_HOME and then more text");
}

#[test]
fn nested_or_multiple_braces() {
let result = replace_placeholders("text {first} middle {second");
// Second has no closing brace, so everything from first { to end stays
assert_eq!(result, "text {first} middle {second");
}

#[test]
fn nonexistent_env_var() {
let result = replace_placeholders("{env:THIS_SHOULD_NOT_EXIST_123}");
assert_eq!(result, "{env:THIS_SHOULD_NOT_EXIST_123}");
}

#[test]
fn nonexistent_env_var_in_interpolation() {
let result = replace_placeholders("prefix_{env:THIS_SHOULD_NOT_EXIST_456}_suffix");
assert_eq!(result, "prefix_{env:THIS_SHOULD_NOT_EXIST_456}_suffix");
}

#[test]
fn placeholder_without_colon_passthrough() {
let result = replace_placeholders("{justtext}");
assert_eq!(result, "{justtext}");
}


#[test]
fn multiple_placeholders_mixed_with_missing() {
env::set_var("EXISTS", "found");
let result = replace_placeholders("{env:EXISTS} then {missing");
assert_eq!(result, "found then {missing");
}
}
2 changes: 2 additions & 0 deletions ferron-common/src/util/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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::*;
Expand Down
4 changes: 2 additions & 2 deletions ferron/src/config/adapters/kdl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::{
str::FromStr,
};

use ferron_common::observability::ObservabilityBackendChannels;
use ferron_common::{observability::ObservabilityBackendChannels, util::*};
use glob::glob;
use kdl::{KdlDocument, KdlNode, KdlValue};

Expand All @@ -23,7 +23,7 @@ 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) => ServerConfigurationValue::String(replace_placeholders(&value)),
KdlValue::Integer(value) => ServerConfigurationValue::Integer(value),
KdlValue::Float(value) => ServerConfigurationValue::Float(value),
KdlValue::Bool(value) => ServerConfigurationValue::Bool(value),
Expand Down