Skip to main content

flowcat_core/sip/
sdp.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! Minimal SDP (RFC 4566) for G.711 telephony — just enough to offer/answer a
4//! single `audio` stream over RTP/AVP with PCMU/PCMA.
5//!
6//! Hand-rolled (no `sdp-rs` dep) because our needs are tiny and fixed: one media
7//! line, two possible codecs, ptime 20, sendrecv (see SIP-DESIGN.md §2). We build
8//! offers/answers and parse a peer's offer/answer to extract its media IP, port,
9//! and chosen codec.
10//!
11//! Build output shape (one media stream, IPv4):
12//! ```text
13//! v=0
14//! o=- 0 0 IN IP4 <ip>
15//! s=flowcat
16//! c=IN IP4 <ip>
17//! t=0 0
18//! m=audio <port> RTP/AVP 0 8      (offer: both; answer: the one chosen)
19//! a=rtpmap:0 PCMU/8000
20//! a=rtpmap:8 PCMA/8000
21//! a=ptime:20
22//! a=sendrecv
23//! ```
24
25use std::net::Ipv4Addr;
26
27use super::rtp::{PT_PCMA, PT_PCMU};
28
29/// The G.711 codec negotiated for a stream.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum G711Codec {
32    /// μ-law, RTP payload type 0.
33    Pcmu,
34    /// A-law, RTP payload type 8.
35    Pcma,
36}
37
38impl G711Codec {
39    /// The static RTP/AVP payload type number (0 = PCMU, 8 = PCMA).
40    pub fn payload_type(self) -> u8 {
41        match self {
42            G711Codec::Pcmu => PT_PCMU,
43            G711Codec::Pcma => PT_PCMA,
44        }
45    }
46
47    /// The `a=rtpmap` encoding name (`PCMU` / `PCMA`).
48    pub fn rtpmap_name(self) -> &'static str {
49        match self {
50            G711Codec::Pcmu => "PCMU",
51            G711Codec::Pcma => "PCMA",
52        }
53    }
54
55    /// Map a static payload-type number to a G.711 codec, if it is one.
56    pub fn from_payload_type(pt: u8) -> Option<Self> {
57        match pt {
58            PT_PCMU => Some(G711Codec::Pcmu),
59            PT_PCMA => Some(G711Codec::Pcma),
60            _ => None,
61        }
62    }
63}
64
65/// The audio media parameters parsed from / built into an SDP body.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct SdpMedia {
68    /// Connection IPv4 address the peer receives RTP on (`c=` / media-level).
69    pub ip: Ipv4Addr,
70    /// UDP port the peer receives RTP on (`m=audio <port> …`).
71    pub port: u16,
72    /// The chosen / preferred G.711 codec.
73    pub codec: G711Codec,
74}
75
76/// Build an SDP **offer** advertising both PCMU and PCMA at `ip:port`.
77///
78/// PCMU is listed first (our preference). ptime 20, sendrecv.
79pub fn build_offer(ip: Ipv4Addr, port: u16) -> String {
80    // m= line lists both PTs (0 8); rtpmap for each.
81    build_sdp(ip, port, &[G711Codec::Pcmu, G711Codec::Pcma])
82}
83
84/// Build an SDP **answer** committing to exactly one `codec` at `ip:port`.
85pub fn build_answer(ip: Ipv4Addr, port: u16, codec: G711Codec) -> String {
86    build_sdp(ip, port, &[codec])
87}
88
89/// Shared SDP body builder for an audio/RTP-AVP stream listing `codecs` (in
90/// order) on the `m=` line, with an `a=rtpmap` per codec, ptime 20, sendrecv.
91fn build_sdp(ip: Ipv4Addr, port: u16, codecs: &[G711Codec]) -> String {
92    let pts: Vec<String> = codecs
93        .iter()
94        .map(|c| c.payload_type().to_string())
95        .collect();
96    let mut s = String::new();
97    s.push_str("v=0\r\n");
98    s.push_str(&format!("o=- 0 0 IN IP4 {ip}\r\n"));
99    s.push_str("s=flowcat\r\n");
100    s.push_str(&format!("c=IN IP4 {ip}\r\n"));
101    s.push_str("t=0 0\r\n");
102    s.push_str(&format!("m=audio {port} RTP/AVP {}\r\n", pts.join(" ")));
103    for c in codecs {
104        s.push_str(&format!(
105            "a=rtpmap:{} {}/8000\r\n",
106            c.payload_type(),
107            c.rtpmap_name()
108        ));
109    }
110    s.push_str("a=ptime:20\r\n");
111    s.push_str("a=sendrecv\r\n");
112    s
113}
114
115/// Errors parsing a peer's SDP body.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum SdpParseError {
118    /// No `m=audio` line was found.
119    NoAudioMedia,
120    /// No usable connection (`c=IN IP4 …`) address was found.
121    NoConnection,
122    /// The `m=audio` line had no port / an unparseable port.
123    BadPort,
124    /// The peer offered no G.711 (PCMU/PCMA) payload type we support.
125    NoSupportedCodec,
126}
127
128impl std::fmt::Display for SdpParseError {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        match self {
131            SdpParseError::NoAudioMedia => write!(f, "SDP has no m=audio line"),
132            SdpParseError::NoConnection => write!(f, "SDP has no c=IN IP4 connection address"),
133            SdpParseError::BadPort => write!(f, "SDP m=audio line has no/invalid port"),
134            SdpParseError::NoSupportedCodec => write!(f, "SDP offers no PCMU/PCMA codec"),
135        }
136    }
137}
138
139/// Parse a peer's SDP, returning the audio IP, port, and the **first** G.711
140/// codec listed on the `m=audio` line that we support.
141///
142/// Codec selection follows the order the peer lists payload types on the `m=`
143/// line (its preference). For an offer that lists `0 8` we therefore pick PCMU;
144/// `8 0` would pick PCMA. A media-level `c=` line overrides a session-level one.
145pub fn parse(sdp: &str) -> Result<SdpMedia, SdpParseError> {
146    let mut session_ip: Option<Ipv4Addr> = None;
147    let mut media_ip: Option<Ipv4Addr> = None;
148    let mut port: Option<u16> = None;
149    let mut media_pts: Vec<u8> = Vec::new();
150    let mut in_audio = false;
151    let mut seen_audio = false;
152
153    for line in sdp.lines() {
154        let line = line.trim_end_matches('\r');
155        if let Some(rest) = line.strip_prefix("c=") {
156            // c=IN IP4 <addr>. Before the first m= → session-level; after → the
157            // current media's connection.
158            if let Some(addr) = parse_connection_ipv4(rest) {
159                if in_audio {
160                    media_ip = Some(addr);
161                } else {
162                    session_ip = Some(addr);
163                }
164            }
165        } else if let Some(rest) = line.strip_prefix("m=") {
166            if let Some(audio) = rest.strip_prefix("audio ") {
167                if seen_audio {
168                    // We commit to exactly one audio stream. A second m=audio
169                    // (RFC 4566 allows multiple) must NOT clobber the first
170                    // stream's port or merge its payload types — ignore it, and
171                    // leave audio scope so a trailing c= isn't mis-attributed to it.
172                    in_audio = false;
173                    continue;
174                }
175                // First (and for us only) audio media line.
176                in_audio = true;
177                seen_audio = true;
178                let mut parts = audio.split_whitespace();
179                port = parts.next().and_then(|p| p.parse::<u16>().ok());
180                // Skip the transport token ("RTP/AVP"); the rest are payload types.
181                let _transport = parts.next();
182                for pt in parts {
183                    if let Ok(n) = pt.parse::<u8>() {
184                        media_pts.push(n);
185                    }
186                }
187            } else {
188                // A different (e.g. video) media section starts — leave audio scope.
189                in_audio = false;
190            }
191        }
192    }
193
194    if !seen_audio {
195        return Err(SdpParseError::NoAudioMedia);
196    }
197    let ip = media_ip.or(session_ip).ok_or(SdpParseError::NoConnection)?;
198    let port = port.ok_or(SdpParseError::BadPort)?;
199    if port == 0 {
200        // Port 0 means the stream is declined / inactive — no media to send.
201        return Err(SdpParseError::BadPort);
202    }
203    // Pick the first PCMU/PCMA in the peer's listed order (their preference).
204    let codec = media_pts
205        .iter()
206        .find_map(|&pt| G711Codec::from_payload_type(pt))
207        .ok_or(SdpParseError::NoSupportedCodec)?;
208
209    Ok(SdpMedia { ip, port, codec })
210}
211
212/// Parse the IPv4 address out of a `c=` line body of the form `IN IP4 <addr>`.
213fn parse_connection_ipv4(rest: &str) -> Option<Ipv4Addr> {
214    let mut parts = rest.split_whitespace();
215    match (parts.next(), parts.next(), parts.next()) {
216        (Some("IN"), Some("IP4"), Some(addr)) => {
217            // Strip any TTL/count suffix (e.g. "224.2.1.1/127") for multicast.
218            addr.split('/').next()?.parse().ok()
219        }
220        _ => None,
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    fn ip(a: u8, b: u8, c: u8, d: u8) -> Ipv4Addr {
229        Ipv4Addr::new(a, b, c, d)
230    }
231
232    // ── symmetric-RTP: answer must carry the node's public IP + bound port ──────
233    #[test]
234    fn answer_carries_public_ip_port_sendrecv_ptime_and_rtpmap() {
235        let pub_ip = ip(34, 124, 207, 59);
236        let sdp = build_answer(pub_ip, 41234, G711Codec::Pcmu);
237        assert!(
238            sdp.contains("c=IN IP4 34.124.207.59\r\n"),
239            "answer c= must be the public IP"
240        );
241        assert!(
242            sdp.contains("o=- 0 0 IN IP4 34.124.207.59\r\n"),
243            "o= origin must be the public IP"
244        );
245        assert!(
246            sdp.contains("m=audio 41234 RTP/AVP 0\r\n"),
247            "m= must carry the bound RTP port + PCMU only"
248        );
249        assert!(sdp.contains("a=rtpmap:0 PCMU/8000\r\n"));
250        assert!(sdp.contains("a=ptime:20\r\n"));
251        assert!(sdp.contains("a=sendrecv\r\n"));
252        assert!(
253            !sdp.contains("PCMA"),
254            "answer must commit to exactly one codec"
255        );
256    }
257
258    // ── codec selection from a realistic carrier offer (PCMU + DTMF) ────────────
259    #[test]
260    fn parse_zadarma_style_offer_selects_pcmu() {
261        let sdp = "v=0\r\no=Zadarma 12345 1 IN IP4 185.45.152.42\r\ns=Zadarma\r\n\
262                   c=IN IP4 185.45.152.42\r\nt=0 0\r\nm=audio 18722 RTP/AVP 0 101\r\n\
263                   a=rtpmap:0 PCMU/8000\r\na=rtpmap:101 telephone-event/8000\r\n\
264                   a=fmtp:101 0-16\r\na=ptime:20\r\na=sendrecv\r\n";
265        let m = parse(sdp).unwrap();
266        assert_eq!(m.codec, G711Codec::Pcmu);
267        assert_eq!(m.ip, ip(185, 45, 152, 42));
268        assert_eq!(m.port, 18722);
269    }
270
271    // ── an offer with NO G.711 must be rejected, not silently mis-selected ──────
272    #[test]
273    fn parse_rejects_offer_without_g711() {
274        let sdp = "v=0\r\nc=IN IP4 9.9.9.9\r\nm=audio 5000 RTP/AVP 111 101\r\n\
275                   a=rtpmap:111 opus/48000/2\r\na=rtpmap:101 telephone-event/8000\r\n";
276        assert_eq!(parse(sdp), Err(SdpParseError::NoSupportedCodec));
277    }
278
279    #[test]
280    fn parse_rejects_dtmf_only_offer() {
281        let sdp = "v=0\r\nc=IN IP4 1.2.3.4\r\nm=audio 5000 RTP/AVP 101\r\n\
282                   a=rtpmap:101 telephone-event/8000\r\n";
283        assert_eq!(parse(sdp), Err(SdpParseError::NoSupportedCodec));
284    }
285
286    // ── line-ending + IPv6 robustness ───────────────────────────────────────────
287    #[test]
288    fn parse_tolerates_lf_only_line_endings() {
289        let sdp = "v=0\nc=IN IP4 7.7.7.7\nm=audio 5000 RTP/AVP 0\na=rtpmap:0 PCMU/8000\n";
290        let m = parse(sdp).unwrap();
291        assert_eq!(m.ip, ip(7, 7, 7, 7));
292        assert_eq!(m.port, 5000);
293        assert_eq!(m.codec, G711Codec::Pcmu);
294    }
295
296    #[test]
297    fn parse_ipv6_only_connection_yields_no_connection() {
298        let sdp = "v=0\r\nc=IN IP6 2001:db8::1\r\nm=audio 5000 RTP/AVP 0\r\n";
299        assert_eq!(parse(sdp), Err(SdpParseError::NoConnection));
300    }
301
302    #[test]
303    fn parse_prefers_ipv4_media_connection_over_ipv6_session() {
304        let sdp = "v=0\r\nc=IN IP6 2001:db8::1\r\nt=0 0\r\nm=audio 9000 RTP/AVP 0\r\n\
305                   c=IN IP4 172.16.5.5\r\na=rtpmap:0 PCMU/8000\r\n";
306        let m = parse(sdp).unwrap();
307        assert_eq!(m.ip, ip(172, 16, 5, 5));
308    }
309
310    // ── hostile/malformed bodies must return Err, never panic ───────────────────
311    #[test]
312    fn parse_malformed_inputs_do_not_panic() {
313        assert!(parse("").is_err());
314        assert!(parse("this is not sdp at all\r\n\r\n").is_err());
315        assert!(parse("c=IN IP4 1.1.1.1\r\nm=audio\r\n").is_err());
316        assert!(parse("c=IN IP4 1.1.1.1\r\nm=audio abc RTP/AVP 0\r\n").is_err());
317        assert!(parse("c=IN IP4 1.1.1.1\r\nm=audio 5000 RTP/AVP\r\n").is_err());
318        assert!(parse("c=IN IP4 1.1.1.1\r\nm=audio 70000 RTP/AVP 0\r\n").is_err());
319        assert!(parse("m=\r\n").is_err());
320        assert!(parse("c=IN IP4\r\nm=audio 5000 RTP/AVP 0\r\n").is_err());
321    }
322
323    // ── regression: a 2nd m=audio must NOT clobber the first stream ─────────────
324    #[test]
325    fn parse_uses_first_audio_section_only() {
326        let sdp = "v=0\r\nc=IN IP4 10.0.0.1\r\nm=audio 8000 RTP/AVP 0\r\n\
327                   a=rtpmap:0 PCMU/8000\r\nm=audio 9000 RTP/AVP 8\r\n\
328                   a=rtpmap:8 PCMA/8000\r\n";
329        let m = parse(sdp).unwrap();
330        assert_eq!(
331            m.port, 8000,
332            "second m=audio must not clobber the first stream's port"
333        );
334        assert_eq!(
335            m.codec,
336            G711Codec::Pcmu,
337            "PTs from the second m=audio must not leak in"
338        );
339    }
340
341    #[test]
342    fn offer_lists_both_codecs_with_ptime_and_sendrecv() {
343        let sdp = build_offer(ip(10, 0, 0, 5), 40000);
344        assert!(sdp.contains("m=audio 40000 RTP/AVP 0 8\r\n"));
345        assert!(sdp.contains("a=rtpmap:0 PCMU/8000\r\n"));
346        assert!(sdp.contains("a=rtpmap:8 PCMA/8000\r\n"));
347        assert!(sdp.contains("c=IN IP4 10.0.0.5\r\n"));
348        assert!(sdp.contains("a=ptime:20\r\n"));
349        assert!(sdp.contains("a=sendrecv\r\n"));
350    }
351
352    #[test]
353    fn answer_commits_to_one_codec() {
354        let sdp = build_answer(ip(192, 168, 1, 9), 5004, G711Codec::Pcma);
355        assert!(sdp.contains("m=audio 5004 RTP/AVP 8\r\n"));
356        assert!(sdp.contains("a=rtpmap:8 PCMA/8000\r\n"));
357        assert!(!sdp.contains("PCMU"));
358    }
359
360    #[test]
361    fn build_then_parse_offer_round_trips_pcmu() {
362        // Our own offer lists 0 first → parse picks PCMU.
363        let sdp = build_offer(ip(203, 0, 113, 7), 12345);
364        let m = parse(&sdp).unwrap();
365        assert_eq!(m.ip, ip(203, 0, 113, 7));
366        assert_eq!(m.port, 12345);
367        assert_eq!(m.codec, G711Codec::Pcmu);
368        assert_eq!(m.codec.payload_type(), 0);
369    }
370
371    #[test]
372    fn build_then_parse_answer_round_trips_pcma() {
373        let sdp = build_answer(ip(8, 8, 4, 4), 6000, G711Codec::Pcma);
374        let m = parse(&sdp).unwrap();
375        assert_eq!(m.ip, ip(8, 8, 4, 4));
376        assert_eq!(m.port, 6000);
377        assert_eq!(m.codec, G711Codec::Pcma);
378    }
379
380    #[test]
381    fn parse_picks_first_listed_payload_type_pcma_when_peer_prefers_it() {
382        // Peer lists 8 before 0 → its preference is PCMA.
383        let sdp = "v=0\r\n\
384                   o=- 0 0 IN IP4 1.2.3.4\r\n\
385                   s=-\r\n\
386                   c=IN IP4 1.2.3.4\r\n\
387                   t=0 0\r\n\
388                   m=audio 7000 RTP/AVP 8 0\r\n\
389                   a=rtpmap:8 PCMA/8000\r\n\
390                   a=rtpmap:0 PCMU/8000\r\n";
391        let m = parse(sdp).unwrap();
392        assert_eq!(m.codec, G711Codec::Pcma);
393        assert_eq!(m.port, 7000);
394    }
395
396    #[test]
397    fn parse_skips_unsupported_codecs_and_finds_g711() {
398        // Telephony-event (101) + opus (111) listed before G.711 PCMU (0).
399        let sdp = "v=0\r\n\
400                   c=IN IP4 9.9.9.9\r\n\
401                   m=audio 5555 RTP/AVP 111 101 0\r\n\
402                   a=rtpmap:111 opus/48000/2\r\n\
403                   a=rtpmap:101 telephone-event/8000\r\n\
404                   a=rtpmap:0 PCMU/8000\r\n";
405        let m = parse(sdp).unwrap();
406        assert_eq!(m.codec, G711Codec::Pcmu);
407    }
408
409    #[test]
410    fn media_level_connection_overrides_session_level() {
411        let sdp = "v=0\r\n\
412                   o=- 0 0 IN IP4 10.0.0.1\r\n\
413                   c=IN IP4 10.0.0.1\r\n\
414                   t=0 0\r\n\
415                   m=audio 9000 RTP/AVP 0\r\n\
416                   c=IN IP4 172.16.5.5\r\n\
417                   a=rtpmap:0 PCMU/8000\r\n";
418        let m = parse(sdp).unwrap();
419        // The media-level c= (after m=audio) wins over the session-level one.
420        assert_eq!(m.ip, ip(172, 16, 5, 5));
421    }
422
423    #[test]
424    fn parse_errors_on_missing_pieces() {
425        // No m=audio at all.
426        assert_eq!(
427            parse("v=0\r\nc=IN IP4 1.1.1.1\r\n"),
428            Err(SdpParseError::NoAudioMedia)
429        );
430        // m=audio but no connection line anywhere.
431        assert_eq!(
432            parse("v=0\r\nm=audio 5000 RTP/AVP 0\r\n"),
433            Err(SdpParseError::NoConnection)
434        );
435        // Port 0 = declined stream.
436        assert_eq!(
437            parse("v=0\r\nc=IN IP4 1.1.1.1\r\nm=audio 0 RTP/AVP 0\r\n"),
438            Err(SdpParseError::BadPort)
439        );
440        // Only an unsupported codec offered.
441        assert_eq!(
442            parse("v=0\r\nc=IN IP4 1.1.1.1\r\nm=audio 5000 RTP/AVP 111\r\n"),
443            Err(SdpParseError::NoSupportedCodec)
444        );
445    }
446}