Skip to main content

uselesskey_webhook/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Webhook fixtures built on `uselesskey-core`.
4//!
5//! This crate provides deterministic provider-style webhook fixtures with canonical
6//! payloads, signature input strings, and signed headers.
7
8use std::collections::BTreeMap;
9use std::fmt;
10
11use base64::Engine as _;
12use base64::engine::general_purpose::URL_SAFE_NO_PAD;
13use hmac::{KeyInit, Mac};
14use rand_chacha10::ChaCha20Rng;
15use rand_core10::{Rng, SeedableRng};
16use sha2::Sha256;
17use uselesskey_core::Factory;
18
19/// Cache domain for webhook fixtures.
20pub const DOMAIN_WEBHOOK_FIXTURE: &str = "uselesskey:webhook:fixture";
21
22/// Supported webhook signing profiles.
23#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
24pub enum WebhookProfile {
25    /// GitHub webhook signature profile.
26    GitHub,
27    /// Stripe webhook signature profile.
28    Stripe,
29    /// Slack webhook signature profile.
30    Slack,
31}
32
33impl WebhookProfile {
34    fn stable_tag(self) -> &'static str {
35        match self {
36            Self::GitHub => "github",
37            Self::Stripe => "stripe",
38            Self::Slack => "slack",
39        }
40    }
41}
42
43/// Canonical payload presets for webhook fixtures.
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub enum WebhookPayloadSpec {
46    /// Use the built-in provider canonical payload template.
47    Canonical,
48    /// Use an explicit payload string.
49    Raw(String),
50}
51
52impl WebhookPayloadSpec {
53    fn stable_bytes(&self) -> Vec<u8> {
54        match self {
55            Self::Canonical => b"canonical".to_vec(),
56            Self::Raw(payload) => {
57                let mut out = b"raw:".to_vec();
58                out.extend_from_slice(payload.as_bytes());
59                out
60            }
61        }
62    }
63}
64
65/// A generated webhook fixture.
66#[derive(Clone)]
67pub struct WebhookFixture {
68    /// Profile used to generate fixture semantics.
69    pub profile: WebhookProfile,
70    /// Signing secret (test-only).
71    pub secret: String,
72    /// Canonical payload body.
73    pub payload: String,
74    /// HTTP headers to attach to the request.
75    pub headers: BTreeMap<String, String>,
76    /// Timestamp used in signature generation (unix epoch seconds).
77    pub timestamp: i64,
78    /// Canonical signature input/base string.
79    pub signature_input: String,
80}
81
82impl fmt::Debug for WebhookFixture {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        f.debug_struct("WebhookFixture")
85            .field("profile", &self.profile)
86            .field("payload", &self.payload)
87            .field("headers", &self.headers)
88            .field("timestamp", &self.timestamp)
89            .field("signature_input", &self.signature_input)
90            .finish_non_exhaustive()
91    }
92}
93
94/// A near-miss webhook fixture for negative tests.
95#[derive(Clone)]
96pub struct NearMissWebhookFixture {
97    /// Negative scenario marker.
98    pub scenario: NearMissScenario,
99    /// Profile used to generate fixture semantics.
100    pub profile: WebhookProfile,
101    /// Signing secret (intentionally wrong for `WrongSecret`).
102    pub secret: String,
103    /// Payload body (intentionally modified for `TamperedPayload`).
104    pub payload: String,
105    /// HTTP headers to attach to the request.
106    pub headers: BTreeMap<String, String>,
107    /// Timestamp used in signature generation (may be stale).
108    pub timestamp: i64,
109    /// Canonical signature input/base string.
110    pub signature_input: String,
111}
112
113impl fmt::Debug for NearMissWebhookFixture {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        f.debug_struct("NearMissWebhookFixture")
116            .field("scenario", &self.scenario)
117            .field("profile", &self.profile)
118            .field("payload", &self.payload)
119            .field("headers", &self.headers)
120            .field("timestamp", &self.timestamp)
121            .field("signature_input", &self.signature_input)
122            .finish_non_exhaustive()
123    }
124}
125
126/// Supported near-miss negative scenarios.
127#[derive(Clone, Copy, Debug, Eq, PartialEq)]
128pub enum NearMissScenario {
129    /// Header timestamp falls outside the acceptable window.
130    StaleTimestamp,
131    /// Request signed with an alternate secret not used by verifier.
132    WrongSecret,
133    /// Payload differs from what was signed.
134    TamperedPayload,
135}
136
137/// Extension trait to generate webhook fixtures from [`Factory`].
138pub trait WebhookFactoryExt {
139    /// Generate a webhook fixture for an explicit profile.
140    fn webhook(
141        &self,
142        profile: WebhookProfile,
143        label: impl AsRef<str>,
144        payload_spec: WebhookPayloadSpec,
145    ) -> WebhookFixture;
146
147    /// Generate a GitHub webhook fixture.
148    fn webhook_github(
149        &self,
150        label: impl AsRef<str>,
151        payload_spec: WebhookPayloadSpec,
152    ) -> WebhookFixture;
153
154    /// Generate a Stripe webhook fixture.
155    fn webhook_stripe(
156        &self,
157        label: impl AsRef<str>,
158        payload_spec: WebhookPayloadSpec,
159    ) -> WebhookFixture;
160
161    /// Generate a Slack webhook fixture.
162    fn webhook_slack(
163        &self,
164        label: impl AsRef<str>,
165        payload_spec: WebhookPayloadSpec,
166    ) -> WebhookFixture;
167}
168
169impl WebhookFactoryExt for Factory {
170    fn webhook(
171        &self,
172        profile: WebhookProfile,
173        label: impl AsRef<str>,
174        payload_spec: WebhookPayloadSpec,
175    ) -> WebhookFixture {
176        let label = label.as_ref();
177        let spec_bytes = stable_spec_bytes(profile, &payload_spec);
178        let cached = self.get_or_init(DOMAIN_WEBHOOK_FIXTURE, label, &spec_bytes, "good", |seed| {
179            build_fixture_from_seed(profile, label, payload_spec.clone(), seed.bytes())
180        });
181        cached.as_ref().clone()
182    }
183
184    fn webhook_github(
185        &self,
186        label: impl AsRef<str>,
187        payload_spec: WebhookPayloadSpec,
188    ) -> WebhookFixture {
189        self.webhook(WebhookProfile::GitHub, label, payload_spec)
190    }
191
192    fn webhook_stripe(
193        &self,
194        label: impl AsRef<str>,
195        payload_spec: WebhookPayloadSpec,
196    ) -> WebhookFixture {
197        self.webhook(WebhookProfile::Stripe, label, payload_spec)
198    }
199
200    fn webhook_slack(
201        &self,
202        label: impl AsRef<str>,
203        payload_spec: WebhookPayloadSpec,
204    ) -> WebhookFixture {
205        self.webhook(WebhookProfile::Slack, label, payload_spec)
206    }
207}
208
209impl WebhookFixture {
210    /// Produce a stale-timestamp variant for replay-window tests.
211    pub fn near_miss_stale_timestamp(&self, max_age_secs: i64) -> NearMissWebhookFixture {
212        let stale_ts = self.timestamp - max_age_secs - 1;
213        let mut f = self.with_timestamp(stale_ts);
214        f.scenario = NearMissScenario::StaleTimestamp;
215        f
216    }
217
218    /// Produce a wrong-secret variant for verifier mismatch tests.
219    pub fn near_miss_wrong_secret(&self) -> NearMissWebhookFixture {
220        let mut wrong_secret = self.secret.clone();
221        wrong_secret.push_str("_wrong");
222        let mut f = build_near_miss(
223            self.profile,
224            wrong_secret,
225            self.payload.clone(),
226            self.timestamp,
227        );
228        f.scenario = NearMissScenario::WrongSecret;
229        f
230    }
231
232    /// Produce a tampered-payload variant for integrity tests.
233    pub fn near_miss_tampered_payload(&self) -> NearMissWebhookFixture {
234        let tampered = format!("{}{}", self.payload, "\n");
235        let mut f = build_near_miss(self.profile, self.secret.clone(), tampered, self.timestamp);
236        f.scenario = NearMissScenario::TamperedPayload;
237        f
238    }
239
240    fn with_timestamp(&self, timestamp: i64) -> NearMissWebhookFixture {
241        build_near_miss(
242            self.profile,
243            self.secret.clone(),
244            self.payload.clone(),
245            timestamp,
246        )
247    }
248}
249
250fn build_near_miss(
251    profile: WebhookProfile,
252    secret: String,
253    payload: String,
254    timestamp: i64,
255) -> NearMissWebhookFixture {
256    let (headers, signature_input) = sign(profile, &secret, &payload, timestamp);
257    NearMissWebhookFixture {
258        scenario: NearMissScenario::StaleTimestamp,
259        profile,
260        secret,
261        payload,
262        headers,
263        timestamp,
264        signature_input,
265    }
266}
267
268fn stable_spec_bytes(profile: WebhookProfile, payload_spec: &WebhookPayloadSpec) -> Vec<u8> {
269    let mut out = profile.stable_tag().as_bytes().to_vec();
270    out.push(0);
271    out.extend_from_slice(&payload_spec.stable_bytes());
272    out
273}
274
275fn build_fixture_from_seed(
276    profile: WebhookProfile,
277    label: &str,
278    payload_spec: WebhookPayloadSpec,
279    seed: &[u8; 32],
280) -> WebhookFixture {
281    let mut rng = ChaCha20Rng::from_seed(*seed);
282    let secret = build_secret(profile, &mut rng);
283    let timestamp = 1_700_000_000_i64 + (rng.next_u32() as i64 % 200_000_000_i64);
284    let payload = canonical_payload(profile, label, payload_spec, rng.next_u32());
285    let (headers, signature_input) = sign(profile, &secret, &payload, timestamp);
286
287    WebhookFixture {
288        profile,
289        secret,
290        payload,
291        headers,
292        timestamp,
293        signature_input,
294    }
295}
296
297fn build_secret(profile: WebhookProfile, rng: &mut ChaCha20Rng) -> String {
298    let mut secret_bytes = [0_u8; 32];
299    rng.fill_bytes(&mut secret_bytes);
300
301    match profile {
302        WebhookProfile::GitHub => format!("ghs_{}", URL_SAFE_NO_PAD.encode(secret_bytes)),
303        WebhookProfile::Stripe => format!("whsec_{}", hex::encode(secret_bytes)),
304        WebhookProfile::Slack => hex::encode(secret_bytes),
305    }
306}
307
308fn canonical_payload(
309    profile: WebhookProfile,
310    label: &str,
311    payload_spec: WebhookPayloadSpec,
312    nonce: u32,
313) -> String {
314    match payload_spec {
315        WebhookPayloadSpec::Raw(payload) => payload,
316        WebhookPayloadSpec::Canonical => match profile {
317            WebhookProfile::GitHub => {
318                let repository = json_string(&format!("acme/{label}"));
319                format!(
320                    "{{\"action\":\"opened\",\"repository\":{{\"full_name\":{repository}}},\"number\":{}}}",
321                    (nonce % 9000) + 1000
322                )
323            }
324            WebhookProfile::Stripe => {
325                let label = json_string(label);
326                let mut payload = format!(
327                    "{{\"id\":\"evt_{nonce:08x}\",\"type\":\"checkout.session.completed\",\"data\":{{\"object\":{{\"metadata\":{{\"label\":"
328                );
329                payload.push_str(&label);
330                payload.push_str("}}}}");
331                payload
332            }
333            WebhookProfile::Slack => {
334                let text = json_string(&format!("ping {label}"));
335                format!(
336                    "{{\"type\":\"event_callback\",\"team_id\":\"T{nonce:08x}\",\"event\":{{\"type\":\"app_mention\",\"text\":{text}}}}}"
337                )
338            }
339        },
340    }
341}
342
343fn json_string(value: &str) -> String {
344    serde_json::to_string(value).expect("serializing a string to JSON cannot fail")
345}
346
347fn sign(
348    profile: WebhookProfile,
349    secret: &str,
350    payload: &str,
351    timestamp: i64,
352) -> (BTreeMap<String, String>, String) {
353    let mut headers = BTreeMap::new();
354    headers.insert("Content-Type".to_string(), "application/json".to_string());
355
356    match profile {
357        WebhookProfile::GitHub => {
358            let signature_input = payload.to_string();
359            let digest = hmac_sha256_hex(secret.as_bytes(), signature_input.as_bytes());
360            headers.insert(
361                "X-Hub-Signature-256".to_string(),
362                format!("sha256={digest}"),
363            );
364            (headers, signature_input)
365        }
366        WebhookProfile::Stripe => {
367            let signature_input = format!("{timestamp}.{payload}");
368            let digest = hmac_sha256_hex(secret.as_bytes(), signature_input.as_bytes());
369            headers.insert(
370                "Stripe-Signature".to_string(),
371                format!("t={timestamp},v1={digest}"),
372            );
373            (headers, signature_input)
374        }
375        WebhookProfile::Slack => {
376            let signature_input = format!("v0:{timestamp}:{payload}");
377            let digest = hmac_sha256_hex(secret.as_bytes(), signature_input.as_bytes());
378            headers.insert(
379                "X-Slack-Request-Timestamp".to_string(),
380                timestamp.to_string(),
381            );
382            headers.insert("X-Slack-Signature".to_string(), format!("v0={digest}"));
383            (headers, signature_input)
384        }
385    }
386}
387
388fn hmac_sha256_hex(secret: &[u8], msg: &[u8]) -> String {
389    let mut mac = hmac::Hmac::<Sha256>::new_from_slice(secret).expect("HMAC key is always valid");
390    mac.update(msg);
391    let out = mac.finalize().into_bytes();
392    hex::encode(out)
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use uselesskey_core::Seed;
399
400    fn verify_github(secret: &str, payload: &str, headers: &BTreeMap<String, String>) -> bool {
401        let expected = format!(
402            "sha256={}",
403            hmac_sha256_hex(secret.as_bytes(), payload.as_bytes())
404        );
405        headers.get("X-Hub-Signature-256") == Some(&expected)
406    }
407
408    fn verify_stripe(
409        secret: &str,
410        payload: &str,
411        headers: &BTreeMap<String, String>,
412        now: i64,
413        tolerance_secs: i64,
414    ) -> bool {
415        let Some(sig_header) = headers.get("Stripe-Signature") else {
416            return false;
417        };
418        let mut ts = None;
419        let mut v1 = None;
420        for part in sig_header.split(',') {
421            if let Some(v) = part.strip_prefix("t=") {
422                ts = v.parse::<i64>().ok();
423            }
424            if let Some(v) = part.strip_prefix("v1=") {
425                v1 = Some(v.to_string());
426            }
427        }
428        let Some(ts) = ts else {
429            return false;
430        };
431        if (now - ts).abs() > tolerance_secs {
432            return false;
433        }
434        let base = format!("{ts}.{payload}");
435        let expected = hmac_sha256_hex(secret.as_bytes(), base.as_bytes());
436        v1.as_deref() == Some(expected.as_str())
437    }
438
439    fn verify_slack(
440        secret: &str,
441        payload: &str,
442        headers: &BTreeMap<String, String>,
443        now: i64,
444        tolerance_secs: i64,
445    ) -> bool {
446        let Some(ts_str) = headers.get("X-Slack-Request-Timestamp") else {
447            return false;
448        };
449        let Ok(ts) = ts_str.parse::<i64>() else {
450            return false;
451        };
452        if (now - ts).abs() > tolerance_secs {
453            return false;
454        }
455        let Some(sig) = headers.get("X-Slack-Signature") else {
456            return false;
457        };
458        let base = format!("v0:{ts}:{payload}");
459        let expected = format!("v0={}", hmac_sha256_hex(secret.as_bytes(), base.as_bytes()));
460        sig == &expected
461    }
462
463    #[test]
464    fn deterministic_github_fixture_is_stable() {
465        let fx = Factory::deterministic(Seed::from_env_value("webhook-gh").unwrap());
466        let a = fx.webhook_github("repo", WebhookPayloadSpec::Canonical);
467        let b = fx.webhook_github("repo", WebhookPayloadSpec::Canonical);
468        assert_eq!(a.secret, b.secret);
469        assert_eq!(a.payload, b.payload);
470        assert_eq!(a.headers, b.headers);
471        assert!(verify_github(&a.secret, &a.payload, &a.headers));
472    }
473
474    #[test]
475    fn provider_signature_paths_verify() {
476        let fx = Factory::deterministic(Seed::from_env_value("webhook-providers").unwrap());
477        let gh = fx.webhook(WebhookProfile::GitHub, "a", WebhookPayloadSpec::Canonical);
478        let st = fx.webhook_stripe("b", WebhookPayloadSpec::Canonical);
479        let sl = fx.webhook_slack("c", WebhookPayloadSpec::Canonical);
480
481        assert!(verify_github(&gh.secret, &gh.payload, &gh.headers));
482        assert!(verify_stripe(
483            &st.secret,
484            &st.payload,
485            &st.headers,
486            st.timestamp,
487            300
488        ));
489        assert!(verify_slack(
490            &sl.secret,
491            &sl.payload,
492            &sl.headers,
493            sl.timestamp,
494            300
495        ));
496    }
497
498    #[test]
499    fn payload_spec_stable_bytes_are_shape_sensitive() {
500        assert_eq!(WebhookPayloadSpec::Canonical.stable_bytes(), b"canonical");
501        assert_eq!(
502            WebhookPayloadSpec::Raw("one".to_string()).stable_bytes(),
503            b"raw:one"
504        );
505        assert_ne!(
506            WebhookPayloadSpec::Raw("one".to_string()).stable_bytes(),
507            WebhookPayloadSpec::Raw("two".to_string()).stable_bytes()
508        );
509        assert_ne!(
510            stable_spec_bytes(WebhookProfile::GitHub, &WebhookPayloadSpec::Canonical),
511            stable_spec_bytes(WebhookProfile::Stripe, &WebhookPayloadSpec::Canonical)
512        );
513    }
514
515    #[test]
516    fn generated_timestamp_uses_expected_seeded_window() {
517        let seed = [7_u8; 32];
518        let mut rng = ChaCha20Rng::from_seed(seed);
519        let mut secret_bytes = [0_u8; 32];
520        rng.fill_bytes(&mut secret_bytes);
521        let expected = 1_700_000_000_i64 + (rng.next_u32() as i64 % 200_000_000_i64);
522
523        let fixture = build_fixture_from_seed(
524            WebhookProfile::Stripe,
525            "billing",
526            WebhookPayloadSpec::Canonical,
527            &seed,
528        );
529
530        assert_eq!(fixture.timestamp, expected);
531        assert!((1_700_000_000..1_900_000_000).contains(&fixture.timestamp));
532    }
533
534    #[test]
535    fn generated_secrets_match_provider_shapes() {
536        let mut rng = ChaCha20Rng::from_seed([9_u8; 32]);
537        let github = build_secret(WebhookProfile::GitHub, &mut rng);
538        let stripe = build_secret(WebhookProfile::Stripe, &mut rng);
539        let slack = build_secret(WebhookProfile::Slack, &mut rng);
540
541        assert_eq!(github.len(), "ghs_".len() + 43);
542        assert!(github.starts_with("ghs_"));
543        assert!(
544            github["ghs_".len()..]
545                .bytes()
546                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
547        );
548
549        assert_eq!(stripe.len(), "whsec_".len() + 64);
550        assert!(stripe.starts_with("whsec_"));
551        assert_lower_hex(&stripe["whsec_".len()..]);
552
553        assert_eq!(slack.len(), 64);
554        assert_lower_hex(&slack);
555    }
556
557    #[test]
558    fn header_shape_matches_provider_conventions() {
559        let fx = Factory::deterministic(Seed::from_env_value("webhook-headers").unwrap());
560        let gh = fx.webhook_github("r", WebhookPayloadSpec::Canonical);
561        assert!(
562            gh.headers
563                .get("X-Hub-Signature-256")
564                .is_some_and(|v| v.starts_with("sha256="))
565        );
566
567        let st = fx.webhook_stripe("r", WebhookPayloadSpec::Canonical);
568        let stripe_header = st.headers.get("Stripe-Signature").expect("stripe header");
569        assert!(stripe_header.contains("t="));
570        assert!(stripe_header.contains(",v1="));
571
572        let sl = fx.webhook_slack("r", WebhookPayloadSpec::Canonical);
573        assert!(sl.headers.contains_key("X-Slack-Request-Timestamp"));
574        assert!(
575            sl.headers
576                .get("X-Slack-Signature")
577                .is_some_and(|v| v.starts_with("v0="))
578        );
579    }
580
581    #[test]
582    fn near_miss_negatives_fail_provider_verification() {
583        let fx = Factory::deterministic(Seed::from_env_value("webhook-nearmiss").unwrap());
584        let st = fx.webhook_stripe("billing", WebhookPayloadSpec::Canonical);
585        let now = st.timestamp;
586
587        let stale = st.near_miss_stale_timestamp(300);
588        assert_eq!(stale.timestamp, st.timestamp - 301);
589        assert_eq!(
590            stale.signature_input,
591            format!("{}.{}", stale.timestamp, stale.payload)
592        );
593        assert!(!verify_stripe(
594            &st.secret,
595            &st.payload,
596            &stale.headers,
597            now,
598            300
599        ));
600
601        let wrong_secret = st.near_miss_wrong_secret();
602        assert!(!verify_stripe(
603            &st.secret,
604            &wrong_secret.payload,
605            &wrong_secret.headers,
606            wrong_secret.timestamp,
607            300
608        ));
609
610        let tampered = st.near_miss_tampered_payload();
611        assert!(!verify_stripe(
612            &tampered.secret,
613            &st.payload,
614            &tampered.headers,
615            tampered.timestamp,
616            300
617        ));
618    }
619
620    #[test]
621    fn debug_redacts_secret() {
622        let fx = Factory::random();
623        let fixture = fx.webhook_slack("debug", WebhookPayloadSpec::Canonical);
624        let out = format!("{fixture:?}");
625        assert!(!out.contains(&fixture.secret));
626        assert!(out.contains("WebhookFixture"));
627
628        let near_miss = fixture.near_miss_wrong_secret();
629        let out = format!("{near_miss:?}");
630        assert!(!out.contains(&near_miss.secret));
631        assert!(out.contains("NearMissWebhookFixture"));
632    }
633
634    #[test]
635    fn canonical_payload_escapes_special_characters_in_label() {
636        let fx = Factory::deterministic(Seed::from_env_value("webhook-label-escape").unwrap());
637        let label = "repo\"line\nbreak\\slash";
638        let fixtures = [
639            fx.webhook_github(label, WebhookPayloadSpec::Canonical),
640            fx.webhook_stripe(label, WebhookPayloadSpec::Canonical),
641            fx.webhook_slack(label, WebhookPayloadSpec::Canonical),
642        ];
643
644        for fixture in fixtures {
645            let parsed: serde_json::Value =
646                serde_json::from_str(&fixture.payload).expect("canonical payload should be valid");
647            let serialized = parsed.to_string();
648            assert!(
649                serialized.contains("repo\\\"line\\nbreak\\\\slash"),
650                "serialized payload should preserve escaped label, got: {serialized}"
651            );
652        }
653    }
654
655    #[test]
656    fn canonical_payload_preserves_plain_label_field_order() {
657        assert_eq!(
658            canonical_payload(
659                WebhookProfile::GitHub,
660                "repo",
661                WebhookPayloadSpec::Canonical,
662                12
663            ),
664            "{\"action\":\"opened\",\"repository\":{\"full_name\":\"acme/repo\"},\"number\":1012}"
665        );
666        assert_eq!(
667            canonical_payload(
668                WebhookProfile::Stripe,
669                "billing",
670                WebhookPayloadSpec::Canonical,
671                0x0f
672            ),
673            "{\"id\":\"evt_0000000f\",\"type\":\"checkout.session.completed\",\"data\":{\"object\":{\"metadata\":{\"label\":\"billing\"}}}}"
674        );
675        assert_eq!(
676            canonical_payload(
677                WebhookProfile::Slack,
678                "alerts",
679                WebhookPayloadSpec::Canonical,
680                0x10
681            ),
682            "{\"type\":\"event_callback\",\"team_id\":\"T00000010\",\"event\":{\"type\":\"app_mention\",\"text\":\"ping alerts\"}}"
683        );
684    }
685
686    fn assert_lower_hex(value: &str) {
687        assert!(
688            value
689                .bytes()
690                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)),
691            "expected lowercase hex: {value}"
692        );
693    }
694}