brain_edge/app/handlers/llm_proxy.rs
1//! The BYOK broker: Brain's LLM calls, re-signed with the tenant's own key.
2//!
3//! # Why this exists
4//!
5//! Brain's extraction and HyPE stages need an LLM, and under BYOK the call must
6//! bill to the tenant's provider account. The engine therefore does not hold the
7//! key — it points its provider client's `base_url` here and sends a namespace
8//! instead. This module opens that namespace's sealed credential and re-signs
9//! the request upstream.
10//!
11//! # Why the namespace is in the path
12//!
13//! Brain's provider clients build their URL as
14//! `format!("{}/v1/chat/completions", base_url)` and authenticate with
15//! `.bearer_auth(api_key)`. They cannot set a custom header. So for those
16//! clients to work **unchanged**, both routing inputs have to travel in the two
17//! channels they do control: the namespace in `base_url`, the broker token as
18//! the bearer.
19//!
20//! Pointing `base_url` at `{edge}/llm/ns/{namespace}/openai` therefore resolves
21//! to this module, and the otherwise-redundant-looking path suffix is what
22//! makes a zero-change integration possible.
23//!
24//! A namespace is a tenant identifier, not a secret, and it will appear in
25//! access logs — which is acceptable here and is why the *key* never travels
26//! this way.
27//!
28//! # Threat model — stated precisely, because it is easy to overclaim
29//!
30//! What this buys: **Brain's data at rest contains no provider keys.** Its WAL,
31//! snapshots and backups never see one, so a stolen disk or a leaked backup
32//! yields nothing. The store behind the edge holds ciphertext only, so a
33//! database compromise yields nothing either.
34//!
35//! What it does **not** buy: a fully compromised *running* Brain can ask the
36//! edge for any namespace it can name, and get completions billed to that
37//! tenant. The shared service token authenticates "a Brain", not "this
38//! namespace's Brain".
39//!
40//! **That gap is structural, not a missing feature.** It is tempting to reach
41//! for per-namespace signed grants, but they do not close it:
42//!
43//! - If Brain *requests* a grant using the shared token, a compromised Brain
44//! requests any namespace it likes — exactly the situation we started in.
45//! - If the grant instead rides on the client's ENCODE request, it has to
46//! survive into derivation, which runs **asynchronously after the write is
47//! acknowledged**. Surviving that means persisting it in Brain, which is
48//! precisely the key-at-rest this design exists to avoid.
49//!
50//! The root cause is that a single multi-tenant Brain must be able to obtain a
51//! key for every namespace it processes writes for. No token scheme removes
52//! that. What genuinely limits the blast radius is **detection and bounds**:
53//! every resolution is audit-logged below, and per-namespace spend caps belong
54//! here too. Separating the credential plane from the data plane
55//! (a deployment concern) keeps provider traffic out of the busiest process.
56//!
57//! Recorded at this length because "we'll add signed grants later" reads like a
58//! plan, and acting on it would have produced ceremony with no security gain.
59
60use axum::body::Body;
61use axum::extract::State;
62use axum::http::{HeaderMap, HeaderName, StatusCode};
63use axum::response::Response;
64
65use crate::crypto::SecretBinding;
66use crate::error::ApiError;
67use crate::state::EdgeState;
68
69/// Header carrying the shared token Brain authenticates to the broker with.
70///
71/// Accepted as an alternative to `Authorization: Bearer`, which is what Brain's
72/// own provider clients send. Both are checked so a hand-rolled caller (curl, a
73/// future service) is not forced into the bearer slot.
74pub const PROXY_TOKEN_HEADER: &str = "x-brain-edge-llm-token";
75
76/// Env var holding the broker's shared service token. Unset ⇒ the proxy is off.
77pub const PROXY_TOKEN_ENV: &str = "BRAIN_EDGE_LLM_PROXY_TOKEN";
78
79/// Upstream bases. Not configurable per request: a caller-supplied upstream
80/// would turn this into an open relay that signs arbitrary requests with a
81/// tenant's key.
82const OPENAI_BASE: &str = "https://api.openai.com";
83const ANTHROPIC_BASE: &str = "https://api.anthropic.com";
84
85/// Canonical wire name for OpenAI.
86///
87/// A named constant rather than a literal at each use: this exact string is
88/// the `provider` field of a stored credential, a segment of the broker URL,
89/// **and** part of the AEAD binding the credential is sealed under. A typo in
90/// any one place is a runtime decrypt failure, not a compile error.
91pub const PROVIDER_OPENAI: &str = "openai";
92/// Canonical wire name for Anthropic. See [`PROVIDER_OPENAI`].
93pub const PROVIDER_ANTHROPIC: &str = "anthropic";
94
95/// Path prefix the broker routes live under.
96///
97/// Kept beside [`Upstream`] because `brain-llm`'s `BrokerResolver` builds
98/// `{edge}/llm/ns/{namespace}/{provider}` to match. The two live in separate
99/// repos, so neither can reference the other's constant — a test on each side
100/// pins the shape instead.
101pub const BROKER_PATH_PREFIX: &str = "/llm/ns";
102
103/// Which upstream a route serves.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum Upstream {
106 /// OpenAI's chat-completions API.
107 OpenAi,
108 /// Anthropic's messages API.
109 Anthropic,
110}
111
112impl Upstream {
113 /// Every upstream this edge can serve. The single source of truth: the
114 /// accepted-provider list and the credential validator both derive from
115 /// it, so adding a provider cannot leave one of them behind.
116 pub const ALL: [Self; 2] = [Self::OpenAi, Self::Anthropic];
117
118 /// Canonical wire name.
119 #[must_use]
120 pub const fn provider(self) -> &'static str {
121 match self {
122 Self::OpenAi => PROVIDER_OPENAI,
123 Self::Anthropic => PROVIDER_ANTHROPIC,
124 }
125 }
126
127 /// Parse a canonical provider name. The inverse of [`Self::provider`].
128 #[must_use]
129 pub fn from_name(name: &str) -> Option<Self> {
130 Self::ALL.into_iter().find(|u| u.provider() == name)
131 }
132
133 const fn base(self) -> &'static str {
134 match self {
135 Self::OpenAi => OPENAI_BASE,
136 Self::Anthropic => ANTHROPIC_BASE,
137 }
138 }
139}
140
141/// Compare the presented token against the configured one in constant time.
142///
143/// A byte-wise `==` short-circuits on the first differing byte, leaking the
144/// length of the shared prefix through timing — enough to recover a token given
145/// enough attempts.
146///
147/// `subtle`, not `ring::constant_time::verify_slices_are_equal`: the latter is
148/// deprecated and documents "no promises regarding side channels", which is the
149/// single promise this function needs.
150fn token_matches(presented: &str, configured: &str) -> bool {
151 use subtle::ConstantTimeEq;
152 // Length is compared non-secretly: a constant-time compare needs equal
153 // lengths, and the length of a shared deployment token is not the secret.
154 presented.len() == configured.len()
155 && bool::from(presented.as_bytes().ct_eq(configured.as_bytes()))
156}
157
158/// Authenticate the caller against the configured broker token.
159fn authenticate(headers: &HeaderMap) -> Result<(), ApiError> {
160 authenticate_with(std::env::var(PROXY_TOKEN_ENV).ok().as_deref(), headers)
161}
162
163/// The token the caller presented, from either accepted channel.
164fn presented_token(headers: &HeaderMap) -> &str {
165 if let Some(v) = headers
166 .get(HeaderName::from_static(PROXY_TOKEN_HEADER))
167 .and_then(|v| v.to_str().ok())
168 {
169 return v.trim();
170 }
171 headers
172 .get(axum::http::header::AUTHORIZATION)
173 .and_then(|v| v.to_str().ok())
174 .map(str::trim)
175 .and_then(|v| {
176 v.strip_prefix("Bearer ")
177 .or_else(|| v.strip_prefix("bearer "))
178 })
179 .map(str::trim)
180 .unwrap_or_default()
181}
182
183/// The testable core of [`authenticate`]: the configured token is a parameter
184/// rather than an environment read, so the auth rules can be tested without
185/// mutating process state (which this crate forbids — `unsafe` is denied, and
186/// `set_var` is unsafe in edition 2024).
187fn authenticate_with(configured: Option<&str>, headers: &HeaderMap) -> Result<(), ApiError> {
188 let Some(configured) = configured.map(str::trim).filter(|s| !s.is_empty()) else {
189 return Err(ApiError::not_implemented(
190 "the BYOK LLM broker is not enabled on this edge",
191 ));
192 };
193 if !token_matches(presented_token(headers), configured) {
194 // Deliberately identical to the missing-token case: distinguishing
195 // them would confirm that a token was *shaped* right.
196 return Err(ApiError::unauthorized("broker token missing or invalid"));
197 }
198 Ok(())
199}
200
201/// `POST /llm/openai/v1/chat/completions`
202///
203/// # Errors
204/// See [`proxy`].
205pub async fn openai_chat_completions(
206 State(state): State<EdgeState>,
207 axum::extract::Path(namespace): axum::extract::Path<String>,
208 headers: HeaderMap,
209 body: axum::body::Bytes,
210) -> Result<Response, ApiError> {
211 proxy(
212 &state,
213 &headers,
214 &namespace,
215 Upstream::OpenAi,
216 "/v1/chat/completions",
217 body,
218 )
219 .await
220}
221
222/// `POST /llm/anthropic/v1/messages`
223///
224/// # Errors
225/// See [`proxy`].
226pub async fn anthropic_messages(
227 State(state): State<EdgeState>,
228 axum::extract::Path(namespace): axum::extract::Path<String>,
229 headers: HeaderMap,
230 body: axum::body::Bytes,
231) -> Result<Response, ApiError> {
232 proxy(
233 &state,
234 &headers,
235 &namespace,
236 Upstream::Anthropic,
237 "/v1/messages",
238 body,
239 )
240 .await
241}
242
243/// `GET /llm/ns/{namespace}/resolve` — what credential serves this namespace.
244///
245/// Brain's resolver calls this to learn which provider and model to build a
246/// client for, and to find out whether a credential exists at all. It returns
247/// **no key material** — only what Brain needs to route, which is why Brain can
248/// hold the answer in memory without that being a key at rest.
249///
250/// # Errors
251/// `401` on a bad broker token, `409 llm_credential.missing` when the namespace
252/// has no key, `501` when BYOK or the broker is unconfigured.
253pub async fn resolve(
254 State(state): State<EdgeState>,
255 axum::extract::Path(namespace): axum::extract::Path<String>,
256 headers: HeaderMap,
257) -> Result<axum::response::Json<crate::dto::llm_credential::ResolvedLlmDto>, ApiError> {
258 authenticate(&headers)?;
259 let store = state.llm_credentials().ok_or_else(|| {
260 ApiError::not_implemented("BYOK is not configured on this edge: no key-encryption key")
261 })?;
262 let record = store.get(&namespace).await?.ok_or_else(|| {
263 ApiError::llm_credential_missing(format!(
264 "no LLM provider key is registered for namespace {namespace}"
265 ))
266 })?;
267 tracing::info!(
268 target: "brain_edge::llm_audit",
269 namespace = %namespace,
270 provider = %record.provider,
271 "BYOK: credential resolved for a namespace",
272 );
273 Ok(axum::response::Json(
274 crate::dto::llm_credential::ResolvedLlmDto {
275 provider: record.provider,
276 model: record.model,
277 },
278 ))
279}
280
281/// Open the namespace's key and re-sign the request upstream.
282///
283/// # Errors
284/// `401` on a bad broker token, `409` when the tenant's registered provider is
285/// not the one this route serves, `501` when BYOK or the broker is
286/// unconfigured, `502` when the upstream cannot be reached. A missing
287/// credential is `409 llm_credential.missing`, matching what the data plane
288/// returns for the same condition so one code means one thing everywhere.
289#[allow(clippy::too_many_arguments)]
290async fn proxy(
291 state: &EdgeState,
292 headers: &HeaderMap,
293 namespace: &str,
294 upstream: Upstream,
295 path: &str,
296 body: axum::body::Bytes,
297) -> Result<Response, ApiError> {
298 authenticate(headers)?;
299
300 let store = state.llm_credentials().ok_or_else(|| {
301 ApiError::not_implemented("BYOK is not configured on this edge: no key-encryption key")
302 })?;
303 let record = store.get(namespace).await?.ok_or_else(|| {
304 ApiError::llm_credential_missing(format!(
305 "no LLM provider key is registered for namespace {namespace}"
306 ))
307 })?;
308
309 // A tenant registered for one provider must not have their key replayed
310 // against another: the AAD would reject it anyway, but failing here gives
311 // an actionable error instead of an opaque decrypt failure.
312 if record.provider != upstream.provider() {
313 return Err(ApiError::conflict(format!(
314 "namespace {namespace} is registered for `{}`, not `{}`",
315 record.provider,
316 upstream.provider()
317 )));
318 }
319
320 let key = state.open_llm_key(
321 &record.sealed,
322 SecretBinding {
323 namespace,
324 provider: upstream.provider(),
325 },
326 )?;
327 let key = std::str::from_utf8(&key)
328 .map_err(|_| ApiError::internal("stored provider key is not valid UTF-8"))?;
329
330 // Audit trail. Since a compromised Brain can name any namespace (see the
331 // module docs), detection is the control that actually applies — an
332 // unexpected namespace appearing here is the signal. Logged before the
333 // upstream call so an attempt is recorded even if the call then fails.
334 tracing::info!(
335 target: "brain_edge::llm_audit",
336 namespace = %namespace,
337 provider = upstream.provider(),
338 fingerprint = %record.fingerprint,
339 "BYOK: provider key used for an upstream call",
340 );
341
342 let url = format!("{}{path}", upstream.base());
343 let client = reqwest::Client::new();
344 let mut req = client
345 .post(&url)
346 .header("content-type", "application/json")
347 .body(body.to_vec());
348
349 // Each provider authenticates differently. The tenant's key is attached
350 // here and nowhere else; the inbound request never carried it.
351 req = match upstream {
352 Upstream::OpenAi => req.header("authorization", format!("Bearer {key}")),
353 Upstream::Anthropic => req
354 .header("x-api-key", key)
355 .header("anthropic-version", anthropic_version(headers)),
356 };
357
358 let resp = req.send().await.map_err(|e| {
359 // The error is logged, not returned: a reqwest error can embed the URL
360 // and headers, and these headers carry the tenant's key.
361 tracing::warn!(
362 namespace = %namespace,
363 provider = upstream.provider(),
364 error = %e,
365 "LLM upstream request failed",
366 );
367 ApiError::bad_gateway("LLM provider request failed")
368 })?;
369
370 let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
371 let content_type = resp
372 .headers()
373 .get(reqwest::header::CONTENT_TYPE)
374 .and_then(|v| v.to_str().ok())
375 .unwrap_or("application/json")
376 .to_string();
377
378 // Stream the body through rather than buffering: a streamed completion is
379 // the common case and buffering it would both add latency and let one
380 // large response pin memory.
381 let stream = resp.bytes_stream();
382 let mut out = Response::builder()
383 .status(status)
384 .header("content-type", content_type);
385 // Only the status and content type are forwarded. Upstream headers are NOT
386 // echoed wholesale: they can carry provider-side rate-limit and
387 // organisation identifiers belonging to the tenant's account.
388 if status.is_success() {
389 out = out.header("x-brain-llm-provider", upstream.provider());
390 }
391 out.body(Body::from_stream(stream))
392 .map_err(|e| ApiError::internal(format!("build proxy response: {e}")))
393}
394
395/// Anthropic requires an API version header. Pass the caller's through when
396/// present so Brain controls it, with a pinned default otherwise.
397fn anthropic_version(headers: &HeaderMap) -> String {
398 headers
399 .get("anthropic-version")
400 .and_then(|v| v.to_str().ok())
401 .map(str::trim)
402 .filter(|s| !s.is_empty())
403 .unwrap_or("2023-06-01")
404 .to_string()
405}
406
407/// Outcome of probing a provider key before storing it.
408///
409/// The three-way split mirrors the credential-resolution one, for the same
410/// reason: "the provider says this key is bad" and "I could not reach the
411/// provider" demand opposite responses, and collapsing them would either
412/// accept dead keys during an outage or refuse good ones.
413#[derive(Debug, Clone, PartialEq, Eq)]
414pub enum KeyCheck {
415 /// The provider accepted the key.
416 Valid,
417 /// The provider rejected it — a typo or a revoked key.
418 Rejected(String),
419 /// The provider could not be reached, or answered something unexpected.
420 Unreachable(String),
421}
422
423/// Classify a probe response status.
424///
425/// Only `401`/`403` are treated as "this key is bad". A `429` or `5xx` says
426/// nothing about the key — rejecting on those would make key registration fail
427/// whenever the provider is rate-limiting or having an incident.
428fn classify_probe(status: u16) -> KeyCheck {
429 match status {
430 200..=299 => KeyCheck::Valid,
431 401 | 403 => KeyCheck::Rejected(format!("the provider rejected this key ({status})")),
432 other => KeyCheck::Unreachable(format!("provider returned {other} while verifying")),
433 }
434}
435
436/// Probe a provider key with a cheap read-only call before storing it.
437///
438/// Without this a typo'd key is accepted happily and then fails on every write
439/// with an opaque upstream `401` that looks like a Brain bug. Catching it at
440/// registration puts the error where the mistake was made.
441///
442/// `GET /v1/models` on both providers: read-only, no tokens billed, and it
443/// exercises exactly the credential path a real call would.
444pub async fn validate_key(upstream: Upstream, key: &str) -> KeyCheck {
445 let url = format!("{}/v1/models", upstream.base());
446 let client = reqwest::Client::new();
447 let req = match upstream {
448 Upstream::OpenAi => client.get(&url).bearer_auth(key),
449 Upstream::Anthropic => client
450 .get(&url)
451 .header("x-api-key", key)
452 .header("anthropic-version", "2023-06-01"),
453 };
454
455 match req.send().await {
456 Ok(resp) => classify_probe(resp.status().as_u16()),
457 Err(e) => {
458 // Logged, not returned: a reqwest error can embed the request
459 // headers, which carry the key being verified.
460 tracing::warn!(
461 target: "brain_edge::llm_proxy",
462 provider = upstream.provider(),
463 error = %e,
464 "could not reach provider to verify key",
465 );
466 KeyCheck::Unreachable("could not reach the provider to verify this key".into())
467 }
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 #[test]
476 fn token_comparison_rejects_wrong_and_accepts_right() {
477 assert!(token_matches("s3cret", "s3cret"));
478 assert!(!token_matches("s3cret", "s3creT"));
479 assert!(!token_matches("", "s3cret"));
480 assert!(!token_matches("s3cret", ""));
481 assert!(!token_matches("s3cret-longer", "s3cret"));
482 }
483
484 #[test]
485 fn upstream_bases_are_fixed() {
486 // A caller-supplied upstream would make this an open relay signing
487 // arbitrary requests with a tenant's key.
488 assert_eq!(Upstream::OpenAi.base(), "https://api.openai.com");
489 assert_eq!(Upstream::Anthropic.base(), "https://api.anthropic.com");
490 }
491
492 #[test]
493 fn provider_names_round_trip() {
494 // The name is the stored `provider` field AND part of the sealing
495 // AAD, so the two directions disagreeing means credentials stop
496 // opening.
497 for u in Upstream::ALL {
498 assert_eq!(Upstream::from_name(u.provider()), Some(u));
499 }
500 assert_eq!(Upstream::from_name("cohere"), None);
501 }
502
503 #[test]
504 fn the_broker_path_matches_what_brain_builds() {
505 // `brain-llm`'s BrokerResolver constructs
506 // `{edge}/llm/ns/{namespace}/{provider}` and appends the provider's
507 // own suffix. Separate repos, so this pins our half of the contract.
508 assert_eq!(BROKER_PATH_PREFIX, "/llm/ns");
509 assert_eq!(
510 format!(
511 "{BROKER_PATH_PREFIX}/acme/{}/v1/chat/completions",
512 Upstream::OpenAi.provider()
513 ),
514 "/llm/ns/acme/openai/v1/chat/completions"
515 );
516 assert_eq!(
517 format!(
518 "{BROKER_PATH_PREFIX}/acme/{}/v1/messages",
519 Upstream::Anthropic.provider()
520 ),
521 "/llm/ns/acme/anthropic/v1/messages"
522 );
523 }
524
525 #[test]
526 fn anthropic_version_passes_through_or_defaults() {
527 let mut h = HeaderMap::new();
528 assert_eq!(anthropic_version(&h), "2023-06-01");
529 h.insert("anthropic-version", "2024-10-22".parse().unwrap());
530 assert_eq!(anthropic_version(&h), "2024-10-22");
531 h.insert("anthropic-version", " ".parse().unwrap());
532 assert_eq!(anthropic_version(&h), "2023-06-01", "blank falls back");
533 }
534
535 fn headers_with(token: &str) -> HeaderMap {
536 let mut h = HeaderMap::new();
537 if !token.is_empty() {
538 h.insert(PROXY_TOKEN_HEADER, token.parse().unwrap());
539 }
540 h
541 }
542
543 fn bearer_headers(token: &str) -> HeaderMap {
544 let mut h = HeaderMap::new();
545 h.insert(
546 axum::http::header::AUTHORIZATION,
547 format!("Bearer {token}").parse().unwrap(),
548 );
549 h
550 }
551
552 #[test]
553 fn an_unconfigured_broker_is_501_not_401() {
554 // The deployment has not turned the broker on, which is a different
555 // problem from a bad credential — and a 401 would send an operator
556 // hunting for a wrong token that does not exist.
557 for configured in [None, Some(""), Some(" ")] {
558 let err = authenticate_with(configured, &headers_with("t")).unwrap_err();
559 assert_eq!(err.status(), StatusCode::NOT_IMPLEMENTED);
560 }
561 }
562
563 #[test]
564 fn a_wrong_or_missing_token_is_rejected_identically() {
565 // Same status and message either way: distinguishing them confirms a
566 // token was shaped right, which is a probing aid.
567 let missing = authenticate_with(Some("real"), &headers_with("")).unwrap_err();
568 let wrong = authenticate_with(Some("real"), &headers_with("nope")).unwrap_err();
569 assert_eq!(missing.status(), StatusCode::UNAUTHORIZED);
570 assert_eq!(wrong.status(), StatusCode::UNAUTHORIZED);
571 assert_eq!(missing.message(), wrong.message());
572 }
573
574 #[test]
575 fn a_valid_token_authenticates_via_either_channel() {
576 // Brain's provider clients can only send a bearer; a hand-rolled caller
577 // may prefer the explicit header. Both must work.
578 assert!(authenticate_with(Some("real"), &headers_with("real")).is_ok());
579 assert!(authenticate_with(Some("real"), &bearer_headers("real")).is_ok());
580 }
581
582 #[test]
583 fn the_explicit_header_wins_over_a_bearer() {
584 // Deterministic precedence: a caller sending both should not get a
585 // result that depends on header iteration order.
586 let mut h = bearer_headers("real");
587 h.insert(PROXY_TOKEN_HEADER, "wrong".parse().unwrap());
588 assert!(authenticate_with(Some("real"), &h).is_err());
589 }
590
591 #[test]
592 fn a_bearer_without_the_prefix_is_not_accepted() {
593 let mut h = HeaderMap::new();
594 h.insert(axum::http::header::AUTHORIZATION, "real".parse().unwrap());
595 assert!(authenticate_with(Some("real"), &h).is_err());
596 }
597}
598
599#[cfg(test)]
600mod key_check_tests {
601 use super::*;
602
603 #[test]
604 fn only_auth_statuses_mean_the_key_is_bad() {
605 assert_eq!(classify_probe(200), KeyCheck::Valid);
606 assert_eq!(classify_probe(204), KeyCheck::Valid);
607 assert!(matches!(classify_probe(401), KeyCheck::Rejected(_)));
608 assert!(matches!(classify_probe(403), KeyCheck::Rejected(_)));
609 }
610
611 #[test]
612 fn transient_statuses_do_not_condemn_the_key() {
613 // Rejecting on these would make key registration fail whenever the
614 // provider is rate-limiting or having an incident — the user would
615 // conclude their perfectly good key is invalid.
616 for status in [429, 500, 502, 503, 504] {
617 assert!(
618 matches!(classify_probe(status), KeyCheck::Unreachable(_)),
619 "{status} says nothing about the key"
620 );
621 }
622 }
623
624 #[test]
625 fn an_unexpected_status_is_unreachable_not_rejected() {
626 // Fail open on the unknown: a 404 means the probe endpoint moved, not
627 // that the operator's key is wrong.
628 assert!(matches!(classify_probe(404), KeyCheck::Unreachable(_)));
629 assert!(matches!(classify_probe(418), KeyCheck::Unreachable(_)));
630 }
631
632 #[test]
633 fn no_check_message_can_contain_a_key() {
634 // These strings are returned to the caller and logged.
635 for c in [
636 classify_probe(401),
637 classify_probe(500),
638 classify_probe(200),
639 ] {
640 let text = format!("{c:?}");
641 assert!(!text.contains("sk-"), "{text}");
642 }
643 }
644}