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}