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), body: None })
50//! });
51//! let ctx = CustomRequestContext { method: "GET", path: "/", client_ip: None, body: 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    /// The buffered request body, when the pipeline has one at the
78    /// validator's position (the reference validators read the
79    /// `GuardRequest` body directly; the adapters pass their buffer and
80    /// the framework lanes without one pass `None`).
81    pub body: Option<&'a str>,
82}
83
84/// A response a custom function hands back.
85///
86/// `status` is the response's status code when it carries one (the
87/// reference's `response_status ... else "unknown"` metadata arm); `body`
88/// is the response body when the function authors one (the reference's
89/// `_SimpleResponse`), the family default rendering otherwise.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct CustomResponse {
92    /// The response's status code, `None` for a response without one.
93    pub status: Option<u16>,
94    /// The response body, `None` for the family default shape.
95    pub body: Option<String>,
96}
97
98/// The host-supplied `custom_request_check` function.
99pub type CustomRequestFn =
100    Arc<dyn Fn(&CustomRequestContext<'_>) -> Option<CustomResponse> + Send + Sync>;
101
102/// What a route validator returned: the reference's truthy response
103/// (blocks with its own shape) versus a truthy non-response value (logs
104/// and emits, never blocks).
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum ValidatorAnswer {
107    /// A truthy non-`GuardResponse` value: observability only.
108    TruthyNonResponse,
109    /// A real response: blocks with it as-is.
110    Response(CustomResponse),
111}
112
113/// The host-supplied route validator function.
114pub type CustomValidatorFn =
115    Arc<dyn Fn(&CustomRequestContext<'_>) -> Option<ValidatorAnswer> + Send + Sync>;
116
117/// What [`decide_custom_request`] decided.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum CustomRequestVerdict {
120    /// The check returned nothing: allow.
121    Allowed,
122    /// The check returned a response: the reference emits
123    /// `custom_request_check` with the response status (or `"unknown"`)
124    /// and the function name (or `"anonymous"`), then applies the
125    /// response modifier and returns it.
126    Blocked {
127        /// The response's status, `None` for the `"unknown"` arm.
128        status: Option<u16>,
129        /// The registered function name, `"anonymous"` when unnamed.
130        function: String,
131    },
132}
133
134/// The `custom_request` check body: the function runs once; a returned
135/// response blocks with it.
136#[must_use]
137pub fn decide_custom_request(
138    check: &CustomRequestFn,
139    function_name: &str,
140    ctx: &CustomRequestContext<'_>,
141) -> CustomRequestVerdict {
142    if let Some(response) = check(ctx) {
143        return CustomRequestVerdict::Blocked {
144            status: response.status,
145            function: function_name.to_owned(),
146        };
147    }
148    CustomRequestVerdict::Allowed
149}
150
151/// What [`decide_custom_validators`] decided.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub enum CustomValidatorsVerdict {
154    /// Every validator passed (or the route carries none).
155    Allowed,
156    /// A validator returned a truthy answer: the reference logs and emits
157    /// (`"Custom validation failed"`, `decorator_type="content_filtering"`,
158    /// `violation_type="custom_validation"`, the validator's name or
159    /// `"anonymous"`); `block` carries the response only when the answer
160    /// was a real response - a truthy non-response never blocks.
161    Failed {
162        /// The blocking response, when the answer was one.
163        block: Option<CustomResponse>,
164        /// The validator's registered name, `"anonymous"` when unnamed.
165        validator: String,
166    },
167}
168
169/// The `custom_validators` check body: run in order, first truthy answer
170/// wins (the reference stops at the first truthy response, never running
171/// the rest).
172#[must_use]
173pub fn decide_custom_validators(
174    validators: &[(String, CustomValidatorFn)],
175    ctx: &CustomRequestContext<'_>,
176) -> CustomValidatorsVerdict {
177    for (name, validator) in validators {
178        let Some(answer) = validator(ctx) else {
179            continue;
180        };
181        return CustomValidatorsVerdict::Failed {
182            block: match answer {
183                ValidatorAnswer::Response(response) => Some(response),
184                ValidatorAnswer::TruthyNonResponse => None,
185            },
186            validator: name.clone(),
187        };
188    }
189    CustomValidatorsVerdict::Allowed
190}
191
192/// The `"anonymous"` name the reference falls back to for unnamed
193/// callables.
194#[must_use]
195pub fn anonymous_name() -> String {
196    String::from("anonymous")
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    fn ctx<'a>() -> CustomRequestContext<'a> {
204        CustomRequestContext {
205            method: "GET",
206            path: "/private",
207            client_ip: Some("192.0.2.9"),
208            body: None,
209        }
210    }
211
212    #[test]
213    fn custom_request_passes_and_blocks() {
214        let ctx = ctx();
215        let pass: CustomRequestFn = Arc::new(|_ctx| None);
216        assert_eq!(
217            decide_custom_request(&pass, "my_check", &ctx),
218            CustomRequestVerdict::Allowed
219        );
220
221        let block: CustomRequestFn = Arc::new(|_ctx| {
222            Some(CustomResponse {
223                status: Some(403),
224                body: None,
225            })
226        });
227        assert_eq!(
228            decide_custom_request(&block, "my_check", &ctx),
229            CustomRequestVerdict::Blocked {
230                status: Some(403),
231                function: "my_check".to_owned()
232            }
233        );
234
235        // A response without a status is the reference's "unknown" arm.
236        let unknown: CustomRequestFn = Arc::new(|_ctx| {
237            Some(CustomResponse {
238                status: None,
239                body: None,
240            })
241        });
242        assert_eq!(
243            decide_custom_request(&unknown, "my_check", &ctx),
244            CustomRequestVerdict::Blocked {
245                status: None,
246                function: "my_check".to_owned()
247            }
248        );
249    }
250
251    #[test]
252    fn validators_run_in_order_and_first_truthy_wins() {
253        let ctx = ctx();
254        let never: CustomValidatorFn = Arc::new(|_ctx| None);
255        let blocking: CustomValidatorFn = Arc::new(|_ctx| {
256            Some(ValidatorAnswer::Response(CustomResponse {
257                status: Some(403),
258                body: None,
259            }))
260        });
261        let flagging: CustomValidatorFn = Arc::new(|_ctx| Some(ValidatorAnswer::TruthyNonResponse));
262
263        // All pass.
264        let all_pass = vec![
265            (String::from("first"), never.clone()),
266            (String::from("second"), never.clone()),
267        ];
268        assert_eq!(
269            decide_custom_validators(&all_pass, &ctx),
270            CustomValidatorsVerdict::Allowed
271        );
272
273        // First truthy wins: the earlier validator's name rides the verdict.
274        let first_blocks = vec![
275            (String::from("first"), never.clone()),
276            (String::from("second"), blocking.clone()),
277            (String::from("third"), flagging.clone()),
278        ];
279        assert_eq!(
280            decide_custom_validators(&first_blocks, &ctx),
281            CustomValidatorsVerdict::Failed {
282                block: Some(CustomResponse {
283                    status: Some(403),
284                    body: None
285                }),
286                validator: "second".to_owned()
287            }
288        );
289
290        // The first truthy answer wins even when it cannot block: the
291        // flagging validator's name rides a blockless verdict.
292        let first_flags = vec![
293            (String::from("first"), flagging.clone()),
294            (String::from("second"), blocking.clone()),
295        ];
296        assert_eq!(
297            decide_custom_validators(&first_flags, &ctx),
298            CustomValidatorsVerdict::Failed {
299                block: None,
300                validator: "first".to_owned()
301            }
302        );
303    }
304
305    #[test]
306    fn a_truthy_non_response_logs_but_never_blocks() {
307        let ctx = ctx();
308        let flagging: CustomValidatorFn = Arc::new(|_ctx| Some(ValidatorAnswer::TruthyNonResponse));
309        let validators = vec![(String::from("flagger"), flagging)];
310        assert_eq!(
311            decide_custom_validators(&validators, &ctx),
312            CustomValidatorsVerdict::Failed {
313                block: None,
314                validator: "flagger".to_owned()
315            },
316            "the reference returns the validator's response only when it is a \
317             real GuardResponse; a truthy non-response cannot block"
318        );
319    }
320
321    #[test]
322    fn the_context_carries_the_request_facts() {
323        let seen: CustomValidatorFn = Arc::new(|ctx| {
324            // The validator can decide on the method/path/client_ip.
325            (ctx.path == "/private" && ctx.method == "POST").then_some(ValidatorAnswer::Response(
326                CustomResponse {
327                    status: Some(403),
328                    body: None,
329                },
330            ))
331        });
332        let validators = vec![(String::from("gate"), seen)];
333        assert_eq!(
334            decide_custom_validators(&validators, &ctx()),
335            CustomValidatorsVerdict::Allowed,
336            "GET /private passes the POST-only gate"
337        );
338        let post = CustomRequestContext {
339            method: "POST",
340            path: "/private",
341            client_ip: None,
342            body: None,
343        };
344        assert_eq!(
345            decide_custom_validators(&validators, &post),
346            CustomValidatorsVerdict::Failed {
347                block: Some(CustomResponse {
348                    status: Some(403),
349                    body: None
350                }),
351                validator: "gate".to_owned()
352            },
353            "a real GuardResponse both blocks and carries the response"
354        );
355    }
356
357    #[test]
358    fn anonymous_fallback_name() {
359        assert_eq!(anonymous_name(), "anonymous");
360    }
361}