Skip to main content

sipx_sdp/
parse.rs

1//! Reading SDP (RFC 8866 §5).
2//!
3//! SDP is line-oriented and strict about order, and forgiving about almost nothing else. What
4//! this parser is deliberately lenient about: line endings (CRLF is required, LF is what half
5//! the world sends) and unknown line types, which are kept rather than rejected.
6
7use std::net::IpAddr;
8
9use crate::session::{
10    Address, Attribute, Connection, MediaDescription, Origin, SessionDescription, Timing,
11};
12use crate::{Result, SdpError};
13
14/// Parse a session description.
15pub fn parse(input: &str) -> Result<SessionDescription> {
16    let mut origin = None;
17    let mut session_name = None;
18    let mut connection = None;
19    let mut timing = Vec::new();
20    let mut attributes = Vec::new();
21    let mut media: Vec<MediaDescription> = Vec::new();
22    let mut other = Vec::new();
23
24    for line in input.lines() {
25        let line = line.trim_end_matches('\r');
26        if line.is_empty() {
27            continue;
28        }
29
30        let mut chars = line.chars();
31        let kind = chars
32            .next()
33            .ok_or_else(|| SdpError::MalformedLine(line.to_owned()))?;
34        if chars.next() != Some('=') {
35            return Err(SdpError::MalformedLine(line.to_owned()));
36        }
37        let value = line.get(2..).unwrap_or("");
38
39        match kind {
40            'v' => {
41                // The version is always 0 and carries no information; rejecting a different
42                // one would be pedantry with no upside.
43            }
44            'o' => origin = Some(parse_origin(value)?),
45            's' => {
46                // RFC 8866 §5.3: the line MUST NOT be empty — `s=-` is the spelling for "no
47                // name" — so an empty one takes the same `-` a missing one does.
48                if !value.is_empty() {
49                    session_name = Some(value.to_owned());
50                }
51            }
52            'c' => {
53                let parsed = parse_connection(value)?;
54                // A `c=` after an `m=` belongs to that stream, not the session.
55                match media.last_mut() {
56                    Some(stream) => stream.connection = Some(parsed),
57                    None => connection = Some(parsed),
58                }
59            }
60            't' => timing.push(parse_timing(value)),
61            'm' => media.push(parse_media(value)?),
62            'a' => {
63                let attribute = parse_attribute(value);
64                match media.last_mut() {
65                    Some(stream) => stream.attributes.push(attribute),
66                    None => attributes.push(attribute),
67                }
68            }
69            // Everything else is kept verbatim. Dropping unknown lines is how an element
70            // breaks features it has never heard of. RFC 8866 §5 places `i=`, `b=` and `k=`
71            // inside each media description too, so a line after an `m=` belongs to that
72            // stream, not the session.
73            _ => match media.last_mut() {
74                Some(stream) => stream.other.push((kind, value.to_owned())),
75                None => other.push((kind, value.to_owned())),
76            },
77        }
78    }
79
80    Ok(SessionDescription {
81        origin: origin.ok_or(SdpError::Missing("o="))?,
82        session_name: session_name.unwrap_or_else(|| "-".to_owned()),
83        connection,
84        timing,
85        attributes,
86        media,
87        other,
88    })
89}
90
91fn parse_origin(value: &str) -> Result<Origin> {
92    let mut parts = value.split_whitespace();
93    let username = parts.next().unwrap_or("-").to_owned();
94    let session_id = parts
95        .next()
96        .and_then(|v| v.parse().ok())
97        .ok_or_else(|| invalid("origin session id", value))?;
98    let session_version = parts
99        .next()
100        .and_then(|v| v.parse().ok())
101        .ok_or_else(|| invalid("origin session version", value))?;
102    let _net_type = parts.next();
103    let _addr_type = parts.next();
104    let address = parts
105        .next()
106        .map(parse_address)
107        .ok_or_else(|| invalid("origin address", value))?;
108
109    Ok(Origin {
110        username,
111        session_id,
112        session_version,
113        address,
114    })
115}
116
117fn parse_connection(value: &str) -> Result<Connection> {
118    let address = value
119        .split_whitespace()
120        .nth(2)
121        .and_then(|raw| {
122            // A multicast address may carry `/ttl` or `/ttl/count`, which is not part of the
123            // address.
124            raw.split('/').next().filter(|v| !v.is_empty())
125        })
126        .map(parse_address)
127        .ok_or_else(|| invalid("connection address", value))?;
128    Ok(Connection { address })
129}
130
131fn parse_address(raw: &str) -> Address {
132    // RFC 8866 §5.2 and §5.7 allow a fully-qualified domain name where a literal is more
133    // common. A token that is not a literal is kept as a name rather than rejected, because
134    // refusing the address refuses the call.
135    raw.parse::<IpAddr>()
136        .map_or_else(|_| Address::Host(raw.to_owned()), Address::Ip)
137}
138
139fn parse_timing(value: &str) -> Timing {
140    let mut parts = value.split_whitespace();
141    // A malformed t= is treated as unbounded rather than fatal: it carries no information
142    // sipx uses, and rejecting a whole description over it would refuse calls that work.
143    Timing {
144        start: parts.next().and_then(|v| v.parse().ok()).unwrap_or(0),
145        stop: parts.next().and_then(|v| v.parse().ok()).unwrap_or(0),
146    }
147}
148
149fn parse_media(value: &str) -> Result<MediaDescription> {
150    let mut parts = value.split_whitespace();
151    let media = parts
152        .next()
153        .ok_or_else(|| invalid("media type", value))?
154        .to_owned();
155    let port = parts
156        .next()
157        .and_then(|raw| {
158            // `port/count` is legal; the count is for hierarchical encoding and sipx uses the
159            // base port.
160            raw.split('/').next().and_then(|v| v.parse::<u16>().ok())
161        })
162        .ok_or_else(|| invalid("media port", value))?;
163    let protocol = parts
164        .next()
165        .ok_or_else(|| invalid("media protocol", value))?
166        .to_owned();
167    let formats: Vec<String> = parts.map(str::to_owned).collect();
168    // RFC 8866 §5.14: the ABNF requires at least one format. A bare `m=` line cannot even be
169    // rejected in a well-formed answer, so it is refused here rather than propagated.
170    if formats.is_empty() {
171        return Err(invalid("media formats", value));
172    }
173
174    Ok(MediaDescription {
175        media,
176        port,
177        protocol,
178        formats,
179        connection: None,
180        attributes: Vec::new(),
181        other: Vec::new(),
182    })
183}
184
185fn parse_attribute(value: &str) -> Attribute {
186    match value.split_once(':') {
187        Some((name, rest)) => Attribute::valued(name, rest),
188        None => Attribute::flag(value),
189    }
190}
191
192fn invalid(field: &'static str, value: &str) -> SdpError {
193    SdpError::Invalid {
194        field,
195        value: value.to_owned(),
196    }
197}
198
199#[cfg(test)]
200#[allow(
201    clippy::unwrap_used,
202    clippy::expect_used,
203    clippy::panic,
204    clippy::indexing_slicing
205)]
206mod tests {
207    use super::*;
208    use crate::session::Direction;
209
210    const OFFER: &str = "v=0\r\n\
211        o=alice 2890844526 2890844526 IN IP4 192.0.2.10\r\n\
212        s=A call\r\n\
213        c=IN IP4 192.0.2.10\r\n\
214        t=0 0\r\n\
215        m=audio 49170 RTP/AVP 0 8 101\r\n\
216        a=rtpmap:0 PCMU/8000\r\n\
217        a=rtpmap:8 PCMA/8000\r\n\
218        a=rtpmap:101 telephone-event/8000\r\n\
219        a=fmtp:101 0-15\r\n\
220        a=sendrecv\r\n\
221        a=ptime:20\r\n";
222
223    #[test]
224    fn a_session_description_parses_into_its_parts() {
225        let sdp = parse(OFFER).expect("parses");
226        assert_eq!(sdp.origin.username, "alice");
227        assert_eq!(sdp.origin.session_id, 2_890_844_526);
228        assert_eq!(sdp.session_name, "A call");
229        assert_eq!(
230            sdp.connection.expect("a connection").address.to_string(),
231            "192.0.2.10"
232        );
233        assert_eq!(sdp.media.len(), 1);
234
235        let audio = &sdp.media[0];
236        assert_eq!(audio.media, "audio");
237        assert_eq!(audio.port, 49170);
238        assert_eq!(audio.protocol, "RTP/AVP");
239        assert_eq!(audio.formats, vec!["0", "8", "101"]);
240        assert_eq!(audio.rtpmap("0"), Some("PCMU/8000"));
241        assert_eq!(audio.rtpmap("8"), Some("PCMA/8000"));
242        assert_eq!(audio.direction(), Direction::SendRecv);
243    }
244
245    /// Attributes after an `m=` belong to that stream, not the session. Attaching them to the
246    /// session instead makes a two-stream description nonsense.
247    #[test]
248    fn attributes_attach_to_the_media_line_they_follow() {
249        let sdp = parse(
250            "v=0\r\n\
251             o=- 1 1 IN IP4 192.0.2.1\r\n\
252             s=-\r\n\
253             c=IN IP4 192.0.2.1\r\n\
254             t=0 0\r\n\
255             a=session-level\r\n\
256             m=audio 5000 RTP/AVP 0\r\n\
257             a=sendonly\r\n\
258             m=video 5002 RTP/AVP 96\r\n\
259             a=recvonly\r\n",
260        )
261        .expect("parses");
262
263        assert_eq!(sdp.attributes.len(), 1);
264        assert_eq!(sdp.attributes[0].name, "session-level");
265        assert_eq!(sdp.media[0].direction(), Direction::SendOnly);
266        assert_eq!(sdp.media[1].direction(), Direction::RecvOnly);
267    }
268
269    /// A `c=` under an `m=` overrides the session's for that stream only.
270    #[test]
271    fn a_media_connection_overrides_the_session_one() {
272        let sdp = parse(
273            "v=0\r\n\
274             o=- 1 1 IN IP4 192.0.2.1\r\n\
275             s=-\r\n\
276             c=IN IP4 192.0.2.1\r\n\
277             t=0 0\r\n\
278             m=audio 5000 RTP/AVP 0\r\n\
279             c=IN IP4 198.51.100.7\r\n\
280             m=video 5002 RTP/AVP 96\r\n",
281        )
282        .expect("parses");
283
284        assert_eq!(
285            sdp.address_for(&sdp.media[0])
286                .expect("an address")
287                .to_string(),
288            "198.51.100.7"
289        );
290        assert_eq!(
291            sdp.address_for(&sdp.media[1])
292                .expect("an address")
293                .to_string(),
294            "192.0.2.1",
295            "a stream with no c= falls back to the session's"
296        );
297    }
298
299    /// An absent direction attribute means `sendrecv` (RFC 4566 §6). Defaulting to anything
300    /// else silences calls that would otherwise work.
301    #[test]
302    fn an_absent_direction_means_sendrecv() {
303        let sdp = parse(
304            "v=0\r\no=- 1 1 IN IP4 192.0.2.1\r\ns=-\r\nc=IN IP4 192.0.2.1\r\nt=0 0\r\n\
305             m=audio 5000 RTP/AVP 0\r\n",
306        )
307        .expect("parses");
308        assert_eq!(sdp.media[0].direction(), Direction::SendRecv);
309    }
310
311    /// Half the world sends bare LF. Rejecting it would be correct and useless.
312    #[test]
313    fn bare_line_feeds_are_accepted() {
314        let sdp = parse(
315            "v=0\no=- 1 1 IN IP4 192.0.2.1\ns=-\nc=IN IP4 192.0.2.1\nt=0 0\nm=audio 5000 RTP/AVP 0\n",
316        )
317        .expect("parses");
318        assert_eq!(sdp.media[0].port, 5000);
319    }
320
321    /// Unknown lines survive, and they come back out where RFC 8866 §5 puts them: `i=`
322    /// before `c=`, `b=` before the timing lines, `z=` after them — even when the input had
323    /// them elsewhere. Receivers enforce the grammar's order, so emitting a kept line in the
324    /// wrong slot turns tolerance into a parse error at the far end.
325    #[test]
326    fn unknown_lines_survive_a_round_trip() {
327        let input = "v=0\r\n\
328             o=- 1 1 IN IP4 192.0.2.1\r\n\
329             s=-\r\n\
330             c=IN IP4 192.0.2.1\r\n\
331             t=0 0\r\n\
332             b=AS:64\r\n\
333             i=session info\r\n\
334             z=0 0\r\n\
335             m=audio 5000 RTP/AVP 0\r\n";
336        let sdp = parse(input).expect("parses");
337        let out = sdp.to_string_sdp();
338        let position = |needle: &str| {
339            out.find(needle)
340                .unwrap_or_else(|| panic!("{needle} missing from {out}"))
341        };
342        assert!(position("i=session info") < position("c=IN"), "{out}");
343        assert!(position("c=IN") < position("b=AS:64"), "{out}");
344        assert!(position("b=AS:64") < position("t=0 0"), "{out}");
345        assert!(position("t=0 0") < position("z=0 0"), "{out}");
346        assert!(position("z=0 0") < position("m=audio"), "{out}");
347    }
348
349    /// RFC 8866 §5 places `i=`, `b=` and `k=` inside each media description, and §5.8 says a
350    /// media-level `b=` is that stream's bandwidth. Hoisting them to session level collapses
351    /// two streams' bandwidths into one meaningless pair.
352    #[test]
353    fn media_level_lines_stay_with_their_stream() {
354        let sdp = parse(
355            "v=0\r\n\
356             o=- 1 1 IN IP4 192.0.2.1\r\n\
357             s=-\r\n\
358             c=IN IP4 192.0.2.1\r\n\
359             t=0 0\r\n\
360             m=audio 5000 RTP/AVP 0\r\n\
361             b=TIAS:64000\r\n\
362             m=video 5002 RTP/AVP 96\r\n\
363             b=TIAS:256000\r\n",
364        )
365        .expect("parses");
366
367        assert!(sdp.other.is_empty(), "nothing here is session-level");
368
369        let out = sdp.to_string_sdp();
370        let position = |needle: &str| {
371            out.find(needle)
372                .unwrap_or_else(|| panic!("{needle} missing from {out}"))
373        };
374        assert!(position("m=audio") < position("b=TIAS:64000"), "{out}");
375        assert!(position("b=TIAS:64000") < position("m=video"), "{out}");
376        assert!(position("m=video") < position("b=TIAS:256000"), "{out}");
377    }
378
379    #[test]
380    fn a_parsed_description_reserializes_to_the_same_meaning() {
381        let sdp = parse(OFFER).expect("parses");
382        let again = parse(&sdp.to_string_sdp()).expect("reparses");
383        assert_eq!(sdp, again);
384    }
385
386    /// A rejected stream is present with port 0, not absent. This is what keeps an answer's
387    /// media lines aligned with the offer's.
388    #[test]
389    fn port_zero_is_a_rejected_stream_not_a_parse_error() {
390        let sdp = parse(
391            "v=0\r\no=- 1 1 IN IP4 192.0.2.1\r\ns=-\r\nc=IN IP4 192.0.2.1\r\nt=0 0\r\n\
392             m=audio 0 RTP/AVP 0\r\n",
393        )
394        .expect("parses");
395        assert!(sdp.media[0].is_rejected());
396    }
397
398    /// RFC 8866 §5.14: the ABNF requires at least one format on an `m=` line. Accepting a
399    /// bare one means later emitting a format-less `m=` in the answer's rejection, which the
400    /// far end cannot parse.
401    #[test]
402    fn a_media_line_without_formats_is_rejected() {
403        assert!(matches!(
404            parse(
405                "v=0\r\no=- 1 1 IN IP4 192.0.2.1\r\ns=-\r\nc=IN IP4 192.0.2.1\r\nt=0 0\r\n\
406                 m=application 49174 udp\r\n",
407            ),
408            Err(SdpError::Invalid {
409                field: "media formats",
410                ..
411            })
412        ));
413    }
414
415    #[test]
416    fn a_line_without_an_equals_sign_is_rejected() {
417        assert!(matches!(
418            parse("v=0\r\nthis is not sdp\r\n"),
419            Err(SdpError::MalformedLine(_))
420        ));
421    }
422
423    /// RFC 8866 §5.3: the `s=` line MUST NOT be empty; `s=-` is the spelling for "no name".
424    /// An empty one is taken the same way a missing one is, so it is never re-emitted
425    /// invalid.
426    #[test]
427    fn an_empty_session_name_becomes_a_dash() {
428        let sdp = parse(
429            "v=0\r\no=- 1 1 IN IP4 192.0.2.1\r\ns=\r\nc=IN IP4 192.0.2.1\r\nt=0 0\r\n\
430             m=audio 5000 RTP/AVP 0\r\n",
431        )
432        .expect("parses");
433        assert_eq!(sdp.session_name, "-");
434        assert!(sdp.to_string_sdp().contains("s=-\r\n"));
435    }
436
437    #[test]
438    fn a_description_without_an_origin_is_rejected() {
439        assert!(matches!(
440            parse("v=0\r\ns=-\r\nt=0 0\r\n"),
441            Err(SdpError::Missing("o="))
442        ));
443    }
444
445    #[test]
446    fn ipv6_addresses_round_trip() {
447        let sdp = parse(
448            "v=0\r\no=- 1 1 IN IP6 2001:db8::1\r\ns=-\r\nc=IN IP6 2001:db8::1\r\nt=0 0\r\n\
449             m=audio 5000 RTP/AVP 0\r\n",
450        )
451        .expect("parses");
452        let out = sdp.to_string_sdp();
453        assert!(out.contains("c=IN IP6 2001:db8::1"), "{out}");
454        assert!(out.contains("o=- 1 1 IN IP6 2001:db8::1"), "{out}");
455    }
456
457    /// RFC 8866 §5.2 and §5.7 allow a fully-qualified domain name as the unicast address,
458    /// and offers carrying one exist (RFC 3264 §10.1 opens with one). Rejecting the name
459    /// rejects the whole description, and with it the call.
460    #[test]
461    fn a_domain_name_address_is_kept_not_rejected() {
462        let sdp = parse(
463            "v=0\r\n\
464             o=alice 2890844526 2890844526 IN IP4 host.anywhere.com\r\n\
465             s=-\r\n\
466             c=IN IP4 host.anywhere.com\r\n\
467             t=0 0\r\n\
468             m=audio 49170 RTP/AVP 0\r\n\
469             a=rtpmap:0 PCMU/8000\r\n",
470        )
471        .expect("parses");
472
473        assert_eq!(sdp.origin.address.to_string(), "host.anywhere.com");
474        assert_eq!(
475            sdp.address_for(&sdp.media[0]),
476            None,
477            "a name is not an address until someone resolves it, and this crate does no I/O"
478        );
479
480        let out = sdp.to_string_sdp();
481        assert!(
482            out.contains("o=alice 2890844526 2890844526 IN IP4 host.anywhere.com"),
483            "{out}"
484        );
485        assert!(out.contains("c=IN IP4 host.anywhere.com"), "{out}");
486    }
487
488    /// A multicast `c=` carries `/ttl`, which is not part of the address.
489    #[test]
490    fn a_multicast_connection_drops_its_ttl_suffix() {
491        let sdp = parse(
492            "v=0\r\no=- 1 1 IN IP4 192.0.2.1\r\ns=-\r\nc=IN IP4 224.0.1.1/127\r\nt=0 0\r\n\
493             m=audio 5000 RTP/AVP 0\r\n",
494        )
495        .expect("parses");
496        assert_eq!(
497            sdp.connection.expect("a connection").address.to_string(),
498            "224.0.1.1"
499        );
500    }
501}