Skip to main content

rtsp_runtime/
client.rs

1//! Client-side RTSP session engine — RFC 2326 Appendix A.1.
2//!
3//! [`ClientSession`] is a sans-IO driver: request-builder methods return the
4//! outbound bytes to send, and [`ClientSession::handle_data`] consumes inbound
5//! bytes (responses and interleaved `$` frames) and returns typed
6//! [`ClientEvent`]s. It holds the session state, the next `CSeq`, the negotiated
7//! `Session` id and timeout, optional credentials, and the digest
8//! [`Authenticator`].
9//!
10//! Behaviour implemented here (see [`docs/state-machines.md`](../docs/state-machines.md),
11//! [`docs/methods-and-status.md`](../docs/methods-and-status.md),
12//! [`docs/auth.md`](../docs/auth.md)):
13//!
14//! - Request builders reject any method not valid in the current state
15//!   ([`Error::MethodNotValidInState`]) before emitting bytes.
16//! - Every request carries an incrementing `CSeq`, the `Session` id once known,
17//!   and (once authenticated) a freshly-computed `Authorization` header.
18//! - A `2xx` response advances the state per the §A.1 table; a `3xx` resets it
19//!   to `Init`.
20//! - A `401` with configured credentials transparently re-sends the request
21//!   with `Authorization` (a new `CSeq`), including on `stale=true`.
22//! - The `Session` id and timeout are captured from the SETUP response.
23//! - Interleaved frames are surfaced as [`ClientEvent::MediaData`].
24
25use std::collections::HashMap;
26use std::time::{Duration, Instant};
27
28use rtsp_types::{Message, Method, Request, StatusCode, Version, headers};
29
30use crate::auth::{Authenticator, Credentials, RequestContext};
31use crate::error::{Error, Result};
32use crate::interleaved::{self, MAGIC};
33use crate::state::{SessionState, client_next_state};
34use crate::transport::Transport;
35
36/// A message body type: owned bytes.
37type Body = Vec<u8>;
38
39/// RFC 2326 §12.37: the `Session` timeout when the server declares none.
40const DEFAULT_SESSION_TIMEOUT_SECS: u64 = 60;
41
42/// The keepalive is sent at `timeout / KEEPALIVE_FRACTION_DEN` after the last
43/// activity, so one lost keepalive still leaves time for a retry.
44const KEEPALIVE_FRACTION_DEN: u32 = 2;
45
46/// Most recent `Session` warnings kept by [`ClientSession::session_warnings`]
47/// (a hostile server cannot grow memory by sending a bad header on every response).
48pub const MAX_SESSION_WARNINGS: usize = 16;
49
50/// Shortest keepalive interval, whatever the session timeout: a hostile or buggy
51/// `timeout=` (the parser already maps 0 to the default) must never turn the
52/// keepalive into a busy loop.
53pub const MIN_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(1);
54
55/// Maximum bytes retained in [`ClientSession::inbound`] while waiting for a
56/// complete response or interleaved frame. An unterminated header, or a
57/// `Content-Length` that promises more than the peer ever sends, would
58/// otherwise grow this buffer without bound (multimux pulls from
59/// operator-configured but externally hosted RTSP URLs) — 1 MiB is generous
60/// for RTSP headers plus an SDP body (audit run-09 W6).
61const MAX_INBOUND_BYTES: usize = 1024 * 1024;
62
63/// Record of a request the client has sent and is awaiting a response for.
64#[derive(Debug, Clone)]
65struct Pending {
66    method: Method,
67    uri: String,
68    /// The full request, retained so it can be re-signed and re-sent on a 401.
69    request: Request<Body>,
70    /// Whether an auth retry has already been attempted for this logical request.
71    auth_retried: bool,
72}
73
74/// An event produced by [`ClientSession::handle_data`].
75#[non_exhaustive]
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum ClientEvent {
78    /// A response was correlated to a request and the state machine updated.
79    Response {
80        /// The `CSeq` of the correlated request.
81        cseq: u32,
82        /// The method that was responded to.
83        method: Method,
84        /// The response status code.
85        status: StatusCode,
86        /// The response body (e.g. the SDP for a DESCRIBE), possibly empty.
87        body: Vec<u8>,
88    },
89    /// The engine transparently re-sent a request with an `Authorization`
90    /// header after a `401`. The caller MUST write `request` to the socket.
91    AuthRetry {
92        /// The method being retried.
93        method: Method,
94        /// The `CSeq` assigned to the retried request.
95        cseq: u32,
96        /// The serialized retried request bytes to send.
97        request: Vec<u8>,
98    },
99    /// Interleaved binary media data (RFC 2326 §10.12).
100    MediaData {
101        /// The interleaved channel id.
102        channel: u8,
103        /// The payload bytes (one upper-layer PDU).
104        data: Vec<u8>,
105    },
106}
107
108/// A driveable RTSP client session (RFC 2326 §A.1).
109#[derive(Debug)]
110pub struct ClientSession {
111    state: SessionState,
112    next_cseq: u32,
113    session_id: Option<String>,
114    session_timeout: Option<u64>,
115    credentials: Option<Credentials>,
116    authenticator: Option<Authenticator>,
117    negotiated_transport: Option<Transport>,
118    pending: HashMap<u32, Pending>,
119    /// Accumulates inbound bytes across `handle_data` calls (partial frames /
120    /// partial messages).
121    inbound: Vec<u8>,
122    user_agent: String,
123    /// When the caller last reported traffic (see [`ClientSession::mark_activity`]).
124    last_activity: Option<Instant>,
125    /// The URI of the most recent request, reused for the keepalive.
126    last_uri: Option<String>,
127    /// Recoverable `Session` header problems seen so far (see `session_warnings`).
128    session_warnings: Vec<String>,
129    /// Total `Session` warnings ever recorded (the list keeps only the latest).
130    session_warning_total: usize,
131    /// How much of `inbound` the header-terminator search already covered.
132    scanned: usize,
133    /// `inbound` length below which a re-parse cannot succeed (body wait).
134    skip_until: usize,
135}
136
137impl Default for ClientSession {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143impl ClientSession {
144    /// Creates a fresh client session in the `Init` state with `CSeq` starting
145    /// at 1.
146    pub fn new() -> Self {
147        ClientSession {
148            state: SessionState::Init,
149            next_cseq: 1,
150            session_id: None,
151            session_timeout: None,
152            credentials: None,
153            authenticator: None,
154            negotiated_transport: None,
155            pending: HashMap::new(),
156            inbound: Vec::new(),
157            user_agent: "rtsp-runtime".to_string(),
158            last_activity: None,
159            last_uri: None,
160            session_warnings: Vec::new(),
161            session_warning_total: 0,
162            scanned: 0,
163            skip_until: 0,
164        }
165    }
166
167    /// Attaches credentials so the engine can answer `401` challenges (§14).
168    pub fn with_credentials(mut self, credentials: Credentials) -> Self {
169        self.credentials = Some(credentials);
170        self
171    }
172
173    /// Overrides the `User-Agent` header value sent on requests.
174    pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
175        self.user_agent = ua.into();
176        self
177    }
178
179    /// The current session state.
180    pub fn state(&self) -> SessionState {
181        self.state
182    }
183
184    /// The `CSeq` the next request-builder call (`options`/`describe`/…) will
185    /// assign. Lets an IO adapter capture which response it must wait for
186    /// *before* building the request, since building it also consumes this
187    /// value (audit run-09 W7: `io::AsyncRtspClient::exchange` needs this to
188    /// tell "the" response apart from a stray one for an abandoned request).
189    pub fn peek_next_cseq(&self) -> u32 {
190        self.next_cseq
191    }
192
193    fn record_session_warning(&mut self, w: String) {
194        self.session_warning_total += 1;
195        if self.session_warnings.len() >= MAX_SESSION_WARNINGS {
196            self.session_warnings.remove(0);
197        }
198        self.session_warnings.push(w);
199    }
200
201    /// Total number of `Session` warnings recorded, including ones that have
202    /// since been dropped from [`session_warnings`](Self::session_warnings).
203    pub fn session_warning_count(&self) -> usize {
204        self.session_warning_total
205    }
206
207    /// Warnings about `Session` response headers (a malformed or zero `timeout`
208    /// that fell back to the 60 s default, or an unusable header), oldest first.
209    /// Each is also emitted with `log::warn!`.
210    pub fn session_warnings(&self) -> &[String] {
211        &self.session_warnings
212    }
213
214    /// True if a partial message or frame is buffered (bytes fed to
215    /// [`handle_data`](Self::handle_data) that did not yet form a whole one). An
216    /// adapter uses it to tell a clean end of stream from a truncated one.
217    pub fn has_buffered_input(&self) -> bool {
218        !self.inbound.is_empty()
219    }
220
221    /// Records that a request was written at `now`. The core never reads a clock;
222    /// `now` is always caller-supplied. Only requests count: the server's
223    /// session timeout is refreshed by requests (RFC 2326 §12.37), not by
224    /// inbound media or responses.
225    pub fn mark_activity(&mut self, now: Instant) {
226        self.last_activity = Some(now);
227    }
228
229    /// Next keepalive deadline: `last_activity + timeout/2` once a Session id
230    /// exists (timeout = the SETUP response's `timeout=`, else the RFC 2326
231    /// §12.37 default of 60 s). `None` before SETUP, after TEARDOWN, or if no
232    /// activity was ever recorded.
233    pub fn poll_timeout(&self) -> Option<Instant> {
234        self.session_id.as_ref()?;
235        let timeout =
236            Duration::from_secs(self.session_timeout.unwrap_or(DEFAULT_SESSION_TIMEOUT_SECS));
237        // `checked_add`: a hostile huge `timeout=` must not overflow `Instant`.
238        self.last_activity?
239            .checked_add((timeout / KEEPALIVE_FRACTION_DEN).max(MIN_KEEPALIVE_INTERVAL))
240    }
241
242    /// If `now >= poll_timeout()`, the bytes of a `GET_PARAMETER` keepalive on
243    /// the last request URI (and activity is re-armed at `now`); `Ok(None)`
244    /// otherwise.
245    pub fn handle_timeout(&mut self, now: Instant) -> Result<Option<Vec<u8>>> {
246        match self.poll_timeout() {
247            Some(deadline) if now >= deadline => {}
248            _ => return Ok(None),
249        }
250        let Some(uri) = self.last_uri.clone() else {
251            return Ok(None);
252        };
253        let bytes = self.get_parameter(&uri, b"")?;
254        self.last_activity = Some(now);
255        Ok(Some(bytes))
256    }
257
258    /// The negotiated session id, once a SETUP response has been processed.
259    pub fn session_id(&self) -> Option<&str> {
260        self.session_id.as_deref()
261    }
262
263    /// The session timeout in seconds, if the SETUP response declared one.
264    pub fn session_timeout(&self) -> Option<u64> {
265        self.session_timeout
266    }
267
268    /// The transport negotiated in the SETUP response, if any.
269    pub fn negotiated_transport(&self) -> Option<&Transport> {
270        self.negotiated_transport.as_ref()
271    }
272
273    // --- Request builders -------------------------------------------------
274
275    /// Builds an `OPTIONS` request (state-neutral).
276    pub fn options(&mut self, uri: &str) -> Result<Vec<u8>> {
277        self.build_request(Method::Options, uri, None, &[])
278    }
279
280    /// Builds a `DESCRIBE` request with `Accept: application/sdp` (state-neutral).
281    pub fn describe(&mut self, uri: &str) -> Result<Vec<u8>> {
282        self.build_request(
283            Method::Describe,
284            uri,
285            None,
286            &[(headers::ACCEPT, "application/sdp".to_string())],
287        )
288    }
289
290    /// Builds a `SETUP` request carrying the given `Transport` (Init/Ready/…).
291    pub fn setup(&mut self, uri: &str, transport: &Transport) -> Result<Vec<u8>> {
292        self.build_request(
293            Method::Setup,
294            uri,
295            None,
296            &[(headers::TRANSPORT, transport.to_header_value()?)],
297        )
298    }
299
300    /// Builds a `PLAY` request (valid in Ready/Playing).
301    pub fn play(&mut self, uri: &str) -> Result<Vec<u8>> {
302        self.build_request(Method::Play, uri, None, &[])
303    }
304
305    /// Builds a `PAUSE` request (valid in Playing/Recording).
306    pub fn pause(&mut self, uri: &str) -> Result<Vec<u8>> {
307        self.build_request(Method::Pause, uri, None, &[])
308    }
309
310    /// Builds a `TEARDOWN` request (valid in any non-Init state, and Init).
311    pub fn teardown(&mut self, uri: &str) -> Result<Vec<u8>> {
312        self.build_request(Method::Teardown, uri, None, &[])
313    }
314
315    /// Builds an `ANNOUNCE` request carrying an SDP body (RFC 2326 §10.3).
316    pub fn announce(&mut self, uri: &str, sdp: &str) -> Result<Vec<u8>> {
317        self.build_request_with_body(
318            Method::Announce,
319            uri,
320            sdp.as_bytes(),
321            &[(headers::CONTENT_TYPE, "application/sdp".to_string())],
322        )
323    }
324
325    /// Builds a `RECORD` request (RFC 2326 §10.11, valid in Ready).
326    pub fn record(&mut self, uri: &str) -> Result<Vec<u8>> {
327        self.build_request(Method::Record, uri, None, &[])
328    }
329
330    /// Builds a `GET_PARAMETER` request, optionally with a body (state-neutral;
331    /// an empty body is the liveness ping).
332    pub fn get_parameter(&mut self, uri: &str, body: &[u8]) -> Result<Vec<u8>> {
333        self.build_request_with_body(Method::GetParameter, uri, body, &[])
334    }
335
336    fn build_request(
337        &mut self,
338        method: Method,
339        uri: &str,
340        _range: Option<&str>,
341        extra: &[(headers::HeaderName, String)],
342    ) -> Result<Vec<u8>> {
343        self.build_request_with_body(method, uri, &[], extra)
344    }
345
346    fn build_request_with_body(
347        &mut self,
348        method: Method,
349        uri: &str,
350        body: &[u8],
351        extra: &[(headers::HeaderName, String)],
352    ) -> Result<Vec<u8>> {
353        // Reject methods not valid in the current state (state-neutral pass).
354        client_next_state(self.state, &method)?;
355
356        let cseq = self.next_cseq;
357        let request = self.assemble(method.clone(), uri, cseq, body, extra)?;
358        let bytes = serialize(&Message::from(request.clone()))?;
359        self.next_cseq += 1;
360        self.last_uri = Some(uri.to_string());
361        self.pending.insert(
362            cseq,
363            Pending {
364                method,
365                uri: uri.to_string(),
366                request,
367                auth_retried: false,
368            },
369        );
370        Ok(bytes)
371    }
372
373    /// Assembles a `Request` with CSeq, User-Agent, Session (if known),
374    /// Authorization (if authenticated), any extra headers, and the body.
375    fn assemble(
376        &mut self,
377        method: Method,
378        uri: &str,
379        cseq: u32,
380        body: &[u8],
381        extra: &[(headers::HeaderName, String)],
382    ) -> Result<Request<Body>> {
383        let url = rtsp_types::Url::parse(uri)
384            .map_err(|e| Error::TransportParse(format!("invalid request URI {uri:?}: {e}")))?;
385        let mut builder = Request::builder(method.clone(), Version::V1_0)
386            .request_uri(url)
387            .header(headers::CSEQ, cseq.to_string())
388            .header(headers::USER_AGENT, self.user_agent.clone());
389        if let Some(sid) = &self.session_id {
390            builder = builder.header(headers::SESSION, sid.clone());
391        }
392        for (name, value) in extra {
393            builder = builder.header(name.clone(), value.clone());
394        }
395        if let Some(auth) = &mut self.authenticator {
396            let ctx = RequestContext::new(<&str>::from(&method), uri);
397            let value = auth.authorization(&ctx)?;
398            builder = builder.header(headers::AUTHORIZATION, value);
399        }
400        let request = if body.is_empty() {
401            builder.build(Vec::new())
402        } else {
403            builder.build(body.to_vec())
404        };
405        Ok(request)
406    }
407
408    // --- Inbound handling -------------------------------------------------
409
410    /// Feeds inbound bytes and returns the events produced. Retains any partial
411    /// trailing message or frame internally for the next call.
412    pub fn handle_data(&mut self, data: &[u8]) -> Result<Vec<ClientEvent>> {
413        self.inbound.extend_from_slice(data);
414        if self.inbound.len() > MAX_INBOUND_BYTES {
415            return Err(Error::MessageParse(format!(
416                "inbound buffer of {} bytes exceeds the {MAX_INBOUND_BYTES}-byte maximum \
417                 (unterminated header or an unfulfilled Content-Length)",
418                self.inbound.len()
419            )));
420        }
421        let mut events = Vec::new();
422
423        loop {
424            if self.inbound.is_empty() {
425                break;
426            }
427            if self.inbound[0] == MAGIC {
428                // Interleaved frame path.
429                match interleaved::InterleavedFrame::parse(&self.inbound)? {
430                    Some((frame, consumed)) => {
431                        events.push(ClientEvent::MediaData {
432                            channel: frame.channel,
433                            data: frame.payload,
434                        });
435                        self.inbound.drain(..consumed);
436                    }
437                    None => break, // need more bytes
438                }
439                continue;
440            }
441
442            // RTSP message path. `framing::head_end` is a bounded framing check
443            // (header cap, parse once per message), not protocol parsing.
444            if self.inbound.len() < self.skip_until {
445                break;
446            }
447            if crate::framing::head_end(&self.inbound, self.scanned)?.is_none() {
448                self.scanned = self.inbound.len();
449                break;
450            }
451            match Message::<Body>::parse(&self.inbound) {
452                Ok((message, consumed)) => {
453                    self.inbound.drain(..consumed);
454                    self.scanned = 0;
455                    self.skip_until = 0;
456                    self.process_message(message, &mut events)?;
457                }
458                Err(rtsp_types::ParseError::Incomplete(needed)) => {
459                    if let Some(more) = needed {
460                        self.skip_until = self.inbound.len().saturating_add(more.get());
461                    }
462                    break;
463                }
464                Err(rtsp_types::ParseError::Error) => {
465                    return Err(Error::MessageParse("malformed RTSP message".into()));
466                }
467            }
468        }
469        Ok(events)
470    }
471
472    fn process_message(
473        &mut self,
474        message: Message<Body>,
475        events: &mut Vec<ClientEvent>,
476    ) -> Result<()> {
477        match message {
478            Message::Response(response) => {
479                // A response with no parseable CSeq can't be correlated to
480                // anything pending. Skip it rather than aborting the whole
481                // `handle_data` call — bytes already drained from `inbound`
482                // (and events already decoded from them earlier in this same
483                // call) must not be discarded for one bad response (RFC 2326
484                // gives no reason to treat this as fatal).
485                let Some(cseq) = header_value(response.header(&headers::CSEQ))
486                    .and_then(|s| s.trim().parse::<u32>().ok())
487                else {
488                    return Ok(());
489                };
490                let status = response.status();
491
492                // 401: attempt a transparent auth retry.
493                if status == StatusCode::Unauthorized
494                    && let Some(retry) = self.try_auth_retry(cseq, &response)?
495                {
496                    events.push(retry);
497                    return Ok(());
498                }
499
500                // An unknown or already-answered CSeq (a duplicate response,
501                // or a reply to a request the caller gave up waiting for) is
502                // likewise skipped rather than fatal, for the same reason.
503                let Some(pending) = self.pending.remove(&cseq) else {
504                    return Ok(());
505                };
506
507                // Capture Session id + timeout (typically from SETUP). Recoverable
508                // problems (a malformed or zero timeout) are logged and kept in
509                // `session_warnings()`; an unusable header is logged and ignored.
510                if let Some(session_hdr) = header_value(response.header(&headers::SESSION)) {
511                    match crate::session_header::SessionHeader::parse_with_warnings(session_hdr) {
512                        Ok((parsed, warnings)) => {
513                            for w in warnings {
514                                log::warn!("RTSP Session header {session_hdr:?}: {w}");
515                                self.record_session_warning(w);
516                            }
517                            self.session_id = Some(parsed.id);
518                            if let Some(t) = parsed.timeout {
519                                self.session_timeout = Some(t.as_secs());
520                            }
521                        }
522                        Err(e) => {
523                            let w = format!("unusable Session header {session_hdr:?}: {e}");
524                            log::warn!("RTSP {w}");
525                            self.record_session_warning(w);
526                        }
527                    }
528                }
529                // Capture negotiated transport from SETUP response.
530                if pending.method == Method::Setup
531                    && let Some(t) = header_value(response.header(&headers::TRANSPORT))
532                {
533                    self.negotiated_transport = Some(Transport::parse(t)?);
534                }
535
536                // State transition.
537                if status.is_success() {
538                    self.state = client_next_state(self.state, &pending.method)?;
539                    // TEARDOWN invalidates the session.
540                    if pending.method == Method::Teardown {
541                        self.session_id = None;
542                        self.session_timeout = None;
543                        self.authenticator = None;
544                    }
545                } else if status.is_redirection() {
546                    self.state = SessionState::Init;
547                }
548                // 4xx (other than the handled 401) / 5xx: no state change.
549
550                events.push(ClientEvent::Response {
551                    cseq,
552                    method: pending.method,
553                    status,
554                    body: response.into_body(),
555                });
556                Ok(())
557            }
558            Message::Data(data) => {
559                events.push(ClientEvent::MediaData {
560                    channel: data.channel_id(),
561                    data: data.into_body(),
562                });
563                Ok(())
564            }
565            Message::Request(_) => {
566                // Server-initiated requests (e.g. S->C OPTIONS, REDIRECT,
567                // ANNOUNCE) are out of scope for this round; ignore.
568                Ok(())
569            }
570        }
571    }
572
573    /// On a 401, build/refresh the authenticator from `WWW-Authenticate` and
574    /// re-send the pending request with an `Authorization` header, unless a
575    /// retry was already attempted (wrong credentials) or none are configured.
576    fn try_auth_retry(
577        &mut self,
578        cseq: u32,
579        response: &rtsp_types::Response<Body>,
580    ) -> Result<Option<ClientEvent>> {
581        let creds = match &self.credentials {
582            Some(c) => c.clone(),
583            None => return Ok(None),
584        };
585        // Only retry if the original request is still pending and hasn't retried.
586        let (method, uri, already) = match self.pending.get(&cseq) {
587            Some(p) => (p.method.clone(), p.uri.clone(), p.auth_retried),
588            None => return Ok(None),
589        };
590
591        let challenge = header_value(response.header(&headers::WWW_AUTHENTICATE))
592            .ok_or_else(|| Error::Auth("401 without WWW-Authenticate".into()))?;
593        let stale = crate::headers_util::challenge_is_stale(challenge);
594
595        // Always rebuild the authenticator from THIS challenge. A server can
596        // rotate its nonce per session or per time window without setting
597        // `stale=true` (RFC 2326 §14 doesn't require it); gating the rebuild
598        // on `stale` meant a rotated-but-not-stale nonce got signed with the
599        // old value, failed again, and was then abandoned instead of retried
600        // with the fresh one.
601        self.authenticator = Some(Authenticator::from_challenge(challenge, creds)?);
602        // Guard: if this pending request was already retried once and it's
603        // still a 401 with no `stale=true`, give up so the caller sees it
604        // (wrong credentials). `stale=true` is the server's explicit signal
605        // that the credentials were fine and only the nonce needs refreshing,
606        // so it's allowed one more retry even after an earlier one.
607        if already && !stale {
608            return Ok(None);
609        }
610
611        // Preserve the original body and every non-hop-by-hop header (e.g.
612        // Content-Type for an ANNOUNCE's SDP, Accept/Transport/Range for a
613        // SETUP/PLAY) before we drop the old pending entry, then issue a new
614        // request with a fresh CSeq. RFC 2326 §10.3 requires the retried
615        // ANNOUNCE to carry the same SDP body as the original.
616        let (body, extra) = self.replay_extra(cseq);
617        self.pending.remove(&cseq);
618        let new_cseq = self.next_cseq;
619        let request = self.assemble(method.clone(), &uri, new_cseq, &body, &extra)?;
620        let bytes = serialize(&Message::from(request.clone()))?;
621        self.next_cseq += 1;
622        self.pending.insert(
623            new_cseq,
624            Pending {
625                method: method.clone(),
626                uri,
627                request,
628                auth_retried: true,
629            },
630        );
631        Ok(Some(ClientEvent::AuthRetry {
632            method,
633            cseq: new_cseq,
634            request: bytes,
635        }))
636    }
637
638    /// Re-derives the body and every replayable header (e.g. Content-Type,
639    /// Accept/Transport/Range) for an auth replay from the previously-sent
640    /// request. `CSeq`, `Session` and `Authorization` are excluded because
641    /// `assemble` sets them itself for the retry, and `Content-Length` is
642    /// excluded because `RequestBuilder::build` recomputes it from `body`.
643    fn replay_extra(&self, old_cseq: u32) -> (Vec<u8>, Vec<(headers::HeaderName, String)>) {
644        let Some(p) = self.pending.get(&old_cseq) else {
645            return (Vec::new(), Vec::new());
646        };
647        let hop_by_hop = [
648            headers::CSEQ,
649            headers::SESSION,
650            headers::AUTHORIZATION,
651            headers::USER_AGENT,
652            headers::CONTENT_LENGTH,
653        ];
654        let extra = p
655            .request
656            .headers()
657            .filter(|(name, _)| !hop_by_hop.iter().any(|h| h == *name))
658            .map(|(name, value)| (name.clone(), value.as_str().to_string()))
659            .collect();
660        (p.request.body().clone(), extra)
661    }
662}
663
664/// Extracts the string value of an optional header.
665fn header_value(h: Option<&headers::HeaderValue>) -> Option<&str> {
666    h.map(|v| v.as_str())
667}
668
669/// Serializes an RTSP message to bytes.
670fn serialize(message: &Message<Body>) -> Result<Vec<u8>> {
671    let mut out = Vec::new();
672    message
673        .write(&mut out)
674        .map_err(|e| Error::MessageWrite(e.to_string()))?;
675    Ok(out)
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681
682    #[test]
683    fn play_in_init_bites() {
684        let mut c = ClientSession::new();
685        assert!(c.play("rtsp://h/s").is_err());
686    }
687
688    #[test]
689    fn setup_allowed_in_init() {
690        let mut c = ClientSession::new();
691        let t = Transport::single(crate::transport::TransportSpec::rtp_avp_tcp_interleaved(
692            0, 1,
693        ));
694        assert!(c.setup("rtsp://h/s", &t).is_ok());
695    }
696
697    #[test]
698    fn cseq_increments() {
699        let mut c = ClientSession::new();
700        let a = c.options("rtsp://h/s").unwrap();
701        let b = c.describe("rtsp://h/s").unwrap();
702        assert!(String::from_utf8_lossy(&a).contains("CSeq: 1"));
703        assert!(String::from_utf8_lossy(&b).contains("CSeq: 2"));
704    }
705
706    #[test]
707    fn announce_emits_request_with_sdp_content_type() {
708        let mut c = ClientSession::new();
709        let sdp = "v=0\r\no=- 0 0 IN IP4 0.0.0.0\r\ns=Test\r\n";
710        let bytes = c.announce("rtsp://h/s", sdp).unwrap();
711        let s = String::from_utf8_lossy(&bytes);
712        assert!(s.contains("ANNOUNCE rtsp://h/s"));
713        assert!(s.contains("Content-Type: application/sdp"));
714        assert!(s.contains(sdp));
715    }
716
717    // Regression (audit run-09 W5): one unknown/duplicate CSeq in a batch of
718    // inbound bytes must not discard events already decoded earlier in the
719    // same `handle_data` call. Before the fix, `handle_data` returned `Err`
720    // on the first unmatched CSeq, so a caller lost the CSeq-1 `Response`
721    // event even though its bytes had already been drained from `inbound`.
722    #[test]
723    fn unknown_cseq_is_skipped_without_discarding_earlier_events() {
724        fn wire(s: &str) -> Vec<u8> {
725            s.replace('\n', "\r\n").into_bytes()
726        }
727        let mut c = ClientSession::new();
728        c.options("rtsp://h/s").unwrap(); // CSeq 1
729        c.describe("rtsp://h/s").unwrap(); // CSeq 2
730
731        // A valid response (CSeq 1), then a duplicate/unmatched response
732        // (CSeq 99, never sent), then another valid response (CSeq 2), all
733        // in one `handle_data` call.
734        let mut batch = wire("RTSP/1.0 200 OK\nCSeq: 1\n\n");
735        batch.extend(wire("RTSP/1.0 200 OK\nCSeq: 99\n\n"));
736        batch.extend(wire("RTSP/1.0 200 OK\nCSeq: 2\n\n"));
737
738        let events = c
739            .handle_data(&batch)
740            .expect("an unmatched CSeq must not fail the whole batch");
741        let cseqs: Vec<u32> = events
742            .into_iter()
743            .map(|e| match e {
744                ClientEvent::Response { cseq, .. } => cseq,
745                other => panic!("unexpected event: {other:?}"),
746            })
747            .collect();
748        assert_eq!(
749            cseqs,
750            vec![1, 2],
751            "expected both real responses despite the unmatched CSeq 99"
752        );
753    }
754
755    // Regression (audit run-09 W6): an unterminated header (or a peer that
756    // drips bytes forever without completing a message) must not grow
757    // `inbound` without bound.
758    #[test]
759    fn inbound_buffer_is_capped_against_an_unterminated_header() {
760        let mut c = ClientSession::new();
761        c.options("rtsp://h/s").unwrap();
762        let chunk = vec![b'A'; 64 * 1024];
763        let mut last = Ok(Vec::new());
764        for _ in 0..(MAX_INBOUND_BYTES / chunk.len() + 2) {
765            last = c.handle_data(&chunk);
766            if last.is_err() {
767                break;
768            }
769        }
770        assert!(
771            last.is_err(),
772            "expected the oversized inbound buffer to be rejected, not grown forever"
773        );
774    }
775
776    #[test]
777    fn record_fails_from_init_state() {
778        let mut c = ClientSession::new();
779        assert!(c.record("rtsp://h/s").is_err());
780    }
781
782    #[test]
783    fn record_transitions_to_recording_state() {
784        // RTSP lines are CRLF-terminated (RFC 2326 §1); convert "\n" -> CRLF.
785        fn wire(s: &str) -> Vec<u8> {
786            s.replace('\n', "\r\n").into_bytes()
787        }
788        let mut c = ClientSession::new();
789        // Drive Init -> Ready via a successful SETUP round-trip.
790        let t = Transport::single(crate::transport::TransportSpec::rtp_avp_tcp_interleaved(
791            0, 1,
792        ));
793        c.setup("rtsp://h/s", &t).unwrap();
794        c.handle_data(&wire(
795            "RTSP/1.0 200 OK\nCSeq: 1\nSession: 42\nTransport: RTP/AVP/TCP;interleaved=0-1\n\n",
796        ))
797        .unwrap();
798        assert_eq!(c.state(), SessionState::Ready);
799
800        // RECORD is valid from Ready; the request alone does not move state.
801        c.record("rtsp://h/s").unwrap();
802        assert_eq!(c.state(), SessionState::Ready);
803        // A 2xx response advances Ready -> Recording.
804        c.handle_data(&wire("RTSP/1.0 200 OK\nCSeq: 2\nSession: 42\n\n"))
805            .unwrap();
806        assert_eq!(c.state(), SessionState::Recording);
807    }
808
809    // Regression (audit run-09 C1): a 401 auth retry must replay the ORIGINAL
810    // request body and Content-Type, not an empty body. Before the fix, the
811    // retried ANNOUNCE carried `Content-Length: 0` and no SDP, so an
812    // authenticated ANNOUNCE could never reach a real server (RFC 2326 §10.3).
813    #[test]
814    fn auth_retry_replays_original_body_and_content_type() {
815        fn wire(s: &str) -> Vec<u8> {
816            s.replace('\n', "\r\n").into_bytes()
817        }
818        let mut c = ClientSession::new().with_credentials(Credentials::new("admin", "12345"));
819        let sdp = "v=0\r\no=- 0 0 IN IP4 0.0.0.0\r\ns=Test\r\n";
820        let first = c.announce("rtsp://h/s", sdp).unwrap();
821        assert!(String::from_utf8_lossy(&first).contains("CSeq: 1"));
822
823        let events = c
824            .handle_data(&wire(
825                "RTSP/1.0 401 Unauthorized\nCSeq: 1\nWWW-Authenticate: Digest realm=\"cam\",nonce=\"abc123\",qop=\"auth\"\n\n",
826            ))
827            .unwrap();
828        let retry = events
829            .into_iter()
830            .find_map(|e| match e {
831                ClientEvent::AuthRetry { request, .. } => Some(request),
832                _ => None,
833            })
834            .expect("expected an AuthRetry event");
835        let retry = String::from_utf8_lossy(&retry);
836
837        assert!(retry.contains("ANNOUNCE rtsp://h/s"), "{retry}");
838        assert!(retry.contains("Authorization: Digest "), "{retry}");
839        assert!(
840            retry.contains("Content-Type: application/sdp"),
841            "Content-Type dropped on auth retry: {retry}"
842        );
843        assert!(
844            retry.contains(sdp),
845            "SDP body dropped on auth retry: {retry}"
846        );
847        let expected_len = format!("Content-Length: {}", sdp.len());
848        assert!(
849            retry.contains(&expected_len),
850            "expected {expected_len:?} in: {retry}"
851        );
852    }
853
854    // Regression (audit run-09 W4): a server that rotates its Digest nonce
855    // without `stale=true` must still get a correctly-signed retry on the
856    // very next request, not one signed with the stale nonce. Before the fix,
857    // the authenticator was rebuilt only when it was `None`, `already`
858    // retried, or `stale` was set — none of which hold for a fresh request's
859    // first 401 when the client had already authenticated earlier in the
860    // session — so the retry reused the old nonce, failed again, and the
861    // engine gave up (`already && !stale`) instead of adapting.
862    #[test]
863    fn nonce_rotation_without_stale_is_picked_up_on_the_first_retry() {
864        fn wire(s: &str) -> Vec<u8> {
865            s.replace('\n', "\r\n").into_bytes()
866        }
867        let mut c = ClientSession::new().with_credentials(Credentials::new("admin", "12345"));
868
869        // Establish the authenticator via an ordinary first challenge.
870        c.options("rtsp://h/s").unwrap(); // CSeq 1
871        let events = c
872            .handle_data(&wire(
873                "RTSP/1.0 401 Unauthorized\nCSeq: 1\nWWW-Authenticate: Digest realm=\"cam\",nonce=\"nonce-1\",qop=\"auth\"\n\n",
874            ))
875            .unwrap();
876        assert!(
877            events
878                .iter()
879                .any(|e| matches!(e, ClientEvent::AuthRetry { cseq: 2, .. })),
880            "{events:?}"
881        );
882        c.handle_data(&wire("RTSP/1.0 200 OK\nCSeq: 2\n\n"))
883            .unwrap();
884
885        // A later, unrelated request (e.g. a GET_PARAMETER keepalive) hits a
886        // rotated nonce with no `stale=true`.
887        c.options("rtsp://h/s").unwrap(); // CSeq 3
888        let events = c
889            .handle_data(&wire(
890                "RTSP/1.0 401 Unauthorized\nCSeq: 3\nWWW-Authenticate: Digest realm=\"cam\",nonce=\"nonce-2\",qop=\"auth\"\n\n",
891            ))
892            .unwrap();
893        let retry = events
894            .into_iter()
895            .find_map(|e| match e {
896                ClientEvent::AuthRetry {
897                    cseq: 4, request, ..
898                } => Some(request),
899                _ => None,
900            })
901            .expect("expected a retry (CSeq 4), not a give-up");
902        let retry = String::from_utf8_lossy(&retry);
903        assert!(
904            retry.contains("nonce=\"nonce-2\""),
905            "retry signed with the stale nonce instead of the rotated one: {retry}"
906        );
907    }
908
909    fn established(timeout: Option<u64>) -> ClientSession {
910        let mut c = ClientSession::new();
911        let _ = c
912            .setup(
913                "rtsp://h/s",
914                &Transport::single(crate::transport::TransportSpec::rtp_avp_tcp_interleaved(
915                    0, 1,
916                )),
917            )
918            .unwrap();
919        let sess = match timeout {
920            Some(t) => format!("abc;timeout={t}"),
921            None => "abc".into(),
922        };
923        let resp = format!(
924            "RTSP/1.0 200 OK\r\nCSeq: 1\r\nSession: {sess}\r\nTransport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n\r\n"
925        );
926        c.handle_data(resp.as_bytes()).unwrap();
927        c
928    }
929
930    #[test]
931    fn the_keepalive_interval_is_floored_and_fires_at_most_once_per_interval() {
932        // timeout=1 would give a 0.5 s half-timeout; the floor makes it 1 s.
933        let t0 = Instant::now();
934        let mut c = established(Some(1));
935        c.mark_activity(t0);
936        assert_eq!(c.poll_timeout(), Some(t0 + MIN_KEEPALIVE_INTERVAL));
937        assert!(
938            c.handle_timeout(t0 + Duration::from_millis(500))
939                .unwrap()
940                .is_none()
941        );
942        // hammer one instant: exactly one keepalive per floor interval
943        let mut sent = 0;
944        let tick = t0 + MIN_KEEPALIVE_INTERVAL;
945        for _ in 0..1000 {
946            if c.handle_timeout(tick).unwrap().is_some() {
947                sent += 1;
948            }
949        }
950        assert_eq!(sent, 1, "keepalive must not busy-loop");
951    }
952
953    #[test]
954    fn a_zero_timeout_is_a_warning_and_the_default_not_a_busy_loop() {
955        let t0 = Instant::now();
956        let mut c = ClientSession::new();
957        let _ = c
958            .setup(
959                "rtsp://h/s",
960                &Transport::single(crate::transport::TransportSpec::rtp_avp_tcp_interleaved(
961                    0, 1,
962                )),
963            )
964            .unwrap();
965        c.handle_data(
966            b"RTSP/1.0 200 OK\r\nCSeq: 1\r\nSession: abc;timeout=0\r\nTransport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n\r\n",
967        )
968        .unwrap();
969        assert_eq!(c.session_warnings().len(), 1, "{:?}", c.session_warnings());
970        assert!(c.session_warnings()[0].contains("timeout"));
971        c.mark_activity(t0);
972        assert_eq!(c.poll_timeout(), Some(t0 + Duration::from_secs(30)));
973    }
974
975    /// Interop (review round 4): every received session id is stored and echoed back
976    /// verbatim on the next request, as before the own-parser change.
977    #[test]
978    fn odd_received_session_ids_are_echoed_verbatim() {
979        for (hdr, id) in [
980            ("\"weird\"", "\"weird\""),
981            ("a b;timeout=30", "a b"),
982            ("abc,def", "abc,def"),
983            ("ab\"c", "ab\"c"),
984        ] {
985            let mut c = ClientSession::new();
986            let _ = c
987                .setup(
988                    "rtsp://h/s",
989                    &Transport::single(crate::transport::TransportSpec::rtp_avp_tcp_interleaved(
990                        0, 1,
991                    )),
992                )
993                .unwrap();
994            let resp = format!(
995                "RTSP/1.0 200 OK\r\nCSeq: 1\r\nSession: {hdr}\r\nTransport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n\r\n"
996            );
997            c.handle_data(resp.as_bytes()).unwrap();
998            assert_eq!(c.session_id(), Some(id), "{hdr}");
999            let play = String::from_utf8(c.play("rtsp://h/s").unwrap()).unwrap();
1000            assert!(
1001                play.contains(&format!("Session: {id}\r\n")),
1002                "{hdr}: {play}"
1003            );
1004        }
1005    }
1006
1007    #[test]
1008    fn session_warnings_are_capped_but_counted() {
1009        let mut c = ClientSession::new();
1010        for i in 1..=(MAX_SESSION_WARNINGS as u32 + 10) {
1011            let _ = c.options("rtsp://h/s").unwrap();
1012            c.handle_data(
1013                format!("RTSP/1.0 200 OK\r\nCSeq: {i}\r\nSession: abc;timeout=x\r\n\r\n")
1014                    .as_bytes(),
1015            )
1016            .unwrap();
1017        }
1018        assert_eq!(c.session_warnings().len(), MAX_SESSION_WARNINGS);
1019        assert_eq!(c.session_warning_count(), MAX_SESSION_WARNINGS + 10);
1020    }
1021
1022    #[test]
1023    fn an_unusable_session_header_is_observable_not_silent() {
1024        let mut c = ClientSession::new();
1025        let _ = c.options("rtsp://h/s").unwrap();
1026        c.handle_data(b"RTSP/1.0 200 OK\r\nCSeq: 1\r\nSession: ;timeout=5\r\n\r\n")
1027            .unwrap();
1028        assert!(c.session_id().is_none());
1029        assert_eq!(c.session_warnings().len(), 1);
1030    }
1031
1032    #[test]
1033    fn keepalive_is_due_at_half_the_session_timeout() {
1034        let t0 = Instant::now();
1035        let mut c = established(Some(20));
1036        assert_eq!(c.poll_timeout(), None, "no activity recorded yet");
1037        c.mark_activity(t0);
1038        assert_eq!(c.poll_timeout(), Some(t0 + Duration::from_secs(10)));
1039        assert!(
1040            c.handle_timeout(t0 + Duration::from_secs(9))
1041                .unwrap()
1042                .is_none()
1043        );
1044        let req = c
1045            .handle_timeout(t0 + Duration::from_secs(10))
1046            .unwrap()
1047            .expect("keepalive due");
1048        let text = String::from_utf8(req).unwrap();
1049        assert!(
1050            text.starts_with("GET_PARAMETER rtsp://h/s RTSP/1.0"),
1051            "{text}"
1052        );
1053        assert!(text.contains("Session: abc"), "{text}");
1054        // re-armed from the moment it was sent
1055        assert_eq!(
1056            c.poll_timeout(),
1057            Some(t0 + Duration::from_secs(10) + Duration::from_secs(10))
1058        );
1059    }
1060
1061    #[test]
1062    fn keepalive_uses_the_rfc_default_timeout_when_none_was_declared() {
1063        let t0 = Instant::now();
1064        let mut c = established(None);
1065        c.mark_activity(t0);
1066        assert_eq!(c.poll_timeout(), Some(t0 + Duration::from_secs(30))); // 60 s / 2
1067    }
1068
1069    #[test]
1070    fn no_keepalive_before_setup_or_after_teardown() {
1071        let t0 = Instant::now();
1072        let mut c = ClientSession::new();
1073        c.mark_activity(t0);
1074        assert_eq!(c.poll_timeout(), None);
1075        assert!(
1076            c.handle_timeout(t0 + Duration::from_secs(3600))
1077                .unwrap()
1078                .is_none()
1079        );
1080    }
1081
1082    #[test]
1083    fn head_cap_holds_for_a_70_kib_run_of_a_and_for_9000_header_lines() {
1084        let mut c = ClientSession::new();
1085        assert!(matches!(
1086            c.handle_data(&vec![b'A'; 70 * 1024]),
1087            Err(Error::MessageParse(_))
1088        ));
1089        let mut c = ClientSession::new();
1090        let mut junk = b"RTSP/1.0 200 OK\r\n".to_vec();
1091        junk.extend(b"X-A: b\r\n".repeat(9000));
1092        assert!(matches!(c.handle_data(&junk), Err(Error::MessageParse(_))));
1093    }
1094
1095    #[test]
1096    fn a_response_fed_one_byte_at_a_time_is_still_decoded() {
1097        let mut c = ClientSession::new();
1098        let _ = c.options("rtsp://h/s").unwrap();
1099        let resp = b"RTSP/1.0 200 OK\r\nCSeq: 1\r\nPublic: OPTIONS\r\n\r\n";
1100        let mut got = 0;
1101        for b in resp {
1102            got += c.handle_data(&[*b]).unwrap().len();
1103        }
1104        assert_eq!(got, 1);
1105    }
1106
1107    #[test]
1108    fn an_unterminated_header_over_the_head_cap_is_rejected() {
1109        let mut c = ClientSession::new();
1110        let mut junk = b"RTSP/1.0 200 OK\r\nX-Pad: ".to_vec();
1111        junk.extend(std::iter::repeat_n(b'a', 70 * 1024)); // no terminator, ever
1112        let err = c
1113            .handle_data(&junk)
1114            .expect_err("header phase is capped at 64 KiB");
1115        assert!(matches!(err, Error::MessageParse(_)), "{err:?}");
1116    }
1117
1118    #[test]
1119    fn stale_quoted_rechallenge_refreshes_nonce_after_a_prior_retry() {
1120        fn wire(s: &str) -> Vec<u8> {
1121            s.replace('\n', "\r\n").into_bytes()
1122        }
1123        let mut c = ClientSession::new().with_credentials(Credentials::new("admin", "12345"));
1124        c.options("rtsp://h/s").unwrap(); // CSeq 1
1125
1126        // First 401 (no stale) -> normal retry at CSeq 2, marked retried.
1127        c.handle_data(&wire(
1128            "RTSP/1.0 401 Unauthorized\nCSeq: 1\nWWW-Authenticate: Digest realm=\"cam\",nonce=\"nonce-1\",qop=\"auth\"\n\n",
1129        )).unwrap();
1130
1131        // Re-challenge with a quoted `stale="True"` and a fresh nonce: the
1132        // retried request must be re-signed with the NEW nonce, not given up.
1133        let events = c
1134            .handle_data(&wire(
1135                "RTSP/1.0 401 Unauthorized\nCSeq: 2\nWWW-Authenticate: Digest realm=\"cam\",nonce=\"nonce-2\",qop=\"auth\",stale=\"True\"\n\n",
1136            ))
1137            .unwrap();
1138        let retry = events
1139            .into_iter()
1140            .find_map(|e| match e {
1141                ClientEvent::AuthRetry {
1142                    cseq: 3, request, ..
1143                } => Some(request),
1144                _ => None,
1145            })
1146            .expect("a quoted stale=true re-challenge must trigger a retry");
1147        let retry = String::from_utf8_lossy(&retry);
1148        assert!(
1149            retry.contains("nonce=\"nonce-2\""),
1150            "quoted stale=\"True\" was not detected: {retry}"
1151        );
1152    }
1153
1154    // Security-blocker regression (pre-release audit): `ClientSession`
1155    // derives `Debug` and embeds `Option<Credentials>` directly — it must
1156    // inherit `Credentials`'s redacting `Debug`, never the raw secret.
1157    #[test]
1158    fn client_session_debug_does_not_leak_embedded_credentials_secret() {
1159        let c = ClientSession::new()
1160            .with_credentials(Credentials::new("admin", "extremely-secret-password"));
1161        let debug = format!("{c:?}");
1162        assert!(
1163            !debug.contains("extremely-secret-password"),
1164            "leaked via ClientSession Debug: {debug}"
1165        );
1166        assert!(debug.contains("***"), "expected redaction marker: {debug}");
1167    }
1168}