polyjose 0.1.2

Pure-Rust JOSE: JWA, JWK, JWS, JWE and JWT per the RFCs, with no OpenSSL
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
//! The polyjose error type and its stable, cross-platform error codes.
//!
//! # Stability contract
//!
//! - **Stable:** the code strings returned by [`Error::code`] and listed in
//!   [`Error::CODES`]. Every binding (Node, WASM, Kotlin, Swift) surfaces
//!   them unchanged, and the shared conformance vectors name them in
//!   `"expect": { "ok": false, "error": "<code>" }`. A released code is never
//!   renamed or removed; new codes may be added.
//! - **Not stable:** the `Display` text and the `&'static str` details. They
//!   are for humans and may be reworded in any release. Don't match on them.
//!
//! # No secrets, no input (ADR 0007)
//!
//! An error never contains key material or bytes of the input being
//! processed: a detail can only be a string literal from polyjose itself,
//! and no error wraps another error as its `source()`. polyjose parses JSON
//! and base64url itself (ADR 0016), so no dependency's error message, which
//! might quote input, ever reaches an `Error`. JWE decryption has a single,
//! detail-free failure, [`Error::DecryptionFailed`] (RFC 7516 §11.5).
//!
//! # Codes are for logs, not for clients
//!
//! A code says exactly which check failed. That is what logs and
//! diagnostics need, but don't pass it on to whoever presented the token.
//! A server should answer every rejected token with one opaque failure,
//! such as the `invalid_token` error of an OAuth bearer-token response
//! (RFC 6750 §3.1), and keep the code in its own logs.
//!
//! Returning codes to clients lets an unauthenticated token probe the
//! verifier's configuration. Verifying against a JWK Set, for example
//! (ADR 0029 §4):
//!
//! - [`Error::NoMatchingKey`] says that no member serves the token's
//!   `kid` and `alg`, while [`Error::InvalidSignature`] says that one does;
//! - [`Error::InvalidKey`] says that a matching `oct` member without a
//!   JWK `alg` is shorter than the hash output of the token's `HS*`
//!   (RFC 7518 §3.2).
//!
//! Selecting one key from a set (`JwkSet::select`, ADR 0054 §4) is
//! likewise caller-driven diagnostics: [`Error::MultipleMatchingKeys`]
//! against [`Error::NoMatchingKey`] says whether the selector matched
//! several members or none, so it is not for a client either.
//!
//! The codes stay deterministic and stable regardless (ADR 0007); only
//! what reaches the client should be opaque.
//!
//! # Adding a variant
//!
//! Give it a doc comment citing its RFC section, add its code to
//! [`Error::code`] and [`Error::CODES`] (same order as the declaration), and
//! add it to `one_of_each` in this module's tests. The compiler flags the
//! first and the last; the tests check the list.

