use std::collections::HashMap;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::Arc;
pub type OnErrorHook = Arc<dyn Fn(&str, &str, &[(String, String)]) + Send + Sync>;
use crate::redact::{SensitiveNames, redact_url_for_display};
pub use guard_core_engine::payload::{BlockPayload, ON_BLOCK_EXCLUDED_CHECK_NAMES, OnBlockHook};
pub type CustomErrorResponses = HashMap<u16, String>;
#[must_use]
pub fn resolve_error_body(custom: &CustomErrorResponses, status: u16, default: &str) -> String {
custom
.get(&status)
.cloned()
.unwrap_or_else(|| default.to_owned())
}
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn build_block_payload(
check_name: &str,
reason: &str,
trigger_info: &str,
passive_mode: bool,
client_ip: &str,
path: &str,
method: &str,
status_code: Option<u16>,
sensitive: &SensitiveNames,
) -> BlockPayload {
BlockPayload {
check_name: check_name.to_owned(),
reason: reason.to_owned(),
trigger_info: trigger_info.to_owned(),
passive_mode,
client_ip: client_ip.to_owned(),
path: redact_url_for_display(path, sensitive),
method: method.to_owned(),
status_code,
}
}
pub fn fire_block_hook(hook: Option<&OnBlockHook>, payload: &BlockPayload) {
let Some(hook) = hook else {
return;
};
if ON_BLOCK_EXCLUDED_CHECK_NAMES.contains(&payload.check_name.as_str()) {
return;
}
let _ = catch_unwind(AssertUnwindSafe(|| hook(payload)));
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::Mutex;
fn names() -> SensitiveNames {
SensitiveNames::default()
}
#[test]
fn custom_bodies_override_the_default_only_when_configured() {
let mut custom = CustomErrorResponses::new();
custom.insert(403, "Nope".to_owned());
assert_eq!(
resolve_error_body(&custom, 403, "IP address banned"),
"Nope"
);
assert_eq!(
resolve_error_body(&custom, 429, "Too many requests"),
"Too many requests"
);
}
#[test]
fn payload_matches_the_reference_keys_and_redacts_the_path() {
let payload = build_block_payload(
"rate_limit",
"Rate limit exceeded",
"",
false,
"192.0.2.1",
"/login?token=abc",
"GET",
Some(429),
&names(),
);
assert_eq!(payload.check_name, "rate_limit");
assert_eq!(payload.path, "/login?token=[REDACTED]");
assert_eq!(payload.status_code, Some(429));
assert!(!payload.passive_mode);
}
#[test]
fn passive_payloads_carry_no_status() {
let payload = build_block_payload(
"suspicious_activity",
"Suspicious activity detected",
"trigger",
true,
"192.0.2.1",
"/x",
"GET",
None,
&names(),
);
assert_eq!(payload.status_code, None);
assert!(payload.passive_mode);
}
#[test]
fn excluded_check_names_never_fire() {
let seen = Arc::new(Mutex::new(0_usize));
let sink = seen.clone();
let hook: OnBlockHook = Arc::new(move |_| {
*sink.lock().expect("sink") += 1;
});
let payload = build_block_payload(
"ip_security",
"r",
"",
false,
"ip",
"/p",
"GET",
Some(403),
&names(),
);
fire_block_hook(Some(&hook), &payload);
assert_eq!(*seen.lock().expect("sink"), 1);
for name in ON_BLOCK_EXCLUDED_CHECK_NAMES {
let payload =
build_block_payload(name, "r", "", false, "ip", "/p", "GET", Some(403), &names());
fire_block_hook(Some(&hook), &payload);
}
assert_eq!(*seen.lock().expect("sink"), 1);
}
#[test]
fn excluded_set_matches_the_reference() {
let expected: HashSet<&str> = ["custom_request", "custom_validators", "https_enforcement"]
.into_iter()
.collect();
assert_eq!(
ON_BLOCK_EXCLUDED_CHECK_NAMES
.iter()
.copied()
.collect::<HashSet<&str>>(),
expected
);
}
#[test]
fn a_raising_hook_does_not_propagate() {
let hook: OnBlockHook = Arc::new(|_: &BlockPayload| panic!("hook blew up"));
let payload = build_block_payload(
"rate_limit",
"r",
"",
false,
"ip",
"/p",
"GET",
Some(429),
&names(),
);
fire_block_hook(Some(&hook), &payload);
}
#[test]
fn no_hook_is_a_no_op() {
let payload = build_block_payload(
"rate_limit",
"r",
"",
false,
"ip",
"/p",
"GET",
Some(429),
&names(),
);
fire_block_hook(None, &payload);
}
}