Skip to main content

sipx_testkit/
rfc5118.rs

1//! The RFC 5118 IPv6 SIP torture-test corpus.
2//!
3//! RFC 5118 is the IPv6 twin of RFC 4475: ten sections of messages built to break parsers that
4//! guess at where an IPv6 reference ends. Almost all of them are *valid*, which is the point —
5//! the document exists because implementations were rejecting messages they were obliged to
6//! accept, and because a colon means two different things inside `[...]` and after it.
7//!
8//! The messages are recovered from the bit-exact archive in that RFC's Appendix A by
9//! `scripts/import-rfc5118-corpus.sh`, not retyped. Retyping is a worse idea here than for
10//! RFC 4475: every case turns on the exact placement of `:`, `[` and `]`, and two of the
11//! messages are wrapped across lines in the RFC's body text with an `<allOneLine>` convention
12//! that a transcriber has to unwrap by hand. Run the script with `--check` to verify the
13//! committed corpus still matches the RFC.
14//!
15//! # The archive is not wire bytes
16//!
17//! One difference from RFC 4475 has to be handled rather than admired. The files in RFC 5118's
18//! archive are terminated with **bare LF**, not CRLF — there is not one CR octet in any of the
19//! twelve — and the two §4.10 files carry no terminating blank line at all. SIP requires CRLF
20//! (RFC 3261 §7), so the archived bytes are not a legal SIP message as shipped.
21//!
22//! On top of that, the three messages carrying SDP declare a `Content-Length` that matches no
23//! convention — see [`Case::wire`] for the numbers.
24//!
25//! The corpus on disk is kept bit-exact anyway, because that is what `--check` verifies against
26//! the RFC. [`Case::wire`] performs the documented transformations needed to get on-the-wire
27//! bytes, and [`Case::bytes`] remains the archive's own content. Those transformations touch only
28//! line terminators and the digits of one header value; every octet of every IPv6 reference,
29//! URI and body is the RFC's.
30//!
31//! # What the classification is for
32//!
33//! Each case carries an [`Expect`] naming which layer must object to it, and the tests assert
34//! against that rather than a bare pass/fail. The vocabulary is [`crate::rfc4475`]'s, imported
35//! rather than redefined: a reader comparing the two corpora is then comparing like with like,
36//! and there is exactly one definition of what `ParseOk` claims.
37//!
38//! Unlike RFC 4475, this corpus is almost entirely `ParseOk`. Only §4.2 is invalid, and the RFC
39//! says so in its title. The other nine sections are demonstrations that a parser must *accept*
40//! things it may not expect, so the converse assertion — that nothing valid is rejected — is
41//! where the value of this corpus lies.
42
43use bytes::Bytes;
44
45pub use crate::rfc4475::{Expect, Fault};
46
47/// One message from the corpus.
48#[derive(Debug, Clone, Copy)]
49pub struct Case {
50    /// The RFC's own name for the message, e.g. `ipv6-good`. RFC 5118 labels each message with
51    /// this name ("Message Details: ipv6-good"), and the archive's file names match, so the
52    /// name is the link from a fixture back to the prose that describes it.
53    pub name: &'static str,
54    /// The section of RFC 5118 that describes it, e.g. `4.1`. Two sections describe two
55    /// messages each, so this is not unique across cases.
56    pub section: &'static str,
57    /// That section's title, verbatim.
58    pub title: &'static str,
59    /// Which layer must object, and how.
60    pub expect: Expect,
61    /// The message exactly as Appendix A's archive holds it — LF-terminated, and for the §4.10
62    /// pair without a terminating blank line. Use [`Case::wire`] to get bytes a SIP parser is
63    /// meant to see.
64    pub bytes: &'static [u8],
65}
66
67impl Case {
68    /// The archive bytes turned into on-the-wire SIP.
69    ///
70    /// Three transformations, all forced by the archive rather than chosen:
71    ///
72    /// 1. **LF becomes CRLF.** RFC 3261 §7 terminates every start line and header field with
73    ///    CRLF. The archive holds none, so without this every message is one unterminated
74    ///    header line and the corpus would measure nothing but that fact.
75    /// 2. **The header section is terminated** if the archive left it open, which it does for
76    ///    §4.10's two files. An unterminated header section is indistinguishable from a
77    ///    truncated message, so a parser is right to refuse it — see how RFC 4475's `baddn` is
78    ///    classified in [`crate::rfc4475`]. Refusing it here would test the archive's
79    ///    formatting, not sipx's IPv6 handling.
80    /// 3. **`Content-Length` is set to the actual body length.** The three messages that carry
81    ///    SDP declare a length that matches neither the LF-terminated body in the archive nor
82    ///    the CRLF-terminated one:
83    ///
84    ///    | case | declared | archive body (LF) | wire body (CRLF) |
85    ///    | --- | --- | --- | --- |
86    ///    | §4.6 `ipv6-in-sdp` | 268 | 242 | 251 |
87    ///    | §4.8 `mult-ip-in-sdp` | 181 | 180 | 189 |
88    ///    | §4.9 `ipv4-mapped-ipv6` | 236 | 236 | 245 |
89    ///
90    ///    No single convention reconciles those, so the declared values are simply wrong — and
91    ///    RFC 5118 has one verified erratum, on §4.3's wording, which does not mention them. A
92    ///    parser is *right* to refuse §4.6 as truncated and right to discard the tail of the
93    ///    other two, but doing so here would measure the RFC's arithmetic instead of sipx's
94    ///    IPv6 handling, and would cut §4.8's and §4.9's SDP off mid-body before `sipx-sdp`
95    ///    ever saw the `c=` lines the sections exist to exercise.
96    ///
97    ///    Framing is covered thoroughly by RFC 4475, which has cases built for it
98    ///    (`clerr`, `ncl`, `mcl01`) and correct lengths everywhere else.
99    ///
100    /// None of the three touches an octet inside an IPv6 reference, a URI, or a body — only line
101    /// terminators and the digits of one header value. What the corpus is for survives intact,
102    /// and `wire_changes_only_terminators_and_content_length` holds that claim.
103    #[must_use]
104    pub fn wire(&self) -> Bytes {
105        let mut out = Vec::with_capacity(self.bytes.len() + self.bytes.len() / 8 + 2);
106        for &b in self.bytes {
107            if b == b'\n' && out.last() != Some(&b'\r') {
108                out.push(b'\r');
109            }
110            out.push(b);
111        }
112        if !out.windows(4).any(|w| w == b"\r\n\r\n") {
113            out.extend_from_slice(b"\r\n");
114        }
115
116        let Some(separator) = out.windows(4).position(|w| w == b"\r\n\r\n") else {
117            return Bytes::from(out);
118        };
119        let (headers, body) = out.split_at(separator + 4);
120        let body_len = body.len();
121
122        // Rewrite only the digits of the Content-Length value. The field name, its case and the
123        // separator are left exactly as they arrived, so nothing about how the header is spelled
124        // is quietly normalised on the way through.
125        let mut result = Vec::with_capacity(out.len() + 8);
126        for (i, line) in headers.split(|&b| b == b'\n').enumerate() {
127            if i > 0 {
128                result.push(b'\n');
129            }
130            let name_len = line.iter().position(|&b| b == b':');
131            let is_content_length = name_len
132                .and_then(|c| line.get(..c))
133                .is_some_and(|name| name.eq_ignore_ascii_case(b"Content-Length"));
134            match name_len.filter(|_| is_content_length) {
135                Some(colon) => {
136                    result.extend_from_slice(line.get(..=colon).unwrap_or(line));
137                    result.push(b' ');
138                    result.extend_from_slice(body_len.to_string().as_bytes());
139                    result.push(b'\r');
140                }
141                None => result.extend_from_slice(line),
142            }
143        }
144        result.extend_from_slice(body);
145        Bytes::from(result)
146    }
147
148    /// The message as a lossy string, for assertion messages.
149    #[must_use]
150    pub fn lossy(&self) -> std::borrow::Cow<'_, str> {
151        String::from_utf8_lossy(self.bytes)
152    }
153
154    /// Whether this case is asserted on by the parser tests. Every RFC 5118 case is: unlike
155    /// RFC 4475's archive, this one carries no file that no section references.
156    #[must_use]
157    pub fn is_classified(&self) -> bool {
158        self.expect != Expect::Unreferenced
159    }
160
161    /// Whether the message carries an SDP body, and so belongs to the `sipx-sdp` half of the
162    /// harness as well as the `sipx-sip` half.
163    #[must_use]
164    pub fn has_sdp(&self) -> bool {
165        matches!(
166            self.name,
167            "ipv6-in-sdp" | "mult-ip-in-sdp" | "ipv4-mapped-ipv6"
168        )
169    }
170}
171
172macro_rules! corpus {
173    ($($name:literal => $section:literal, $title:literal, $expect:expr;)*) => {
174        /// Every message in the corpus, in RFC section order.
175        pub static CASES: &[Case] = &[$(
176            Case {
177                name: $name,
178                section: $section,
179                title: $title,
180                expect: $expect,
181                bytes: include_bytes!(concat!("../corpus/rfc5118/", $name)),
182            },
183        )*];
184    };
185}
186
187use Expect::{ParseErr, ParseOk};
188use Fault::StartLine;
189
190corpus! {
191    // ---- 4.1 ------------------------------------------------------------------------
192    // An IPv6 reference in the R-URI, the Via and the Contact, all correctly delimited.
193    // "well-formatted according to the grammar in [RFC3261]".
194    "ipv6-good"  => "4.1", "Valid SIP Message with an IPv6 Reference", ParseOk;
195
196    // ---- 4.2 ------------------------------------------------------------------------
197    // The only invalid message in the corpus, and the RFC's title says so. The R-URI is
198    // `sip:2001:db8::10` — an IPv6 address with the mandated "[" "]" stripped off. The RFC:
199    // "A SIP implementation receiving this request should respond with a 400 Bad Request".
200    //
201    // Classified as a start-line fault rather than a header one because that is where the
202    // undelimited reference is: the Request-URI. The same treatment RFC 4475 gives `lwsruri`.
203    "ipv6-bad"   => "4.2", "Invalid SIP Message with an IPv6 Reference", ParseErr(StartLine);
204
205    // ---- 4.3 ------------------------------------------------------------------------
206    // `sip:[2001:db8::10:5070]` — the sender meant port 5070 and put it inside the "]". The RFC
207    // is explicit that this is not a parse error: "From a parsing perspective, the request below
208    // is well-formed. However, from a semantic point of view, it will not yield the desired
209    // result." So the parser must accept it, and what it decides the host and port *are* is a
210    // choice this corpus exists to pin down. See the harness test
211    // `port_ambiguous_is_decided_the_way_the_rfc_predicts`.
212    "port-ambiguous"   => "4.3", "Port Ambiguous in a SIP URI", ParseOk;
213
214    // ---- 4.4 ------------------------------------------------------------------------
215    // The contrast to 4.3: `sip:[2001:db8::10]:5070`, where the port is outside the "]".
216    "port-unambiguous" => "4.4", "Port Unambiguous in a SIP URI", ParseOk;
217
218    // ---- 4.5 ------------------------------------------------------------------------
219    // Two messages for one section, and the pair is the test. RFC 3261's `via-received`
220    // production takes a bare `IPv6address`, with no "[" "]" — while `sent-by` takes an
221    // `IPv6reference`, which has them. Implementations split roughly 50/50 on what they sent,
222    // so the RFC's instruction is the Robustness Principle: "implementations must follow the
223    // Robustness Principle [RFC1122] and be liberal in accepting a 'received' parameter with or
224    // without the delimiting '[' and ']' tokens", and "A SIP implementation receiving either of
225    // these messages must parse them successfully."
226    //
227    // So `with-delim` is ParseOk *despite* being invalid under a strict reading of the grammar.
228    // That is not sloppiness in the classification; it is what the RFC requires, and it is the
229    // one place in either corpus where "must accept" and "matches the ABNF" come apart.
230    "via-received-param-with-delim" => "4.5", "IPv6 Reference Delimiters in Via Header", ParseOk;
231    "via-received-param-no-delim"   => "4.5", "IPv6 Reference Delimiters in Via Header", ParseOk;
232
233    // ---- 4.6 ------------------------------------------------------------------------
234    // "valid and well-formed". Carries SDP whose `o=` and `c=` lines hold IPv6 addresses
235    // *without* "[" "]" — SDP has its own grammar (RFC 4566/8866) and never adopted the
236    // brackets. A stack that reuses its SIP host parser for `c=` lines fails here.
237    "ipv6-in-sdp" => "4.6", "SIP Request with IPv6 Addresses in Session Description Protocol (SDP) Body", ParseOk;
238
239    // ---- 4.7 ------------------------------------------------------------------------
240    // Three Via headers mixing IPv4 and IPv6, one with a port inside the reference's "]" and
241    // one with a `received` IPv4 parameter.
242    "mult-ip-in-header" => "4.7", "Multiple IP Addresses in SIP Headers", ParseOk;
243
244    // ---- 4.8 ------------------------------------------------------------------------
245    // Per-media `c=` lines, one IPv4 and one IPv6, overriding an `o=` line that names a
246    // hostname rather than an address. The session has no session-level `c=` at all.
247    "mult-ip-in-sdp" => "4.8", "Multiple IP Addresses in SDP", ParseOk;
248
249    // ---- 4.9 ------------------------------------------------------------------------
250    // IPv4-mapped addresses (`::ffff:192.0.2.2`) in two Vias, a Contact, and the SDP. "A SIP
251    // implementation receiving a message that contains such a mapped address must be prepared
252    // to parse it successfully."
253    "ipv4-mapped-ipv6" => "4.9", "IPv4-Mapped IPv6 Addresses", ParseOk;
254
255    // ---- 4.10 -----------------------------------------------------------------------
256    // Another contrast pair. RFC 3261's ABNF, inherited from the obsolete RFC 2373, permits
257    // `[2001:db8:::192.0.2.1]` — three colons before the embedded IPv4 address. RFC 4291
258    // fixed the grammar; RFC 5118's instruction is to tolerate both: "following the Robustness
259    // Principle [RFC1122], an implementation must tolerate both of the above constructs."
260    //
261    // The RFC permits, but does not require, re-serializing the three-colon form as two. Which
262    // sipx does is asserted in `abnf_bug_reference_is_tolerated`.
263    "ipv6-bug-abnf-3-colons"     => "4.10", "IPv6 Reference Bug in RFC 3261 ABNF", ParseOk;
264    "ipv6-correct-abnf-2-colons" => "4.10", "IPv6 Reference Bug in RFC 3261 ABNF", ParseOk;
265}
266
267/// A place where sipx currently departs from what RFC 5118 requires.
268///
269/// The [`Case`] table above records what the *RFC* says, always — a classification that drifted
270/// towards what sipx happens to do would stop being a measurement. This is the separate, explicit
271/// record of the gap, so that neither fact has to be softened to accommodate the other.
272///
273/// Keeping the gap in a typed list rather than a comment is deliberate. The harness asserts that
274/// each deviation still behaves exactly as described, so the moment the underlying defect is
275/// fixed the assertion fails and whoever fixed it is told to delete the entry. A deviation
276/// recorded in prose would instead quietly become false.
277#[derive(Debug, Clone, Copy)]
278pub struct Deviation {
279    /// The case that deviates.
280    pub case: &'static str,
281    /// What RFC 5118 requires, quoted or closely paraphrased.
282    pub rfc_requires: &'static str,
283    /// What sipx does instead, in enough detail to assert on.
284    pub sipx_does: &'static str,
285    /// Why this story records the gap rather than closing it.
286    pub why_recorded: &'static str,
287}
288
289/// Every known departure from RFC 5118. None: sipx conforms to all twelve messages.
290///
291/// The list held one entry from `X-16`, which imported this corpus, until `S-31` closed it —
292/// §4.10's three-colon reference `[2001:db8:::192.0.2.1]`, which sipx rejected as a malformed
293/// address where the RFC requires tolerance. It is now parsed under the narrow rule in
294/// `docs/specs/sip-parser.md` §4.8, so the conformance assertions cover the case and there is
295/// nothing left to except from them.
296///
297/// Empty is the state this list is supposed to be in, and it stays declared rather than deleted:
298/// the next corpus that measures before it fixes needs the same machinery, and an empty list is
299/// the honest way to say "measured, nothing outstanding".
300pub static DEVIATIONS: &[Deviation] = &[];
301
302/// The recorded deviation for a case, if it has one.
303#[must_use]
304pub fn deviation(name: &str) -> Option<&'static Deviation> {
305    DEVIATIONS.iter().find(|d| d.case == name)
306}
307
308/// Whether a case is one sipx is known to handle contrary to the RFC.
309#[must_use]
310pub fn deviates(name: &str) -> bool {
311    deviation(name).is_some()
312}
313
314/// Every case the parser tests assert on.
315pub fn classified() -> impl Iterator<Item = &'static Case> {
316    CASES.iter().filter(|c| c.is_classified())
317}
318
319/// Cases that behave as RFC 5118 requires — every case except the recorded deviations.
320///
321/// This is what the conformance assertions iterate. It is deliberately *not* a filter on
322/// `expect`: the classification says what the RFC requires, and subtracting the deviations from it
323/// is what keeps "what the RFC says" and "what sipx does" as two separate, comparable facts.
324pub fn conforming() -> impl Iterator<Item = &'static Case> {
325    CASES.iter().filter(|c| !deviates(c.name))
326}
327
328/// Cases matching a given expectation.
329pub fn expecting(expect: Expect) -> impl Iterator<Item = &'static Case> {
330    CASES.iter().filter(move |c| c.expect == expect)
331}
332
333/// Cases carrying an SDP body.
334pub fn with_sdp() -> impl Iterator<Item = &'static Case> {
335    CASES.iter().filter(|c| c.has_sdp())
336}
337
338/// Look up a case by its RFC name.
339#[must_use]
340pub fn case(name: &str) -> Option<&'static Case> {
341    CASES.iter().find(|c| c.name == name)
342}
343
344#[cfg(test)]
345// The no-unwrap/no-panic rules exist because library code parses hostile input. A test that
346// cannot read its own fixtures should fail loudly.
347#[allow(
348    clippy::unwrap_used,
349    clippy::expect_used,
350    clippy::panic,
351    clippy::indexing_slicing,
352    // Counting newlines in a 600-byte fixture does not warrant a dependency.
353    clippy::naive_bytecount
354)]
355mod tests {
356    use super::*;
357    use std::collections::HashSet;
358
359    /// The corpus is only a correctness bar if it is complete. RFC 5118 §4 runs 4.1 to 4.10,
360    /// and two of those sections carry a contrast pair — drop either half and the section stops
361    /// testing the thing it was written to test.
362    #[test]
363    fn corpus_is_complete() {
364        assert_eq!(CASES.len(), 12, "Appendix A's archive holds 12 files");
365        assert_eq!(
366            classified().count(),
367            12,
368            "every file is referenced by a section"
369        );
370
371        let sections: HashSet<_> = CASES.iter().map(|c| c.section).collect();
372        assert_eq!(sections.len(), 10, "RFC 5118 section 4 has ten subsections");
373
374        for n in 1..=10 {
375            let section = if n == 10 {
376                "4.10".to_owned()
377            } else {
378                format!("4.{n}")
379            };
380            assert!(
381                CASES.iter().any(|c| c.section == section),
382                "no case for RFC 5118 section {section}"
383            );
384        }
385
386        // The two contrast pairs, named so a reader knows the duplication is deliberate.
387        for section in ["4.5", "4.10"] {
388            assert_eq!(
389                CASES.iter().filter(|c| c.section == section).count(),
390                2,
391                "section {section} contrasts two messages"
392            );
393        }
394    }
395
396    /// Only §4.2 is invalid, and this corpus is worth running because of that imbalance rather
397    /// than in spite of it. If a later edit quietly reclassified a valid message as a rejection,
398    /// the corpus would start asserting the opposite of what the RFC says while staying green.
399    #[test]
400    fn only_section_4_2_is_a_rejection() {
401        let rejected: Vec<_> = CASES
402            .iter()
403            .filter(|c| matches!(c.expect, Expect::ParseErr(_)))
404            .map(|c| c.name)
405            .collect();
406        assert_eq!(
407            rejected,
408            vec!["ipv6-bad"],
409            "RFC 5118 titles exactly one message invalid (§4.2)"
410        );
411        assert_eq!(
412            expecting(ParseOk).count(),
413            11,
414            "the other eleven are demonstrations a parser must accept"
415        );
416    }
417
418    /// A deviation must name a real case, and must not be recorded for a case the RFC itself
419    /// calls invalid — "sipx rejects a message the RFC says to reject" is conformance, not a gap.
420    #[test]
421    fn deviations_name_real_and_valid_cases() {
422        for d in DEVIATIONS {
423            let c = case(d.case).unwrap_or_else(|| panic!("{} is not in the corpus", d.case));
424            assert_eq!(
425                c.expect, ParseOk,
426                "{}: a deviation only makes sense for a message the RFC calls valid",
427                d.case
428            );
429            assert!(
430                !d.rfc_requires.is_empty() && !d.sipx_does.is_empty() && !d.why_recorded.is_empty(),
431                "{}: a deviation has to say what the RFC wants, what sipx does, and why it stands",
432                d.case
433            );
434        }
435        assert_eq!(
436            conforming().count() + DEVIATIONS.len(),
437            CASES.len(),
438            "every case is either conforming or a recorded deviation"
439        );
440    }
441
442    #[test]
443    fn case_names_are_unique() {
444        let names: HashSet<_> = CASES.iter().map(|c| c.name).collect();
445        assert_eq!(names.len(), CASES.len(), "duplicate case name");
446    }
447
448    /// The table is hand-written; the directory is generated by the import script. If they
449    /// drift, the table is silently ignoring a message.
450    #[test]
451    fn table_matches_the_imported_directory() {
452        let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/corpus/rfc5118");
453        let mut on_disk: Vec<String> = std::fs::read_dir(dir)
454            .expect("corpus directory")
455            .filter_map(Result::ok)
456            .map(|e| e.file_name().to_string_lossy().into_owned())
457            .filter(|name| name != "README.md")
458            .collect();
459        on_disk.sort();
460
461        let mut in_table: Vec<String> = CASES.iter().map(|c| c.name.to_owned()).collect();
462        in_table.sort();
463
464        assert_eq!(
465            on_disk, in_table,
466            "corpus directory and case table disagree"
467        );
468    }
469
470    /// The archive's own shape, asserted so the [`Case::wire`] transformation stays justified.
471    /// If a future re-import brought CRLF files, `wire` would become a no-op and this test
472    /// would say so rather than leaving a transformation nobody could explain.
473    #[test]
474    fn the_archive_is_lf_terminated_which_is_why_wire_exists() {
475        for c in CASES {
476            assert!(!c.bytes.is_empty(), "{} is empty", c.name);
477            assert!(
478                !c.bytes.contains(&b'\r'),
479                "{} carries a CR; RFC 5118's archive has none, so `wire` needs revisiting",
480                c.name
481            );
482        }
483    }
484
485    /// `wire` has to produce something a SIP parser can be asked about: every line terminated
486    /// with CRLF, and a terminated header section.
487    #[test]
488    fn wire_terminates_every_line_and_the_header_section() {
489        for c in CASES {
490            let wire = c.wire();
491            assert_eq!(
492                wire.iter().filter(|&&b| b == b'\n').count(),
493                c.bytes.iter().filter(|&&b| b == b'\n').count()
494                    + usize::from(!c.bytes.windows(2).any(|w| w == b"\n\n")),
495                "{}: wire must not invent or lose lines",
496                c.name
497            );
498            // No bare LF survives: every LF is preceded by CR.
499            for (i, &b) in wire.iter().enumerate() {
500                if b == b'\n' {
501                    assert_eq!(
502                        i.checked_sub(1).and_then(|j| wire.get(j)),
503                        Some(&b'\r'),
504                        "{}: bare LF at offset {i} in the wire form",
505                        c.name
506                    );
507                }
508            }
509            assert!(
510                wire.windows(4).any(|w| w == b"\r\n\r\n"),
511                "{}: wire must terminate the header section",
512                c.name
513            );
514        }
515    }
516
517    /// The transformation must not touch anything the corpus is *for*. Strip line terminators
518    /// and the Content-Length line from both forms and they must be identical — which is exactly
519    /// the claim that no IPv6 reference, URI or body octet was altered.
520    #[test]
521    fn wire_changes_only_terminators_and_content_length() {
522        for c in CASES {
523            // Trailing blank lines are dropped from both sides: adding the header-section
524            // terminator the archive omits for §4.10 is transformation 2, and comparing with it
525            // in place would flag the very thing `wire` documents. Every other line, in order,
526            // must be identical.
527            let reduce = |b: &[u8]| -> Vec<Vec<u8>> {
528                let mut lines: Vec<Vec<u8>> = b
529                    .split(|&b| b == b'\n')
530                    .map(|line| {
531                        line.iter()
532                            .copied()
533                            .filter(|&b| b != b'\r')
534                            .collect::<Vec<u8>>()
535                    })
536                    .filter(|line| !starts_with_ignore_case(line, b"Content-Length:"))
537                    .collect();
538                while lines.last().is_some_and(Vec::is_empty) {
539                    lines.pop();
540                }
541                lines
542            };
543            assert_eq!(
544                reduce(&c.wire()),
545                reduce(c.bytes),
546                "{}: wire altered more than line terminators and the Content-Length value",
547                c.name
548            );
549        }
550    }
551
552    fn starts_with_ignore_case(line: &[u8], prefix: &[u8]) -> bool {
553        line.get(..prefix.len())
554            .is_some_and(|head| head.eq_ignore_ascii_case(prefix))
555    }
556
557    /// `wire`'s Content-Length must describe the body it actually ships, or the SDP cases get cut
558    /// off mid-body before `sipx-sdp` sees the `c=` lines they exist to exercise.
559    #[test]
560    fn wire_content_length_matches_the_body_it_ships() {
561        for c in CASES {
562            let wire = c.wire();
563            let separator = wire
564                .windows(4)
565                .position(|w| w == b"\r\n\r\n")
566                .expect("wire terminates the header section");
567            let body_len = wire.len() - (separator + 4);
568
569            let declared: Option<usize> = wire
570                .get(..separator)
571                .unwrap_or(&[])
572                .split(|&b| b == b'\n')
573                .find(|line| starts_with_ignore_case(line, b"Content-Length:"))
574                .and_then(|line| {
575                    let value = line.split(|&b| b == b':').nth(1)?;
576                    std::str::from_utf8(value).ok()?.trim().parse().ok()
577                });
578
579            assert_eq!(
580                declared,
581                Some(body_len),
582                "{}: wire's Content-Length must match its body",
583                c.name
584            );
585        }
586    }
587
588    /// The RFC's own arithmetic, recorded rather than papered over.
589    ///
590    /// RFC 5118's three SDP-bearing messages declare a Content-Length that matches neither the
591    /// archive's LF-terminated body nor a CRLF-terminated one, and the RFC's single verified
592    /// erratum (1311, on §4.3's wording) does not mention it. This test states the discrepancy as
593    /// a fact about the corpus, so that a future re-import which silently "fixed" the archive
594    /// would be noticed rather than absorbed.
595    #[test]
596    fn the_rfc_declares_wrong_content_lengths_for_its_sdp_messages() {
597        let declared_in_archive = |c: &Case| -> Option<usize> {
598            c.bytes
599                .split(|&b| b == b'\n')
600                .find(|line| starts_with_ignore_case(line, b"Content-Length:"))
601                .and_then(|line| {
602                    let value = line.split(|&b| b == b':').nth(1)?;
603                    std::str::from_utf8(value).ok()?.trim().parse().ok()
604                })
605        };
606
607        // (case, what the RFC declares, the archive's actual LF-terminated body length)
608        for (name, declared, actual) in [
609            ("ipv6-in-sdp", 268, 242),
610            ("mult-ip-in-sdp", 181, 180),
611            ("ipv4-mapped-ipv6", 236, 236),
612        ] {
613            let c = case(name).expect("in corpus");
614            assert_eq!(
615                declared_in_archive(c),
616                Some(declared),
617                "{name}: RFC 5118 declares Content-Length {declared}"
618            );
619
620            let separator = c
621                .bytes
622                .windows(2)
623                .position(|w| w == b"\n\n")
624                .expect("an SDP-bearing message has a body");
625            assert_eq!(
626                c.bytes.len() - (separator + 2),
627                actual,
628                "{name}: the archive's body is {actual} bytes as shipped"
629            );
630        }
631
632        // §4.9 is the only one whose declared length happens to match the LF form, and it still
633        // needs correcting for the wire form, because CRLF makes the body nine bytes longer.
634        // Stating that keeps the table above from reading as if §4.9 were fine.
635        let mapped = case("ipv4-mapped-ipv6").expect("in corpus");
636        let wire = mapped.wire();
637        let separator = wire
638            .windows(4)
639            .position(|w| w == b"\r\n\r\n")
640            .expect("terminated");
641        assert_eq!(
642            wire.len() - (separator + 4),
643            245,
644            "§4.9's body is 245 bytes once CRLF-terminated, not the 236 it declares"
645        );
646    }
647}