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}