Skip to main content

sipx_sip/headers/
grammar.rs

1//! Shared pieces of the header grammar (RFC 3261 §25.1).
2//!
3//! Header values look simple until you notice that commas, semicolons and angle brackets are
4//! all delimiters that can also appear *inside* a value — in a quoted display name, in a URI,
5//! in a comment. Every split in this module is aware of that, which is why none of them is a
6//! call to `split`.
7
8use std::ops::Range;
9
10use crate::error::HeaderError;
11
12/// RFC 3261 §25.1 `token`.
13#[must_use]
14pub(crate) fn is_token_char(b: u8) -> bool {
15    b.is_ascii_alphanumeric()
16        || matches!(
17            b,
18            b'-' | b'.' | b'!' | b'%' | b'*' | b'_' | b'+' | b'`' | b'\'' | b'~'
19        )
20}
21
22/// Skip spaces and tabs.
23#[must_use]
24pub(crate) fn skip_ws(input: &[u8], mut at: usize) -> usize {
25    while matches!(input.get(at), Some(b' ' | b'\t')) {
26        at += 1;
27    }
28    at
29}
30
31/// Trim spaces and tabs from both ends.
32#[must_use]
33pub(crate) fn trim(mut b: &[u8]) -> &[u8] {
34    while let Some((f, rest)) = b.split_first() {
35        if matches!(f, b' ' | b'\t') {
36            b = rest;
37        } else {
38            break;
39        }
40    }
41    while let Some((l, rest)) = b.split_last() {
42        if matches!(l, b' ' | b'\t') {
43            b = rest;
44        } else {
45            break;
46        }
47    }
48    b
49}
50
51/// Where a quoted string starting at `at` ends, as the index just past its closing quote.
52///
53/// Returns `None` if it is never closed — RFC 4475 §3.1.2.6 is precisely this case, and it
54/// matters because an unterminated quote would otherwise swallow the rest of the message.
55#[must_use]
56pub(crate) fn quoted_string_end(input: &[u8], at: usize) -> Option<usize> {
57    if input.get(at) != Some(&b'"') {
58        return None;
59    }
60    let mut i = at + 1;
61    while let Some(&b) = input.get(i) {
62        match b {
63            // A backslash quotes the next octet, including another backslash or a quote.
64            b'\\' if i + 1 < input.len() => i += 2,
65            b'"' => return Some(i + 1),
66            _ => i += 1,
67        }
68    }
69    None
70}
71
72/// Split a header value on commas that are actual list separators.
73///
74/// Commas inside quoted strings, angle brackets and parenthesized comments belong to the
75/// value. Getting this wrong is how `From: "Bell, Alexander" <sip:…>` turns into two
76/// mangled addresses.
77pub(crate) fn split_list<'a>(
78    value: &'a [u8],
79    header: &'static str,
80) -> Result<Vec<&'a [u8]>, HeaderError> {
81    split_list_spans(value, header).map(|spans| {
82        spans
83            .into_iter()
84            .map(|span| value.get(span).unwrap_or(&[]))
85            .collect()
86    })
87}
88
89/// Split a list while retaining each value's half-open range in the grammar input.
90///
91/// Range ownership is needed by lossless editors: returning only decoded values would force a
92/// caller to search for their bytes, which is ambiguous when display text repeats a URI.
93pub(crate) fn split_list_spans(
94    value: &[u8],
95    header: &'static str,
96) -> Result<Vec<Range<usize>>, HeaderError> {
97    let mut parts = Vec::new();
98    let mut start = 0usize;
99    let mut i = 0usize;
100    let mut angle = 0usize;
101    let mut paren = 0usize;
102
103    while i < value.len() {
104        match value.get(i) {
105            Some(b'"') => {
106                i = quoted_string_end(value, i)
107                    .ok_or(HeaderError::UnterminatedQuotedString { header })?;
108            }
109            Some(b'<') => {
110                angle += 1;
111                i += 1;
112            }
113            Some(b'>') => {
114                angle = angle.saturating_sub(1);
115                i += 1;
116            }
117            Some(b'(') => {
118                paren += 1;
119                i += 1;
120            }
121            Some(b')') => {
122                paren = paren.saturating_sub(1);
123                i += 1;
124            }
125            Some(b',') if angle == 0 && paren == 0 => {
126                parts.push(start..i);
127                i += 1;
128                start = i;
129            }
130            Some(_) => i += 1,
131            None => break,
132        }
133    }
134    parts.push(start..value.len());
135    Ok(parts)
136}
137
138/// Index of the first semicolon that separates parameters, skipping quoted strings, angle
139/// brackets and comments.
140#[must_use]
141pub(crate) fn find_param_start(value: &[u8]) -> Option<usize> {
142    let mut i = 0usize;
143    let mut angle = 0usize;
144    while i < value.len() {
145        match value.get(i) {
146            Some(b'"') => i = quoted_string_end(value, i)?,
147            Some(b'<') => {
148                angle += 1;
149                i += 1;
150            }
151            Some(b'>') => {
152                angle = angle.saturating_sub(1);
153                i += 1;
154            }
155            Some(b';') if angle == 0 => return Some(i),
156            Some(_) => i += 1,
157            None => break,
158        }
159    }
160    None
161}
162
163/// One header parameter: `name` or `name=value`, where the value may be quoted.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct HeaderParam {
166    /// The parameter name, lowercased for comparison.
167    pub name: Vec<u8>,
168    /// The value, with any surrounding quotes removed and escapes resolved.
169    pub value: Option<Vec<u8>>,
170}
171
172impl HeaderParam {
173    /// Whether the name matches, case-insensitively.
174    #[must_use]
175    pub fn is(&self, name: &str) -> bool {
176        self.name == name.as_bytes()
177    }
178}
179
180/// Parse a `;`-separated parameter list from the tail of a header value.
181///
182/// Empty segments are rejected. The ABNF has `*( SEMI generic-param )` with a non-empty name,
183/// so `;;` is not a quirky spelling of `;` — RFC 4475 §3.1.2.1 turns a message invalid on
184/// exactly that, in a `Via`.
185pub(crate) fn parse_params(
186    tail: &[u8],
187    header: &'static str,
188) -> Result<Vec<HeaderParam>, HeaderError> {
189    let mut params = Vec::new();
190    let mut i = 0usize;
191
192    while i < tail.len() {
193        // Each iteration must start at a semicolon.
194        i = skip_ws(tail, i);
195        if tail.get(i) != Some(&b';') {
196            return Err(HeaderError::Syntax { header });
197        }
198        i = skip_ws(tail, i + 1);
199
200        let name_start = i;
201        while tail.get(i).is_some_and(|&b| is_token_char(b)) {
202            i += 1;
203        }
204        let name = tail.get(name_start..i).unwrap_or(&[]);
205        if name.is_empty() {
206            return Err(HeaderError::Syntax { header });
207        }
208
209        i = skip_ws(tail, i);
210        let value = if tail.get(i) == Some(&b'=') {
211            i = skip_ws(tail, i + 1);
212            if tail.get(i) == Some(&b'"') {
213                let end = quoted_string_end(tail, i)
214                    .ok_or(HeaderError::UnterminatedQuotedString { header })?;
215                let raw = tail.get(i + 1..end.saturating_sub(1)).unwrap_or(&[]);
216                let unescaped = unescape_quoted(raw);
217                i = end;
218                Some(unescaped)
219            } else {
220                let start = i;
221                // A bare parameter value is a token, but hosts and IPv6 references appear as
222                // values too (maddr, received), so `[`, `]`, `:` and `/` are accepted here.
223                while tail
224                    .get(i)
225                    .is_some_and(|&b| is_token_char(b) || matches!(b, b'[' | b']' | b':' | b'/'))
226                {
227                    i += 1;
228                }
229                if i == start {
230                    return Err(HeaderError::Syntax { header });
231                }
232                Some(tail.get(start..i).unwrap_or(&[]).to_vec())
233            }
234        } else {
235            None
236        };
237
238        params.push(HeaderParam {
239            name: name.to_ascii_lowercase(),
240            value,
241        });
242        i = skip_ws(tail, i);
243    }
244
245    Ok(params)
246}
247
248/// Resolve backslash escapes inside a quoted string.
249#[must_use]
250fn unescape_quoted(raw: &[u8]) -> Vec<u8> {
251    let mut out = Vec::with_capacity(raw.len());
252    let mut i = 0usize;
253    while let Some(&b) = raw.get(i) {
254        if b == b'\\'
255            && let Some(&next) = raw.get(i + 1)
256        {
257            out.push(next);
258            i += 2;
259            continue;
260        }
261        out.push(b);
262        i += 1;
263    }
264    out
265}
266
267/// Find a parameter by name.
268#[must_use]
269pub(crate) fn param<'a>(params: &'a [HeaderParam], name: &str) -> Option<&'a HeaderParam> {
270    params.iter().find(|p| p.is(name))
271}
272
273/// Parse an unsigned decimal, rejecting anything that is not entirely digits.
274///
275/// Leading zeros are fine — RFC 4475 §3.1.1.1 writes `Max-Forwards: 0068` and `CSeq: 0009`.
276/// A sign character is not: it never reaches a conversion that could produce a negative
277/// number.
278pub(crate) fn parse_u64(value: &[u8], header: &'static str) -> Result<u64, HeaderError> {
279    if value.is_empty() || !value.iter().all(u8::is_ascii_digit) {
280        return Err(HeaderError::Syntax { header });
281    }
282    let mut n: u64 = 0;
283    for &b in value {
284        n = n
285            .checked_mul(10)
286            .and_then(|n| n.checked_add(u64::from(b - b'0')))
287            .ok_or(HeaderError::OutOfRange { header })?;
288    }
289    Ok(n)
290}
291
292#[cfg(test)]
293#[allow(
294    clippy::unwrap_used,
295    clippy::expect_used,
296    clippy::panic,
297    clippy::indexing_slicing
298)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn list_splitting_respects_quotes_and_brackets() {
304        // The comma belongs to the display name, not to the list.
305        let parts = split_list(br#""Bell, Alexander" <sip:a@b>, <sip:c@d>"#, "From").unwrap();
306        assert_eq!(parts.len(), 2);
307        assert_eq!(trim(parts[0]), br#""Bell, Alexander" <sip:a@b>"#);
308        assert_eq!(trim(parts[1]), b"<sip:c@d>");
309    }
310
311    #[test]
312    fn list_splitting_reports_an_unterminated_quote() {
313        // RFC 4475 3.1.2.6. Without this the open quote swallows the rest of the message.
314        let err = split_list(br#""Mr. J. User <sip:j@example.com>"#, "To").unwrap_err();
315        assert!(matches!(
316            err,
317            HeaderError::UnterminatedQuotedString { header: "To" }
318        ));
319    }
320
321    #[test]
322    fn parameters_reject_empty_segments() {
323        // RFC 4475 3.1.2.1: `;;,;,,` is not a quirky spelling of nothing.
324        assert!(parse_params(b";;", "Via").is_err());
325        assert!(parse_params(b";a=1;;", "Via").is_err());
326        assert!(parse_params(b";", "Via").is_err());
327    }
328
329    #[test]
330    fn parameters_parse_flags_quoted_and_bare_values() {
331        let params = parse_params(br#";lr;tag=abc;text="a;b\"c""#, "To").unwrap();
332        assert_eq!(params.len(), 3);
333        assert!(params[0].is("lr") && params[0].value.is_none());
334        assert_eq!(params[1].value.as_deref(), Some(&b"abc"[..]));
335        // The quoted value keeps its semicolon and its escaped quote.
336        assert_eq!(params[2].value.as_deref(), Some(&br#"a;b"c"#[..]));
337    }
338
339    #[test]
340    fn parameter_names_compare_case_insensitively() {
341        let params = parse_params(b";Transport=TCP", "Via").unwrap();
342        assert!(param(&params, "transport").is_some());
343    }
344
345    #[test]
346    fn parameter_values_may_be_hosts_and_ipv6_references() {
347        let params = parse_params(b";received=192.0.2.1;maddr=[2001:db8::1]", "Via").unwrap();
348        assert_eq!(
349            param(&params, "received").and_then(|p| p.value.as_deref()),
350            Some(&b"192.0.2.1"[..])
351        );
352        assert_eq!(
353            param(&params, "maddr").and_then(|p| p.value.as_deref()),
354            Some(&b"[2001:db8::1]"[..])
355        );
356    }
357
358    #[test]
359    fn numbers_keep_leading_zeros_and_reject_signs() {
360        // RFC 4475 3.1.1.1 sends `Max-Forwards: 0068`.
361        assert_eq!(parse_u64(b"0068", "Max-Forwards").unwrap(), 68);
362        assert!(parse_u64(b"-1", "Max-Forwards").is_err());
363        assert!(parse_u64(b"+1", "Max-Forwards").is_err());
364        assert!(parse_u64(b"", "Max-Forwards").is_err());
365        assert!(parse_u64(b"12x", "Max-Forwards").is_err());
366        // Overflow is an out-of-range error, not a wrap.
367        assert!(matches!(
368            parse_u64(b"99999999999999999999999", "CSeq"),
369            Err(HeaderError::OutOfRange { .. })
370        ));
371    }
372}