Skip to main content

sipx_testkit/
rfc4475.rs

1//! The RFC 4475 SIP torture-test corpus.
2//!
3//! RFC 4475 collects messages designed to break naive parsers: legal ones that look illegal,
4//! illegal ones that look legal, and a long tail of whitespace, escaping and scalar-range
5//! edge cases. It is the standard correctness bar for a SIP implementation, so sipx runs it
6//! continuously rather than at the end.
7//!
8//! The messages are recovered from the bit-exact archive in that RFC's Appendix A by
9//! `scripts/import-rfc4475-corpus.sh`, not retyped: several cases hinge on octets that do not
10//! survive transcription (escaped NULs, UTF-8 display names, trailing whitespace). Run the
11//! script with `--check` to verify the committed corpus still matches the RFC.
12//!
13//! # What the classification is for
14//!
15//! The RFC groups its messages by the *layer* that should object to them, and that grouping
16//! is the useful part. A negative `Content-Length` is a framing failure — the byte stream
17//! cannot be cut into messages at all. An overlarge `CSeq` is nothing of the sort: the message
18//! frames and forwards perfectly well, and the fault appears only when something reads
19//! `CSeq`. An implementation that conflates the two either drops messages it could have
20//! forwarded, or accepts messages it cannot frame.
21//!
22//! So each case carries an [`Expect`] naming which layer must object, and the parser tests
23//! assert against that rather than against a bare pass/fail.
24
25/// Which layer must object to a message, and how.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Expect {
28    /// Parses, and re-serializes byte-identically. Anything else about the message —
29    /// unknown schemes, unknown methods, a `Max-Forwards` of zero — is a concern for a layer
30    /// above the parser.
31    ParseOk,
32    /// The structural parser must reject it: the bytes cannot be turned into a message.
33    ParseErr(Fault),
34    /// Parses. Reading the named header must yield a `HeaderError`.
35    HeaderErr(&'static str),
36    /// Parses, and every header parses. Request/response validation must reject it, with the
37    /// stated reason.
38    ValidateErr(&'static str),
39    /// Present in the Appendix A archive but referenced by no section of the RFC. Carried so
40    /// the corpus is a faithful copy of the archive, but asserted on by nothing.
41    Unreferenced,
42}
43
44/// The structural fault a [`Expect::ParseErr`] case must produce.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Fault {
47    /// The request or status line is malformed.
48    StartLine,
49    /// A header line is malformed — bad field name, stray separator.
50    HeaderSyntax,
51    /// The body cannot be delimited: `Content-Length` absent, repeated, negative,
52    /// non-numeric, or larger than the datagram.
53    Framing,
54}
55
56/// One message from the corpus.
57#[derive(Debug, Clone, Copy)]
58pub struct Case {
59    /// The RFC's own name for the message, e.g. `wsinv`.
60    pub name: &'static str,
61    /// The section of RFC 4475 that describes it, e.g. `3.1.1.1`.
62    pub section: &'static str,
63    /// That section's title.
64    pub title: &'static str,
65    /// Which layer must object, and how.
66    pub expect: Expect,
67    /// The message, bit-exact.
68    pub bytes: &'static [u8],
69}
70
71impl Case {
72    /// The message as a lossy string, for assertion messages. Several cases are not UTF-8;
73    /// never use this for parsing.
74    #[must_use]
75    pub fn lossy(&self) -> std::borrow::Cow<'_, str> {
76        String::from_utf8_lossy(self.bytes)
77    }
78
79    /// Whether this case is asserted on by the parser tests.
80    #[must_use]
81    pub fn is_classified(&self) -> bool {
82        self.expect != Expect::Unreferenced
83    }
84}
85
86macro_rules! corpus {
87    ($($name:literal => $section:literal, $title:literal, $expect:expr;)*) => {
88        /// Every message in the corpus, in RFC section order.
89        pub static CASES: &[Case] = &[$(
90            Case {
91                name: $name,
92                section: $section,
93                title: $title,
94                expect: $expect,
95                bytes: include_bytes!(concat!("../corpus/rfc4475/", $name, ".dat")),
96            },
97        )*];
98    };
99}
100
101use Expect::{HeaderErr, ParseErr, ParseOk, Unreferenced, ValidateErr};
102use Fault::{Framing, StartLine};
103
104corpus! {
105    // ---- 3.1.1 Valid messages -------------------------------------------------------
106    "wsinv"      => "3.1.1.1",  "A Short Tortuous INVITE", ParseOk;
107    "intmeth"    => "3.1.1.2",  "Wide Range of Valid Characters", ParseOk;
108    "esc01"      => "3.1.1.3",  "Valid Use of the % Escaping Mechanism", ParseOk;
109    "escnull"    => "3.1.1.4",  "Escaped Nulls in URIs", ParseOk;
110    "esc02"      => "3.1.1.5",  "Use of % When It Is Not an Escape", ParseOk;
111    "lwsdisp"    => "3.1.1.6",  "Message with No LWS between Display Name and <", ParseOk;
112    "longreq"    => "3.1.1.7",  "Long Values in Header Fields", ParseOk;
113    "dblreq"     => "3.1.1.8",  "Extra Trailing Octets in a UDP Datagram", ParseOk;
114    "semiuri"    => "3.1.1.9",  "Semicolon-Separated Parameters in URI User Part", ParseOk;
115    "transports" => "3.1.1.10", "Varied and Unknown Transport Types", ParseOk;
116    "mpart01"    => "3.1.1.11", "Multipart MIME Message", ParseOk;
117    "unreason"   => "3.1.1.12", "Unusual Reason Phrase", ParseOk;
118    "noreason"   => "3.1.1.13", "Empty Reason Phrase", ParseOk;
119
120    // ---- 3.1.2 Invalid messages -----------------------------------------------------
121    // Structural: the bytes cannot be framed or the grammar is violated.
122    "clerr"      => "3.1.2.2",  "Content Length Larger Than Message", ParseErr(Framing);
123    "ncl"        => "3.1.2.3",  "Negative Content-Length", ParseErr(Framing);
124    "ltgtruri"   => "3.1.2.7",  "<> Enclosing Request-URI", ParseErr(StartLine);
125    "lwsruri"    => "3.1.2.8",  "Malformed SIP Request-URI (embedded LWS)", ParseErr(StartLine);
126    "lwsstart"   => "3.1.2.9",  "Multiple SP Separating Request-Line Elements", ParseErr(StartLine);
127    "trws"       => "3.1.2.10", "SP Characters at End of Request-Line", ParseErr(StartLine);
128    "bigcode"    => "3.1.2.19", "Overlarge Response Code", ParseErr(StartLine);
129
130    // Value-level: the message frames, and one header's value is bad.
131    //
132    // badinv01 reads like a structural fault and is not one: its Via is
133    // `SIP/2.0/UDP 192.0.2.15;;,;,,`, which frames as a perfectly ordinary header line. The
134    // stray separators violate the *Via grammar*. The same junk in an unknown header is legal
135    // — wsinv, a valid message, carries `UnknownHeaderWithUnusualValue: ;;,,;;,;` — so the
136    // fault cannot be found without knowing which header it is.
137    "badinv01"   => "3.1.2.1",  "Extraneous Header Field Separators", HeaderErr("Via");
138    "scalar02"   => "3.1.2.4",  "Request Scalar Fields with Overlarge Values", HeaderErr("CSeq");
139    "scalarlg"   => "3.1.2.5",  "Response Scalar Fields with Overlarge Values", HeaderErr("CSeq");
140    "quotbal"    => "3.1.2.6",  "Unterminated Quoted String in Display Name", HeaderErr("To");
141    "baddate"    => "3.1.2.12", "Invalid Time Zone in Date Header Field", HeaderErr("Date");
142    "regbadct"   => "3.1.2.13", "Failure to Enclose name-addr URI in <>", HeaderErr("Contact");
143    "badaspec"   => "3.1.2.14", "Spaces within addr-spec", HeaderErr("To");
144    // The fault this section illustrates is the unquoted comma in `From: Bell, Alexander
145    // <sip:...>`, which is a From-header fault. But this one file in the Appendix A archive
146    // has no terminating blank line — it is the only one of the fifty that does not — so it
147    // never reaches the header layer: the header section is unterminated, which on a datagram
148    // is indistinguishable from truncation and on a stream means "wait for more".
149    //
150    // We keep the parser strict rather than tolerate a missing terminator, because accepting
151    // one means accepting a truncated message as a complete one. The display-name fault is
152    // covered by a hand-built message in the From header's own tests.
153    "baddn"      => "3.1.2.15", "Non-token Characters in Display Name", ParseErr(Framing);
154
155    // Semantic: everything parses; the message is still not one we may act on.
156    "escruri"    => "3.1.2.11", "Escaped Headers in SIP Request-URI",
157                                ValidateErr("a Request-URI may not carry headers (RFC 3261 19.1.1)");
158    "badvers"    => "3.1.2.16", "Unknown Protocol Version",
159                                ValidateErr("unsupported SIP version; answer 505");
160    "mismatch01" => "3.1.2.17", "Start Line and CSeq Method Mismatch",
161                                ValidateErr("CSeq method must match the request line");
162    "mismatch02" => "3.1.2.18", "Unknown Method with CSeq Method Mismatch",
163                                ValidateErr("CSeq method must match the request line");
164
165    // ---- 3.2 Transaction layer ------------------------------------------------------
166    "badbranch"  => "3.2.1",    "Missing Transaction Identifier", ParseOk;
167
168    // ---- 3.3 Application layer ------------------------------------------------------
169    "insuf"      => "3.3.1",    "Missing Required Header Fields",
170                                ValidateErr("To, From, Call-ID, CSeq and Via are required");
171    "unkscm"     => "3.3.2",    "Request-URI with Unknown Scheme", ParseOk;
172    "novelsc"    => "3.3.3",    "Request-URI with Known but Atypical Scheme", ParseOk;
173    "unksm2"     => "3.3.4",    "Unknown URI Schemes in Header Fields", ParseOk;
174    "bext01"     => "3.3.5",    "Proxy-Require and Require", ParseOk;
175    "invut"      => "3.3.6",    "Unknown Content-Type", ParseOk;
176    "regaut01"   => "3.3.7",    "Unknown Authorization Scheme", ParseOk;
177    "multi01"    => "3.3.8",    "Multiple Values in Single Value Required Fields",
178                                ValidateErr("single-value headers must not be repeated");
179    // The RFC files this under the application layer, permitting a 400. sipx rejects it while
180    // framing instead: two Content-Length values means the body's extent is unknown, which is
181    // a framing question, not a semantic one. See docs/specs/sip-parser.md, section 4.4.
182    "mcl01"      => "3.3.9",    "Multiple Content-Length Values", ParseErr(Framing);
183    "bcast"      => "3.3.10",   "200 OK Response with Broadcast Via Header Field Value", ParseOk;
184    "zeromf"     => "3.3.11",   "Max-Forwards of Zero", ParseOk;
185    "cparam01"   => "3.3.12",   "REGISTER with a Contact Header Parameter", ParseOk;
186    "cparam02"   => "3.3.13",   "REGISTER with a url-parameter", ParseOk;
187    "regescrt"   => "3.3.14",   "REGISTER with a URL Escaped Header", ParseOk;
188    "sdp01"      => "3.3.15",   "Unacceptable Accept Offering", ParseOk;
189
190    // ---- 3.4 Backward compatibility -------------------------------------------------
191    "inv2543"    => "3.4.1",    "INVITE with RFC 2543 Syntax", ParseOk;
192
193    // ---- Present in the archive, referenced by no section ---------------------------
194    "test"       => "-",        "(not referenced by RFC 4475)", Unreferenced;
195}
196
197/// Every case the parser tests assert on.
198pub fn classified() -> impl Iterator<Item = &'static Case> {
199    CASES.iter().filter(|c| c.is_classified())
200}
201
202/// Cases matching a given expectation.
203pub fn expecting(expect: Expect) -> impl Iterator<Item = &'static Case> {
204    CASES.iter().filter(move |c| c.expect == expect)
205}
206
207/// Look up a case by its RFC name.
208#[must_use]
209pub fn case(name: &str) -> Option<&'static Case> {
210    CASES.iter().find(|c| c.name == name)
211}
212
213#[cfg(test)]
214// The no-unwrap/no-panic rules exist because library code parses hostile input. A test that
215// cannot read its own fixtures should fail loudly.
216#[allow(
217    clippy::unwrap_used,
218    clippy::expect_used,
219    clippy::panic,
220    clippy::indexing_slicing
221)]
222mod tests {
223    use super::*;
224    use std::collections::HashSet;
225
226    /// The corpus is only a correctness bar if it is complete. A case that quietly goes
227    /// missing — a renamed file, a botched import — would weaken the suite invisibly, so
228    /// assert the counts the RFC itself states.
229    #[test]
230    fn corpus_is_complete() {
231        assert_eq!(CASES.len(), 50, "archive holds 50 files");
232        assert_eq!(classified().count(), 49, "49 are referenced by a section");
233
234        let valid = CASES
235            .iter()
236            .filter(|c| c.section.starts_with("3.1.1"))
237            .count();
238        let invalid = CASES
239            .iter()
240            .filter(|c| c.section.starts_with("3.1.2"))
241            .count();
242        assert_eq!(valid, 13, "RFC 4475 3.1.1 defines 13 valid messages");
243        assert_eq!(invalid, 19, "RFC 4475 3.1.2 defines 19 invalid messages");
244        assert_eq!(
245            CASES
246                .iter()
247                .filter(|c| c.section.starts_with("3.3"))
248                .count(),
249            15,
250            "RFC 4475 3.3 defines 15 application-layer messages"
251        );
252    }
253
254    #[test]
255    fn case_names_and_sections_are_unique() {
256        let names: HashSet<_> = CASES.iter().map(|c| c.name).collect();
257        assert_eq!(names.len(), CASES.len(), "duplicate case name");
258
259        let sections: HashSet<_> = classified().map(|c| c.section).collect();
260        assert_eq!(sections.len(), 49, "duplicate section reference");
261    }
262
263    /// The table is hand-written; the directory is generated by the import script. If they
264    /// drift, the table is silently ignoring a message.
265    #[test]
266    fn table_matches_the_imported_directory() {
267        let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/corpus/rfc4475");
268        let mut on_disk: Vec<String> = std::fs::read_dir(dir)
269            .expect("corpus directory")
270            .filter_map(Result::ok)
271            .filter_map(|e| {
272                let name = e.file_name().to_string_lossy().into_owned();
273                name.strip_suffix(".dat").map(str::to_owned)
274            })
275            .collect();
276        on_disk.sort();
277
278        let mut in_table: Vec<String> = CASES.iter().map(|c| c.name.to_owned()).collect();
279        in_table.sort();
280
281        assert_eq!(
282            on_disk, in_table,
283            "corpus directory and case table disagree"
284        );
285    }
286
287    #[test]
288    fn every_case_has_content() {
289        for c in CASES {
290            assert!(!c.bytes.is_empty(), "{} is empty", c.name);
291            assert!(
292                c.bytes.windows(2).any(|w| w == b"\r\n"),
293                "{} has no CRLF; the import may have mangled line endings",
294                c.name
295            );
296        }
297    }
298}