/// Errors returned by polyjose.
///
/// Each variant has a stable [`code`](Error::code). The code is the contract
/// shared by every binding and every conformance vector (ADR 0007), so it
/// never changes once released. New variants may be added (the enum is
/// `#[non_exhaustive]`).
///
/// No variant carries key material or bytes from the input being processed.
/// Any detail is a `&'static str` written into polyjose itself, so `Debug`
/// and `Display` never echo untrusted input or secrets (ADR 0007).
///
/// ```
/// use polyjose::Error;
///
/// let err = Error::InvalidClaim("aud");
/// assert_eq!(err.code(), "InvalidClaim");
/// assert!(Error::CODES.contains(&err.code()));
///
/// // Match on the code (or the variant), never on the message text.
/// assert_eq!(Error::DecryptionFailed.code(), "DecryptionFailed");
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Error {
    /// Input is not valid unpadded base64url (RFC 7515 §2, RFC 4648 §5).
    ///
    /// Raised when decoding any base64url-encoded JOSE component
    /// (RFC 7515 §5.2 steps 2, 6 and 7; RFC 7516 §5.2) or JWK member.
    ///
    /// In JWS verification the payload's base64url (skipped for
    /// `b64: false`, RFC 7797 §3) is checked after the protected header is
    /// decoded and before the whole-header checks (ADR 0048, clarifying
    /// ADR 0026 §2).
    InvalidBase64,

    /// Input is not a valid JSON object of the expected shape.
    ///
    /// A JOSE Header must be a completely valid JSON object (RFC 7515 §5.2
    /// step 3, RFC 7516 §5.2 step 3), and so must a JWT Claims Set
    /// (RFC 7519 §7.2 step 10) and a JWK (RFC 7517 §4). That includes a
    /// member name occurring twice in one object, a JOSE Header included
    /// (RFC 7515 §5.2 step 4, RFC 7516 §5.2 step 4; ADR 0041 §1). The
    /// detail names the failure category only, never the offending input.
    InvalidJson(&'static str),

    /// The JOSE Header is structurally invalid.
    ///
    /// For example: a required parameter such as `alg` is missing
    /// (RFC 7515 §4.1.1, RFC 7516 §4.1.1–§4.1.2), a registered parameter
    /// has the wrong type, the protected and unprotected headers share a
    /// name (RFC 7515 §5.2 step 4, §7.2.1; RFC 7516 §5.2 step 4), `crit`
    /// is malformed or not in the protected header (RFC 7515 §4.1.11,
    /// RFC 7516 §4.1.13), or `b64` breaks the RFC 7797 §3/§6 rules.
    ///
    /// A member name that occurs twice **inside one** header object is not
    /// this error: the JSON layer rejects it first, as
    /// [`Error::InvalidJson`] (ADR 0041 §1, amending ADR 0026 §2 step 4).
    ///
    /// In JWS verification (ADR 0026 §2 as clarified by ADR 0048), the
    /// per-parameter type checks and the `crit` syntax checks run while the
    /// protected header is decoded, so before the payload's base64url is
    /// checked. The whole-header checks (`crit` naming an absent parameter,
    /// a missing `alg`, overlapping protected and unprotected headers,
    /// `b64` placement) run after it.
    InvalidHeader(&'static str),

    /// The `alg` or `enc` value does not name an algorithm polyjose
    /// implements.
    ///
    /// The JWS is invalid if `alg` is not a supported algorithm
    /// (RFC 7515 §4.1.1); the same holds for JWE `alg` and `enc`
    /// (RFC 7516 §4.1.1–§4.1.2). The rejected value is not echoed.
    ///
    /// Also a well-formed key or key encoding that polyjose does not
    /// support, e.g. encrypted PKCS#8 (RFC 5958 §3) or RSA private keys
    /// (ADR 0018, ADR 0021).
    UnsupportedAlgorithm,

    /// The algorithm is supported but not in the caller's allow-list.
    ///
    /// Libraries must let the caller specify the accepted algorithms and
    /// must not use any other (RFC 8725 §3.1, §2.1; ADR 0007). `none` is
    /// never allowed.
    AlgorithmNotAllowed,

    /// The key is malformed or too weak for any use.
    ///
    /// For example: missing or malformed JWK members (RFC 7517 §4,
    /// RFC 7518 §6), an RSA modulus under 2048 bits (RFC 7518 §3.3, §3.5,
    /// §4.2), an HMAC key shorter than the hash output (RFC 7518 §3.2), or
    /// an EC point that is not on its curve (RFC 8725 §3.4), and malformed
    /// or non-canonical DER/PEM key encodings (ADR 0018). The detail never
    /// contains key material.
    InvalidKey(&'static str),

    /// The key is valid but not for this algorithm or operation.
    ///
    /// Each key must be used with exactly one algorithm (RFC 8725 §3.1),
    /// which blocks the RS256→HS256 confusion attack (RFC 8725 §2.1). Also
    /// raised when the JWK `alg`, `use` or `key_ops` members forbid the
    /// operation (RFC 7517 §4.2–§4.4).
    KeyAlgorithmMismatch,

    /// A JWS signature or MAC did not validate (RFC 7515 §5.2 step 8).
    InvalidSignature,

    /// JWE decryption failed.
    ///
    /// Every decryption failure maps to this one variant, whatever the
    /// internal cause, and it deliberately carries no detail. The recipient
    /// must not distinguish format, padding and length errors
    /// (RFC 7516 §11.5), so no padding or key-unwrap oracle is exposed
    /// (RFC 7516 §5.2, ADR 0007).
    DecryptionFailed,

    /// `crit` lists an extension polyjose does not understand.
    ///
    /// Recipients must reject the JWS/JWE in that case (RFC 7515 §4.1.11,
    /// RFC 7516 §4.1.13). The unknown name is not echoed.
    UnknownCritical,

    /// The JWT `exp` time has passed, allowing for the configured clock
    /// skew (RFC 7519 §4.1.4).
    TokenExpired,

    /// The JWT `nbf` time has not been reached yet, allowing for the
    /// configured clock skew (RFC 7519 §4.1.5).
    TokenNotYetValid,

    /// A JWT claim is missing, malformed or failed validation.
    ///
    /// The detail names the claim (for example `"aud"`), never its value.
    /// Covers RFC 7519 §4.1 and §7.2 and the RFC 8725 §3.8–§3.9 issuer and
    /// audience checks.
    InvalidClaim(&'static str),

    /// The operating-system CSPRNG could not supply random bytes.
    ///
    /// Keys, IVs and CEKs require a cryptographically secure source
    /// (RFC 7515 §10.1, RFC 8725 §3.5); polyjose never falls back to a
    /// weaker one (ADR 0005).
    RandomnessUnavailable,

    /// The serialization does not have the shape its RFC requires, or the
    /// input exceeds a configured limit (ADR 0026 §1, §7).
    ///
    /// For JWS: a compact JWS without exactly three dot-separated parts
    /// (RFC 7515 §7.1, §5.2 step 1); a JSON JWS that is not an object of
    /// the RFC 7515 §7.2.1/§7.2.2 shape (for example a missing or
    /// non-string `signature`, an empty `signatures` array, `signatures`
    /// mixed with top-level signature members, or an empty-string
    /// `protected`); a payload that is present or absent against the
    /// detached-content mode (RFC 7515 Appendix F); a `b64: false` payload
    /// that the serialization cannot carry (RFC 7797 §5.2, §5.3); or an
    /// input over the size or signature-count limits.
    ///
    /// For JWE (RFC 7516 §5.2 step 1, §7.1, §7.2): a compact JWE without
    /// exactly five dot-separated parts (§7.1, §5.2 step 1); a JSON JWE
    /// that is not an object of the §7.2.1/§7.2.2 shape (for example a
    /// missing `ciphertext`, an empty `recipients` array, a flattened JWE
    /// with `recipients`, or a member of the wrong JSON type); an input over
    /// the size or recipient-count limits; and, on encrypt, a request the
    /// chosen serialization cannot carry (compact with more or fewer than
    /// one recipient, with AAD or with an unprotected header, §7.1).
    ///
    /// JSON syntax errors stay [`Error::InvalidJson`] and base64url errors
    /// stay [`Error::InvalidBase64`]. The detail names the rule, never the
    /// input.
    InvalidFormat(&'static str),

    /// No key of the caller's JWK Set may verify this JWS (ADR 0029 §4).
    ///
    /// Raised by JWT verification against a JWK Set when no member is a
    /// candidate for the JWS Protected Header's `alg` and `kid`: none has
    /// a matching key type and curve, JWK `alg`, `use`, `key_ops` and
    /// `kid` (RFC 7515 §4.1.4, §6; RFC 7517 §4.2–§4.5, §5; RFC 8725 §3.1),
    /// or more members match than the policy's candidate bound, which is
    /// checked before any signature is computed (ADR 0029 §4). Neither the
    /// `kid` nor any key is echoed.
    NoMatchingKey,

    /// More than one key of a JWK Set matches the caller's selector
    /// (ADR 0054 §4, §5).
    ///
    /// A JWK Set's keys have no order of preference (RFC 7517 §5.1), and
    /// distinct `kid` values within a set are only a SHOULD
    /// (RFC 7517 §4.5), so two members can share a `kid`. polyjose refuses
    /// to guess between two equally valid members.
    ///
    /// Only `JwkSet::select` (and the bindings' `jwks_select`) raises it.
    /// JWT verification against a set never does: it tries every candidate
    /// in order, and more candidates than its bound is
    /// [`Error::NoMatchingKey`] (ADR 0029 §4). Neither the selector nor
    /// any key is echoed.
    MultipleMatchingKeys,
}

impl Error {
    /// Every stable error code, in declaration order.
    ///
    /// Bindings and the conformance harness use this to check that an
    /// expected `error` value names a real code.
    pub const CODES: &'static [&'static str] = &[
        "InvalidBase64",
        "InvalidJson",
        "InvalidHeader",
        "UnsupportedAlgorithm",
        "AlgorithmNotAllowed",
        "InvalidKey",
        "KeyAlgorithmMismatch",
        "InvalidSignature",
        "DecryptionFailed",
        "UnknownCritical",
        "TokenExpired",
        "TokenNotYetValid",
        "InvalidClaim",
        "RandomnessUnavailable",
        "InvalidFormat",
        "NoMatchingKey",
        "MultipleMatchingKeys",
    ];

    /// The stable, cross-platform code for this error (ADR 0007).
    ///
    /// Match on this, not on the `Display` text: the code never changes,
    /// while the message may be reworded.
    pub fn code(&self) -> &'static str {
        match self {
            Error::InvalidBase64 => "InvalidBase64",
            Error::InvalidJson(_) => "InvalidJson",
            Error::InvalidHeader(_) => "InvalidHeader",
            Error::UnsupportedAlgorithm => "UnsupportedAlgorithm",
            Error::AlgorithmNotAllowed => "AlgorithmNotAllowed",
            Error::InvalidKey(_) => "InvalidKey",
            Error::KeyAlgorithmMismatch => "KeyAlgorithmMismatch",
            Error::InvalidSignature => "InvalidSignature",
            Error::DecryptionFailed => "DecryptionFailed",
            Error::UnknownCritical => "UnknownCritical",
            Error::TokenExpired => "TokenExpired",
            Error::TokenNotYetValid => "TokenNotYetValid",
            Error::InvalidClaim(_) => "InvalidClaim",
            Error::RandomnessUnavailable => "RandomnessUnavailable",
            Error::InvalidFormat(_) => "InvalidFormat",
            Error::NoMatchingKey => "NoMatchingKey",
            Error::MultipleMatchingKeys => "MultipleMatchingKeys",
        }
    }
}

/// Human-readable text: a fixed phrase per variant, followed by the
/// `&'static str` detail for the variants that carry one.
///
/// The text holds nothing but string literals from polyjose, so it never
/// contains input or key material (ADR 0007). It is not stable: match on
/// [`Error::code`] instead.
impl core::fmt::Display for Error {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            Error::InvalidBase64 => f.write_str("invalid base64url"),
            Error::InvalidJson(detail) => write!(f, "invalid JSON: {detail}"),
            Error::InvalidHeader(detail) => write!(f, "invalid header: {detail}"),
            Error::UnsupportedAlgorithm => f.write_str("unsupported algorithm"),
            Error::AlgorithmNotAllowed => f.write_str("algorithm not allowed"),
            Error::InvalidKey(detail) => write!(f, "invalid key: {detail}"),
            Error::KeyAlgorithmMismatch => f.write_str("key does not match the algorithm"),
            Error::InvalidSignature => f.write_str("signature verification failed"),
            Error::DecryptionFailed => f.write_str("decryption failed"),
            Error::UnknownCritical => f.write_str("unknown critical header parameter"),
            Error::TokenExpired => f.write_str("token expired"),
            Error::TokenNotYetValid => f.write_str("token not yet valid"),
            Error::InvalidClaim(detail) => write!(f, "invalid claim: {detail}"),
            Error::RandomnessUnavailable => f.write_str("randomness unavailable"),
            Error::InvalidFormat(detail) => write!(f, "invalid format: {detail}"),
            Error::NoMatchingKey => f.write_str("no matching key"),
            Error::MultipleMatchingKeys => f.write_str("several keys match"),
        }
    }
}

/// No variant has a [`source`](std::error::Error::source): an error never
/// wraps a dependency's error, whose message could quote input (ADR 0007).
/// In particular [`Error::DecryptionFailed`] exposes no cause, so callers
/// cannot tell decryption failures apart (RFC 7516 §11.5).
impl std::error::Error for Error {}

/// Result alias using [`Error`].
pub type Result<T> = core::result::Result<T, Error>;

#[cfg(test)]
mod tests {
    use super::*;

    /// One value of every variant, in declaration order.
    ///
    /// Keep in sync with [`variant_index`]: adding a variant breaks that
    /// exhaustive match at compile time, which is the reminder to add it here.
    fn one_of_each() -> Vec<Error> {
        vec![
            Error::InvalidBase64,
            Error::InvalidJson("syntax error"),
            Error::InvalidHeader("missing \"alg\""),
            Error::UnsupportedAlgorithm,
            Error::AlgorithmNotAllowed,
            Error::InvalidKey("RSA modulus is under 2048 bits"),
            Error::KeyAlgorithmMismatch,
            Error::InvalidSignature,
            Error::DecryptionFailed,
            Error::UnknownCritical,
            Error::TokenExpired,
            Error::TokenNotYetValid,
            Error::InvalidClaim("aud"),
            Error::RandomnessUnavailable,
            Error::InvalidFormat("compact JWS must have three parts"),
            Error::NoMatchingKey,
            Error::MultipleMatchingKeys,
        ]
    }

    /// Compile-time exhaustiveness guard for [`one_of_each`].
    fn variant_index(e: &Error) -> usize {
        match e {
            Error::InvalidBase64 => 0,
            Error::InvalidJson(_) => 1,
            Error::InvalidHeader(_) => 2,
            Error::UnsupportedAlgorithm => 3,
            Error::AlgorithmNotAllowed => 4,
            Error::InvalidKey(_) => 5,
            Error::KeyAlgorithmMismatch => 6,
            Error::InvalidSignature => 7,
            Error::DecryptionFailed => 8,
            Error::UnknownCritical => 9,
            Error::TokenExpired => 10,
            Error::TokenNotYetValid => 11,
            Error::InvalidClaim(_) => 12,
            Error::RandomnessUnavailable => 13,
            Error::InvalidFormat(_) => 14,
            Error::NoMatchingKey => 15,
            Error::MultipleMatchingKeys => 16,
        }
    }

    #[test]
    fn codes_are_the_stable_names() {
        // These strings are the cross-binding contract (ADR 0007). Changing one
        // is a breaking change for every binding and conformance vector.
        let expected = [
            "InvalidBase64",
            "InvalidJson",
            "InvalidHeader",
            "UnsupportedAlgorithm",
            "AlgorithmNotAllowed",
            "InvalidKey",
            "KeyAlgorithmMismatch",
            "InvalidSignature",
            "DecryptionFailed",
            "UnknownCritical",
            "TokenExpired",
            "TokenNotYetValid",
            "InvalidClaim",
            "RandomnessUnavailable",
            "InvalidFormat",
            "NoMatchingKey",
            "MultipleMatchingKeys",
        ];
        let actual: Vec<&str> = one_of_each().iter().map(Error::code).collect();
        assert_eq!(actual, expected);
    }

    #[test]
    fn multiple_matching_keys_is_appended_last() {
        // ADR 0054 §5: a new code is appended, so every released index
        // keeps its meaning.
        assert_eq!(Error::CODES.len(), 17);
        assert_eq!(Error::CODES[15], "NoMatchingKey");
        assert_eq!(Error::CODES[16], "MultipleMatchingKeys");
        assert_eq!(Error::MultipleMatchingKeys.code(), "MultipleMatchingKeys");
        assert_eq!(
            Error::MultipleMatchingKeys.to_string(),
            "several keys match"
        );
        assert_eq!(
            format!("{:?}", Error::MultipleMatchingKeys),
            "MultipleMatchingKeys"
        );
    }

    #[test]
    fn one_of_each_covers_every_variant_once() {
        let indices: Vec<usize> = one_of_each().iter().map(variant_index).collect();
        let expected: Vec<usize> = (0..Error::CODES.len()).collect();
        assert_eq!(indices, expected);
    }

    #[test]
    fn codes_list_matches_variants() {
        let from_variants: Vec<&str> = one_of_each().iter().map(Error::code).collect();
        assert_eq!(Error::CODES, from_variants.as_slice());
    }

    #[test]
    fn codes_are_unique_identifiers() {
        let mut seen = std::collections::BTreeSet::new();
        for code in Error::CODES {
            assert!(seen.insert(*code), "duplicate code {code}");
            assert!(
                !code.is_empty() && code.bytes().all(|b| b.is_ascii_alphanumeric()),
                "code {code} must be a non-empty ASCII identifier"
            );
        }
    }

    #[test]
    fn display_text_of_every_variant() {
        // Not part of the stability contract (codes are), but pinned so a
        // change to the formatting code cannot alter messages by accident.
        let expected = [
            "invalid base64url",
            "invalid JSON: syntax error",
            "invalid header: missing \"alg\"",
            "unsupported algorithm",
            "algorithm not allowed",
            "invalid key: RSA modulus is under 2048 bits",
            "key does not match the algorithm",
            "signature verification failed",
            "decryption failed",
            "unknown critical header parameter",
            "token expired",
            "token not yet valid",
            "invalid claim: aud",
            "randomness unavailable",
            "invalid format: compact JWS must have three parts",
            "no matching key",
            "several keys match",
        ];
        let actual: Vec<String> = one_of_each().iter().map(Error::to_string).collect();
        assert_eq!(actual, expected);
    }

    /// A distinctive value standing in for untrusted input or key material.
    const MARKER: &str = "pj-SECRET-marker-7f3a";

    /// Asserts that neither `Debug` nor `Display` of `e` contains `MARKER`.
    fn assert_no_marker(e: &Error) {
        let debug = format!("{e:?}");
        let display = e.to_string();
        assert!(!debug.contains(MARKER), "Debug leaks input: {debug}");
        assert!(!display.contains(MARKER), "Display leaks input: {display}");
    }

    #[test]
    fn no_variant_renders_input_or_has_a_cause() {
        // Details are &'static str, so a variant cannot hold runtime input;
        // this guards the rendering and the (absent) cause chain.
        for e in one_of_each() {
            assert_no_marker(&e);
            assert!(!e.to_string().is_empty(), "{} has no message", e.code());
            assert!(
                std::error::Error::source(&e).is_none(),
                "{} exposes a cause",
                e.code()
            );
        }
    }

    #[test]
    fn decryption_failed_has_no_detail() {
        // RFC 7516 §11.5, ADR 0007: one uniform failure, no oracle.
        let e = Error::DecryptionFailed;
        assert_eq!(format!("{e:?}"), "DecryptionFailed");
        assert_eq!(e.to_string(), "decryption failed");
        assert!(std::error::Error::source(&e).is_none());
    }

    /// Malformed JSON texts containing `MARKER`, each with the
    /// [`Error::InvalidJson`] detail that [`crate::json::parse`] must return.
    ///
    /// The marker sits next to the fault, where a parser that quotes the
    /// offending input (as `serde_json` does) would echo it.
    fn marked_malformed_json() -> Vec<(Vec<u8>, &'static str)> {
        let mut invalid_utf8 = format!("\"{MARKER}").into_bytes();
        invalid_utf8.extend_from_slice(&[0xff, 0xfe, b'"']);
        vec![
            (
                format!("{{\"{MARKER}\" {MARKER}}}").into_bytes(),
                "unexpected character",
            ),
            (
                format!("{{\"{MARKER}\":").into_bytes(),
                "unexpected end of input",
            ),
            (
                format!("\"{MARKER}\" {MARKER}").into_bytes(),
                "trailing content after the value",
            ),
            (format!("\"{MARKER}\\q\"").into_bytes(), "invalid escape"),
            (
                format!("\"{MARKER}\\ud800\"").into_bytes(),
                "lone surrogate escape",
            ),
            (
                format!("\"{MARKER}\n\"").into_bytes(),
                "unescaped control character in string",
            ),
            (
                format!("{{\"{MARKER}\":1,\"{MARKER}\":2}}").into_bytes(),
                "duplicate member name",
            ),
            (
                format!("{{\"{MARKER}\":1e400}}").into_bytes(),
                "number out of range",
            ),
            (invalid_utf8, "invalid UTF-8"),
        ]
    }

    #[test]
    fn json_errors_are_invalid_json_without_input() {
        for (input, detail) in marked_malformed_json() {
            // The probe is meaningful only if the marker is in the input.
            assert!(
                input.windows(MARKER.len()).any(|w| w == MARKER.as_bytes()),
                "probe for {detail:?} lacks the marker"
            );
            let e = crate::json::parse(&input).err();
            assert_eq!(e, Some(Error::InvalidJson(detail)));
            if let Some(e) = e {
                assert_eq!(e.code(), "InvalidJson");
                assert_no_marker(&e);
                assert!(std::error::Error::source(&e).is_none());
            }
        }
    }
}