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}