Skip to main content

sipx_sdp/
session.rs

1//! The session description AST.
2//!
3//! Every line is kept. SDP grows new attributes constantly, and an element that silently drops
4//! what it does not understand breaks features it has never heard of — the typed fields here
5//! are a view over the lines, not a replacement for them.
6
7use std::fmt::{self, Write as _};
8use std::net::IpAddr;
9
10/// A unicast address as written on an `o=` or `c=` line.
11///
12/// RFC 8866 §5.2 and §5.7 allow a fully-qualified domain name here, not just a literal. A
13/// name is kept as written: resolving it takes a resolver, which is I/O this crate does not
14/// do, and re-emitting it verbatim is what keeps a round trip faithful.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Address {
17    /// An IP literal.
18    Ip(IpAddr),
19    /// A fully-qualified domain name, kept as written.
20    Host(String),
21}
22
23impl Address {
24    /// The IP, when the address is a literal. A name yields `None`; turning it into an
25    /// address is the caller's job.
26    #[must_use]
27    pub fn ip(&self) -> Option<IpAddr> {
28        match self {
29            Self::Ip(ip) => Some(*ip),
30            Self::Host(_) => None,
31        }
32    }
33
34    fn address_type(&self) -> &'static str {
35        match self {
36            Self::Ip(IpAddr::V6(_)) => "IP6",
37            // RFC 8866 §5.7 requires an addrtype even for a name, whose family the
38            // description alone cannot reveal; IP4 is the one every implementation accepts.
39            _ => "IP4",
40        }
41    }
42}
43
44impl From<IpAddr> for Address {
45    fn from(ip: IpAddr) -> Self {
46        Self::Ip(ip)
47    }
48}
49
50impl fmt::Display for Address {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        match self {
53            Self::Ip(ip) => ip.fmt(f),
54            Self::Host(host) => f.write_str(host),
55        }
56    }
57}
58
59/// Which way media flows, from the point of view of the description that carries it.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum Direction {
62    /// Both ways. The default when no direction attribute is present (RFC 4566 §6).
63    #[default]
64    SendRecv,
65    /// This side sends only.
66    SendOnly,
67    /// This side receives only.
68    RecvOnly,
69    /// Neither, but the stream stays negotiated.
70    Inactive,
71}
72
73impl Direction {
74    /// The direction an answer must carry to match an offer.
75    ///
76    /// This is a mirror, not a copy. An offer of `sendonly` means "I will send, you will
77    /// receive", so the answer says `recvonly`. Copying the offer's direction instead is a
78    /// common bug and produces a call where both ends wait for audio.
79    #[must_use]
80    pub fn mirrored(self) -> Self {
81        match self {
82            Self::SendRecv => Self::SendRecv,
83            Self::SendOnly => Self::RecvOnly,
84            Self::RecvOnly => Self::SendOnly,
85            Self::Inactive => Self::Inactive,
86        }
87    }
88
89    /// Parse a direction attribute name.
90    #[must_use]
91    pub fn parse(name: &str) -> Option<Self> {
92        match name {
93            "sendrecv" => Some(Self::SendRecv),
94            "sendonly" => Some(Self::SendOnly),
95            "recvonly" => Some(Self::RecvOnly),
96            "inactive" => Some(Self::Inactive),
97            _ => None,
98        }
99    }
100
101    /// The attribute name.
102    #[must_use]
103    pub fn as_str(self) -> &'static str {
104        match self {
105            Self::SendRecv => "sendrecv",
106            Self::SendOnly => "sendonly",
107            Self::RecvOnly => "recvonly",
108            Self::Inactive => "inactive",
109        }
110    }
111
112    /// Whether this side will send media.
113    #[must_use]
114    pub fn sends(self) -> bool {
115        matches!(self, Self::SendRecv | Self::SendOnly)
116    }
117
118    /// Whether this side will receive media.
119    #[must_use]
120    pub fn receives(self) -> bool {
121        matches!(self, Self::SendRecv | Self::RecvOnly)
122    }
123}
124
125/// An `a=` line: either `a=name` or `a=name:value`.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct Attribute {
128    /// The attribute name.
129    pub name: String,
130    /// Its value, if it has one.
131    pub value: Option<String>,
132}
133
134impl Attribute {
135    /// A flag attribute, like `a=sendrecv`.
136    #[must_use]
137    pub fn flag(name: impl Into<String>) -> Self {
138        Self {
139            name: name.into(),
140            value: None,
141        }
142    }
143
144    /// A valued attribute, like `a=rtpmap:0 PCMU/8000`.
145    #[must_use]
146    pub fn valued(name: impl Into<String>, value: impl Into<String>) -> Self {
147        Self {
148            name: name.into(),
149            value: Some(value.into()),
150        }
151    }
152
153    fn write_to(&self, out: &mut String) {
154        match &self.value {
155            Some(value) => {
156                let _ = writeln!(out, "a={}:{value}\r", self.name);
157            }
158            None => {
159                let _ = writeln!(out, "a={}\r", self.name);
160            }
161        }
162    }
163}
164
165/// An `o=` line.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct Origin {
168    /// The originator's name, or `-`.
169    pub username: String,
170    /// A session identifier.
171    pub session_id: u64,
172    /// The version, which increases with each modified offer.
173    pub session_version: u64,
174    /// The address the session is described from.
175    pub address: Address,
176}
177
178impl Origin {
179    /// An origin for an address, with the identifier and version supplied by the caller.
180    ///
181    /// The caller supplies them deliberately: a session version has to *increase* across
182    /// re-offers, and only the caller knows what it used last.
183    #[must_use]
184    pub fn new(address: IpAddr, session_id: u64, session_version: u64) -> Self {
185        Self {
186            username: "-".to_owned(),
187            session_id,
188            session_version,
189            address: Address::Ip(address),
190        }
191    }
192}
193
194/// A `c=` line.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct Connection {
197    /// Where media should be sent.
198    pub address: Address,
199}
200
201impl Connection {
202    /// A connection line for an address.
203    #[must_use]
204    pub fn new(address: IpAddr) -> Self {
205        Self {
206            address: Address::Ip(address),
207        }
208    }
209}
210
211/// A `t=` line.
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
213pub struct Timing {
214    /// Start time, 0 for unbounded.
215    pub start: u64,
216    /// Stop time, 0 for unbounded.
217    pub stop: u64,
218}
219
220/// An `m=` line and everything under it.
221#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct MediaDescription {
223    /// `audio`, `video`, `application`…
224    pub media: String,
225    /// The port. Zero means the stream is rejected — and a rejected stream is still *present*,
226    /// which is what keeps the answer's media lines aligned with the offer's.
227    pub port: u16,
228    /// The transport protocol, such as `RTP/AVP`.
229    pub protocol: String,
230    /// Payload type numbers, in preference order.
231    pub formats: Vec<String>,
232    /// A `c=` line for this stream, overriding the session's.
233    pub connection: Option<Connection>,
234    /// Attributes under this media line.
235    pub attributes: Vec<Attribute>,
236    /// Lines under this media line that this crate does not model, kept so they survive a
237    /// round trip.
238    pub other: Vec<(char, String)>,
239}
240
241/// Whether one media section uses a separate RTCP port or multiplexes it with RTP (RFC 5761).
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
243pub enum RtcpMode {
244    /// RTP uses the media port and RTCP uses its control port.
245    #[default]
246    Separate,
247    /// RTP and RTCP share the media port.
248    Mux,
249}
250
251impl RtcpMode {
252    /// Settle an offer/answer exchange.
253    ///
254    /// RFC 5761 §5.1.3: mux is active only when the offer and the answer both carry the flag.
255    /// Omission in the answer is the separate-port fallback and needs no second exchange.
256    #[must_use]
257    pub fn from_exchange(offer: &MediaDescription, answer: &MediaDescription) -> Self {
258        if !offer.is_rejected() && !answer.is_rejected() && offer.rtcp_mux() && answer.rtcp_mux() {
259            Self::Mux
260        } else {
261            Self::Separate
262        }
263    }
264}
265
266impl MediaDescription {
267    /// An audio stream offering these payload types.
268    #[must_use]
269    pub fn audio(port: u16, formats: Vec<String>) -> Self {
270        Self {
271            media: "audio".to_owned(),
272            port,
273            protocol: "RTP/AVP".to_owned(),
274            formats,
275            connection: None,
276            attributes: Vec::new(),
277            other: Vec::new(),
278        }
279    }
280
281    /// Whether this stream is rejected.
282    #[must_use]
283    pub fn is_rejected(&self) -> bool {
284        self.port == 0
285    }
286
287    /// The direction, defaulting to `sendrecv` when no attribute says otherwise.
288    #[must_use]
289    pub fn direction(&self) -> Direction {
290        self.declared_direction().unwrap_or_default()
291    }
292
293    /// The direction attribute written under this `m=` line, if any.
294    ///
295    /// RFC 8866 §6.7: a stream without a direction of its own takes the session-level one,
296    /// so an absent attribute is meaningful and not the same thing as `sendrecv`.
297    #[must_use]
298    pub fn declared_direction(&self) -> Option<Direction> {
299        self.attributes.iter().find_map(|a| {
300            a.value
301                .is_none()
302                .then(|| Direction::parse(&a.name))
303                .flatten()
304        })
305    }
306
307    /// Set the direction, replacing any existing one.
308    pub fn set_direction(&mut self, direction: Direction) {
309        self.attributes
310            .retain(|a| !(a.value.is_none() && Direction::parse(&a.name).is_some()));
311        self.attributes.push(Attribute::flag(direction.as_str()));
312    }
313
314    /// The first `a=crypto` line this stream carries that sipx can act on (RFC 4568).
315    ///
316    /// Several may be offered, in preference order. sipx takes the first it can perform rather
317    /// than the first listed: an offer whose favourite suite is one sipx does not implement is
318    /// still an offer worth answering.
319    #[must_use]
320    pub fn crypto(&self) -> Option<crate::crypto::Crypto> {
321        self.attributes
322            .iter()
323            .filter(|attribute| attribute.name == "crypto")
324            .filter_map(|attribute| attribute.value.as_deref())
325            .find_map(crate::crypto::Crypto::parse)
326    }
327
328    /// The first `a=fingerprint` this stream carries that sipx may act on (RFC 8122 §5).
329    ///
330    /// §5.1 has an endpoint offer a fingerprint under *several* hash functions — "the 'SHA-256'
331    /// hash function algorithm and the hash function used to generate the signature on the
332    /// certificate" — so more than one line is normal and any of them identifies the same
333    /// certificate. Taking the first sipx can compute is therefore correct rather than a shortcut;
334    /// the ones it skips are `md5` and `md2`, which §5 forbids acting on.
335    ///
336    /// Looked for on the media description and not the session: a fingerprint may be given at
337    /// either level, and the media-level one wins where both appear. A caller that wants the
338    /// session-level fallback reads [`SessionDescription::fingerprint`].
339    #[must_use]
340    pub fn fingerprint(&self) -> Option<crate::fingerprint::Fingerprint> {
341        self.attributes
342            .iter()
343            .filter(|attribute| attribute.name == "fingerprint")
344            .filter_map(|attribute| attribute.value.as_deref())
345            .find_map(crate::fingerprint::Fingerprint::parse)
346    }
347
348    /// The `a=setup` role this stream declares (RFC 4145 §4).
349    #[must_use]
350    pub fn setup(&self) -> Option<crate::fingerprint::Setup> {
351        self.attributes
352            .iter()
353            .find(|attribute| attribute.name == "setup")
354            .and_then(|attribute| attribute.value.as_deref())
355            .and_then(crate::fingerprint::Setup::parse)
356    }
357
358    /// Whether this stream carries the `a=rtcp-mux` flag (RFC 5761 §5).
359    #[must_use]
360    pub fn rtcp_mux(&self) -> bool {
361        self.attributes
362            .iter()
363            .any(|attribute| attribute.name == "rtcp-mux" && attribute.value.is_none())
364    }
365
366    /// Every `a=candidate` under this stream that sipx can act on (RFC 8839 §5.1).
367    ///
368    /// Media-level, and only media-level: §5.1 defines the attribute there and nowhere else.
369    ///
370    /// Lines sipx cannot act on are **left out of the result rather than turned into an error** —
371    /// an FQDN, an unsupported address family, a transport other than UDP, an unknown candidate
372    /// type. §5.1 requires that a candidate be ignored, and ignoring it means ignoring the line:
373    /// the attribute is still on the description and still round-trips, and the rest of the
374    /// stream is still usable. A stack that refused the description instead would fail calls with
375    /// peers doing nothing wrong.
376    #[must_use]
377    pub fn ice_candidates(&self) -> Vec<crate::ice::Candidate> {
378        self.attributes
379            .iter()
380            .filter(|attribute| attribute.name == "candidate")
381            .filter_map(|attribute| attribute.value.as_deref())
382            .filter_map(crate::ice::Candidate::parse)
383            .collect()
384    }
385
386    /// The `a=remote-candidates` this stream carries (RFC 8839 §5.2). Media-level.
387    ///
388    /// Present only in an offer from a controlling agent for a stream that is Completed, so an
389    /// empty result is the normal case rather than a sign of anything.
390    #[must_use]
391    pub fn ice_remote_candidates(&self) -> Vec<crate::ice::RemoteCandidate> {
392        self.attributes
393            .iter()
394            .filter(|attribute| attribute.name == "remote-candidates")
395            .filter_map(|attribute| attribute.value.as_deref())
396            .filter_map(crate::ice::RemoteCandidate::parse_list)
397            .flatten()
398            .collect()
399    }
400
401    /// This stream's own `a=ice-ufrag` (RFC 8839 §5.4), before the session-level default.
402    ///
403    /// Read [`SessionDescription::ice_credentials_for`] instead unless the distinction matters:
404    /// a stream with no fragment of its own inherits the session's, and RFC 8839 §4.4.1.1.1
405    /// makes the *pair* of values, not either alone, what an ICE restart changes.
406    #[must_use]
407    pub fn ice_ufrag(&self) -> Option<&str> {
408        self.attribute_value("ice-ufrag")
409    }
410
411    /// This stream's own `a=ice-pwd` (RFC 8839 §5.4), before the session-level default.
412    #[must_use]
413    pub fn ice_pwd(&self) -> Option<&str> {
414        self.attribute_value("ice-pwd")
415    }
416
417    /// The option tags this stream advertises (RFC 8839 §5.6).
418    pub fn ice_options(&self) -> impl Iterator<Item = &str> {
419        self.attributes
420            .iter()
421            .filter(|attribute| attribute.name == "ice-options")
422            .filter_map(|attribute| attribute.value.as_deref())
423            .flat_map(crate::ice::option_tags)
424    }
425
426    /// Whether this stream carries `a=ice-mismatch` (RFC 8839 §5.3). Media-level, in an answer.
427    ///
428    /// It means the offer's default destination for a component had no matching `candidate`
429    /// attribute, and therefore that ICE MUST NOT be used for this stream — RFC 3264 procedures
430    /// apply instead. Not a failure: it is the answerer saying an intermediary rewrote the
431    /// addresses, which is what ICE was going to discover the hard way.
432    #[must_use]
433    pub fn ice_mismatch(&self) -> bool {
434        self.has_flag("ice-mismatch")
435    }
436
437    fn attribute_value(&self, name: &str) -> Option<&str> {
438        self.attribute(name)
439            .and_then(|attribute| attribute.value.as_deref())
440    }
441
442    fn has_flag(&self, name: &str) -> bool {
443        self.attributes
444            .iter()
445            .any(|attribute| attribute.name == name && attribute.value.is_none())
446    }
447
448    /// The `rtpmap` for a payload type, if the description gives one.
449    #[must_use]
450    pub fn rtpmap(&self, format: &str) -> Option<&str> {
451        self.attributes.iter().find_map(|a| {
452            if a.name != "rtpmap" {
453                return None;
454            }
455            let value = a.value.as_deref()?;
456            let (payload, rest) = value.split_once(' ')?;
457            (payload == format).then_some(rest)
458        })
459    }
460
461    /// The first attribute with this name.
462    #[must_use]
463    pub fn attribute(&self, name: &str) -> Option<&Attribute> {
464        self.attributes.iter().find(|a| a.name == name)
465    }
466
467    fn write_to(&self, out: &mut String) {
468        let _ = write!(out, "m={} {} {}", self.media, self.port, self.protocol);
469        for format in &self.formats {
470            let _ = write!(out, " {format}");
471        }
472        let _ = writeln!(out, "\r");
473        // RFC 8866 §5.14 fixes the order inside a media description: `i=` before `c=`, then
474        // `b=` and `k=`, then the attributes.
475        write_other_lines(out, &self.other, |kind| kind == 'i');
476        if let Some(connection) = &self.connection {
477            let _ = writeln!(
478                out,
479                "c=IN {} {}\r",
480                connection.address.address_type(),
481                connection.address
482            );
483        }
484        write_other_lines(out, &self.other, |kind| kind != 'i');
485        for attribute in &self.attributes {
486            attribute.write_to(out);
487        }
488    }
489}
490
491fn write_other_lines(out: &mut String, lines: &[(char, String)], take: impl Fn(char) -> bool) {
492    for (kind, value) in lines {
493        if take(*kind) {
494            let _ = writeln!(out, "{kind}={value}\r");
495        }
496    }
497}
498
499/// A whole session description.
500#[derive(Debug, Clone, PartialEq, Eq)]
501pub struct SessionDescription {
502    /// The `o=` line.
503    pub origin: Origin,
504    /// The `s=` line.
505    pub session_name: String,
506    /// The session-level `c=` line.
507    pub connection: Option<Connection>,
508    /// The `t=` lines.
509    pub timing: Vec<Timing>,
510    /// Session-level attributes.
511    pub attributes: Vec<Attribute>,
512    /// The media streams, in order. The order is load-bearing: an answer's streams correspond
513    /// to the offer's by position.
514    pub media: Vec<MediaDescription>,
515    /// Lines this crate does not model, kept so they survive a round trip.
516    pub other: Vec<(char, String)>,
517}
518
519impl SessionDescription {
520    /// A session description for an address.
521    #[must_use]
522    pub fn new(address: IpAddr, session_id: u64, session_version: u64) -> Self {
523        Self {
524            origin: Origin::new(address, session_id, session_version),
525            session_name: "-".to_owned(),
526            connection: Some(Connection::new(address)),
527            timing: vec![Timing::default()],
528            attributes: Vec::new(),
529            media: Vec::new(),
530            other: Vec::new(),
531        }
532    }
533
534    /// The connection address for a stream: its own `c=` if it has one, else the session's.
535    ///
536    /// A domain-name address yields `None` here — the name is preserved in the description,
537    /// but only a resolver can turn it into somewhere to send media.
538    #[must_use]
539    pub fn address_for(&self, media: &MediaDescription) -> Option<IpAddr> {
540        media
541            .connection
542            .as_ref()
543            .or(self.connection.as_ref())
544            .and_then(|connection| connection.address.ip())
545    }
546
547    /// The session-level direction, defaulting to `sendrecv`.
548    #[must_use]
549    pub fn direction(&self) -> Direction {
550        self.attributes
551            .iter()
552            .find_map(|a| {
553                a.value
554                    .is_none()
555                    .then(|| Direction::parse(&a.name))
556                    .flatten()
557            })
558            .unwrap_or_default()
559    }
560
561    /// The session-level `a=fingerprint`, if the description carries one (RFC 8122 §5).
562    ///
563    /// §5 allows the attribute at either level, and one given here applies to every stream that
564    /// does not override it. A browser puts it here and on no `m=` line at all, so a stack that
565    /// reads only the media level finds nothing and refuses a perfectly good offer.
566    #[must_use]
567    pub fn fingerprint(&self) -> Option<crate::fingerprint::Fingerprint> {
568        self.attributes
569            .iter()
570            .filter(|attribute| attribute.name == "fingerprint")
571            .filter_map(|attribute| attribute.value.as_deref())
572            .find_map(crate::fingerprint::Fingerprint::parse)
573    }
574
575    /// The session-level `a=ice-ufrag` (RFC 8839 §5.4), which is a default for every stream.
576    #[must_use]
577    pub fn ice_ufrag(&self) -> Option<&str> {
578        self.attribute_value("ice-ufrag")
579    }
580
581    /// The session-level `a=ice-pwd` (RFC 8839 §5.4), which is a default for every stream.
582    #[must_use]
583    pub fn ice_pwd(&self) -> Option<&str> {
584        self.attribute_value("ice-pwd")
585    }
586
587    /// The short-term credentials that apply to one stream (RFC 8839 §5.4).
588    ///
589    /// **Media level wins.** §5.4 allows the attributes at either level and makes the session
590    /// level a default, so a stream with its own `ice-ufrag` uses it and a stream without it
591    /// inherits — and the two must not be mixed: taking the fragment from the media line and the
592    /// password from the session line produces a credential neither end can authenticate with,
593    /// and it looks exactly like a network fault. The pair is therefore resolved together, from
594    /// whichever level supplied the fragment.
595    ///
596    /// `None` when the description gives no usable pair at either level, which per §5.4 means
597    /// the stream is not doing ICE. Values up to 256 characters are accepted, as §5.4 requires,
598    /// even though sipx will not send one longer than 32.
599    #[must_use]
600    pub fn ice_credentials_for(&self, media: &MediaDescription) -> Option<crate::ice::Credentials> {
601        let level = |ufrag: Option<&str>, pwd: Option<&str>| match (ufrag, pwd) {
602            (Some(ufrag), Some(pwd)) => crate::ice::Credentials::received(ufrag, pwd),
603            _ => None,
604        };
605        level(media.ice_ufrag(), media.ice_pwd())
606            .or_else(|| level(self.ice_ufrag(), self.ice_pwd()))
607    }
608
609    /// The session-level option tags (RFC 8839 §5.6).
610    pub fn ice_options(&self) -> impl Iterator<Item = &str> {
611        self.attributes
612            .iter()
613            .filter(|attribute| attribute.name == "ice-options")
614            .filter_map(|attribute| attribute.value.as_deref())
615            .flat_map(crate::ice::option_tags)
616    }
617
618    /// The option tags that apply to one stream: the session's and the stream's together.
619    ///
620    /// A union and not an override, which is where this differs from the credentials above.
621    /// §5.6 makes the attribute a statement that "a certain extension is supported by the agent",
622    /// and an agent does not stop supporting an extension because a particular `m=` line named a
623    /// different one. Tags may repeat if both levels name the same one.
624    pub fn ice_options_for<'a>(
625        &'a self,
626        media: &'a MediaDescription,
627    ) -> impl Iterator<Item = &'a str> {
628        self.ice_options().chain(media.ice_options())
629    }
630
631    /// Whether the description carries `a=ice-lite` (RFC 8839 §5.3). Session-level only.
632    ///
633    /// A lite peer never gathers, never sends a check and never nominates, so sipx takes the
634    /// controlling role unconditionally against one (RFC 8445 §6.1.1) and must not wait for
635    /// checks that will never arrive. sipx itself is always a full agent and never sends this.
636    #[must_use]
637    pub fn is_ice_lite(&self) -> bool {
638        self.has_flag("ice-lite")
639    }
640
641    /// The `a=ice-pacing` the description asks for (RFC 8839 §5.5). Session-level only.
642    ///
643    /// [`Pacing::DEFAULT`] when the attribute is absent or unreadable, because §5.5 gives the
644    /// absent case a value — 50 ms — rather than leaving it undefined.
645    ///
646    /// [`Pacing::DEFAULT`]: crate::ice::Pacing::DEFAULT
647    #[must_use]
648    pub fn ice_pacing(&self) -> crate::ice::Pacing {
649        self.attribute_value("ice-pacing")
650            .and_then(crate::ice::Pacing::parse)
651            .unwrap_or(crate::ice::Pacing::DEFAULT)
652    }
653
654    fn attribute_value(&self, name: &str) -> Option<&str> {
655        self.attributes
656            .iter()
657            .find(|attribute| attribute.name == name)
658            .and_then(|attribute| attribute.value.as_deref())
659    }
660
661    fn has_flag(&self, name: &str) -> bool {
662        self.attributes
663            .iter()
664            .any(|attribute| attribute.name == name && attribute.value.is_none())
665    }
666
667    /// Serialize to the wire format.
668    ///
669    /// Line order follows RFC 8866 §5, which is not a style preference: the grammar fixes the
670    /// order, and receivers do reject descriptions that get it wrong.
671    #[must_use]
672    pub fn to_string_sdp(&self) -> String {
673        let mut out = String::with_capacity(256);
674        let _ = writeln!(out, "v=0\r");
675        let _ = writeln!(
676            out,
677            "o={} {} {} IN {} {}\r",
678            self.origin.username,
679            self.origin.session_id,
680            self.origin.session_version,
681            self.origin.address.address_type(),
682            self.origin.address
683        );
684        let _ = writeln!(out, "s={}\r", self.session_name);
685        // RFC 8866 §5 gives every line type a fixed slot: `i=`, `u=`, `e=` and `p=` before
686        // `c=`, `b=` between `c=` and the timing lines, everything else after them. Kept
687        // lines go into their slot, not wherever is convenient, because receivers enforce
688        // the grammar's order.
689        write_other_lines(&mut out, &self.other, |kind| {
690            matches!(kind, 'i' | 'u' | 'e' | 'p')
691        });
692        if let Some(connection) = &self.connection {
693            let _ = writeln!(
694                out,
695                "c=IN {} {}\r",
696                connection.address.address_type(),
697                connection.address
698            );
699        }
700        write_other_lines(&mut out, &self.other, |kind| kind == 'b');
701        if self.timing.is_empty() {
702            let _ = writeln!(out, "t=0 0\r");
703        }
704        for timing in &self.timing {
705            let _ = writeln!(out, "t={} {}\r", timing.start, timing.stop);
706        }
707        write_other_lines(&mut out, &self.other, |kind| {
708            !matches!(kind, 'i' | 'u' | 'e' | 'p' | 'b')
709        });
710        for attribute in &self.attributes {
711            attribute.write_to(&mut out);
712        }
713        for media in &self.media {
714            media.write_to(&mut out);
715        }
716        out
717    }
718}