Skip to main content

praxis_filter/
results.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2024 Praxis Contributors
3
4//! Filter result feedback for branch chain evaluation.
5//!
6//! Filters write key-value results to `ctx.filter_results` using
7//! their **type name** ([`HttpFilter::name()`]) as the outer map
8//! key. For example, the guardrails filter writes:
9//!
10//! ```text
11//! ctx.filter_results
12//!     .entry("guardrails")           // filter TYPE name
13//!     .or_default()
14//!     .set("status", "blocked")?;    // key-value result
15//! ```
16//!
17//! Branch conditions match against these results via
18//! [`on_result.filter`] (which must equal the filter type name)
19//! and `on_result.key` / `on_result.result`.
20//!
21//! Results are **cleared after branch evaluation** at each filter
22//! — they are ephemeral, scoped to the window between a filter's
23//! execution and its branch evaluation.
24//!
25//! [`HttpFilter::name()`]: crate::HttpFilter::name
26//! [`on_result.filter`]: praxis_core::config::BranchCondition::filter
27
28use std::{borrow::Cow, collections::HashMap};
29
30use crate::FilterError;
31
32// -----------------------------------------------------------------------------
33// Constants
34// -----------------------------------------------------------------------------
35
36/// Maximum length of a result key in bytes.
37const MAX_KEY_LEN: usize = 64;
38
39/// Maximum length of a result value in bytes.
40const MAX_VALUE_LEN: usize = 256;
41
42// -----------------------------------------------------------------------------
43// FilterResultSet
44// -----------------------------------------------------------------------------
45
46/// Result feedback from a single filter execution.
47///
48/// Filters populate this to communicate outcomes
49/// (e.g. cache hit/miss, auth success/failure)
50/// without knowing about branching.
51///
52/// ```
53/// use praxis_filter::FilterResultSet;
54///
55/// let mut results = FilterResultSet::new();
56/// results.set("status", "hit").unwrap();
57/// assert_eq!(results.get("status"), Some("hit"));
58/// assert!(results.matches("status", "hit"));
59/// assert!(!results.matches("status", "miss"));
60/// ```
61#[derive(Clone, Debug, Default)]
62pub struct FilterResultSet {
63    /// Key-value result entries.
64    entries: HashMap<Cow<'static, str>, Cow<'static, str>>,
65}
66
67/// Opt-in cross-phase snapshot of ephemeral filter results.
68///
69/// The iterative router inserts this extension into nested contexts so results
70/// can drive step transitions after ordinary branch evaluation clears them.
71#[derive(Default)]
72pub(crate) struct RetainedFilterResults(pub HashMap<&'static str, FilterResultSet>);
73
74impl FilterResultSet {
75    /// Create an empty result set.
76    ///
77    /// ```
78    /// use praxis_filter::FilterResultSet;
79    ///
80    /// let rs = FilterResultSet::new();
81    /// assert!(rs.is_empty());
82    /// ```
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    /// Get a result value by key.
88    ///
89    /// ```
90    /// use praxis_filter::FilterResultSet;
91    ///
92    /// let mut rs = FilterResultSet::new();
93    /// rs.set("action", "allow").unwrap();
94    /// assert_eq!(rs.get("action"), Some("allow"));
95    /// assert_eq!(rs.get("missing"), None);
96    /// ```
97    pub fn get(&self, key: &str) -> Option<&str> {
98        self.entries.get(key).map(AsRef::as_ref)
99    }
100
101    /// Whether the result set has no entries.
102    pub fn is_empty(&self) -> bool {
103        self.entries.is_empty()
104    }
105
106    /// Whether a key-value pair matches.
107    ///
108    /// ```
109    /// use praxis_filter::FilterResultSet;
110    ///
111    /// let mut rs = FilterResultSet::new();
112    /// rs.set("tier", "premium").unwrap();
113    /// assert!(rs.matches("tier", "premium"));
114    /// assert!(!rs.matches("tier", "free"));
115    /// assert!(!rs.matches("missing", "x"));
116    /// ```
117    pub fn matches(&self, key: &str, value: &str) -> bool {
118        self.get(key).is_some_and(|v| v == value)
119    }
120
121    /// Set a result key-value pair.
122    ///
123    /// # Errors
124    ///
125    /// Returns [`FilterError`] if:
126    /// - `key` is empty, exceeds 64 bytes, or contains non-ASCII-alphanumeric characters (besides `_` and `-`)
127    /// - `value` exceeds 256 bytes or contains control characters (0x00-0x1F except 0x09/tab)
128    ///
129    /// ```
130    /// use praxis_filter::FilterResultSet;
131    ///
132    /// let mut rs = FilterResultSet::new();
133    /// assert!(rs.set("valid-key_1", "value").is_ok());
134    /// assert!(rs.set("", "value").is_err());
135    /// ```
136    pub fn set(
137        &mut self,
138        key: impl Into<Cow<'static, str>>,
139        value: impl Into<Cow<'static, str>>,
140    ) -> Result<(), FilterError> {
141        let key = key.into();
142        let value = value.into();
143        validate_result_key(&key)?;
144        validate_result_value(&value)?;
145        self.entries.insert(key, value);
146        Ok(())
147    }
148}
149
150// -----------------------------------------------------------------------------
151// Cross-filter result matching
152// -----------------------------------------------------------------------------
153
154/// Whether a filter's result set contains a specific key-value pair.
155///
156/// Looks up `filter_name` in the results map, then checks whether
157/// the result set contains `key` with value `value`. Used by both
158/// branch chain evaluation and step transition matching.
159///
160/// ```
161/// use std::collections::HashMap;
162///
163/// use praxis_filter::{FilterResultSet, matches_filter_result};
164///
165/// let mut results = HashMap::new();
166/// let mut rs = FilterResultSet::new();
167/// rs.set("action", "loop").unwrap();
168/// results.insert("classifier", rs);
169///
170/// assert!(matches_filter_result(
171///     &results,
172///     "classifier",
173///     "action",
174///     "loop"
175/// ));
176/// assert!(!matches_filter_result(
177///     &results,
178///     "classifier",
179///     "action",
180///     "done"
181/// ));
182/// assert!(!matches_filter_result(
183///     &results, "unknown", "action", "loop"
184/// ));
185/// ```
186pub fn matches_filter_result(
187    results: &HashMap<&str, FilterResultSet>,
188    filter_name: &str,
189    key: &str,
190    value: &str,
191) -> bool {
192    results.get(filter_name).is_some_and(|rs| rs.matches(key, value))
193}
194
195// -----------------------------------------------------------------------------
196// Validation
197// -----------------------------------------------------------------------------
198
199/// Validate a result key.
200fn validate_result_key(key: &str) -> Result<(), FilterError> {
201    if key.is_empty() || key.len() > MAX_KEY_LEN {
202        let len = key.len();
203        return Err(format!("result key must be 1-{MAX_KEY_LEN} bytes, got {len}").into());
204    }
205    if !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') {
206        return Err(format!("result key '{key}' must be ASCII alphanumeric, '_', or '-'").into());
207    }
208    Ok(())
209}
210
211/// Validate a result value.
212fn validate_result_value(value: &str) -> Result<(), FilterError> {
213    if value.len() > MAX_VALUE_LEN {
214        let len = value.len();
215        return Err(format!("result value must not exceed {MAX_VALUE_LEN} bytes, got {len}").into());
216    }
217    if value.bytes().any(|b| (b < 0x20 && b != 0x09) || b == 0x7F) {
218        return Err("result value must not contain control characters".into());
219    }
220    Ok(())
221}
222
223// -----------------------------------------------------------------------------
224// Tests
225// -----------------------------------------------------------------------------
226
227#[cfg(test)]
228#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
229#[allow(
230    clippy::unwrap_used,
231    clippy::expect_used,
232    clippy::indexing_slicing,
233    clippy::panic,
234    reason = "tests"
235)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn new_is_empty() {
241        let rs = FilterResultSet::new();
242        assert!(rs.is_empty(), "new result set should be empty");
243    }
244
245    #[test]
246    fn set_and_get() {
247        let mut rs = FilterResultSet::new();
248        rs.set("status", "hit").unwrap();
249        assert_eq!(rs.get("status"), Some("hit"), "should return set value");
250        assert!(!rs.is_empty(), "should not be empty after set");
251    }
252
253    #[test]
254    fn get_missing_key() {
255        let rs = FilterResultSet::new();
256        assert_eq!(rs.get("missing"), None, "missing key should return None");
257    }
258
259    #[test]
260    fn matches_true() {
261        let mut rs = FilterResultSet::new();
262        rs.set("status", "hit").unwrap();
263        assert!(rs.matches("status", "hit"), "exact match should return true");
264    }
265
266    #[test]
267    fn matches_false_wrong_value() {
268        let mut rs = FilterResultSet::new();
269        rs.set("status", "hit").unwrap();
270        assert!(!rs.matches("status", "miss"), "wrong value should return false");
271    }
272
273    #[test]
274    fn matches_false_missing_key() {
275        let rs = FilterResultSet::new();
276        assert!(!rs.matches("status", "hit"), "missing key should return false");
277    }
278
279    #[test]
280    fn set_overwrites_existing() {
281        let mut rs = FilterResultSet::new();
282        rs.set("status", "hit").unwrap();
283        rs.set("status", "miss").unwrap();
284        assert_eq!(rs.get("status"), Some("miss"), "second set should overwrite");
285    }
286
287    #[test]
288    fn set_multiple_keys() {
289        let mut rs = FilterResultSet::new();
290        rs.set("status", "hit").unwrap();
291        rs.set("tier", "premium").unwrap();
292        assert_eq!(rs.get("status"), Some("hit"), "first key should be retained");
293        assert_eq!(rs.get("tier"), Some("premium"), "second key should be present");
294    }
295
296    #[test]
297    fn reject_empty_key() {
298        let mut rs = FilterResultSet::new();
299        let err = rs.set("", "value").unwrap_err();
300        assert!(
301            err.to_string().contains("1-64 bytes"),
302            "empty key error should mention size constraint: {err}"
303        );
304    }
305
306    #[test]
307    fn reject_key_too_long() {
308        let mut rs = FilterResultSet::new();
309        let long_key = "a".repeat(65);
310        let err = rs.set(long_key, "value").unwrap_err();
311        assert!(
312            err.to_string().contains("1-64 bytes"),
313            "long key error should mention size constraint: {err}"
314        );
315    }
316
317    #[test]
318    fn accept_key_at_max_length() {
319        let mut rs = FilterResultSet::new();
320        let key = "a".repeat(64);
321        assert!(rs.set(key, "value").is_ok(), "64-byte key should be accepted");
322    }
323
324    #[test]
325    fn reject_key_with_spaces() {
326        let mut rs = FilterResultSet::new();
327        let err = rs.set("bad key", "value").unwrap_err();
328        assert!(
329            err.to_string().contains("alphanumeric"),
330            "key with spaces should be rejected: {err}"
331        );
332    }
333
334    #[test]
335    fn reject_key_with_special_chars() {
336        let mut rs = FilterResultSet::new();
337        let err = rs.set("key.dot", "value").unwrap_err();
338        assert!(
339            err.to_string().contains("alphanumeric"),
340            "key with dots should be rejected: {err}"
341        );
342    }
343
344    #[test]
345    fn accept_key_with_underscore_and_hyphen() {
346        let mut rs = FilterResultSet::new();
347        assert!(
348            rs.set("my-key_1", "value").is_ok(),
349            "key with underscore and hyphen should be accepted"
350        );
351    }
352
353    #[test]
354    fn reject_value_too_long() {
355        let mut rs = FilterResultSet::new();
356        let long_value = "x".repeat(257);
357        let err = rs.set("key", long_value).unwrap_err();
358        assert!(
359            err.to_string().contains("256 bytes"),
360            "long value error should mention size constraint: {err}"
361        );
362    }
363
364    #[test]
365    fn accept_value_at_max_length() {
366        let mut rs = FilterResultSet::new();
367        let value = "x".repeat(256);
368        assert!(rs.set("key", value).is_ok(), "256-byte value should be accepted");
369    }
370
371    #[test]
372    fn reject_value_with_control_chars() {
373        let mut rs = FilterResultSet::new();
374        let err = rs.set("key", "line\x00null").unwrap_err();
375        assert!(
376            err.to_string().contains("control characters"),
377            "value with null byte should be rejected: {err}"
378        );
379    }
380
381    #[test]
382    fn reject_value_with_newline() {
383        let mut rs = FilterResultSet::new();
384        let err = rs.set("key", "line\nbreak").unwrap_err();
385        assert!(
386            err.to_string().contains("control characters"),
387            "value with newline should be rejected: {err}"
388        );
389    }
390
391    #[test]
392    fn accept_value_with_tab() {
393        let mut rs = FilterResultSet::new();
394        assert!(rs.set("key", "col1\tcol2").is_ok(), "value with tab should be accepted");
395    }
396
397    #[test]
398    fn reject_value_with_del() {
399        let mut rs = FilterResultSet::new();
400        let err = rs.set("key", "before\x7Fafter").unwrap_err();
401        assert!(
402            err.to_string().contains("control characters"),
403            "value with DEL (0x7F) should be rejected: {err}"
404        );
405    }
406
407    #[test]
408    fn accept_empty_value() {
409        let mut rs = FilterResultSet::new();
410        assert!(rs.set("key", "").is_ok(), "empty value should be accepted");
411    }
412
413    #[test]
414    fn default_is_empty() {
415        let rs = FilterResultSet::default();
416        assert!(rs.is_empty(), "default result set should be empty");
417    }
418
419    #[test]
420    fn clone_preserves_entries() {
421        let mut rs = FilterResultSet::new();
422        rs.set("a", "1").unwrap();
423        let cloned = rs.clone();
424        assert_eq!(cloned.get("a"), Some("1"), "clone should preserve entries");
425    }
426
427    #[test]
428    fn set_with_cow_borrowed() {
429        let mut rs = FilterResultSet::new();
430        rs.set(Cow::Borrowed("static_key"), Cow::Borrowed("static_val"))
431            .unwrap();
432        assert_eq!(rs.get("static_key"), Some("static_val"), "Cow::Borrowed should work");
433    }
434
435    #[test]
436    fn set_with_cow_owned() {
437        let mut rs = FilterResultSet::new();
438        rs.set(
439            Cow::<str>::Owned("owned_key".to_owned()),
440            Cow::<str>::Owned("owned_val".to_owned()),
441        )
442        .unwrap();
443        assert_eq!(rs.get("owned_key"), Some("owned_val"), "Cow::Owned should work");
444    }
445}