#[cfg(feature = "chain-binding")]
use std::sync::Arc;
use std::{
cell::{Cell, RefCell},
collections::HashMap,
};
#[cfg(feature = "chain-binding")]
use praxis_core::config::{
ChainRef, validate_chain_entries_branch_chains, validate_chain_entries_cardinality,
validate_chain_entries_conditions, validate_chain_entries_inline_clusters,
};
use praxis_core::config::{FilterEntry, InsecureOptions};
#[cfg(feature = "chain-binding")]
use crate::filter::HttpFilter;
#[cfg(any(feature = "iterative-request-router", feature = "chain-binding"))]
use crate::pipeline::FilterPipeline;
use crate::{FilterError, registry::FilterRegistry};
#[cfg(feature = "chain-binding")]
pub(crate) const MAX_OUTBOUND_CHAIN_DEPTH: usize = 10;
#[cfg(feature = "chain-binding")]
pub type ChainBindingHttpFactory =
Arc<dyn Fn(&serde_yaml::Value, &ChainBindingContext<'_>) -> Result<Box<dyn HttpFilter>, FilterError> + Send + Sync>;
pub(crate) struct ResolutionStack {
active: RefCell<Vec<Box<str>>>,
}
impl ResolutionStack {
pub(crate) fn new() -> Self {
Self {
active: RefCell::new(Vec::new()),
}
}
pub(crate) fn enter(&self, name: &str) -> Result<ResolutionGuard<'_>, FilterError> {
let mut active = self.active.borrow_mut();
if let Some(start) = active.iter().position(|n| n.as_ref() == name) {
let mut path: Vec<&str> = active.iter().skip(start).map(Box::as_ref).collect();
path.push(name);
return Err(format!("chain reference cycle detected: {}", path.join(" -> ")).into());
}
active.push(Box::from(name));
Ok(ResolutionGuard { stack: self })
}
}
pub(crate) struct ResolutionGuard<'a> {
stack: &'a ResolutionStack,
}
impl Drop for ResolutionGuard<'_> {
fn drop(&mut self) {
self.stack.active.borrow_mut().pop();
}
}
pub struct ChainBindingContext<'a> {
registry: &'a FilterRegistry,
#[cfg_attr(
not(any(feature = "iterative-request-router", feature = "chain-binding")),
expect(
dead_code,
reason = "read only by the gated bind_chain / build_nested_step_pipeline paths"
)
)]
chains: &'a HashMap<&'a str, &'a [FilterEntry]>,
#[cfg_attr(
not(any(feature = "iterative-request-router", feature = "chain-binding")),
expect(
dead_code,
reason = "read only by the gated bind_chain / build_nested_step_pipeline paths"
)
)]
stack: &'a ResolutionStack,
#[cfg_attr(
not(any(feature = "iterative-request-router", feature = "chain-binding")),
expect(
dead_code,
reason = "read only by the gated bind_chain / build_nested_step_pipeline paths"
)
)]
outbound_depth: usize,
#[cfg_attr(
not(any(feature = "iterative-request-router", feature = "chain-binding")),
expect(
dead_code,
reason = "read only by the gated bind_chain / build_nested_step_pipeline paths"
)
)]
insecure: &'a InsecureOptions,
#[cfg_attr(
not(any(feature = "iterative-request-router", feature = "chain-binding")),
expect(
dead_code,
reason = "read only by the gated bind_chain / build_nested_step_pipeline paths"
)
)]
budget: &'a Cell<usize>,
#[cfg_attr(
not(any(feature = "iterative-request-router", feature = "chain-binding")),
expect(
dead_code,
reason = "read only by the gated bind_chain / build_nested_step_pipeline paths"
)
)]
branch_budget: &'a Cell<usize>,
}
impl<'a> ChainBindingContext<'a> {
#[expect(
clippy::too_many_arguments,
reason = "bundles the build-time resources outbound binding threads through"
)]
pub(crate) fn new(
registry: &'a FilterRegistry,
chains: &'a HashMap<&'a str, &'a [FilterEntry]>,
stack: &'a ResolutionStack,
outbound_depth: usize,
insecure: &'a InsecureOptions,
budget: &'a Cell<usize>,
branch_budget: &'a Cell<usize>,
) -> Self {
Self {
registry,
chains,
stack,
outbound_depth,
insecure,
budget,
branch_budget,
}
}
#[cfg(feature = "iterative-request-router")]
pub(crate) fn with_standalone<R>(
registry: &FilterRegistry,
insecure: &InsecureOptions,
f: impl FnOnce(&ChainBindingContext<'_>) -> R,
) -> R {
let chains: HashMap<&str, &[FilterEntry]> = HashMap::new();
let stack = ResolutionStack::new();
let budget = Cell::new(0);
let branch_budget = Cell::new(0);
let ctx = ChainBindingContext::new(registry, &chains, &stack, 0, insecure, &budget, &branch_budget);
f(&ctx)
}
#[cfg(feature = "iterative-request-router")]
pub(crate) fn build_nested_step_pipeline(
&self,
entries: &mut [FilterEntry],
) -> Result<FilterPipeline, FilterError> {
let mut next_filter_id: usize = 0;
let filters = crate::pipeline::build_branch::resolve_chain_filters_with_stack(
entries,
self.registry,
self.chains,
0,
&mut next_filter_id,
self.insecure,
self.stack,
self.budget,
self.branch_budget,
self.outbound_depth,
)?;
Ok(FilterPipeline::from_filters(filters))
}
pub(crate) fn registry(&self) -> &'a FilterRegistry {
self.registry
}
#[cfg(feature = "chain-binding")]
pub fn bind_chain(&self, chain_ref: &ChainRef) -> Result<FilterPipeline, FilterError> {
if self.outbound_depth >= MAX_OUTBOUND_CHAIN_DEPTH {
return Err(format!("outbound chain nesting depth exceeds maximum ({MAX_OUTBOUND_CHAIN_DEPTH})").into());
}
let (name, mut entries) = self.resolve_ref(chain_ref)?;
let _guard = name.map(|n| self.stack.enter(n)).transpose()?;
self.validate_bound_entries(name, &entries)?;
let mut next_filter_id: usize = 0;
let filters = crate::pipeline::build_branch::resolve_chain_filters_with_stack(
&mut entries,
self.registry,
self.chains,
0,
&mut next_filter_id,
self.insecure,
self.stack,
self.budget,
self.branch_budget,
self.outbound_depth + 1,
)?;
let pipeline = FilterPipeline::from_filters(filters);
Self::reject_non_http_filters(&pipeline, name)?;
Self::reject_terminal_filters(&pipeline, name)?;
self.enforce_bound_ordering(&pipeline, &entries, name.unwrap_or("<inline>"))?;
Ok(pipeline)
}
#[cfg(feature = "chain-binding")]
fn validate_bound_entries(&self, name: Option<&str>, entries: &[FilterEntry]) -> Result<(), FilterError> {
let label = name.unwrap_or("<inline>");
validate_chain_entries_cardinality(label, entries).map_err(|e| FilterError::from(e.to_string()))?;
validate_chain_entries_conditions(label, entries).map_err(|e| FilterError::from(e.to_string()))?;
validate_chain_entries_inline_clusters(label, entries, self.insecure)
.map_err(|e| FilterError::from(e.to_string()))?;
let known_chains: std::collections::HashSet<&str> = self.chains.keys().copied().collect();
if name.is_none() {
let prior = self.branch_budget.get();
let total = validate_chain_entries_branch_chains(label, entries, &known_chains, prior)
.map_err(|e| FilterError::from(e.to_string()))?;
self.branch_budget.set(total);
} else {
validate_chain_entries_branch_chains(label, entries, &known_chains, 0)
.map_err(|e| FilterError::from(e.to_string()))?;
}
Ok(())
}
#[cfg(feature = "chain-binding")]
fn reject_non_http_filters(pipeline: &FilterPipeline, name: Option<&str>) -> Result<(), FilterError> {
let tcp_filters = pipeline.non_http_filters();
if !tcp_filters.is_empty() {
return Err(format!(
"outbound chain '{}' contains TCP-level filter(s) [{}] that an HTTP filtered \
sub-request cannot run",
name.unwrap_or("<inline>"),
tcp_filters.join(", ")
)
.into());
}
Ok(())
}
#[cfg(feature = "chain-binding")]
fn reject_terminal_filters(pipeline: &FilterPipeline, name: Option<&str>) -> Result<(), FilterError> {
let terminal = pipeline.terminal_filters();
if !terminal.is_empty() {
return Err(format!(
"outbound chain '{}' contains terminal filter(s) [{}] that an HTTP filtered \
sub-request cannot run (it forwards to a resolved upstream and cannot surface a \
terminal response)",
name.unwrap_or("<inline>"),
terminal.join(", ")
)
.into());
}
Ok(())
}
#[cfg(feature = "chain-binding")]
fn resolve_ref<'r>(&self, chain_ref: &'r ChainRef) -> Result<(Option<&'r str>, Vec<FilterEntry>), FilterError> {
match chain_ref {
ChainRef::Named(name) => {
let entries = self
.chains
.get(name.as_str())
.ok_or_else(|| FilterError::from(format!("outbound chain references unknown chain '{name}'")))?
.to_vec();
Ok((Some(name.as_str()), entries))
},
ChainRef::Inline { filters, .. } => Ok((None, filters.clone())),
}
}
#[cfg(feature = "chain-binding")]
fn enforce_bound_ordering(
&self,
pipeline: &FilterPipeline,
entries: &[FilterEntry],
chain_label: &str,
) -> Result<(), FilterError> {
let errors = pipeline.ordering_errors(
entries,
self.insecure.allow_open_security_filters,
&self.insecure.skip_pipeline_checks,
);
if self.insecure.skip_pipeline_validation {
for msg in &errors {
tracing::warn!(outbound_chain = chain_label, "{msg}");
}
} else if !errors.is_empty() {
return Err(format!(
"outbound chain '{chain_label}' failed pipeline validation: {}",
errors.join("; ")
)
.into());
}
Ok(())
}
}
#[cfg(test)]
#[cfg(feature = "chain-binding")]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic,
reason = "tests"
)]
mod tests {
use std::sync::{
Mutex,
atomic::{AtomicBool, Ordering},
};
use async_trait::async_trait;
use praxis_core::config::{BranchChainConfig, FailureMode, MAX_BRANCH_DEPTH, SkipPipelineChecks};
use super::*;
use crate::{FilterAction, FilterFactory, HttpFilterContext};
struct ProbeFilter;
#[async_trait]
impl HttpFilter for ProbeFilter {
fn name(&self) -> &'static str {
"test_callout"
}
async fn on_request(&self, _ctx: &mut HttpFilterContext<'_>) -> Result<FilterAction, FilterError> {
Ok(FilterAction::Continue)
}
}
fn register_probe(registry: &mut FilterRegistry, sink: Arc<Mutex<Option<usize>>>) {
registry
.register_chain_binding(
"test_callout",
Arc::new(move |config: &serde_yaml::Value, ctx: &ChainBindingContext<'_>| {
let raw = config
.get("outbound_chain")
.cloned()
.ok_or_else(|| FilterError::from("missing outbound_chain"))?;
let chain_ref: ChainRef = serde_yaml::from_value(raw)
.map_err(|e| FilterError::from(format!("bad outbound_chain: {e}")))?;
let pipeline = ctx.bind_chain(&chain_ref)?;
*sink.lock().expect("sink lock") = Some(pipeline.len());
let filter: Box<dyn HttpFilter> = Box::new(ProbeFilter);
Ok(filter)
}),
)
.expect("register chain binding");
}
fn entries(yaml: &str) -> Vec<FilterEntry> {
serde_yaml::from_str(yaml).expect("parse entries")
}
fn make_entry(filter_type: &str) -> FilterEntry {
FilterEntry {
branch_chains: None,
conditions: vec![],
config: serde_yaml::Value::Mapping(serde_yaml::Mapping::new()),
failure_mode: FailureMode::default(),
filter_type: filter_type.to_owned(),
name: None,
response_conditions: vec![],
}
}
fn fanout_chain(target: &str, refs: usize, branch: &str) -> Vec<FilterEntry> {
vec![FilterEntry {
branch_chains: Some(vec![BranchChainConfig {
chains: std::iter::repeat_with(|| ChainRef::Named(target.to_owned()))
.take(refs)
.collect(),
max_iterations: None,
name: branch.to_owned(),
on_result: None,
rejoin: "next".to_owned(),
}]),
..make_entry("request_id")
}]
}
struct OutboundCallout {
outbound: Arc<FilterPipeline>,
}
#[async_trait]
impl HttpFilter for OutboundCallout {
fn name(&self) -> &'static str {
"outbound_callout"
}
fn visit_nested_pipelines(&mut self, visitor: &mut dyn FnMut(&mut FilterPipeline)) {
if let Some(pipeline) = Arc::get_mut(&mut self.outbound) {
visitor(pipeline);
} else {
debug_assert!(false, "outbound pipeline must be uniquely owned during configuration");
}
}
fn referenced_files(&self) -> Vec<std::path::PathBuf> {
self.outbound.referenced_files()
}
fn apply_insecure_options(&self, options: &InsecureOptions) {
self.outbound.apply_insecure_options(options);
}
async fn on_request(&self, _ctx: &mut HttpFilterContext<'_>) -> Result<FilterAction, FilterError> {
Ok(FilterAction::Continue)
}
}
fn register_outbound_callout(registry: &mut FilterRegistry) {
registry
.register_chain_binding(
"outbound_callout",
Arc::new(|config: &serde_yaml::Value, ctx: &ChainBindingContext<'_>| {
let raw = config
.get("outbound_chain")
.cloned()
.ok_or_else(|| FilterError::from("missing outbound_chain"))?;
let chain_ref: ChainRef = serde_yaml::from_value(raw)
.map_err(|e| FilterError::from(format!("bad outbound_chain: {e}")))?;
let outbound = ctx.bind_chain(&chain_ref)?;
let filter: Box<dyn HttpFilter> = Box::new(OutboundCallout {
outbound: Arc::new(outbound),
});
Ok(filter)
}),
)
.expect("register outbound_callout");
}
struct OutboundProbeFilter;
#[async_trait]
impl HttpFilter for OutboundProbeFilter {
fn name(&self) -> &'static str {
"outbound_probe"
}
fn referenced_files(&self) -> Vec<std::path::PathBuf> {
vec![std::path::PathBuf::from("/etc/praxis/outbound-probe-doc.yaml")]
}
async fn on_request(&self, _ctx: &mut HttpFilterContext<'_>) -> Result<FilterAction, FilterError> {
Ok(FilterAction::Continue)
}
}
fn register_outbound_probe(registry: &mut FilterRegistry) {
registry
.register(
"outbound_probe",
FilterFactory::Http(Arc::new(|_| Ok(Box::new(OutboundProbeFilter)))),
)
.expect("register outbound_probe");
}
struct CustomTerminalFilter;
#[async_trait]
impl HttpFilter for CustomTerminalFilter {
fn name(&self) -> &'static str {
"custom_terminal"
}
fn produces_terminal_response(&self) -> bool {
true
}
async fn on_request(&self, _ctx: &mut HttpFilterContext<'_>) -> Result<FilterAction, FilterError> {
Ok(FilterAction::TerminalResponse(Box::new(crate::TerminalResponse::new(
200,
))))
}
}
fn register_custom_terminal(registry: &mut FilterRegistry) {
registry
.register(
"custom_terminal",
FilterFactory::Http(Arc::new(|_| Ok(Box::new(CustomTerminalFilter)))),
)
.expect("register custom_terminal");
}
struct InsecureSinkFilter {
applied: Arc<AtomicBool>,
}
#[async_trait]
impl HttpFilter for InsecureSinkFilter {
fn name(&self) -> &'static str {
"insecure_sink"
}
fn apply_insecure_options(&self, _options: &InsecureOptions) {
self.applied.store(true, Ordering::SeqCst);
}
async fn on_request(&self, _ctx: &mut HttpFilterContext<'_>) -> Result<FilterAction, FilterError> {
Ok(FilterAction::Continue)
}
}
fn register_insecure_sink(registry: &mut FilterRegistry, applied: Arc<AtomicBool>) {
registry
.register(
"insecure_sink",
FilterFactory::Http(Arc::new(move |_| {
let filter: Box<dyn HttpFilter> = Box::new(InsecureSinkFilter {
applied: Arc::clone(&applied),
});
Ok(filter)
})),
)
.expect("register insecure_sink");
}
#[test]
fn binds_inline_outbound_chain() {
let sink = Arc::new(Mutex::new(None));
let mut registry = FilterRegistry::with_builtins();
register_probe(&mut registry, Arc::clone(&sink));
let mut top = entries(
"
- filter: test_callout
outbound_chain:
name: outbound
filters:
- filter: request_id
- filter: headers
",
);
let chains = HashMap::new();
FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.expect("build pipeline");
assert_eq!(
*sink.lock().expect("sink lock"),
Some(2),
"callout should bind the 2-filter inline outbound chain"
);
}
#[test]
fn binds_named_outbound_chain() {
let sink = Arc::new(Mutex::new(None));
let mut registry = FilterRegistry::with_builtins();
register_probe(&mut registry, Arc::clone(&sink));
let outbound = entries("- filter: request_id\n- filter: headers\n- filter: compression\n");
let chains: HashMap<&str, &[FilterEntry]> = HashMap::from([("outbound", outbound.as_slice())]);
let mut top = entries("- filter: test_callout\n outbound_chain: outbound\n");
FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.expect("build pipeline");
assert_eq!(
*sink.lock().expect("sink lock"),
Some(3),
"callout should resolve the named outbound chain against filter_chains"
);
}
#[test]
fn unknown_named_outbound_chain_errors() {
let sink = Arc::new(Mutex::new(None));
let mut registry = FilterRegistry::with_builtins();
register_probe(&mut registry, Arc::clone(&sink));
let chains = HashMap::new();
let mut top = entries("- filter: test_callout\n outbound_chain: missing\n");
let err = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.err()
.expect("build should fail");
assert!(
err.to_string().contains("unknown chain") && err.to_string().contains("missing"),
"an outbound reference to an undefined chain must fail the build: {err}"
);
}
#[test]
fn direct_cycle_rejected() {
let sink = Arc::new(Mutex::new(None));
let mut registry = FilterRegistry::with_builtins();
register_probe(&mut registry, Arc::clone(&sink));
let a = entries("- filter: test_callout\n outbound_chain: a\n");
let chains: HashMap<&str, &[FilterEntry]> = HashMap::from([("a", a.as_slice())]);
let mut top = entries("- filter: test_callout\n outbound_chain: a\n");
let err = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.err()
.expect("build should fail");
assert!(
err.to_string().contains("cycle") && err.to_string().contains("a -> a"),
"a chain that binds itself must be rejected as a cycle, not a depth error: {err}"
);
}
#[test]
fn indirect_cycle_rejected() {
let sink = Arc::new(Mutex::new(None));
let mut registry = FilterRegistry::with_builtins();
register_probe(&mut registry, Arc::clone(&sink));
let a = entries("- filter: test_callout\n outbound_chain: b\n");
let b = entries("- filter: test_callout\n outbound_chain: a\n");
let chains: HashMap<&str, &[FilterEntry]> = HashMap::from([("a", a.as_slice()), ("b", b.as_slice())]);
let mut top = entries("- filter: test_callout\n outbound_chain: a\n");
let err = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.err()
.expect("build should fail");
assert!(
err.to_string().contains("cycle") && err.to_string().contains("a -> b -> a"),
"an a -> b -> a cycle across outbound chains must be reported as a cycle: {err}"
);
}
#[test]
fn excessive_nesting_rejected() {
let sink = Arc::new(Mutex::new(None));
let mut registry = FilterRegistry::with_builtins();
register_probe(&mut registry, Arc::clone(&sink));
let names: Vec<String> = (0..=MAX_OUTBOUND_CHAIN_DEPTH).map(|i| format!("c{i}")).collect();
let owned: Vec<Vec<FilterEntry>> = (0..=MAX_OUTBOUND_CHAIN_DEPTH)
.map(|i| entries(&format!("- filter: test_callout\n outbound_chain: c{}\n", i + 1)))
.collect();
let chains: HashMap<&str, &[FilterEntry]> = names
.iter()
.zip(owned.iter())
.map(|(name, e)| (name.as_str(), e.as_slice()))
.collect();
let mut top = entries("- filter: test_callout\n outbound_chain: c0\n");
let err = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.err()
.expect("build should fail");
assert!(
err.to_string().contains("nesting depth"),
"an outbound chain nested past the maximum depth must be rejected: {err}"
);
}
#[test]
fn inline_outbound_chain_ssrf_endpoint_rejected() {
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
let mut top = entries(
"
- filter: outbound_callout
outbound_chain:
name: outbound
filters:
- filter: load_balancer
clusters:
- name: web
endpoints:
- address: \"127.0.0.1:80\"
",
);
let chains = HashMap::new();
let err = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.err()
.expect("build should fail");
assert!(
err.to_string().contains("sensitive address"),
"an inline outbound-chain endpoint resolving to a sensitive address must be rejected \
unless insecure_options.allow_private_endpoints is set: {err}"
);
}
#[test]
fn inline_outbound_chain_ssrf_endpoint_allowed_with_flag() {
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
let mut top = entries(
"
- filter: outbound_callout
outbound_chain:
name: outbound
filters:
- filter: load_balancer
clusters:
- name: web
endpoints:
- address: \"127.0.0.1:80\"
",
);
let chains = HashMap::new();
let insecure = InsecureOptions {
allow_private_endpoints: true,
skip_pipeline_checks: SkipPipelineChecks {
lb_without_router: true,
..SkipPipelineChecks::default()
},
..InsecureOptions::default()
};
FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &insecure)
.expect("outbound chain with an opted-in private endpoint must build");
}
#[test]
fn outbound_binding_does_not_reset_materialization_budget() {
let mut registry = FilterRegistry::with_builtins();
register_probe(&mut registry, Arc::new(Mutex::new(None)));
let leaf = vec![make_entry("request_id")];
let c1 = fanout_chain("leaf", 20, "b1");
let c2 = fanout_chain("c1", 20, "b2");
let c3 = fanout_chain("c2", 20, "b3");
let outbound = fanout_chain("c3", 7, "b_out");
let chains: HashMap<&str, &[FilterEntry]> = HashMap::from([
("leaf", leaf.as_slice()),
("c1", c1.as_slice()),
("c2", c2.as_slice()),
("c3", c3.as_slice()),
("outbound", outbound.as_slice()),
]);
let mut top = entries(
"
- filter: test_callout
outbound_chain: outbound
- filter: test_callout
outbound_chain: outbound
",
);
let err = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.err()
.expect("two ~59k outbound bindings must exceed the shared 100k budget");
assert!(
err.to_string().contains("filter instances"),
"binding an outbound chain must not reset the materialization budget: {err}"
);
}
fn outbound_callout_with_branches(branches: usize) -> String {
use std::fmt::Write as _;
let mut s = String::from("- filter: outbound_callout\n outbound_chain:\n name: outbound\n filters:\n");
let mut emitted = 0;
while emitted < branches {
s.push_str(" - filter: request_id\n branch_chains:\n");
for _ in 0..16 {
if emitted >= branches {
break;
}
writeln!(s, " - name: br_{emitted}\n chains: [utility]").unwrap();
emitted += 1;
}
}
s
}
#[test]
fn outbound_bindings_share_total_branch_budget() {
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
let utility = entries("- filter: headers\n");
let chains: HashMap<&str, &[FilterEntry]> = HashMap::from([("utility", utility.as_slice())]);
let top_yaml = format!(
"{}{}",
outbound_callout_with_branches(144),
outbound_callout_with_branches(144)
);
let mut top = entries(&top_yaml);
let err = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.err()
.expect("two 144-branch outbound bindings must exceed the shared 256-branch ceiling");
assert!(
err.to_string().contains("total branch count") && err.to_string().contains("256"),
"binding an outbound chain must not reset the total-branch budget: {err}"
);
}
#[test]
fn outbound_binding_branch_budget_counts_listener_branches() {
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
let utility = entries("- filter: headers\n");
let chains: HashMap<&str, &[FilterEntry]> = HashMap::from([("utility", utility.as_slice())]);
let mut listener_branches = {
use std::fmt::Write as _;
let mut s = String::new();
let mut emitted = 0;
while emitted < 144 {
s.push_str("- filter: request_id\n branch_chains:\n");
for _ in 0..16 {
if emitted >= 144 {
break;
}
writeln!(s, " - name: top_br_{emitted}\n chains: [utility]").unwrap();
emitted += 1;
}
}
s
};
listener_branches.push_str(&outbound_callout_with_branches(144));
let mut top = entries(&listener_branches);
let err = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.err()
.expect("listener branches plus a bound chain's branches must exceed the shared ceiling");
assert!(
err.to_string().contains("total branch count") && err.to_string().contains("256"),
"the bound chain's branch budget must count the listener's existing branches: {err}"
);
}
fn filters_with_branches(count: usize, prefix: &str) -> String {
use std::fmt::Write as _;
let mut s = String::new();
let mut emitted = 0;
while emitted < count {
s.push_str("- filter: request_id\n branch_chains:\n");
for _ in 0..16 {
if emitted >= count {
break;
}
writeln!(s, " - name: {prefix}_{emitted}\n chains: [utility]").unwrap();
emitted += 1;
}
}
s
}
#[test]
fn reused_named_outbound_chain_counts_branches_once() {
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
let utility = entries("- filter: headers\n");
let outbound_yaml = filters_with_branches(130, "nb");
let outbound = entries(&outbound_yaml);
let chains: HashMap<&str, &[FilterEntry]> =
HashMap::from([("utility", utility.as_slice()), ("outbound", outbound.as_slice())]);
let mut top = entries(
"
- filter: outbound_callout
outbound_chain: outbound
- filter: outbound_callout
outbound_chain: outbound
",
);
FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.expect("a shared named outbound chain's branches must be counted once, not per binding");
}
#[test]
fn outbound_named_and_inline_branches_share_config_wide_budget() {
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
let utility = entries("- filter: headers\n");
let named_yaml = filters_with_branches(200, "nb");
let named = entries(&named_yaml);
let chains: HashMap<&str, &[FilterEntry]> =
HashMap::from([("utility", utility.as_slice()), ("named_ob", named.as_slice())]);
let top_yaml = format!(
"- filter: outbound_callout\n outbound_chain: named_ob\n{}",
outbound_callout_with_branches(100)
);
let mut top = entries(&top_yaml);
let err = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.err()
.expect("a bound named chain (200) plus an inline chain (100) must exceed the config-wide 256 ceiling");
assert!(
err.to_string().contains("total branch count") && err.to_string().contains("256"),
"the branch budget must span the whole configuration, not just the listener's own branches: {err}"
);
}
#[test]
fn outbound_binding_branch_depth_starts_fresh() {
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
let intermediate = MAX_BRANCH_DEPTH - 1;
let leaf = vec![make_entry("request_id")];
let owned: Vec<Vec<FilterEntry>> = (1..=intermediate)
.map(|i| {
let target = if i == intermediate {
"leaf".to_owned()
} else {
format!("d{}", i + 1)
};
fanout_chain(&target, 1, &format!("b{i}"))
})
.collect();
let names: Vec<String> = (1..=intermediate).map(|i| format!("d{i}")).collect();
let outbound = fanout_chain("d1", 1, "b0");
let mut chains: HashMap<&str, &[FilterEntry]> =
HashMap::from([("leaf", leaf.as_slice()), ("outbound", outbound.as_slice())]);
for (name, chain) in names.iter().zip(owned.iter()) {
chains.insert(name.as_str(), chain.as_slice());
}
let mut top = entries("- filter: outbound_callout\n outbound_chain: outbound\n");
FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.expect("an outbound chain nested to the maximum branch depth must build");
}
#[test]
fn outbound_chain_ordering_violation_rejected() {
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
let mut top = entries(
"
- filter: outbound_callout
outbound_chain:
name: outbound
filters:
- filter: load_balancer
clusters:
- name: web
endpoints:
- address: \"192.0.2.1:80\"
",
);
let chains = HashMap::new();
let err = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.err()
.expect("build should fail");
assert!(
err.to_string().contains("without a preceding router"),
"an outbound chain whose load_balancer has no cluster selector must fail ordering \
validation, not bind silently: {err}"
);
}
#[test]
fn outbound_chain_ordering_violation_downgraded_with_skip_flag() {
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
let mut top = entries(
"
- filter: outbound_callout
outbound_chain:
name: outbound
filters:
- filter: load_balancer
clusters:
- name: web
endpoints:
- address: \"192.0.2.1:80\"
",
);
let chains = HashMap::new();
let insecure = InsecureOptions {
skip_pipeline_checks: SkipPipelineChecks {
lb_without_router: true,
..SkipPipelineChecks::default()
},
..InsecureOptions::default()
};
FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &insecure)
.expect("outbound chain must build when the ordering check is skipped");
}
#[test]
fn outbound_chain_branch_max_iterations_over_ceiling_rejected() {
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
let mut top = entries(
"
- filter: outbound_callout
outbound_chain:
name: outbound
filters:
- filter: request_id
branch_chains:
- name: loop
max_iterations: 101
rejoin: next
chains:
- name: sub
filters:
- filter: headers
",
);
let chains = HashMap::new();
let err = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.err()
.expect("build should fail");
assert!(
err.to_string().contains("max_iterations") && err.to_string().contains("101"),
"an outbound-chain branch exceeding the max_iterations ceiling must be rejected at \
build time, not activate an unbounded re-entrant loop: {err}"
);
}
#[test]
fn outbound_chain_with_tcp_filter_rejected() {
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
let mut top = entries(
"
- filter: outbound_callout
outbound_chain:
name: outbound
filters:
- filter: tcp_access_log
",
);
let chains = HashMap::new();
let err = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.err()
.expect("build should fail");
assert!(
err.to_string().contains("tcp_access_log")
&& (err.to_string().contains("TCP") || err.to_string().contains("HTTP")),
"a TCP-level filter in an HTTP outbound chain must be rejected at build time, not \
silently skipped at runtime: {err}"
);
}
#[test]
fn outbound_chain_with_branch_nested_tcp_filter_rejected() {
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
let mut top = entries(
"
- filter: outbound_callout
outbound_chain:
name: outbound
filters:
- filter: request_id
branch_chains:
- name: b1
chains:
- name: inner
filters:
- filter: tcp_access_log
",
);
let chains = HashMap::new();
let err = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.err()
.expect("build should fail");
assert!(
err.to_string().contains("tcp_access_log")
&& (err.to_string().contains("TCP") || err.to_string().contains("HTTP")),
"a TCP-level filter nested inside a branch of an HTTP outbound chain must be rejected at \
build time, not silently skipped at runtime: {err}"
);
}
#[cfg(feature = "iterative-request-router")]
#[test]
#[expect(clippy::too_many_lines, reason = "inline valid-IRR YAML fixture")]
fn outbound_chain_with_terminal_filter_rejected() {
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
let mut top = entries(
"
- filter: outbound_callout
outbound_chain:
name: outbound
filters:
- filter: iterative_request_router
initial_step: only
max_iterations: 3
steps:
- name: only
filters:
- filter: router
routes:
- path_prefix: \"/\"
cluster: svc
- filter: load_balancer
clusters:
- name: svc
endpoints:
- \"192.0.2.1:80\"
on_result:
- default: true
done: true
",
);
let chains = HashMap::new();
let err = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.err()
.expect("build should fail");
assert!(
err.to_string().contains("terminal") && err.to_string().contains("iterative_request_router"),
"a terminal filter in an outbound chain must be rejected at build time, not activate \
and drop its terminal response at runtime: {err}"
);
}
#[cfg(feature = "iterative-request-router")]
#[test]
#[expect(clippy::too_many_lines, reason = "inline valid-IRR YAML fixture nested in a branch")]
fn outbound_chain_with_branch_nested_terminal_filter_rejected() {
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
let mut top = entries(
"
- filter: outbound_callout
outbound_chain:
name: outbound
filters:
- filter: request_id
branch_chains:
- name: b1
chains:
- name: inner
filters:
- filter: iterative_request_router
initial_step: only
max_iterations: 3
steps:
- name: only
filters:
- filter: router
routes:
- path_prefix: \"/\"
cluster: svc
- filter: load_balancer
clusters:
- name: svc
endpoints:
- \"192.0.2.1:80\"
on_result:
- default: true
done: true
",
);
let chains = HashMap::new();
let err = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.err()
.expect("build should fail");
assert!(
err.to_string().contains("terminal") && err.to_string().contains("iterative_request_router"),
"a terminal filter nested inside a branch of an outbound chain must be rejected at build \
time, not activate and drop its terminal response at runtime: {err}"
);
}
#[test]
fn outbound_chain_with_custom_terminal_filter_rejected() {
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
register_custom_terminal(&mut registry);
let mut top = entries(
"
- filter: outbound_callout
outbound_chain:
name: outbound
filters:
- filter: custom_terminal
",
);
let chains = HashMap::new();
let err = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.err()
.expect("build should fail");
assert!(
err.to_string().contains("terminal") && err.to_string().contains("custom_terminal"),
"a custom filter that declares it produces terminal responses must be rejected in an \
outbound chain, not escape because its name is not a hard-coded builtin: {err}"
);
}
#[test]
fn outbound_chain_over_filter_limit_rejected() {
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
let filters_yaml = " - filter: request_id\n".repeat(101);
let top_yaml =
format!("- filter: outbound_callout\n outbound_chain:\n name: outbound\n filters:\n{filters_yaml}");
let mut top = entries(&top_yaml);
let chains = HashMap::new();
let err = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.err()
.expect("build should fail");
assert!(
err.to_string().contains("too many filters") && err.to_string().contains("101"),
"an outbound chain exceeding the per-chain filter limit must be rejected at build time, \
not build unbounded: {err}"
);
}
#[test]
fn outbound_chain_empty_condition_rejected() {
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
let mut top = entries(
"
- filter: outbound_callout
outbound_chain:
name: outbound
filters:
- filter: request_id
conditions:
- unless: {}
",
);
let chains = HashMap::new();
let err = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.err()
.expect("build should fail");
assert!(
err.to_string().contains("condition 0 is empty"),
"an outbound chain filter with an empty condition predicate must be rejected at build \
time, the same as a top-level chain: {err}"
);
}
#[test]
fn outbound_chain_branch_nested_empty_condition_rejected() {
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
let mut top = entries(
"
- filter: outbound_callout
outbound_chain:
name: outbound
filters:
- filter: request_id
branch_chains:
- name: b1
chains:
- name: inner
filters:
- filter: headers
conditions:
- unless: {}
",
);
let chains = HashMap::new();
let err = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.err()
.expect("build should fail");
assert!(
err.to_string().contains("condition 0 is empty"),
"an empty condition predicate nested inside a branch of an outbound chain must be \
rejected at build time: {err}"
);
}
#[test]
fn outbound_chain_surfaces_referenced_files_for_reload() {
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
register_outbound_probe(&mut registry);
let mut top = entries(
"
- filter: outbound_callout
outbound_chain:
name: outbound
filters:
- filter: outbound_probe
",
);
let chains = HashMap::new();
let parent = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.expect("build pipeline");
assert!(
parent
.referenced_files()
.contains(&std::path::PathBuf::from("/etc/praxis/outbound-probe-doc.yaml")),
"the rebuilt parent must surface documents referenced inside the bound outbound chain so \
editing them triggers a hot reload"
);
}
#[test]
fn outbound_chain_surfaces_branch_nested_referenced_files_for_reload() {
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
register_outbound_probe(&mut registry);
let mut top = entries(
"
- filter: outbound_callout
outbound_chain:
name: outbound
filters:
- filter: request_id
branch_chains:
- name: b1
chains:
- name: inner
filters:
- filter: outbound_probe
",
);
let chains = HashMap::new();
let parent = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.expect("build pipeline");
assert!(
parent
.referenced_files()
.contains(&std::path::PathBuf::from("/etc/praxis/outbound-probe-doc.yaml")),
"the rebuilt parent must surface documents referenced by filters nested inside a branch of \
the bound outbound chain so editing them triggers a hot reload"
);
}
#[test]
fn outbound_pipeline_receives_and_retains_runtime_resources() {
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
let stack = ResolutionStack::new();
let chains: HashMap<&str, &[FilterEntry]> = HashMap::new();
let insecure = InsecureOptions::default();
let (budget, branch_budget) = (Cell::new(0), Cell::new(0));
let ctx = ChainBindingContext::new(®istry, &chains, &stack, 0, &insecure, &budget, &branch_budget);
let chain_ref = ChainRef::Inline {
name: "outbound".to_owned(),
filters: entries("- filter: request_id\n- filter: headers\n"),
};
let outbound = ctx.bind_chain(&chain_ref).expect("bind outbound chain");
let mut callout = OutboundCallout {
outbound: Arc::new(outbound),
};
assert!(
!callout.outbound.records_filter_duration_metrics(),
"the bound pipeline starts with metrics recording disabled"
);
callout.visit_nested_pipelines(&mut |pipeline| pipeline.set_record_filter_duration_metrics(true));
assert!(
callout.outbound.records_filter_duration_metrics(),
"a runtime resource set on the parent must propagate into the bound outbound pipeline"
);
let mut observed = false;
callout.visit_nested_pipelines(&mut |pipeline| observed = pipeline.records_filter_duration_metrics());
assert!(
observed,
"the propagated runtime resource must persist on the bound outbound pipeline"
);
}
#[test]
fn outbound_chain_receives_insecure_options() {
let applied = Arc::new(AtomicBool::new(false));
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
register_insecure_sink(&mut registry, Arc::clone(&applied));
let mut top = entries(
"
- filter: outbound_callout
outbound_chain:
name: outbound
filters:
- filter: insecure_sink
",
);
let chains = HashMap::new();
let parent = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.expect("build pipeline");
parent.apply_insecure_options(&InsecureOptions::default());
assert!(
applied.load(Ordering::SeqCst),
"insecure options applied to the parent must reach filters inside the bound outbound chain"
);
}
#[test]
fn outbound_chain_applies_insecure_options_to_branch_nested_filters() {
let applied = Arc::new(AtomicBool::new(false));
let mut registry = FilterRegistry::with_builtins();
register_outbound_callout(&mut registry);
register_insecure_sink(&mut registry, Arc::clone(&applied));
let mut top = entries(
"
- filter: outbound_callout
outbound_chain:
name: outbound
filters:
- filter: request_id
branch_chains:
- name: b1
chains:
- name: inner
filters:
- filter: insecure_sink
",
);
let chains = HashMap::new();
let parent = FilterPipeline::build_with_chains(&mut top, ®istry, &chains, &InsecureOptions::default())
.expect("build pipeline");
parent.apply_insecure_options(&InsecureOptions::default());
assert!(
applied.load(Ordering::SeqCst),
"insecure options applied to the parent must reach filters nested inside a branch of the \
bound outbound chain"
);
}
#[test]
#[expect(clippy::too_many_lines, reason = "two-version reload with explicit YAML fixtures")]
fn rebuilt_outbound_pipeline_reflects_config_change() {
let sink = Arc::new(Mutex::new(None));
let mut registry = FilterRegistry::with_builtins();
register_probe(&mut registry, Arc::clone(&sink));
let chains = HashMap::new();
let mut v1 = entries(
"
- filter: test_callout
outbound_chain:
name: outbound
filters:
- filter: request_id
- filter: headers
",
);
FilterPipeline::build_with_chains(&mut v1, ®istry, &chains, &InsecureOptions::default()).expect("build v1");
assert_eq!(
*sink.lock().expect("sink lock"),
Some(2),
"v1 binds a 2-filter outbound chain"
);
let mut v2 = entries(
"
- filter: test_callout
outbound_chain:
name: outbound
filters:
- filter: request_id
- filter: headers
- filter: compression
",
);
FilterPipeline::build_with_chains(&mut v2, ®istry, &chains, &InsecureOptions::default()).expect("build v2");
assert_eq!(
*sink.lock().expect("sink lock"),
Some(3),
"rebuilding after a config change must re-bind the outbound chain from the new config"
);
}
}