Skip to main content

rift_types/
wire.rs

1//! Mountebank wire-shape tolerance, shared across crates (issue #936).
2//!
3//! Mountebank accepts several spellings of the same value — a status code as a number *or* a
4//! numeric string, a header as one value *or* an array, a header value as any JSON scalar. Those
5//! rules are a property of the wire format, not of any one subsystem, so they live here rather
6//! than inside the imposter types: the imposter stub path and the intercept rule schema both
7//! parse the same JSON and must agree about what it means. They previously lived in
8//! `rift-mock-core` as `pub(crate)`, which is exactly why the intercept path drifted (issue #933
9//! for `body`, this issue for `statusCode`/`headers`).
10
11/// Serde for multi-value headers (issue #238). Accepts the Mountebank-style `"k": "v"` *and*
12/// `"k": ["v1", "v2"]` on the wire; serializes a single value back as a plain string and multiple
13/// values as an array, so existing single-value consumers are unaffected.
14///
15/// **Invariant on every map this module produces (issue #1039): one entry per case-folded header
16/// name.** No two keys `eq_ignore_ascii_case`-match each other; the surviving key is the *first*
17/// spelling the deserializer presents, and its values are every case-matching entry's values in
18/// that same order. HTTP header names are case-insensitive, so a document that spells one name two
19/// ways describes one header — but a `HashMap` keyed by the literal spelling would hold it as two.
20/// That split is what the eleven case-insensitive find-first lookups downstream (form parsing,
21/// predicate fields, `copy`, the JS/Rhai engines, the verify CLI) resolve nondeterministically, and
22/// what makes `deepEquals` on headers compare against the wrong name count. Holding the invariant
23/// here — at the single deserializer all five header fields that parse through it share — makes
24/// those sites correct by construction rather than by eleven separate case-folding patches. (Two
25/// further fields name this module for `serialize_with` only and never reach the deserializer.)
26///
27/// "First the deserializer presents" is deliberately not "first in the document". Which one you get
28/// depends on how the caller reached this function, and **both shapes are live in production**:
29///
30/// - Deserializing **straight from JSON text or bytes** into the target type streams entries in
31///   document order, so the first spelling written wins. (`POST /imposters` and
32///   `POST /intercept/rules` take this path, as does `--configfile`'s bare-array form.)
33/// - Deserializing from an **already-parsed `serde_json::Value`** walks a `serde_json::Map`, which
34///   is a `BTreeMap` unless the `preserve_order` feature is on — it is not enabled in this
35///   workspace — so entries arrive sorted by key bytes and the lexicographically smallest spelling
36///   wins. (`--configfile`'s `{"imposters": […]}` wrapper and single-object forms both parse to a
37///   `Value` first, as do `rift_apply_config` and the scenarios stub path.)
38///
39/// Note how little those two lists correlate with the user-facing feature: one CLI flag spans both,
40/// depending only on the document's outermost punctuation. That is precisely why **nothing outside
41/// this module should depend on which spelling wins** — and why the user-facing documentation of
42/// this behaviour promises one entry and stops there. What the fix guarantees, and all it needs to
43/// guarantee, is that the result is a *function of the document*: the defect was never the choice
44/// of spelling but that the choice varied between runs of the same input.
45pub mod multi_value_headers {
46    use serde::Deserialize;
47    use serde::de::{Deserializer, MapAccess, Visitor};
48    use serde::ser::{SerializeMap, Serializer};
49    use std::collections::HashMap;
50    use std::fmt;
51
52    pub fn serialize<S: Serializer>(
53        headers: &HashMap<String, Vec<String>>,
54        serializer: S,
55    ) -> Result<S::Ok, S::Error> {
56        let mut map = serializer.serialize_map(Some(headers.len()))?;
57        for (key, values) in headers {
58            match values.as_slice() {
59                [] => continue, // a key with no values would emit no header line; omit it
60                [single] => map.serialize_entry(key, single)?,
61                many => map.serialize_entry(key, many)?,
62            }
63        }
64        map.end()
65    }
66
67    /// A single header value on the wire. Mountebank tolerates non-string scalars — its recorders
68    /// routinely emit `"Content-Length": 124` (a JSON number) and `"X-Flag": true` — and coerces
69    /// them to their string form. Rift matches that so real recorded imposters load unchanged
70    /// (issue #754); previously a numeric/bool value failed both `OneOrMany` variants and rejected
71    /// the whole imposter with a 400.
72    #[derive(Deserialize)]
73    #[serde(untagged)]
74    enum Scalar {
75        Str(String),
76        Num(serde_json::Number),
77        Bool(bool),
78    }
79
80    impl Scalar {
81        fn into_string(self) -> String {
82            match self {
83                Scalar::Str(s) => s,
84                Scalar::Num(n) => n.to_string(),
85                Scalar::Bool(b) => b.to_string(),
86            }
87        }
88    }
89
90    /// Order matters for `#[serde(untagged)]`: a scalar can never match `Many` and an array can
91    /// never match `One`, so either order is sound — `One` first keeps the common case first.
92    #[derive(Deserialize)]
93    #[serde(untagged)]
94    enum OneOrMany {
95        One(Scalar),
96        Many(Vec<Scalar>),
97    }
98
99    impl OneOrMany {
100        /// An empty array yields no values rather than being rejected, which keeps the map
101        /// byte-identical to what the pre-#1039 code produced. It is not load-bearing beyond that:
102        /// `serialize` omits such an entry, and `RequestHeaders` filters it out of both `entries()`
103        /// and `len()`, so a name with no values already reads as absent everywhere downstream.
104        fn into_strings(self) -> Vec<String> {
105            match self {
106                OneOrMany::One(s) => vec![s.into_string()],
107                OneOrMany::Many(v) => v.into_iter().map(Scalar::into_string).collect(),
108            }
109        }
110    }
111
112    struct FoldingVisitor;
113
114    impl<'de> Visitor<'de> for FoldingVisitor {
115        type Value = HashMap<String, Vec<String>>;
116
117        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118            f.write_str("a map of header names to a scalar or an array of scalars")
119        }
120
121        fn visit_map<M: MapAccess<'de>>(self, mut access: M) -> Result<Self::Value, M::Error> {
122            // Accumulated in a `Vec` rather than a `HashMap` so the fold preserves the order the
123            // deserializer presents: which spelling survives and what order the values end up in
124            // are then properties of the document, not of a hash iteration order that varies per
125            // process. Deserializing straight into a `HashMap` also loses a repeated key silently,
126            // since serde's map impl is last-wins.
127            //
128            // `size_hint` is attacker-influenced in the general case — serde's own map impl clamps
129            // it for exactly this reason — so cap the preallocation rather than trusting it. Real
130            // header maps are far below the cap, so this never costs a realistic document a
131            // reallocation.
132            let mut folded: Vec<(String, Vec<String>)> =
133                Vec::with_capacity(access.size_hint().unwrap_or(0).min(1024));
134            while let Some((name, value)) = access.next_entry::<String, OneOrMany>()? {
135                let values = value.into_strings();
136                match folded
137                    .iter_mut()
138                    .find(|(k, _)| k.eq_ignore_ascii_case(&name))
139                {
140                    // `eq_ignore_ascii_case` is exactly the HTTP rule: names differing outside
141                    // ASCII are different names and must not be folded together.
142                    Some((_, existing)) => existing.extend(values),
143                    None => folded.push((name, values)),
144                }
145            }
146            Ok(folded.into_iter().collect())
147        }
148    }
149
150    pub fn deserialize<'de, D: Deserializer<'de>>(
151        deserializer: D,
152    ) -> Result<HashMap<String, Vec<String>>, D::Error> {
153        // The linear scan is O(k²) in the number of distinct names. Header maps are small and this
154        // runs on config load / the admin API, never on the request path.
155        deserializer.deserialize_map(FoldingVisitor)
156    }
157}
158
159/// Serde for header objects that hold **one** value per name (issue #1050) — `proxy.injectHeaders`
160/// and `_rift.fault.error.headers`.
161///
162/// Deserializing rejects a document that names one header twice, case-insensitively, instead of
163/// accepting it and picking a winner. That is deliberately the *opposite* of what
164/// [`multi_value_headers`] does, and the difference is not an inconsistency:
165///
166/// - A multi-value map has a lossless merge — keep both values — so folding costs nothing and
167///   #1039 folds.
168/// - A single-valued map has none *in general*. Two different values for one name have no correct
169///   combination, so a fold would have to pick a winner — and the tie-break cannot even be made
170///   deterministic, because which spelling the deserializer presents first depends on whether the
171///   document was streamed from text or routed through a `serde_json::Value` first, exactly as
172///   documented on [`multi_value_headers`].
173///
174/// The rule is therefore the simple one — *a single-valued header object names each header once* —
175/// and it is applied uniformly. Note that this refuses `{"x": "a", "X": "a"}` too, where a fold
176/// *would* be lossless: the case does not rest on the two values conflicting, and the code does not
177/// check whether they do. One rule that is always true beats two rules that need the reader to work
178/// out which applies.
179///
180/// Nothing that worked stops working. A document this rejects was previously emitting **two header
181/// lines** for that name, ordered by `HashMap` iteration and therefore differently per process:
182/// `RequestBuilder::header` and `http::response::Builder::header` both append rather than replace,
183/// so both spellings went out on the wire.
184///
185/// Values stay `String`-only. `multi_value_headers` also coerces JSON numbers and bools (issue
186/// #754); widening these two fields to match is a separate compatibility question, not a
187/// side effect of this one.
188pub mod single_value_headers {
189    use serde::de::{Deserializer, Error, MapAccess, Visitor};
190    use std::collections::HashMap;
191    use std::fmt;
192
193    struct OneEachVisitor;
194
195    impl<'de> Visitor<'de> for OneEachVisitor {
196        type Value = HashMap<String, String>;
197
198        fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199            f.write_str("a map of header names to single string values, each name given once")
200        }
201
202        fn visit_map<M: MapAccess<'de>>(self, mut access: M) -> Result<Self::Value, M::Error> {
203            // A `Vec` so the scan sees the spelling actually written, and so the error can quote
204            // it. The order is only used for the diagnostic; the rejection itself does not depend
205            // on which of the two the deserializer happened to present first.
206            let mut seen: Vec<(String, String)> =
207                Vec::with_capacity(access.size_hint().unwrap_or(0).min(1024));
208            while let Some((name, value)) = access.next_entry::<String, String>()? {
209                if let Some((existing, _)) =
210                    seen.iter().find(|(k, _)| k.eq_ignore_ascii_case(&name))
211                {
212                    return Err(M::Error::custom(format!(
213                        "header `{name}` is already given as `{existing}`; a single-valued header \
214                         object names each header once"
215                    )));
216                }
217                seen.push((name, value));
218            }
219            Ok(seen.into_iter().collect())
220        }
221    }
222
223    pub fn deserialize<'de, D: Deserializer<'de>>(
224        deserializer: D,
225    ) -> Result<HashMap<String, String>, D::Error> {
226        deserializer.deserialize_map(OneEachVisitor)
227    }
228}
229
230use serde::Deserialize;
231
232/// Parse a JSON `statusCode` value that may be a number or a (numeric) string.
233fn parse_status_code_value<E: serde::de::Error>(value: serde_json::Value) -> Result<u16, E> {
234    match value {
235        serde_json::Value::Number(n) => n
236            .as_u64()
237            .and_then(|n| u16::try_from(n).ok())
238            .ok_or_else(|| E::custom("invalid status code number")),
239        serde_json::Value::String(s) => s
240            .parse::<u16>()
241            .map_err(|_| E::custom(format!("invalid status code string: {s}"))),
242        _ => Err(E::custom("statusCode must be a number or string")),
243    }
244}
245
246/// Deserialize statusCode from either a number or a string
247pub fn deserialize_status_code<'de, D>(deserializer: D) -> Result<u16, D::Error>
248where
249    D: serde::Deserializer<'de>,
250{
251    parse_status_code_value(serde_json::Value::deserialize(deserializer)?)
252}
253
254/// Deserialize an optional top-level `statusCode` (flat response form, issue #304), reusing the
255/// number-or-string parsing. Only invoked when the field is present; a `null` is treated as
256/// absent (`None`) so a stray null on a non-flat response stays accepted as before.
257pub fn deserialize_optional_status_code<'de, D>(deserializer: D) -> Result<Option<u16>, D::Error>
258where
259    D: serde::Deserializer<'de>,
260{
261    match serde_json::Value::deserialize(deserializer)? {
262        serde_json::Value::Null => Ok(None),
263        value => parse_status_code_value(value).map(Some),
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use serde::Serialize;
271    use std::collections::HashMap;
272
273    /// Exercises the helpers through serde attributes, the only way they are ever reached — a
274    /// direct call would not prove the `#[serde(with = ...)]` wiring still resolves.
275    #[derive(Deserialize)]
276    struct HeadersIn {
277        #[serde(default, deserialize_with = "multi_value_headers::deserialize")]
278        headers: HashMap<String, Vec<String>>,
279    }
280
281    #[derive(Serialize)]
282    struct HeadersOut {
283        #[serde(serialize_with = "multi_value_headers::serialize")]
284        headers: HashMap<String, Vec<String>>,
285    }
286
287    #[derive(Deserialize)]
288    struct StatusIn {
289        #[serde(deserialize_with = "deserialize_status_code")]
290        status: u16,
291    }
292
293    #[derive(Deserialize)]
294    struct OptionalStatusIn {
295        #[serde(default, deserialize_with = "deserialize_optional_status_code")]
296        status: Option<u16>,
297    }
298
299    #[test]
300    fn headers_accept_a_bare_string_or_an_array() {
301        let one: HeadersIn = serde_json::from_str(r#"{"headers":{"X-One":"v"}}"#).unwrap();
302        assert_eq!(one.headers["X-One"], vec!["v".to_string()]);
303
304        let many: HeadersIn =
305            serde_json::from_str(r#"{"headers":{"Set-Cookie":["a","b"]}}"#).unwrap();
306        assert_eq!(
307            many.headers["Set-Cookie"],
308            vec!["a".to_string(), "b".to_string()]
309        );
310    }
311
312    // Issue #754: recorded imposters carry non-string scalars; they coerce rather than 400.
313    #[test]
314    fn headers_coerce_numeric_and_bool_scalars() {
315        let r: HeadersIn = serde_json::from_str(
316            r#"{"headers":{"Content-Length":124,"X-Flag":true,"X-Ratio":1.5,"X-Multi":[200,"x",false]}}"#,
317        )
318        .expect("numeric/bool header values must be accepted (mb parity)");
319        assert_eq!(r.headers["Content-Length"], vec!["124".to_string()]);
320        assert_eq!(r.headers["X-Flag"], vec!["true".to_string()]);
321        assert_eq!(r.headers["X-Ratio"], vec!["1.5".to_string()]);
322        assert_eq!(
323            r.headers["X-Multi"],
324            vec!["200".to_string(), "x".to_string(), "false".to_string()]
325        );
326    }
327
328    // Issue #1039: one key per case-folded name. Before this, the deserializer built a
329    // `HashMap<String, OneOrMany>`, which both split `content-type` from `Content-Type` into two
330    // entries and silently last-wins on a repeated key. Two entries for one header name make every
331    // case-insensitive find-first lookup downstream (form parsing, predicates, `copy`, the
332    // scripting engines) answer nondeterministically, and make `deepEquals` on headers compare the
333    // wrong count.
334    #[test]
335    fn headers_merge_case_variant_keys_under_the_first_spelling() {
336        let r: HeadersIn =
337            serde_json::from_str(r#"{"headers":{"content-type":"a","Content-Type":"b"}}"#)
338                .expect("case-variant keys are a valid document, not an error");
339        assert_eq!(
340            r.headers.len(),
341            1,
342            "two spellings of one name is one header"
343        );
344        assert_eq!(
345            r.headers["content-type"],
346            vec!["a".to_string(), "b".to_string()],
347            "first spelling survives; values follow document order"
348        );
349        assert!(
350            !r.headers.contains_key("Content-Type"),
351            "the later spelling must not survive as a second key"
352        );
353    }
354
355    #[test]
356    fn headers_merge_byte_identical_duplicate_keys() {
357        let r: HeadersIn = serde_json::from_str(r#"{"headers":{"X-Dup":"a","X-Dup":"b"}}"#)
358            .expect("a repeated key is accepted");
359        assert_eq!(
360            r.headers["X-Dup"],
361            vec!["a".to_string(), "b".to_string()],
362            "a repeated key keeps both values instead of serde's silent last-wins"
363        );
364    }
365
366    #[test]
367    fn headers_preserve_a_lone_keys_spelling_byte_exact() {
368        // The served header spelling is a contract (`oddly_cased_content_type_not_duplicated`,
369        // and the `Content-type` fixture in the SDK corpus whose replay must deep-equal).
370        let r: HeadersIn = serde_json::from_str(r#"{"headers":{"Content-type":"x"}}"#).unwrap();
371        assert_eq!(r.headers["Content-type"], vec!["x".to_string()]);
372        assert!(!r.headers.contains_key("Content-Type"));
373        assert!(!r.headers.contains_key("content-type"));
374
375        let shouty: HeadersIn =
376            serde_json::from_str(r#"{"headers":{"CONTENT-TYPE":"y"}}"#).unwrap();
377        assert_eq!(shouty.headers["CONTENT-TYPE"], vec!["y".to_string()]);
378
379        // The contract is parse *and serve*, so go the whole way back out to the wire — it is the
380        // served spelling those guards pin, and asserting only the parsed key leaves that half of
381        // the round trip untested.
382        let served = serde_json::to_value(HeadersOut { headers: r.headers }).expect("serialize");
383        assert_eq!(served["headers"], serde_json::json!({"Content-type": "x"}));
384    }
385
386    #[test]
387    fn headers_merge_three_way_variants_with_mixed_scalars_and_arrays() {
388        let r: HeadersIn =
389            serde_json::from_str(r#"{"headers":{"X-A":1,"x-a":[true,"z"],"X-a":2.5}}"#)
390                .expect("#754 scalar coercion still applies to every merged entry");
391        assert_eq!(r.headers.len(), 1);
392        assert_eq!(
393            r.headers["X-A"],
394            vec![
395                "1".to_string(),
396                "true".to_string(),
397                "z".to_string(),
398                "2.5".to_string()
399            ]
400        );
401    }
402
403    #[test]
404    fn headers_merge_is_deterministic_across_repeated_parses() {
405        // The issue's actual symptom: with a `HashMap` intermediate the surviving key depended on
406        // iteration order, so the same stored document answered differently across runs.
407        for _ in 0..200 {
408            let r: HeadersIn = serde_json::from_str(
409                r#"{"headers":{"content-type":"a","Content-Type":"b","CONTENT-TYPE":"c"}}"#,
410            )
411            .unwrap();
412            assert_eq!(r.headers.len(), 1);
413            assert_eq!(
414                r.headers["content-type"],
415                vec!["a".to_string(), "b".to_string(), "c".to_string()]
416            );
417        }
418    }
419
420    // The two deserializer inputs present entries in different orders, and both reach production:
421    // streaming from text or bytes gives document order, while going through a `serde_json::Value`
422    // first walks a `Map` and gives byte order. See the module doc for which callers take which —
423    // `--configfile` alone takes both, depending on the document's outermost punctuation, which is
424    // why nothing outside this module may depend on the answer.
425    //
426    // The byte-order half asserts that `preserve_order` is OFF workspace-wide. If a future
427    // dependency turns it on, this is the test that should fail — deliberately, and first.
428    #[test]
429    fn headers_fold_deterministically_from_a_value_too_even_though_the_order_differs() {
430        const TEXT: &str = r#"{"headers":{"set-cookie":"a","Set-Cookie":"b"}}"#;
431
432        let from_text: HeadersIn = serde_json::from_str(TEXT).unwrap();
433        assert_eq!(
434            from_text.headers["set-cookie"],
435            vec!["a".to_string(), "b".to_string()],
436            "streaming from text keeps document order, so the first spelling written wins"
437        );
438
439        let value: serde_json::Value = serde_json::from_str(TEXT).unwrap();
440        let from_value: HeadersIn = serde_json::from_value(value).unwrap();
441        assert_eq!(from_value.headers.len(), 1, "still one header either way");
442        assert_eq!(
443            from_value.headers["Set-Cookie"],
444            vec!["b".to_string(), "a".to_string()],
445            "a `Map` is key-sorted, so `Set-Cookie` (0x53) precedes `set-cookie` (0x73)"
446        );
447    }
448
449    // Asserts that `float_roundtrip` is ON workspace-wide (issue #1085). Without it serde_json's
450    // float parse is not correctly rounded: each literal below comes back one representable double
451    // away, and is written with different digits (`7e23` as `6.999999999999999e23`). Every JSON
452    // number rift parses — stub bodies, request bodies, lint input — goes through this parser.
453    #[test]
454    fn serde_json_parses_floats_correctly_rounded() {
455        for literal in [
456            "7e23",
457            "1e-23",
458            "1.23e-30",
459            "1.2299999999999999e-30",
460            "0.10018513143495411",
461        ] {
462            let value: serde_json::Value = serde_json::from_str(literal).unwrap();
463            assert_eq!(value.to_string(), literal);
464        }
465    }
466
467    #[test]
468    fn headers_fold_only_ascii_case_and_keep_empty_shapes() {
469        let empty: HeadersIn = serde_json::from_str(r#"{"headers":{}}"#).unwrap();
470        assert!(empty.headers.is_empty());
471
472        // A key present with an empty array stays present with no values — the pre-#1039 map shape,
473        // preserved so the fold changes nothing it does not have to. Downstream it is invisible
474        // either way: `serialize` omits it and `RequestHeaders` filters it out of `entries()` and
475        // `len()`, so such a name already reads as absent (`header_name_with_no_values_reads_as_absent`).
476        let no_values: HeadersIn = serde_json::from_str(r#"{"headers":{"X-None":[]}}"#).unwrap();
477        assert_eq!(no_values.headers["X-None"], Vec::<String>::new());
478
479        // `eq_ignore_ascii_case` is exactly the HTTP rule: names differing outside ASCII are
480        // different names and must not be folded together.
481        let non_ascii: HeadersIn =
482            serde_json::from_str(r#"{"headers":{"X-Kä":"1","X-KÄ":"2"}}"#).unwrap();
483        assert_eq!(non_ascii.headers.len(), 2);
484    }
485
486    #[test]
487    fn headers_serialize_single_as_string_many_as_array_and_omit_empty() {
488        let out = HeadersOut {
489            headers: HashMap::from([
490                ("X-One".to_string(), vec!["v".to_string()]),
491                (
492                    "Set-Cookie".to_string(),
493                    vec!["a".to_string(), "b".to_string()],
494                ),
495                ("X-Empty".to_string(), vec![]),
496            ]),
497        };
498        let v = serde_json::to_value(&out).unwrap();
499        assert_eq!(v["headers"]["X-One"], serde_json::json!("v"));
500        assert_eq!(v["headers"]["Set-Cookie"], serde_json::json!(["a", "b"]));
501        assert!(
502            v["headers"].get("X-Empty").is_none(),
503            "a key with no values emits no header line, so it is omitted"
504        );
505    }
506
507    /// Exercises `single_value_headers` through a serde attribute, the only way it is reached.
508    #[derive(Deserialize, Debug)]
509    struct SingleIn {
510        #[serde(default, deserialize_with = "single_value_headers::deserialize")]
511        headers: HashMap<String, String>,
512    }
513
514    // Issue #1050. Before this, both spellings survived as distinct keys and BOTH went out on the
515    // wire — `RequestBuilder::header` and `response::Builder::header` append rather than replace —
516    // so the peer received two lines for one header, ordered by `HashMap` iteration and therefore
517    // differently per process. Refusing costs nothing that was working.
518    #[test]
519    fn a_name_given_twice_in_different_case_is_rejected() {
520        let err = serde_json::from_str::<SingleIn>(
521            r#"{"headers":{"content-type":"a","Content-Type":"b"}}"#,
522        )
523        .expect_err("a single-valued header object names each header once");
524        let msg = err.to_string();
525        assert!(
526            msg.contains("content-type") && msg.contains("Content-Type"),
527            "the error must name BOTH spellings so the author can find them: {msg}"
528        );
529    }
530
531    // The `Value`-mediated path must reject too. Case variants are distinct keys in a
532    // `serde_json::Map`, so they both survive to here — unlike a byte-identical duplicate, which
533    // the JSON parser has already collapsed by this point (the same two-path split documented on
534    // `multi_value_headers`).
535    #[test]
536    fn a_case_variant_name_is_rejected_through_a_value_too() {
537        let value: serde_json::Value =
538            serde_json::from_str(r#"{"headers":{"x-id":"a","X-Id":"b"}}"#).unwrap();
539        assert!(
540            serde_json::from_value::<SingleIn>(value).is_err(),
541            "routing through a Value must not launder a duplicate past the check"
542        );
543    }
544
545    #[test]
546    fn a_byte_identical_duplicate_is_rejected_on_the_text_path() {
547        assert!(
548            serde_json::from_str::<SingleIn>(r#"{"headers":{"X-Id":"a","X-Id":"b"}}"#).is_err(),
549            "serde's default map visitor would have silently kept the last one"
550        );
551    }
552
553    #[test]
554    fn an_ordinary_single_valued_header_object_is_unchanged() {
555        let ok: SingleIn =
556            serde_json::from_str(r#"{"headers":{"X-Id":"a","Content-Type":"text/plain"}}"#)
557                .expect("every name given once");
558        assert_eq!(ok.headers["X-Id"], "a");
559        assert_eq!(ok.headers["Content-Type"], "text/plain");
560        assert_eq!(ok.headers.len(), 2);
561
562        let empty: SingleIn = serde_json::from_str(r#"{"headers":{}}"#).unwrap();
563        assert!(empty.headers.is_empty());
564
565        // Absent is not an error — both fields carry `#[serde(default)]`.
566        let missing: SingleIn = serde_json::from_str("{}").unwrap();
567        assert!(missing.headers.is_empty());
568    }
569
570    // `eq_ignore_ascii_case` is the HTTP rule, as in `multi_value_headers`: names differing outside
571    // ASCII are different names, and refusing them would reject a document that is fine.
572    #[test]
573    fn names_differing_outside_ascii_are_not_treated_as_duplicates() {
574        let ok: SingleIn =
575            serde_json::from_str(r#"{"headers":{"X-Kä":"1","X-KÄ":"2"}}"#).expect("distinct names");
576        assert_eq!(ok.headers.len(), 2);
577    }
578
579    #[test]
580    fn status_code_accepts_a_number_or_a_numeric_string() {
581        assert_eq!(
582            serde_json::from_str::<StatusIn>(r#"{"status":404}"#)
583                .unwrap()
584                .status,
585            404
586        );
587        assert_eq!(
588            serde_json::from_str::<StatusIn>(r#"{"status":"404"}"#)
589                .unwrap()
590                .status,
591            404
592        );
593    }
594
595    #[test]
596    fn status_code_rejects_junk_rather_than_defaulting() {
597        for junk in [
598            r#"{"status":"abc"}"#,
599            r#"{"status":true}"#,
600            // One past `u16::MAX`, in both spellings — the boundary the two parse paths
601            // (`str::parse::<u16>` and `u16::try_from(u64)`) have to agree on.
602            r#"{"status":65536}"#,
603            r#"{"status":"65536"}"#,
604            r#"{"status":-1}"#,
605            // A non-integer number: `Number::as_u64` yields `None` rather than truncating.
606            r#"{"status":200.5}"#,
607        ] {
608            assert!(
609                serde_json::from_str::<StatusIn>(junk).is_err(),
610                "{junk} must be an error, not a silent default"
611            );
612        }
613
614        // …and the boundary itself is still accepted, so the rejections above are landing on the
615        // right side of it.
616        assert_eq!(
617            serde_json::from_str::<StatusIn>(r#"{"status":65535}"#)
618                .unwrap()
619                .status,
620            65535
621        );
622    }
623
624    #[test]
625    fn optional_status_code_treats_null_and_absent_as_none() {
626        assert_eq!(
627            serde_json::from_str::<OptionalStatusIn>(r#"{}"#)
628                .unwrap()
629                .status,
630            None
631        );
632        assert_eq!(
633            serde_json::from_str::<OptionalStatusIn>(r#"{"status":null}"#)
634                .unwrap()
635                .status,
636            None
637        );
638        assert_eq!(
639            serde_json::from_str::<OptionalStatusIn>(r#"{"status":"201"}"#)
640                .unwrap()
641                .status,
642            Some(201)
643        );
644    }
645}