crates/gunnar-loadbench/src/safety.rs
//! **The safety gate.** The front's Stripe key is `rk_live_`: a checkout this
//! bench opened would be a real charge on a real card. So the bench does not
//! decide at the call site whether a URL is safe — every probe is built through
//! [`SafeGet`], which refuses at CONSTRUCTION, before the target list is even
//! handed to the engine, and the refusal is a hard error that stops the run.
//!
//! Three rules, in this order:
//!
//! 1. **GET only.** There is no other constructor. A POST/PUT/PATCH/DELETE
//! cannot be expressed by this type, so no amount of later editing can
//! make one by accident.
//! 2. **No money word anywhere in the URL.** [`FORBIDDEN`] is matched
//! case-insensitively against the whole URL, path and query alike.
//! 3. **No state-moving verb anywhere in the URL**, even behind GET: the
//! console has GET routes that start work (`/auth/passkey/login/start`
//! mints a webauthn ceremony and stores it), and a load bench must not
//! mint ten thousand of anything.
/// Substrings that are never allowed in a benched URL. Matched
/// case-insensitively against the entire URL.
///
/// The first block is money. `buy.stripe.com` is what `/auth/status` reports as
/// `buy_url`, and `/signup` is the front's own door to it; both are one click
/// from a live charge and neither is ever measured.
///
/// The second block is state: things that create, delete, start or reserve.
/// `start` is in the list because of `/auth/passkey/login/start` specifically —
/// it answers 200 to anyone and looks exactly like a read until you notice it
/// has written a ceremony row per call.
pub const FORBIDDEN: &[&str] = &[
// money
"stripe",
"checkout",
"buy",
"signup",
"purchase",
"invoice",
"billing",
"subscribe",
"entitlement",
"quota",
"plan/set",
// state
"start",
"finish",
"create",
"delete",
"purge",
"undelete",
"reclaim",
"restore",
"reimage",
"grow",
"/gc",
"gate",
"revoke",
"enroll",
"invite",
"logout",
"apply",
"sync",
"resync",
"flush",
"demote",
"abdicate",
"set",
];
/// A URL that has passed the gate. The only way to make one.
#[derive(Debug, Clone)]
pub struct SafeGet(String);
impl SafeGet {
/// Build a GET target, or say exactly which rule refused it.
pub fn new(url: &str) -> Result<SafeGet, String> {
if !(url.starts_with("https://") || url.starts_with("http://")) {
return Err(format!("{url}: not an http(s) URL"));
}
let lower = url.to_ascii_lowercase();
for bad in FORBIDDEN {
if lower.contains(bad) {
return Err(format!(
"{url}: refused by the safety gate — the URL contains {bad:?}. \
This bench measures READ paths only; the front's Stripe key is live."
));
}
}
Ok(SafeGet(url.to_string()))
}
pub fn url(&self) -> &str {
&self.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_health_endpoint_passes() {
assert!(SafeGet::new("https://gunnar.rs/health").is_ok());
}
#[test]
fn a_checkout_is_refused_and_says_why() {
let e = SafeGet::new("https://gunnar.rs/checkout").unwrap_err();
assert!(e.contains("checkout"), "{e}");
}
#[test]
fn the_stripe_buy_url_is_refused() {
assert!(SafeGet::new("https://buy.stripe.com/28E3cvghi1Hd1azci43VC00").is_err());
}
#[test]
fn the_signup_door_is_refused() {
assert!(SafeGet::new("https://gunnar.rs/signup").is_err());
}
/// The trap this list exists for: a 200-answering GET that writes.
#[test]
fn the_passkey_ceremony_is_refused_even_though_it_answers_200_to_anyone() {
assert!(SafeGet::new("https://gunnar.rs/auth/passkey/login/start").is_err());
}
#[test]
fn a_non_http_scheme_is_refused() {
assert!(SafeGet::new("ssh://git.gunnar.rs:2222/nordisk/gunnar").is_err());
}
}