Skip to main content

guard_core_engine/
custom_checks.rs

1//! The custom function seams: the reference pipeline's ninth and
2//! seventeenth checks.
3//!
4//! This is the Rust family's port of the two checks the reference pipeline
5//! runs host-supplied functions through:
6//!
7//! - `guard_core/core/checks/implementations/custom_validators.py`: a
8//!   route's `custom_validators` list, run in order, first truthy response
9//!   wins.
10//! - `guard_core/core/checks/implementations/custom_request.py`: the
11//!   global `custom_request_check` function.
12//!
13//! The reference seams are async callables over the request; the Rust
14//! family's are sync boxed functions over a [`CustomRequestContext`] (the
15//! request facts the reference's checks can see). Both checks share the
16//! same shapes:
17//!
18//! ```text
19//! custom_validators (route-scoped, in order, first truthy wins):
20//!   validator returns None:            continue
21//!   validator returns a response:      Failed (block with the validator's
22//!                                      own response, the reference returns
23//!                                      it as-is, never create_error_response)
24//!   validator returns a truthy
25//!   non-response:                      Failed with no block (the reference
26//!                                      logs and emits but does not block)
27//! custom_request (global):
28//!   check returns None:                Allowed
29//!   check returns a response:          Blocked with it (the reference
30//!                                      applies the response modifier;
31//!                                      passive mode logs only)
32//! ```
33//!
34//! Names are explicit on the seam (the reference reads `__name__`,
35//! falling back to `"anonymous"` for unnamed callables): a Rust closure
36//! carries no name, so hosts name them at registration.
37//!
38//! # Example
39//!
40//! ```
41//! use std::sync::Arc;
42//!
43//! use guard_core_engine::custom_checks::{
44//!     decide_custom_request, CustomRequestContext, CustomRequestFn, CustomRequestVerdict,
45//!     CustomResponse,
46//! };
47//!
48//! let check: CustomRequestFn = Arc::new(|_ctx: &CustomRequestContext<'_>| {
49//!     Some(CustomResponse { status: Some(418) })
50//! });
51//! let ctx = CustomRequestContext { method: "GET", path: "/", client_ip: None };
52//! let verdict = decide_custom_request(&check, "block_maintenance", &ctx);
53//! assert_eq!(
54//!     verdict,
55//!     CustomRequestVerdict::Blocked { status: Some(418), function: "block_maintenance".to_owned() }
56//! );
57//! ```
58
59use std::sync::Arc;
60
61/// The `custom_request` check's stable name (`check_name`).
62pub const CUSTOM_REQUEST_CHECK_NAME: &str = "custom_request";
63
64/// The `custom_validators` check's stable name (`check_name`).
65pub const CUSTOM_VALIDATORS_CHECK_NAME: &str = "custom_validators";
66
67/// The request facts a custom function sees (the pieces of `GuardRequest`
68/// the reference's checks read; everything else stays with the host).
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct CustomRequestContext<'a> {
71    /// The request method.
72    pub method: &'a str,
73    /// The request URL path.
74    pub path: &'a str,
75    /// The client IP, when resolved.
76    pub client_ip: Option<&'a str>,
77}
78
79/// A response a custom function hands back. `status` is the response's
80/// status code when it carries one (the reference's
81/// `response_status ... else "unknown"` metadata arm).
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub struct CustomResponse {
84    /// The response's status code, `None` for a response without one.
85    pub status: Option<u16>,
86}
87
88/// The host-supplied `custom_request_check` function.
89pub type CustomRequestFn =
90    Arc<dyn Fn(&CustomRequestContext<'_>) -> Option<CustomResponse> + Send + Sync>;
91
92/// What a route validator returned: the reference's truthy response
93/// (blocks with its own shape) versus a truthy non-response value (logs
94/// and emits, never blocks).
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum ValidatorAnswer {
97    /// A truthy non-`GuardResponse` value: observability only.
98    TruthyNonResponse,
99    /// A real response: blocks with it as-is.
100    Response(CustomResponse),
101}
102
103/// The host-supplied route validator function.
104pub type CustomValidatorFn =
105    Arc<dyn Fn(&CustomRequestContext<'_>) -> Option<ValidatorAnswer> + Send + Sync>;
106
107/// What [`decide_custom_request`] decided.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum CustomRequestVerdict {
110    /// The check returned nothing: allow.
111    Allowed,
112    /// The check returned a response: the reference emits
113    /// `custom_request_check` with the response status (or `"unknown"`)
114    /// and the function name (or `"anonymous"`), then applies the
115    /// response modifier and returns it.
116    Blocked {
117        /// The response's status, `None` for the `"unknown"` arm.
118        status: Option<u16>,
119        /// The registered function name, `"anonymous"` when unnamed.
120        function: String,
121    },
122}
123
124/// The `custom_request` check body: the function runs once; a returned
125/// response blocks with it.
126#[must_use]
127pub fn decide_custom_request(
128    check: &CustomRequestFn,
129    function_name: &str,
130    ctx: &CustomRequestContext<'_>,
131) -> CustomRequestVerdict {
132    if let Some(response) = check(ctx) {
133        return CustomRequestVerdict::Blocked {
134            status: response.status,
135            function: function_name.to_owned(),
136        };
137    }
138    CustomRequestVerdict::Allowed
139}
140
141/// What [`decide_custom_validators`] decided.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum CustomValidatorsVerdict {
144    /// Every validator passed (or the route carries none).
145    Allowed,
146    /// A validator returned a truthy answer: the reference logs and emits
147    /// (`"Custom validation failed"`, `decorator_type="content_filtering"`,
148    /// `violation_type="custom_validation"`, the validator's name or
149    /// `"anonymous"`); `block` carries the response only when the answer
150    /// was a real response - a truthy non-response never blocks.
151    Failed {
152        /// The blocking response, when the answer was one.
153        block: Option<CustomResponse>,
154        /// The validator's registered name, `"anonymous"` when unnamed.
155        validator: String,
156    },
157}
158
159/// The `custom_validators` check body: run in order, first truthy answer
160/// wins (the reference stops at the first truthy response, never running
161/// the rest).
162#[must_use]
163pub fn decide_custom_validators(
164    validators: &[(String, CustomValidatorFn)],
165    ctx: &CustomRequestContext<'_>,
166) -> CustomValidatorsVerdict {
167    for (name, validator) in validators {
168        let Some(answer) = validator(ctx) else {
169            continue;
170        };
171        return CustomValidatorsVerdict::Failed {
172            block: match answer {
173                ValidatorAnswer::Response(response) => Some(response),
174                ValidatorAnswer::TruthyNonResponse => None,
175            },
176            validator: name.clone(),
177        };
178    }
179    CustomValidatorsVerdict::Allowed
180}
181
182/// The `"anonymous"` name the reference falls back to for unnamed
183/// callables.
184#[must_use]
185pub fn anonymous_name() -> String {
186    String::from("anonymous")
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    fn ctx<'a>() -> CustomRequestContext<'a> {
194        CustomRequestContext {
195            method: "GET",
196            path: "/private",
197            client_ip: Some("192.0.2.9"),
198        }
199    }
200
201    #[test]
202    fn custom_request_passes_and_blocks() {
203        let ctx = ctx();
204        let pass: CustomRequestFn = Arc::new(|_ctx| None);
205        assert_eq!(
206            decide_custom_request(&pass, "my_check", &ctx),
207            CustomRequestVerdict::Allowed
208        );
209
210        let block: CustomRequestFn = Arc::new(|_ctx| Some(CustomResponse { status: Some(403) }));
211        assert_eq!(
212            decide_custom_request(&block, "my_check", &ctx),
213            CustomRequestVerdict::Blocked {
214                status: Some(403),
215                function: "my_check".to_owned()
216            }
217        );
218
219        // A response without a status is the reference's "unknown" arm.
220        let unknown: CustomRequestFn = Arc::new(|_ctx| Some(CustomResponse { status: None }));
221        assert_eq!(
222            decide_custom_request(&unknown, "my_check", &ctx),
223            CustomRequestVerdict::Blocked {
224                status: None,
225                function: "my_check".to_owned()
226            }
227        );
228    }
229
230    #[test]
231    fn validators_run_in_order_and_first_truthy_wins() {
232        let ctx = ctx();
233        let never: CustomValidatorFn = Arc::new(|_ctx| None);
234        let blocking: CustomValidatorFn = Arc::new(|_ctx| {
235            Some(ValidatorAnswer::Response(CustomResponse {
236                status: Some(403),
237            }))
238        });
239        let flagging: CustomValidatorFn = Arc::new(|_ctx| Some(ValidatorAnswer::TruthyNonResponse));
240
241        // All pass.
242        let all_pass = vec![
243            (String::from("first"), never.clone()),
244            (String::from("second"), never.clone()),
245        ];
246        assert_eq!(
247            decide_custom_validators(&all_pass, &ctx),
248            CustomValidatorsVerdict::Allowed
249        );
250
251        // First truthy wins: the earlier validator's name rides the verdict.
252        let first_blocks = vec![
253            (String::from("first"), never.clone()),
254            (String::from("second"), blocking.clone()),
255            (String::from("third"), flagging.clone()),
256        ];
257        assert_eq!(
258            decide_custom_validators(&first_blocks, &ctx),
259            CustomValidatorsVerdict::Failed {
260                block: Some(CustomResponse { status: Some(403) }),
261                validator: "second".to_owned()
262            }
263        );
264
265        // The first truthy answer wins even when it cannot block: the
266        // flagging validator's name rides a blockless verdict.
267        let first_flags = vec![
268            (String::from("first"), flagging.clone()),
269            (String::from("second"), blocking.clone()),
270        ];
271        assert_eq!(
272            decide_custom_validators(&first_flags, &ctx),
273            CustomValidatorsVerdict::Failed {
274                block: None,
275                validator: "first".to_owned()
276            }
277        );
278    }
279
280    #[test]
281    fn a_truthy_non_response_logs_but_never_blocks() {
282        let ctx = ctx();
283        let flagging: CustomValidatorFn = Arc::new(|_ctx| Some(ValidatorAnswer::TruthyNonResponse));
284        let validators = vec![(String::from("flagger"), flagging)];
285        assert_eq!(
286            decide_custom_validators(&validators, &ctx),
287            CustomValidatorsVerdict::Failed {
288                block: None,
289                validator: "flagger".to_owned()
290            },
291            "the reference returns the validator's response only when it is a \
292             real GuardResponse; a truthy non-response cannot block"
293        );
294    }
295
296    #[test]
297    fn the_context_carries_the_request_facts() {
298        let seen: CustomValidatorFn = Arc::new(|ctx| {
299            // The validator can decide on the method/path/client_ip.
300            (ctx.path == "/private" && ctx.method == "POST").then_some(ValidatorAnswer::Response(
301                CustomResponse { status: Some(403) },
302            ))
303        });
304        let validators = vec![(String::from("gate"), seen)];
305        assert_eq!(
306            decide_custom_validators(&validators, &ctx()),
307            CustomValidatorsVerdict::Allowed,
308            "GET /private passes the POST-only gate"
309        );
310        let post = CustomRequestContext {
311            method: "POST",
312            path: "/private",
313            client_ip: None,
314        };
315        assert_eq!(
316            decide_custom_validators(&validators, &post),
317            CustomValidatorsVerdict::Failed {
318                block: Some(CustomResponse { status: Some(403) }),
319                validator: "gate".to_owned()
320            },
321            "a real GuardResponse both blocks and carries the response"
322        );
323    }
324
325    #[test]
326    fn anonymous_fallback_name() {
327        assert_eq!(anonymous_name(), "anonymous");
328    }
329}