Skip to main content

praxis_filter/pipeline/
mod.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2024 Praxis Contributors
3
4//! Filter pipeline: ordered chain of filters executed on each request.
5
6pub(crate) mod body;
7pub(crate) mod branch;
8mod build;
9mod build_branch;
10mod checks;
11mod clusters;
12pub(crate) mod evaluate;
13mod extension;
14pub(crate) mod filter;
15mod http;
16mod http_utils;
17mod tcp;
18
19#[cfg(test)]
20#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
21#[allow(
22    clippy::unwrap_used,
23    clippy::expect_used,
24    clippy::indexing_slicing,
25    clippy::panic,
26    clippy::field_reassign_with_default,
27    clippy::type_complexity,
28    clippy::too_many_lines,
29    clippy::redundant_closure_for_method_calls,
30    clippy::significant_drop_tightening,
31    clippy::doc_markdown,
32    reason = "tests"
33)]
34mod tests;
35
36use std::sync::Arc;
37
38pub use extension::PipelineExtension;
39use praxis_core::{
40    config::{ABSOLUTE_MAX_BODY_BYTES, FailureMode, InsecureOptions},
41    health::HealthRegistry,
42    id::IdGenerator,
43    kv::KvStoreRegistry,
44    time::TimeSource,
45};
46use tracing::warn;
47
48use self::filter::PipelineFilter;
49use crate::{
50    FilterError,
51    body::{BodyCapabilities, BodyMode},
52    builtins::http::payload_processing::compression_config::CompressionConfig,
53    extensions::RequestExtensions,
54};
55
56// -----------------------------------------------------------------------------
57// FilterPipeline
58// -----------------------------------------------------------------------------
59
60/// An ordered list of filters executed on every request.
61///
62/// ```
63/// use praxis_filter::{FilterPipeline, FilterRegistry};
64///
65/// let registry = FilterRegistry::with_builtins();
66/// let pipeline = FilterPipeline::build(&mut [], &registry).unwrap();
67/// assert!(pipeline.is_empty());
68/// ```
69pub struct FilterPipeline {
70    /// Pre-computed body processing capabilities for this pipeline.
71    body_capabilities: BodyCapabilities,
72
73    /// Compression configuration, if a compression filter is present.
74    compression: Option<CompressionConfig>,
75
76    /// Ordered list of filters with their conditions and branches.
77    pub(crate) filters: Vec<PipelineFilter>,
78
79    /// Shared health registry for endpoint health lookups.
80    health_registry: Option<HealthRegistry>,
81
82    /// Shared ID generator for request correlation IDs.
83    id_generator: Arc<IdGenerator>,
84
85    /// Named key-value stores for runtime mappings.
86    kv_stores: Option<KvStoreRegistry>,
87
88    /// External pipeline extensions injected after construction.
89    pipeline_extensions: Vec<Box<dyn PipelineExtension>>,
90
91    /// Wall-clock time source for filters that need timestamps.
92    time_source: Arc<dyn TimeSource>,
93}
94
95#[expect(
96    clippy::multiple_inherent_impl,
97    reason = "pipeline concerns are split across modules"
98)]
99impl FilterPipeline {
100    /// Apply global body size ceilings.
101    ///
102    /// When no filter requires body access (mode is [`Stream`]),
103    /// uses [`SizeLimit`] to enforce the ceiling without
104    /// buffering. When a filter already requested
105    /// [`StreamBuffer`], the ceiling tightens the existing limit.
106    ///
107    /// # Errors
108    ///
109    /// Returns [`FilterError`] if a [`StreamBuffer`] has no byte limit
110    /// and `allow_unbounded` is `false`.
111    ///
112    /// [`Stream`]: BodyMode::Stream
113    /// [`SizeLimit`]: BodyMode::SizeLimit
114    /// [`StreamBuffer`]: BodyMode::StreamBuffer
115    pub fn apply_body_limits(
116        &mut self,
117        max_request: Option<usize>,
118        max_response: Option<usize>,
119        allow_unbounded: bool,
120    ) -> Result<(), FilterError> {
121        if let Some(ceiling) = max_request {
122            self.body_capabilities.request_body_mode = clamp_body_mode(
123                self.body_capabilities.request_body_mode,
124                ceiling,
125                self.body_capabilities.needs_request_body,
126            );
127            self.body_capabilities.needs_request_body = true;
128        }
129
130        if let Some(ceiling) = max_response {
131            self.body_capabilities.response_body_mode = clamp_body_mode(
132                self.body_capabilities.response_body_mode,
133                ceiling,
134                self.body_capabilities.needs_response_body,
135            );
136            self.body_capabilities.needs_response_body = true;
137        }
138
139        check_unbounded_stream_buffer(
140            "request",
141            &mut self.body_capabilities.request_body_mode,
142            allow_unbounded,
143        )?;
144        check_unbounded_stream_buffer(
145            "response",
146            &mut self.body_capabilities.response_body_mode,
147            allow_unbounded,
148        )?;
149
150        Ok(())
151    }
152
153    /// Pre-computed body processing capabilities for this pipeline.
154    pub fn body_capabilities(&self) -> &BodyCapabilities {
155        &self.body_capabilities
156    }
157
158    /// Whether any filter in the pipeline needs body access.
159    pub fn needs_body_filters(&self) -> bool {
160        self.body_capabilities.needs_request_body || self.body_capabilities.needs_response_body
161    }
162
163    /// Number of filters in the pipeline.
164    pub fn len(&self) -> usize {
165        self.filters.len()
166    }
167
168    /// Whether the pipeline has no filters.
169    pub fn is_empty(&self) -> bool {
170        self.filters.is_empty()
171    }
172
173    /// Compression configuration, if a compression filter is present.
174    pub fn compression_config(&self) -> Option<&CompressionConfig> {
175        self.compression.as_ref()
176    }
177
178    /// Set the shared [`HealthRegistry`] for this pipeline.
179    pub fn set_health_registry(&mut self, registry: HealthRegistry) {
180        self.health_registry = Some(registry);
181    }
182
183    /// The shared health registry, if set.
184    pub fn health_registry(&self) -> Option<&HealthRegistry> {
185        self.health_registry.as_ref()
186    }
187
188    /// The shared request ID generator.
189    pub fn id_generator(&self) -> &IdGenerator {
190        &self.id_generator
191    }
192
193    /// Override the [`IdGenerator`] for this pipeline.
194    pub fn set_id_generator(&mut self, generator: Arc<IdGenerator>) {
195        self.id_generator = generator;
196    }
197
198    /// The shared KV store registry, if set.
199    pub fn kv_stores(&self) -> Option<&KvStoreRegistry> {
200        self.kv_stores.as_ref()
201    }
202
203    /// Set the shared [`KvStoreRegistry`] for this pipeline.
204    pub fn set_kv_stores(&mut self, stores: KvStoreRegistry) {
205        self.kv_stores = Some(stores);
206    }
207
208    /// Register an external pipeline extension.
209    ///
210    /// Extensions are called once per request via
211    /// [`prepare_extensions`] to inject pipeline-scoped resources
212    /// into the per-request [`RequestExtensions`] container.
213    ///
214    /// [`prepare_extensions`]: FilterPipeline::prepare_extensions
215    /// [`RequestExtensions`]: crate::RequestExtensions
216    pub fn add_pipeline_extension(&mut self, ext: Box<dyn PipelineExtension>) {
217        self.pipeline_extensions.push(ext);
218    }
219
220    /// Inject pipeline-level resources into per-request extensions.
221    ///
222    /// Called by the protocol adapter when building each request's
223    /// filter context. Delegates to each registered
224    /// [`PipelineExtension`].
225    pub fn prepare_extensions(&self, extensions: &mut RequestExtensions) {
226        for ext in &self.pipeline_extensions {
227            ext.prepare(extensions);
228        }
229    }
230
231    /// The wall-clock time source.
232    pub fn time_source(&self) -> &dyn TimeSource {
233        &*self.time_source
234    }
235
236    /// Override the [`TimeSource`] for this pipeline.
237    pub fn set_time_source(&mut self, source: Arc<dyn TimeSource>) {
238        self.time_source = source;
239    }
240
241    /// Apply [`InsecureOptions`] to all filters in the pipeline.
242    ///
243    /// Delegates to each filter's [`apply_insecure_options`] method.
244    /// Filters that support insecure overrides (e.g. CSRF log-only
245    /// mode) handle the relevant flags; others ignore the call.
246    ///
247    /// [`apply_insecure_options`]: crate::HttpFilter::apply_insecure_options
248    /// [`InsecureOptions`]: praxis_core::config::InsecureOptions
249    pub fn apply_insecure_options(&self, options: &InsecureOptions) {
250        for pf in &self.filters {
251            if let crate::any_filter::AnyFilter::Http(f) = &pf.filter {
252                f.apply_insecure_options(options);
253            }
254        }
255    }
256}
257
258// -----------------------------------------------------------------------------
259// Body Limit Utilities
260// -----------------------------------------------------------------------------
261
262/// Tighten a body mode's size limit to the given ceiling.
263/// When `filter_declared` is true a filter explicitly chose Stream
264/// mode; preserve it so streaming filters keep working. When false
265/// the mode is the default (no filter needs body); convert to
266/// `SizeLimit` so the body limit is enforced by buffering.
267fn clamp_body_mode(mode: BodyMode, ceiling: usize, filter_declared: bool) -> BodyMode {
268    match mode {
269        BodyMode::StreamBuffer { max_bytes } => BodyMode::StreamBuffer {
270            max_bytes: Some(max_bytes.map_or(ceiling, |m| m.min(ceiling))),
271        },
272        BodyMode::SizeLimit { max_bytes } => BodyMode::SizeLimit {
273            max_bytes: max_bytes.min(ceiling),
274        },
275        BodyMode::Stream if filter_declared => BodyMode::Stream,
276        BodyMode::Stream => BodyMode::SizeLimit { max_bytes: ceiling },
277    }
278}
279
280/// Reject or clamp unbounded [`StreamBuffer`] body modes.
281///
282/// When `allow_unbounded` is `true`, the mode is clamped to
283/// [`ABSOLUTE_MAX_BODY_BYTES`] and a warning is emitted.
284///
285/// # Errors
286///
287/// Returns [`FilterError`] when the body mode is unbounded
288/// and `allow_unbounded` is `false`.
289///
290/// [`StreamBuffer`]: BodyMode::StreamBuffer
291/// [`ABSOLUTE_MAX_BODY_BYTES`]: praxis_core::config::ABSOLUTE_MAX_BODY_BYTES
292fn check_unbounded_stream_buffer(
293    direction: &str,
294    mode: &mut BodyMode,
295    allow_unbounded: bool,
296) -> Result<(), FilterError> {
297    if let BodyMode::StreamBuffer { max_bytes: max @ None } = mode {
298        if allow_unbounded {
299            warn!(
300                direction = direction,
301                ceiling = ABSOLUTE_MAX_BODY_BYTES,
302                "StreamBuffer body mode has no per-filter size limit; \
303                 clamped to absolute ceiling ({} MiB)",
304                ABSOLUTE_MAX_BODY_BYTES / 1_048_576
305            );
306            *max = Some(ABSOLUTE_MAX_BODY_BYTES);
307        } else {
308            return Err(format!(
309                "StreamBuffer {direction} body mode has no size limit; \
310                 set max_{direction}_body_bytes or set \
311                 insecure_options.allow_unbounded_body: true to allow"
312            )
313            .into());
314        }
315    }
316    Ok(())
317}
318
319// -----------------------------------------------------------------------------
320// Failure Mode
321// -----------------------------------------------------------------------------
322
323/// Check failure mode and either swallow or propagate a filter error.
324///
325/// When `failure_mode` is [`FailureMode::Open`], the error is logged as a
326/// warning and `Ok(())` is returned so the caller can continue.
327pub(crate) fn check_failure_mode(
328    filter_name: &str,
329    error: FilterError,
330    phase: &str,
331    failure_mode: FailureMode,
332) -> Result<(), FilterError> {
333    match failure_mode {
334        FailureMode::Open => {
335            warn!(
336                filter = filter_name,
337                error = %error,
338                "filter error during {phase}, continuing (failure_mode=open)"
339            );
340            Ok(())
341        },
342        FailureMode::Closed => {
343            warn!(
344                filter = filter_name,
345                error = %error,
346                "filter error during {phase}, aborting"
347            );
348            Err(error)
349        },
350    }
351}