Skip to main content

rtsp_runtime/
session_header.rs

1//! Typed `Session` header — RFC 2326 §12.37 (`docs/rfc2326.md`, `docs/session-header.md`).
2//!
3//! ```text
4//! Session = "Session" ":" session-id [ ";" "timeout" "=" delta-seconds ]
5//! ```
6//!
7//! `session-id` (§3.4) is `1*( ALPHA | DIGIT | safe )` with
8//! `safe = "$" | "-" | "_" | "." | "+"`; identifiers are opaque and a parser must not
9//! reject short ones. `delta-seconds` is `1*DIGIT`; the `timeout` defaults to 60 s
10//! when absent.
11//!
12//! Lenient on input, canonical on output (the interop table is in
13//! `docs/session-header.md`): LWS around `;` and `=`, case-insensitive parameter
14//! names, a bad or zero `timeout` value keeps the id and falls back to the default (with a
15//! warning, see [`SessionHeader::parse_with_warnings`]), unknown parameters are
16//! preserved. The session id is everything before the first `;` outside a
17//! quoted-string, LWS-trimmed; when the quotes do not balance (an unterminated
18//! quote) the id is instead everything before the first `;`, so `ab"c;timeout=30`
19//! keeps id `ab"c` and timeout 30 rather than swallowing the `;`. On RECEIVE any
20//! non-empty id without a control
21//! character is accepted verbatim (`"weird"`, `a b`, `abc,def`, `ab"c`: real servers
22//! send them and the client echoes them back unchanged). Only what we EMIT is strict
23//! (non-empty, trimmed, no control characters, representable). Output is always
24//! `id[;timeout=N][;ext[=value]]…` with no whitespace.
25
26use std::time::Duration;
27
28use crate::error::{Error, Result};
29use crate::rfc2326_lex as lex;
30
31/// §12.37: the session timeout when none is declared.
32pub const DEFAULT_SESSION_TIMEOUT: Duration = Duration::from_secs(60);
33
34/// The `timeout` parameter name (§12.37).
35const PARAM_TIMEOUT: &str = "timeout";
36/// `delta-seconds = 1*DIGIT` — bounded to what a `u64` holds (19 digits).
37const DELTA_SECONDS_DIGITS: usize = 19;
38
39/// A parsed `Session` header (RFC 2326 §12.37).
40#[non_exhaustive]
41#[derive(Debug, Clone, PartialEq, Eq)]
42#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
43pub struct SessionHeader {
44    /// The session identifier (opaque).
45    pub id: String,
46    /// The declared `timeout`, if any (a malformed value is [`DEFAULT_SESSION_TIMEOUT`]).
47    pub timeout: Option<Duration>,
48    /// Unknown parameters `(name, value)` in input order.
49    pub extensions: Vec<(String, Option<String>)>,
50}
51
52impl SessionHeader {
53    /// A header carrying only `id`.
54    pub fn new(id: impl Into<String>) -> Self {
55        SessionHeader {
56            id: id.into(),
57            timeout: None,
58            extensions: Vec::new(),
59        }
60    }
61
62    /// Parses a `Session` field value; see the module docs for the leniency rules.
63    pub fn parse(value: &str) -> Result<Self> {
64        Self::parse_with_warnings(value).map(|(h, _)| h)
65    }
66
67    /// Like [`parse`](Self::parse), also returning the recoverable problems found
68    /// (e.g. a malformed `timeout` that fell back to the default).
69    pub fn parse_with_warnings(value: &str) -> Result<(Self, Vec<String>)> {
70        // The lexer owns the split: the id is everything before the first `;`
71        // outside a quoted-string, falling back to the first `;` when the quotes
72        // do not balance, so `ab"c;timeout=30` keeps id `ab"c` and timeout 30.
73        let (id, segs) = lex::split_session_value(value);
74        if id.is_empty() {
75            return Err(Error::SessionParse("empty session id".into()));
76        }
77        // Receive side is lenient (interop): any non-empty id without a control
78        // character is kept verbatim, whatever else it contains.
79        if lex::has_ctl(id) {
80            return Err(Error::SessionParse(format!(
81                "control character in session id {id:?}"
82            )));
83        }
84        let mut out = SessionHeader::new(id);
85        let mut warnings = Vec::new();
86        for seg in segs {
87            if lex::trim_lws(seg).is_empty() {
88                continue;
89            }
90            let (name, raw) = lex::split_param(seg).map_err(|e| Error::SessionParse(e.0))?;
91            let value = match raw {
92                Some(v) => Some(lex::unquote(v).map_err(|e| Error::SessionParse(e.0))?.0),
93                None => None,
94            };
95            if name.eq_ignore_ascii_case(PARAM_TIMEOUT) {
96                let parsed = value
97                    .as_deref()
98                    .and_then(|v| lex::digits(v, DELTA_SECONDS_DIGITS, PARAM_TIMEOUT).ok());
99                match parsed {
100                    // 0 is invalid too: it would make the keepalive deadline "now".
101                    Some(n) if n > 0 => out.timeout = Some(Duration::from_secs(n.into())),
102                    _ => {
103                        warnings.push(format!(
104                            "malformed or zero timeout {value:?}; using the 60 s default"
105                        ));
106                        out.timeout = Some(DEFAULT_SESSION_TIMEOUT);
107                    }
108                }
109            } else {
110                out.extensions.push((name.to_string(), value));
111            }
112        }
113        Ok((out, warnings))
114    }
115
116    /// Canonical serialization: `id[;timeout=N][;ext[=value]]…`.
117    ///
118    /// # Errors
119    ///
120    /// [`Error::HeaderSerialize`] if the id is not a valid session-id, an extension
121    /// name is not an RFC 2326 §15.1 token, or a value holds a control character;
122    /// nothing unsafe is ever written.
123    pub fn to_header_value(&self) -> Result<String> {
124        // Strict only for what WE emit: non-empty, trimmed, no control characters.
125        if self.id.is_empty() || lex::has_ctl(&self.id) || lex::trim_lws(&self.id) != self.id {
126            return Err(Error::HeaderSerialize(format!(
127                "invalid session id {:?}",
128                self.id
129            )));
130        }
131        let mut s = self.id.clone();
132        if let Some(t) = self.timeout {
133            s.push(lex::PARAM_SEP);
134            s.push_str(&format!("{PARAM_TIMEOUT}{}{}", lex::VALUE_SEP, t.as_secs()));
135        }
136        for (name, value) in &self.extensions {
137            if !lex::is_token(name) {
138                return Err(Error::HeaderSerialize(format!(
139                    "extension name {name:?} is not a token"
140                )));
141            }
142            s.push(lex::PARAM_SEP);
143            s.push_str(name);
144            if let Some(v) = value {
145                s.push(lex::VALUE_SEP);
146                s.push_str(&lex::emit_value(v).map_err(|e| Error::HeaderSerialize(e.0))?);
147            }
148        }
149        // The id must come back as the first `;`-segment (an unbalanced quote in it
150        // would swallow the separator): otherwise it cannot be emitted faithfully.
151        let first = lex::split_outside_quotes(&s, lex::PARAM_SEP)[0];
152        if lex::trim_lws(first) != self.id {
153            return Err(Error::HeaderSerialize(format!(
154                "session id {:?} cannot be emitted unambiguously",
155                self.id
156            )));
157        }
158        Ok(s)
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    fn p(v: &str) -> SessionHeader {
167        SessionHeader::parse(v).unwrap()
168    }
169
170    #[test]
171    fn the_interop_table() {
172        for v in [
173            "abc;timeout=30",
174            "abc; timeout=30",
175            "abc ; timeout=30",
176            "abc;TIMEOUT=30",
177            "abc;timeout = 30",
178        ] {
179            let h = p(v);
180            assert_eq!(
181                (h.id.as_str(), h.timeout),
182                ("abc", Some(Duration::from_secs(30))),
183                "{v}"
184            );
185        }
186        let (h, w) = SessionHeader::parse_with_warnings("abc;timeout=0").unwrap();
187        assert_eq!(
188            h.timeout,
189            Some(DEFAULT_SESSION_TIMEOUT),
190            "timeout=0 is invalid"
191        );
192        assert_eq!(w.len(), 1);
193        let (h, w) = SessionHeader::parse_with_warnings("abc;timeout=x").unwrap();
194        assert_eq!(h.timeout, Some(DEFAULT_SESSION_TIMEOUT));
195        assert_eq!(w.len(), 1);
196        let h = p("abc;foo=bar");
197        assert_eq!(h.extensions, vec![("foo".into(), Some("bar".into()))]);
198        assert!(SessionHeader::parse("").is_err());
199        assert!(SessionHeader::parse("  ;timeout=5").is_err());
200        assert_eq!(p("12345678").timeout, None);
201    }
202
203    #[test]
204    fn an_unbalanced_quote_falls_back_to_the_first_semicolon() {
205        // An unterminated quote must not swallow the `;` that introduces the params.
206        let (h, w) = SessionHeader::parse_with_warnings("ab\"c;timeout=30").unwrap();
207        assert_eq!(h.id, "ab\"c");
208        assert_eq!(h.timeout, Some(Duration::from_secs(30)));
209        assert!(w.is_empty(), "{w:?}");
210        // ... but a balanced quoted id still splits quote-aware.
211        let (h, w) = SessionHeader::parse_with_warnings("\"a;b\";timeout=30").unwrap();
212        assert_eq!(h.id, "\"a;b\"");
213        assert_eq!(h.timeout, Some(Duration::from_secs(30)));
214        assert!(w.is_empty(), "{w:?}");
215        // No parameters: the unbalanced-quote id is kept whole.
216        assert_eq!(p("ab\"c").id, "ab\"c");
217    }
218
219    #[test]
220    fn canonical_output_round_trips_and_is_idempotent() {
221        for v in [
222            "abc",
223            "abc ; Timeout = 30 ; x=\"a b\"",
224            "abc;timeout=x;flag",
225        ] {
226            let a = p(v);
227            let c = a.to_header_value().unwrap();
228            let b = p(&c);
229            assert_eq!(a.id, b.id);
230            assert_eq!(b.to_header_value().unwrap(), c, "idempotent {v}");
231            assert_eq!(p(&c), b);
232        }
233        assert_eq!(
234            p("abc ; Timeout = 30").to_header_value().unwrap(),
235            "abc;timeout=30"
236        );
237    }
238
239    #[test]
240    fn the_serializer_never_emits_control_characters() {
241        let mut h = SessionHeader::new("abc");
242        h.id = "ab\r\nSet-Cookie: x".into();
243        assert!(matches!(
244            h.to_header_value(),
245            Err(Error::HeaderSerialize(_))
246        ));
247        let mut h = SessionHeader::new("abc");
248        h.extensions.push(("x\r\ny".into(), None));
249        assert!(h.to_header_value().is_err());
250        let mut h = SessionHeader::new("abc");
251        h.extensions.push(("x".into(), Some("a\r\nb".into())));
252        assert!(h.to_header_value().is_err());
253        let mut h = SessionHeader::new("abc");
254        h.id = String::new();
255        assert!(h.to_header_value().is_err());
256        // an id that would swallow the `;` separator cannot be emitted faithfully
257        let mut h = SessionHeader::new("ab\"c");
258        h.timeout = Some(Duration::from_secs(5));
259        assert!(h.to_header_value().is_err());
260    }
261    /// Fuzz regression (crash input `";;!` from the rtsp_headers target): a received id of a
262    /// lone `"` parses (receive is lenient) but cannot be emitted unambiguously (emit is strict).
263    #[test]
264    fn a_received_lone_quote_id_parses_but_is_not_emittable() {
265        let (h, _) = SessionHeader::parse_with_warnings("\";;!").unwrap();
266        assert_eq!(h.id, "\"");
267        assert!(matches!(
268            h.to_header_value(),
269            Err(crate::Error::HeaderSerialize(_))
270        ));
271    }
272}