Skip to main content

broadcast_auth/
signed_url.rs

1//! HMAC-signed URL access control (issue #747) — CDN-style, short-lived,
2//! tamper-proof query-string tokens that gate a media egress route without
3//! the caller carrying a credential header at all.
4//!
5//! # Wire form
6//!
7//! Query parameters on the media URL:
8//!
9//! ```text
10//! ?exp=<unix-seconds>&kid=<key-id>&sig=<base64url-nopad>[&ip=<addr>]
11//! ```
12//!
13//! The query is `application/x-www-form-urlencoded`: [`SignedUrlKeySet::sign`]
14//! percent-encodes every value (a `kid` with `&`, `=`, `%`, `+` or a space, and
15//! an IPv6 `ip=2001%3Adb8%3A%3A1`), and verification percent-decodes them, so a
16//! value can never smuggle in an extra parameter. The first occurrence of a key
17//! wins; a bare key with no `=` has the empty value. Decoding is lossy
18//! (`form_urlencoded`): different spellings of a value can read the same
19//! (`%FF` and `%EF%BF%BD` both read as U+FFFD, `%65xp` reads as `exp`); only
20//! the signed fields (path, `exp`, `ip`) matter to the signature and the
21//! first-wins rule is unchanged, so this opens no bypass.
22//!
23//! - `exp` — an absolute Unix timestamp (seconds). The token is invalid once
24//!   `now > exp`; there is no clock-skew grace period, by design (the caller
25//!   sets the window when minting the token).
26//! - `kid` — selects which of [`SignedUrlKeySet`]'s configured secrets
27//!   verifies this token, so keys can rotate (multiple stay valid at once)
28//!   without invalidating URLs already handed out under an older key. An
29//!   unrecognised `kid` is a rejection, not a fallback to any other key.
30//! - `sig` — HMAC-SHA256 over the canonical string below, base64url-encoded
31//!   **without padding** (RFC 4648 §5, no trailing `=`).
32//! - `ip` — optional. When present, it must equal the connection's actual
33//!   peer address (compared as an [`core::net::IpAddr`], ignoring any port —
34//!   see [`Verifier::verify`](crate::Verifier::verify)'s signed-URL arm);
35//!   when absent, no IP check is made. Either way it is part of the signed
36//!   string, so an attacker can neither add nor strip it from a token they
37//!   didn't mint.
38//!
39//! # Canonical string
40//!
41//! The exact newline-separated bytes the HMAC is computed over, in this
42//! order:
43//!
44//! ```text
45//! <path>\n<exp>\n<ip-or-empty-string>
46//! ```
47//!
48//! `<path>` is the request path *without* its query string (whatever the
49//! caller passes to [`SignedUrlKeySet::sign`] — for a `broadcast_auth`-gated
50//! origin, the same pre-rewrite request path a
51//! [`crate::RequestContext::uri`] carries). **The path is always signed.**
52//! Without it, a token minted for one route would verify against every other
53//! route the same keyset gates — the entire point of naming the resource in
54//! the signature. `<ip-or-empty-string>` is the canonical [`core::net::IpAddr`]
55//! `Display` form (not the raw query-string bytes — see
56//! [`SignedUrlKeySet::sign`]'s docs for why), or an empty string when no `ip`
57//! is bound.
58//!
59//! # Security properties
60//!
61//! - **Constant-time compare**: the decoded `sig` bytes are compared against
62//!   the freshly recomputed HMAC via [`subtle::ConstantTimeEq`] — never a
63//!   short-circuiting `==` on a MAC (a classic timing oracle).
64//! - **Uniform rejection**: [`crate::Verifier::verify`] folds every failure
65//!   mode of this scheme (missing/unparseable `exp`, unknown `kid`,
66//!   missing/malformed `sig`, expired, wrong signature, wrong `ip`) into the
67//!   same [`crate::AuthResult::Unauthorized`] — nothing here tells a caller
68//!   *which* check failed.
69//! - **Minimum secret length**: [`SignedUrlKeySet::new`] rejects any secret
70//!   shorter than [`SignedUrlKeySet::MIN_SECRET_LEN`] at construction time
71//!   (a config/setup error), not per-request.
72//! - **No secret/signature logging**: neither this module nor
73//!   [`SignedUrlKeySet`]'s `Debug` impl ever renders a secret or a minted
74//!   signature.
75
76use core::net::IpAddr;
77use std::borrow::Cow;
78
79use base64::Engine;
80use base64::engine::general_purpose::URL_SAFE_NO_PAD;
81use hmac::{Hmac, KeyInit, Mac};
82use sha2::Sha256;
83use subtle::ConstantTimeEq;
84
85use crate::error::{Error, Result};
86use crate::request::RequestContext;
87
88/// One or more `(kid, secret)` HMAC keys valid for signed-URL verification —
89/// see the module docs for the wire form and canonical string, and
90/// [`crate::Verifier::signed_url`] for wiring one into a [`crate::Verifier`].
91///
92/// Multiple keys may be active simultaneously (key rotation, issue #747):
93/// verification looks the token's `kid` up in this set and rejects an
94/// unrecognised one rather than falling back to any other configured key.
95pub struct SignedUrlKeySet {
96    keys: Vec<(String, Vec<u8>)>,
97}
98
99impl SignedUrlKeySet {
100    /// The minimum accepted secret length, in bytes (32 — enough entropy that
101    /// brute-forcing the HMAC key is infeasible). Enforced by [`Self::new`]
102    /// at construction time, never per-request.
103    pub const MIN_SECRET_LEN: usize = 32;
104
105    /// Builds a keyset from `(kid, secret)` pairs.
106    ///
107    /// Rejects (before constructing anything) the first key whose `secret`
108    /// is shorter than [`Self::MIN_SECRET_LEN`] bytes, with
109    /// [`Error::SignedUrlKeyTooShort`] naming the offending `kid` and
110    /// lengths — this is a setup/config-time error, not something a request
111    /// can trigger.
112    ///
113    /// Duplicate `kid`s are not rejected; the internal lookup (used by both
114    /// [`Self::sign`] and verification) returns the *first* match, so a
115    /// duplicate is effectively shadowed rather than causing ambiguity.
116    pub fn new(keys: impl IntoIterator<Item = (String, Vec<u8>)>) -> Result<Self> {
117        let keys: Vec<(String, Vec<u8>)> = keys.into_iter().collect();
118        for (kid, secret) in &keys {
119            if secret.len() < Self::MIN_SECRET_LEN {
120                return Err(Error::SignedUrlKeyTooShort {
121                    kid: kid.clone(),
122                    min: Self::MIN_SECRET_LEN,
123                    actual: secret.len(),
124                });
125            }
126        }
127        Ok(SignedUrlKeySet { keys })
128    }
129
130    fn secret_for(&self, kid: &str) -> Option<&[u8]> {
131        self.keys
132            .iter()
133            .find(|(k, _)| k == kid)
134            .map(|(_, s)| s.as_slice())
135    }
136
137    /// Signing helper: mints the query-string portion of a signed URL for
138    /// `kid`/`path`/`exp`/`ip` — `?`-prefix and the resource path are the
139    /// caller's own to assemble (e.g. `format!("{path}?{query}")`), since
140    /// this returns just the `exp=...&kid=...&sig=...[&ip=...]` part.
141    ///
142    /// `path` must be exactly the request path the eventual request's
143    /// [`RequestContext::uri`] carries (sans query string) — see the module
144    /// docs' canonical-string rule. `ip`, if given, is rendered via
145    /// [`IpAddr`]'s canonical `Display` form in both the signed string and
146    /// the `ip` query parameter — not whatever string representation a
147    /// caller might otherwise have on hand — so that
148    /// [`crate::Verifier::verify`]'s re-parse-then-re-render of the `ip` it
149    /// receives back always reproduces the exact bytes that were signed.
150    ///
151    /// Fails with [`Error::UnknownSignedUrlKeyId`] if `kid` is not in this
152    /// keyset. This is the *signing* side (used by tests and by whatever
153    /// mints tokens for real clients) — [`crate::Verifier::verify`] never
154    /// returns this error; an unknown `kid` there is folded into the same
155    /// [`crate::AuthResult::Unauthorized`] as every other rejection reason.
156    pub fn sign(&self, kid: &str, path: &str, exp: u64, ip: Option<IpAddr>) -> Result<String> {
157        let secret = self
158            .secret_for(kid)
159            .ok_or_else(|| Error::UnknownSignedUrlKeyId(kid.to_string()))?;
160        let sig = URL_SAFE_NO_PAD.encode(hmac_sha256(secret, &canonical_string(path, exp, ip)));
161        let mut query = form_urlencoded::Serializer::new(String::new());
162        query.append_pair("exp", &exp.to_string());
163        query.append_pair("kid", kid);
164        query.append_pair("sig", &sig);
165        if let Some(ip) = ip {
166            query.append_pair("ip", &ip.to_string());
167        }
168        Ok(query.finish())
169    }
170}
171
172/// Manual `Debug` (rather than `#[derive(Debug)]`): every entry carries a
173/// secret that must never render verbatim — only the configured `kid`s are
174/// shown, which is exactly what's useful for diagnosing a rotation.
175impl core::fmt::Debug for SignedUrlKeySet {
176    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
177        f.debug_struct("SignedUrlKeySet")
178            .field(
179                "kids",
180                &self
181                    .keys
182                    .iter()
183                    .map(|(k, _)| k.as_str())
184                    .collect::<Vec<_>>(),
185            )
186            .finish()
187    }
188}
189
190/// The exact newline-separated bytes the HMAC is computed over — see the
191/// module docs' "Canonical string" section. `ip` is rendered via
192/// [`IpAddr`]'s `Display` (canonical form), never the raw query-string
193/// bytes, so sign and verify always agree on the same representation.
194fn canonical_string(path: &str, exp: u64, ip: Option<IpAddr>) -> String {
195    match ip {
196        Some(ip) => format!("{path}\n{exp}\n{ip}"),
197        None => format!("{path}\n{exp}\n"),
198    }
199}
200
201/// HMAC-SHA256(`secret`, `message`) — `new_from_slice` never fails for HMAC
202/// (it accepts a key of any length, padding/hashing it per RFC 2104 §2 if
203/// need be), so the only way this could panic is a `hmac`/`sha2` internal
204/// bug, not anything caller-controlled.
205fn hmac_sha256(secret: &[u8], message: &str) -> Vec<u8> {
206    let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(secret)
207        .expect("HMAC accepts a key of any length");
208    mac.update(message.as_bytes());
209    mac.finalize().into_bytes().to_vec()
210}
211
212/// Splits `uri` (a [`RequestContext::uri`], e.g. `/stream/media.m3u8?exp=…`)
213/// into `(path, query)`; `query` is `""` when there is no `?` at all.
214fn split_path_and_query(uri: &str) -> (&str, &str) {
215    match uri.split_once('?') {
216        Some((path, query)) => (path, query),
217        None => (uri, ""),
218    }
219}
220
221/// The first value for `key` in an `application/x-www-form-urlencoded` query,
222/// percent-decoded; a pair with no `=` has the value `""`.
223fn query_get<'q>(query: &'q str, key: &str) -> Option<Cow<'q, str>> {
224    form_urlencoded::parse(query.as_bytes())
225        .find(|(k, _)| k == key)
226        .map(|(_, v)| v)
227}
228
229/// The current wall-clock time as Unix seconds. `unwrap_or(0)` on a
230/// pre-epoch clock is unreachable on any real system but is not itself a
231/// security-relevant fallback: it can only make [`verify`] *stricter*
232/// (`0` fails every non-zero `exp` as already expired), never laxer.
233fn current_unix_time() -> u64 {
234    std::time::SystemTime::now()
235        .duration_since(std::time::UNIX_EPOCH)
236        .map(|d| d.as_secs())
237        .unwrap_or(0)
238}
239
240/// Verifies a signed-URL token carried in `ctx.uri`'s query string against
241/// `keys` — the implementation behind
242/// [`crate::Verifier::verify`]'s `SignedUrl` arm. See the module docs for
243/// the wire form, canonical string, and rejection semantics.
244///
245/// Every one of `exp`/`kid`/`sig` missing or malformed, `kid` unknown, `sig`
246/// undecodable, or `ip` unparseable is treated identically: `false`. Both the
247/// expiry check and the signature check are always computed (into their own
248/// `bool`s) before being combined — neither is allowed to gate access on its
249/// own; a correctly-signed-but-expired token and a fresh-but-wrongly-signed
250/// token are both rejected.
251pub(crate) fn verify(ctx: &RequestContext<'_>, keys: &SignedUrlKeySet) -> bool {
252    let (path, query) = split_path_and_query(ctx.uri);
253
254    let Some(exp) = query_get(query, "exp").and_then(|s| s.parse::<u64>().ok()) else {
255        return false;
256    };
257    let Some(kid) = query_get(query, "kid").filter(|s| !s.is_empty()) else {
258        return false;
259    };
260    let Some(sig) = query_get(query, "sig").filter(|s| !s.is_empty()) else {
261        return false;
262    };
263    let Some(secret) = keys.secret_for(&kid) else {
264        return false;
265    };
266    let ip: Option<IpAddr> = match query_get(query, "ip") {
267        Some(raw) => match raw.parse::<IpAddr>() {
268            Ok(ip) => Some(ip),
269            Err(_) => return false,
270        },
271        None => None,
272    };
273    let Ok(decoded_sig) = URL_SAFE_NO_PAD.decode(sig.as_bytes()) else {
274        return false;
275    };
276
277    let expected = hmac_sha256(secret, &canonical_string(path, exp, ip));
278    let sig_ok = bool::from(decoded_sig.as_slice().ct_eq(expected.as_slice()));
279    let not_expired = current_unix_time() <= exp;
280    let ip_ok = match ip {
281        Some(bound_ip) => ctx.peer_addr.map(|p| p.ip()) == Some(bound_ip),
282        None => true,
283    };
284
285    sig_ok && not_expired && ip_ok
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::{AuthResult, Verifier};
292
293    const SECRET_A: &[u8; 32] = b"01234567890123456789012345678901";
294    const SECRET_B: &[u8; 32] = b"abcdefghijabcdefghijabcdefghij01";
295
296    fn keyset() -> SignedUrlKeySet {
297        SignedUrlKeySet::new([
298            ("key-a".to_string(), SECRET_A.to_vec()),
299            ("key-b".to_string(), SECRET_B.to_vec()),
300        ])
301        .unwrap()
302    }
303
304    fn far_future() -> u64 {
305        current_unix_time() + 3600
306    }
307
308    fn ctx_for(uri: &str) -> RequestContext<'_> {
309        RequestContext::new("GET", uri)
310    }
311
312    // --- construction ---
313
314    #[test]
315    fn new_rejects_secret_shorter_than_min_len() {
316        let err = SignedUrlKeySet::new([("k".to_string(), vec![0u8; 31])]).unwrap_err();
317        assert!(matches!(
318            err,
319            Error::SignedUrlKeyTooShort {
320                min: 32,
321                actual: 31,
322                ..
323            }
324        ));
325    }
326
327    #[test]
328    fn new_accepts_exactly_min_len_secret() {
329        SignedUrlKeySet::new([("k".to_string(), vec![0u8; 32])]).unwrap();
330    }
331
332    // --- sign/verify round trip (1: valid + unexpired -> allowed) ---
333
334    #[test]
335    fn valid_signature_and_unexpired_is_allowed() {
336        let keys = keyset();
337        let verifier = Verifier::signed_url(keyset());
338        let exp = far_future();
339        let query = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
340        let uri = format!("/stream/media.m3u8?{query}");
341        assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Ok);
342    }
343
344    // --- 2: valid signature + expired -> denied ---
345
346    #[test]
347    fn valid_signature_but_expired_is_denied() {
348        let keys = keyset();
349        let verifier = Verifier::signed_url(keyset());
350        let exp = current_unix_time().saturating_sub(60);
351        let query = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
352        let uri = format!("/stream/media.m3u8?{query}");
353        assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
354    }
355
356    // --- 3: cross-route replay (path substitution) -> denied ---
357    // The single most important test: a signature minted for one route must
358    // not verify against a different route on the same origin/keyset.
359
360    #[test]
361    fn signature_minted_for_one_path_is_rejected_on_another_path() {
362        let keys = keyset();
363        let verifier = Verifier::signed_url(keyset());
364        let exp = far_future();
365        let query = keys
366            .sign("key-a", "/route-a/media.m3u8", exp, None)
367            .unwrap();
368        // Same query string, replayed against a different route's path.
369        let uri = format!("/route-b/media.m3u8?{query}");
370        assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
371    }
372
373    // --- 4: tampered exp (extended without re-signing) -> denied ---
374
375    #[test]
376    fn extending_exp_without_resigning_is_denied() {
377        let keys = keyset();
378        let verifier = Verifier::signed_url(keyset());
379        let original_exp = far_future();
380        let query = keys
381            .sign("key-a", "/stream/media.m3u8", original_exp, None)
382            .unwrap();
383        let tampered = query.replacen(
384            &format!("exp={original_exp}"),
385            &format!("exp={}", original_exp + 1_000_000),
386            1,
387        );
388        assert_ne!(query, tampered, "test setup: exp must actually change");
389        let uri = format!("/stream/media.m3u8?{tampered}");
390        assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
391    }
392
393    // --- 5: unknown kid -> denied ---
394
395    #[test]
396    fn unknown_kid_is_denied() {
397        let keys = keyset();
398        let verifier = Verifier::signed_url(keyset());
399        let exp = far_future();
400        let query = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
401        let tampered = query.replacen("kid=key-a", "kid=nonexistent-key", 1);
402        let uri = format!("/stream/media.m3u8?{tampered}");
403        assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
404    }
405
406    // --- 6/7: IP scoping ---
407
408    #[test]
409    fn ip_scoped_token_from_different_peer_is_denied() {
410        let keys = keyset();
411        let verifier = Verifier::signed_url(keyset());
412        let exp = far_future();
413        let bound_ip: IpAddr = "203.0.113.7".parse().unwrap();
414        let query = keys
415            .sign("key-a", "/stream/media.m3u8", exp, Some(bound_ip))
416            .unwrap();
417        let uri = format!("/stream/media.m3u8?{query}");
418        let other_peer: std::net::SocketAddr = "198.51.100.9:443".parse().unwrap();
419        let ctx = ctx_for(&uri).with_peer_addr(other_peer);
420        assert_eq!(verifier.verify(&ctx), AuthResult::Unauthorized);
421    }
422
423    #[test]
424    fn ip_scoped_token_from_correct_peer_is_allowed() {
425        let keys = keyset();
426        let verifier = Verifier::signed_url(keyset());
427        let exp = far_future();
428        let bound_ip: IpAddr = "203.0.113.7".parse().unwrap();
429        let query = keys
430            .sign("key-a", "/stream/media.m3u8", exp, Some(bound_ip))
431            .unwrap();
432        let uri = format!("/stream/media.m3u8?{query}");
433        // Same IP, different (irrelevant) port — the port must not matter.
434        let same_ip_peer: std::net::SocketAddr = "203.0.113.7:54321".parse().unwrap();
435        let ctx = ctx_for(&uri).with_peer_addr(same_ip_peer);
436        assert_eq!(verifier.verify(&ctx), AuthResult::Ok);
437    }
438
439    #[test]
440    fn unscoped_token_is_allowed_regardless_of_peer() {
441        let keys = keyset();
442        let verifier = Verifier::signed_url(keyset());
443        let exp = far_future();
444        let query = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
445        let uri = format!("/stream/media.m3u8?{query}");
446        let peer: std::net::SocketAddr = "198.51.100.9:443".parse().unwrap();
447        let ctx = ctx_for(&uri).with_peer_addr(peer);
448        assert_eq!(verifier.verify(&ctx), AuthResult::Ok);
449    }
450
451    // --- 8: missing/empty/malformed sig -> denied, no panic ---
452
453    #[test]
454    fn missing_sig_is_denied() {
455        let verifier = Verifier::signed_url(keyset());
456        let uri = format!("/stream/media.m3u8?exp={}&kid=key-a", far_future());
457        assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
458    }
459
460    #[test]
461    fn empty_sig_is_denied() {
462        let verifier = Verifier::signed_url(keyset());
463        let uri = format!("/stream/media.m3u8?exp={}&kid=key-a&sig=", far_future());
464        assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
465    }
466
467    #[test]
468    fn malformed_base64_sig_is_denied_not_panicking() {
469        let verifier = Verifier::signed_url(keyset());
470        let uri = format!(
471            "/stream/media.m3u8?exp={}&kid=key-a&sig=not-valid-base64!!!",
472            far_future()
473        );
474        assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
475    }
476
477    #[test]
478    fn missing_exp_is_denied() {
479        let verifier = Verifier::signed_url(keyset());
480        let uri = "/stream/media.m3u8?kid=key-a&sig=AAAA".to_string();
481        assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
482    }
483
484    #[test]
485    fn unparseable_exp_is_denied() {
486        let verifier = Verifier::signed_url(keyset());
487        let uri = "/stream/media.m3u8?exp=not-a-number&kid=key-a&sig=AAAA".to_string();
488        assert_eq!(verifier.verify(&ctx_for(&uri)), AuthResult::Unauthorized);
489    }
490
491    #[test]
492    fn empty_query_string_is_denied_not_panicking() {
493        let verifier = Verifier::signed_url(keyset());
494        assert_eq!(
495            verifier.verify(&ctx_for("/stream/media.m3u8")),
496            AuthResult::Unauthorized
497        );
498        assert_eq!(
499            verifier.verify(&ctx_for("/stream/media.m3u8?")),
500            AuthResult::Unauthorized
501        );
502    }
503
504    // --- 9: key rotation ---
505
506    #[test]
507    fn both_active_keys_are_accepted_until_one_is_retired() {
508        let keys = keyset();
509        let exp = far_future();
510        let query_a = keys.sign("key-a", "/stream/media.m3u8", exp, None).unwrap();
511        let query_b = keys.sign("key-b", "/stream/media.m3u8", exp, None).unwrap();
512
513        let verifier = Verifier::signed_url(keyset());
514        assert_eq!(
515            verifier.verify(&ctx_for(&format!("/stream/media.m3u8?{query_a}"))),
516            AuthResult::Ok,
517            "key-a must verify while both keys are active"
518        );
519        assert_eq!(
520            verifier.verify(&ctx_for(&format!("/stream/media.m3u8?{query_b}"))),
521            AuthResult::Ok,
522            "key-b must verify while both keys are active"
523        );
524
525        // Retire key-b: a fresh keyset/verifier carrying only key-a.
526        let retired_keyset =
527            SignedUrlKeySet::new([("key-a".to_string(), SECRET_A.to_vec())]).unwrap();
528        let verifier_after_rotation = Verifier::signed_url(retired_keyset);
529        assert_eq!(
530            verifier_after_rotation.verify(&ctx_for(&format!("/stream/media.m3u8?{query_a}"))),
531            AuthResult::Ok,
532            "key-a must still verify after key-b is retired"
533        );
534        assert_eq!(
535            verifier_after_rotation.verify(&ctx_for(&format!("/stream/media.m3u8?{query_b}"))),
536            AuthResult::Unauthorized,
537            "a token signed by the retired key-b must now be rejected"
538        );
539    }
540
541    // --- signing helper errors ---
542
543    #[test]
544    fn sign_with_unknown_kid_is_a_structured_error() {
545        let keys = keyset();
546        let err = keys
547            .sign("no-such-key", "/stream/media.m3u8", far_future(), None)
548            .unwrap_err();
549        assert!(matches!(err, Error::UnknownSignedUrlKeyId(k) if k == "no-such-key"));
550    }
551
552    // --- misc unit coverage ---
553
554    #[test]
555    fn split_path_and_query_handles_no_query() {
556        assert_eq!(split_path_and_query("/a/b"), ("/a/b", ""));
557        assert_eq!(split_path_and_query("/a/b?x=1"), ("/a/b", "x=1"));
558    }
559
560    #[test]
561    fn query_get_skips_malformed_pairs_without_panicking() {
562        assert_eq!(query_get("a=1&garbage&b=2", "b").as_deref(), Some("2"));
563        assert_eq!(query_get("a=1&garbage&b=2", "garbage").as_deref(), Some(""));
564        assert_eq!(query_get("", "a"), None);
565    }
566
567    #[test]
568    fn debug_never_leaks_secret_bytes() {
569        let keys = keyset();
570        let debug = format!("{keys:?}");
571        assert!(debug.contains("key-a"), "kid should render: {debug}");
572        assert!(debug.contains("key-b"), "kid should render: {debug}");
573        assert!(
574            !debug.contains(std::str::from_utf8(SECRET_A).unwrap()),
575            "secret leaked: {debug}"
576        );
577    }
578}