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 `private/specs-verified/rtp/rtsp-runtime/state-machines.md`,
11//! `private/specs-verified/rtp/rtsp-runtime/methods-and-status.md`,
12//! `private/specs-verified/rtp/rtsp-runtime/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, MessageKind, 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/// One stream's entry of a PLAY response `RTP-Info` header (RFC 2326 12.33).
75#[non_exhaustive]
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct RtpInfoEntry {
78    /// The stream's URL.
79    pub uri: String,
80    /// Sequence number of the first packet of the stream, when given.
81    pub seq: Option<u16>,
82    /// RTP timestamp corresponding to the start time in the `Range` header, when given.
83    pub rtptime: Option<u32>,
84}
85
86/// An event produced by [`ClientSession::handle_data`].
87#[non_exhaustive]
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum ClientEvent {
90    /// A response was correlated to a request and the state machine updated.
91    Response {
92        /// The `CSeq` of the correlated request.
93        cseq: u32,
94        /// The method that was responded to.
95        method: Method,
96        /// The response status code.
97        status: StatusCode,
98        /// The response body (e.g. the SDP for a DESCRIBE), possibly empty.
99        body: Vec<u8>,
100    },
101    /// The engine transparently re-sent a request with an `Authorization`
102    /// header after a `401`. The caller MUST write `request` to the socket.
103    AuthRetry {
104        /// The method being retried.
105        method: Method,
106        /// The `CSeq` assigned to the retried request.
107        cseq: u32,
108        /// The serialized retried request bytes to send.
109        request: Vec<u8>,
110    },
111    /// Interleaved binary media data (RFC 2326 §10.12).
112    MediaData {
113        /// The interleaved channel id.
114        channel: u8,
115        /// The payload bytes (one upper-layer PDU).
116        data: Vec<u8>,
117    },
118}
119
120/// What [`ClientSession::handle_data`] decoded from one chunk of inbound
121/// bytes: every event produced, and the terminal error that stopped decoding,
122/// if any. The events are valid even when `error` is `Some` — they were
123/// decoded before the failure and are never discarded with it.
124#[must_use = "an unchecked outcome silently ignores a terminal protocol error"]
125#[derive(Debug)]
126pub struct HandleOutcome {
127    /// Events decoded from the input, in wire order.
128    pub events: Vec<ClientEvent>,
129    /// The terminal error that ended decoding (framing lost), if any.
130    pub error: Option<Error>,
131}
132
133impl HandleOutcome {
134    /// All-or-nothing view: `Ok(events)` when decoding ran clean, else the
135    /// error (dropping the events - prefer reading the fields when they
136    /// matter).
137    pub fn into_result(self) -> Result<Vec<ClientEvent>> {
138        match self.error {
139            Some(e) => Err(e),
140            None => Ok(self.events),
141        }
142    }
143}
144
145/// A driveable RTSP client session (RFC 2326 §A.1).
146#[derive(Debug)]
147pub struct ClientSession {
148    state: SessionState,
149    next_cseq: u32,
150    session_id: Option<String>,
151    session_timeout: Option<u64>,
152    credentials: Option<Credentials>,
153    authenticator: Option<Authenticator>,
154    negotiated_transport: Option<Transport>,
155    pending: HashMap<u32, Pending>,
156    /// Accumulates inbound bytes across `handle_data` calls (partial frames /
157    /// partial messages).
158    inbound: Vec<u8>,
159    user_agent: String,
160    /// When the caller last reported traffic (see [`ClientSession::mark_activity`]).
161    last_activity: Option<Instant>,
162    /// The URI of the most recent request, reused for the keepalive.
163    last_uri: Option<String>,
164    /// Recoverable `Session` header problems seen so far (see `session_warnings`).
165    session_warnings: Vec<String>,
166    /// Total `Session` warnings ever recorded (the list keeps only the latest).
167    session_warning_total: usize,
168    /// How much of `inbound` the header-terminator search already covered.
169    scanned: usize,
170    /// `inbound` length below which a re-parse cannot succeed (body wait).
171    skip_until: usize,
172    /// The most recent `WWW-Authenticate` (else `Proxy-Authenticate`) value seen on any response.
173    last_challenge: Option<String>,
174    /// `Content-Base` (else `Content-Location`) of the most recent successful DESCRIBE response.
175    content_base: Option<String>,
176    /// `Location` of the most recent redirection (3xx) response, cleared by any other response.
177    redirect_location: Option<String>,
178    rtp_info: Vec<RtpInfoEntry>,
179}
180
181impl Default for ClientSession {
182    fn default() -> Self {
183        Self::new()
184    }
185}
186
187impl ClientSession {
188    /// Creates a fresh client session in the `Init` state with `CSeq` starting
189    /// at 1.
190    pub fn new() -> Self {
191        ClientSession {
192            state: SessionState::Init,
193            next_cseq: 1,
194            session_id: None,
195            session_timeout: None,
196            credentials: None,
197            authenticator: None,
198            negotiated_transport: None,
199            pending: HashMap::new(),
200            inbound: Vec::new(),
201            user_agent: "rtsp-runtime".to_string(),
202            last_activity: None,
203            last_uri: None,
204            session_warnings: Vec::new(),
205            session_warning_total: 0,
206            scanned: 0,
207            skip_until: 0,
208            last_challenge: None,
209            content_base: None,
210            redirect_location: None,
211            rtp_info: Vec::new(),
212        }
213    }
214
215    /// Attaches credentials so the engine can answer `401` challenges (§14).
216    pub fn with_credentials(mut self, credentials: Credentials) -> Self {
217        self.credentials = Some(credentials);
218        self
219    }
220
221    /// The most recent `WWW-Authenticate` (else `Proxy-Authenticate`) header value seen on any
222    /// response, kept even when no credentials are configured so the caller can learn the scheme
223    /// and realm to ask for.
224    pub fn last_challenge(&self) -> Option<&str> {
225        self.last_challenge.as_deref()
226    }
227
228    /// Attaches `credentials` to a live session and, when `challenge` (else the last challenge
229    /// seen) is available, builds the authenticator from it so the very next request already
230    /// carries an `Authorization` header instead of waiting for another `401`.
231    ///
232    /// # Errors
233    /// [`Error::Auth`] when the challenge cannot be answered with these credentials.
234    pub fn authenticate_with(
235        &mut self,
236        credentials: Credentials,
237        challenge: Option<&str>,
238    ) -> Result<()> {
239        let line = challenge
240            .map(str::to_string)
241            .or_else(|| self.last_challenge.clone());
242        self.authenticator = match line {
243            Some(line) => Some(Authenticator::from_challenge(&line, credentials.clone())?),
244            None => None,
245        };
246        self.credentials = Some(credentials);
247        Ok(())
248    }
249
250    /// Overrides the `User-Agent` header value sent on requests.
251    pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
252        self.user_agent = ua.into();
253        self
254    }
255
256    /// The current session state.
257    pub fn state(&self) -> SessionState {
258        self.state
259    }
260
261    /// The `CSeq` the next request-builder call (`options`/`describe`/…) will
262    /// assign. Lets an IO adapter capture which response it must wait for
263    /// *before* building the request, since building it also consumes this
264    /// value (audit run-09 W7: `io::AsyncRtspClient::exchange` needs this to
265    /// tell "the" response apart from a stray one for an abandoned request).
266    pub fn peek_next_cseq(&self) -> u32 {
267        self.next_cseq
268    }
269
270    fn record_session_warning(&mut self, w: String) {
271        self.session_warning_total += 1;
272        if self.session_warnings.len() >= MAX_SESSION_WARNINGS {
273            self.session_warnings.remove(0);
274        }
275        self.session_warnings.push(w);
276    }
277
278    /// Total number of `Session` warnings recorded, including ones that have
279    /// since been dropped from [`session_warnings`](Self::session_warnings).
280    #[cfg(test)]
281    pub(crate) fn session_warning_count(&self) -> usize {
282        self.session_warning_total
283    }
284
285    /// Warnings about `Session` response headers (a malformed or zero `timeout`
286    /// that fell back to the 60 s default, or an unusable header), oldest first.
287    pub fn session_warnings(&self) -> &[String] {
288        &self.session_warnings
289    }
290
291    /// True if a partial message or frame is buffered (bytes fed to
292    /// [`handle_data`](Self::handle_data) that did not yet form a whole one). An
293    /// adapter uses it to tell a clean end of stream from a truncated one.
294    pub fn has_buffered_input(&self) -> bool {
295        !self.inbound.is_empty()
296    }
297
298    /// Records that a request was written at `now`. The core never reads a clock;
299    /// `now` is always caller-supplied. Only requests count: the server's
300    /// session timeout is refreshed by requests (RFC 2326 §12.37), not by
301    /// inbound media or responses.
302    pub fn mark_activity(&mut self, now: Instant) {
303        self.last_activity = Some(now);
304    }
305
306    /// Next keepalive deadline: `last_activity + timeout/2` once a Session id
307    /// exists (timeout = the SETUP response's `timeout=`, else the RFC 2326
308    /// §12.37 default of 60 s). `None` before SETUP, after TEARDOWN, or if no
309    /// activity was ever recorded.
310    pub fn poll_timeout(&self) -> Option<Instant> {
311        self.session_id.as_ref()?;
312        let timeout =
313            Duration::from_secs(self.session_timeout.unwrap_or(DEFAULT_SESSION_TIMEOUT_SECS));
314        // `checked_add`: a hostile huge `timeout=` must not overflow `Instant`.
315        self.last_activity?
316            .checked_add((timeout / KEEPALIVE_FRACTION_DEN).max(MIN_KEEPALIVE_INTERVAL))
317    }
318
319    /// If `now >= poll_timeout()`, the bytes of a `GET_PARAMETER` keepalive on
320    /// the last request URI (and activity is re-armed at `now`); `Ok(None)`
321    /// otherwise.
322    pub fn handle_timeout(&mut self, now: Instant) -> Result<Option<Vec<u8>>> {
323        match self.poll_timeout() {
324            Some(deadline) if now >= deadline => {}
325            _ => return Ok(None),
326        }
327        let Some(uri) = self.last_uri.clone() else {
328            return Ok(None);
329        };
330        let bytes = self.get_parameter(&uri, b"")?;
331        self.last_activity = Some(now);
332        Ok(Some(bytes))
333    }
334
335    /// The negotiated session id, once a SETUP response has been processed.
336    pub fn session_id(&self) -> Option<&str> {
337        self.session_id.as_deref()
338    }
339
340    /// The session timeout in seconds, if the SETUP response declared one.
341    pub fn session_timeout(&self) -> Option<u64> {
342        self.session_timeout
343    }
344
345    /// The transport negotiated in the SETUP response, if any.
346    pub fn negotiated_transport(&self) -> Option<&Transport> {
347        self.negotiated_transport.as_ref()
348    }
349
350    /// Takes the `RTP-Info` entries (RFC 2326 12.33) of the most recent successful PLAY response,
351    /// leaving none behind. Empty when the response carried no usable `RTP-Info`.
352    pub fn take_rtp_info(&mut self) -> Vec<RtpInfoEntry> {
353        core::mem::take(&mut self.rtp_info)
354    }
355
356    /// Takes the transport negotiated by the most recent SETUP response, leaving none behind, so
357    /// a later SETUP reply that carries no `Transport` header cannot be mistaken for it.
358    pub fn take_negotiated_transport(&mut self) -> Option<Transport> {
359        self.negotiated_transport.take()
360    }
361
362    /// The base URI for relative `a=control` values (RFC 2326 Appendix C.1.1): the
363    /// `Content-Base` of the most recent successful DESCRIBE response, else its
364    /// `Content-Location`, else `None` (the caller then uses the request URI).
365    pub fn content_base(&self) -> Option<&str> {
366        self.content_base.as_deref()
367    }
368
369    /// The `Location` header of the most recent redirection (3xx) response, `None` when that
370    /// response carried none or the most recent response was not a redirection
371    /// (`ClientEvent::Response` carries no headers).
372    pub fn redirect_location(&self) -> Option<&str> {
373        self.redirect_location.as_deref()
374    }
375
376    // --- Request builders -------------------------------------------------
377
378    /// Builds an `OPTIONS` request (state-neutral).
379    pub fn options(&mut self, uri: &str) -> Result<Vec<u8>> {
380        self.build_request(Method::Options, uri, None, &[])
381    }
382
383    /// Builds a `DESCRIBE` request with `Accept: application/sdp` (state-neutral).
384    pub fn describe(&mut self, uri: &str) -> Result<Vec<u8>> {
385        self.build_request(
386            Method::Describe,
387            uri,
388            None,
389            &[(headers::ACCEPT, "application/sdp".to_string())],
390        )
391    }
392
393    /// Builds a `SETUP` request carrying the given `Transport` (Init/Ready/…).
394    pub fn setup(&mut self, uri: &str, transport: &Transport) -> Result<Vec<u8>> {
395        self.build_request(
396            Method::Setup,
397            uri,
398            None,
399            &[(headers::TRANSPORT, transport.to_header_value()?)],
400        )
401    }
402
403    /// Builds a `PLAY` request (valid in Ready/Playing).
404    pub fn play(&mut self, uri: &str) -> Result<Vec<u8>> {
405        self.build_request(Method::Play, uri, None, &[])
406    }
407
408    /// Builds a `PAUSE` request (valid in Playing/Recording).
409    pub fn pause(&mut self, uri: &str) -> Result<Vec<u8>> {
410        self.build_request(Method::Pause, uri, None, &[])
411    }
412
413    /// Builds a `TEARDOWN` request (valid in any non-Init state, and Init).
414    pub fn teardown(&mut self, uri: &str) -> Result<Vec<u8>> {
415        self.build_request(Method::Teardown, uri, None, &[])
416    }
417
418    /// Builds an `ANNOUNCE` request carrying an SDP body (RFC 2326 §10.3).
419    pub fn announce(&mut self, uri: &str, sdp: &str) -> Result<Vec<u8>> {
420        self.build_request_with_body(
421            Method::Announce,
422            uri,
423            sdp.as_bytes(),
424            &[(headers::CONTENT_TYPE, "application/sdp".to_string())],
425        )
426    }
427
428    /// Builds a `RECORD` request (RFC 2326 §10.11, valid in Ready).
429    pub fn record(&mut self, uri: &str) -> Result<Vec<u8>> {
430        self.build_request(Method::Record, uri, None, &[])
431    }
432
433    /// Builds a `GET_PARAMETER` request, optionally with a body (state-neutral;
434    /// an empty body is the liveness ping).
435    pub fn get_parameter(&mut self, uri: &str, body: &[u8]) -> Result<Vec<u8>> {
436        self.build_request_with_body(Method::GetParameter, uri, body, &[])
437    }
438
439    fn build_request(
440        &mut self,
441        method: Method,
442        uri: &str,
443        _range: Option<&str>,
444        extra: &[(headers::HeaderName, String)],
445    ) -> Result<Vec<u8>> {
446        self.build_request_with_body(method, uri, &[], extra)
447    }
448
449    fn build_request_with_body(
450        &mut self,
451        method: Method,
452        uri: &str,
453        body: &[u8],
454        extra: &[(headers::HeaderName, String)],
455    ) -> Result<Vec<u8>> {
456        // Reject methods not valid in the current state (state-neutral pass).
457        client_next_state(self.state, &method)?;
458
459        let cseq = self.next_cseq;
460        let request = self.assemble(method.clone(), uri, cseq, body, extra)?;
461        let bytes = serialize(&Message::from(request.clone()))?;
462        self.next_cseq += 1;
463        self.last_uri = Some(uri.to_string());
464        self.pending.insert(
465            cseq,
466            Pending {
467                method,
468                uri: uri.to_string(),
469                request,
470                auth_retried: false,
471            },
472        );
473        Ok(bytes)
474    }
475
476    /// Assembles a `Request` with CSeq, User-Agent, Session (if known),
477    /// Authorization (if authenticated), any extra headers, and the body.
478    fn assemble(
479        &mut self,
480        method: Method,
481        uri: &str,
482        cseq: u32,
483        body: &[u8],
484        extra: &[(headers::HeaderName, String)],
485    ) -> Result<Request<Body>> {
486        let url = rtsp_types::Url::parse(uri)
487            .map_err(|e| Error::TransportParse(format!("invalid request URI {uri:?}: {e}")))?;
488        let mut builder = Request::builder(method.clone(), Version::V1_0)
489            .request_uri(url)
490            .header(headers::CSEQ, cseq.to_string())
491            .header(headers::USER_AGENT, self.user_agent.clone());
492        if let Some(sid) = &self.session_id {
493            builder = builder.header(headers::SESSION, sid.clone());
494        }
495        for (name, value) in extra {
496            builder = builder.header(name.clone(), value.clone());
497        }
498        if let Some(auth) = &mut self.authenticator {
499            let ctx = RequestContext::new(<&str>::from(&method), uri);
500            let value = auth.authorization(&ctx)?;
501            builder = builder.header(headers::AUTHORIZATION, value);
502        }
503        let request = if body.is_empty() {
504            builder.build(Vec::new())
505        } else {
506            builder.build(body.to_vec())
507        };
508        Ok(request)
509    }
510
511    // --- Inbound handling -------------------------------------------------
512
513    /// Feeds inbound bytes and returns everything decoded from them.
514    ///
515    /// Retains any partial trailing message or frame internally for the next
516    /// call. A failure never discards what was already decoded in the same
517    /// call (audit T16, #1144): the [`HandleOutcome`] carries the events
518    /// produced *before* the failure together with the terminal
519    /// [`HandleOutcome::error`], if any. A terminal error means the TCP
520    /// framing is lost (an unparseable message head, an over-long header, an
521    /// unfulfillable `Content-Length`) and the session cannot continue; an
522    /// interleaved `$` frame is opaque at this layer (it is handed up whole as
523    /// [`ClientEvent::MediaData`]), so a malformed RTP/RTCP payload inside one
524    /// is the consumer's to skip without ending the session.
525    pub fn handle_data(&mut self, data: &[u8]) -> HandleOutcome {
526        let mut events = Vec::new();
527        let error = self.handle_data_into(data, &mut events).err();
528        HandleOutcome { events, error }
529    }
530
531    /// All-or-nothing form of [`handle_data`](Self::handle_data): `Ok(events)`
532    /// when decoding ran clean, `Err` (discarding any events decoded before
533    /// it) otherwise. For callers - simple scripts, tests - that treat any
534    /// protocol error as fatal; a long-lived session should use
535    /// [`handle_data`](Self::handle_data) so events decoded before a failure
536    /// are not lost.
537    pub fn handle_data_strict(&mut self, data: &[u8]) -> Result<Vec<ClientEvent>> {
538        self.handle_data(data).into_result()
539    }
540
541    fn handle_data_into(&mut self, data: &[u8], events: &mut Vec<ClientEvent>) -> Result<()> {
542        self.inbound.extend_from_slice(data);
543        if self.inbound.len() > MAX_INBOUND_BYTES {
544            return Err(Error::MessageParse {
545                what: MessageKind::Response,
546                source: format!(
547                    "inbound buffer of {} bytes exceeds the {MAX_INBOUND_BYTES}-byte maximum \
548                 (unterminated header or an unfulfilled Content-Length)",
549                    self.inbound.len()
550                )
551                .into(),
552            });
553        }
554        // Parse with a cursor and drop the consumed prefix ONCE, whatever the
555        // outcome: a per-message `drain(..n)` memmoves the whole remainder for
556        // every frame, O(n^2) over a buffer of many small frames (audit
557        // run-09 O3).
558        let mut pos = 0;
559        let result = self.decode_from(&mut pos, events);
560        self.inbound.drain(..pos);
561        result
562    }
563
564    /// Decodes messages/frames from `self.inbound[*pos..]`, advancing `*pos`
565    /// past each one fully consumed (the caller drains `..*pos` afterwards).
566    fn decode_from(&mut self, pos: &mut usize, events: &mut Vec<ClientEvent>) -> Result<()> {
567        loop {
568            let rest = &self.inbound[*pos..];
569            if rest.is_empty() {
570                break;
571            }
572            if rest[0] == MAGIC {
573                // Interleaved frame path.
574                match interleaved::InterleavedFrame::parse(rest)? {
575                    Some((frame, consumed)) => {
576                        events.push(ClientEvent::MediaData {
577                            channel: frame.channel,
578                            data: frame.payload,
579                        });
580                        *pos += consumed;
581                    }
582                    None => break, // need more bytes
583                }
584                continue;
585            }
586
587            // RTSP message path. `framing::head_end` is a bounded framing check
588            // (header cap, parse once per message), not protocol parsing.
589            if rest.len() < self.skip_until {
590                break;
591            }
592            if crate::framing::head_end(rest, self.scanned)?.is_none() {
593                self.scanned = rest.len();
594                break;
595            }
596            match Message::<Body>::parse(rest) {
597                Ok((message, consumed)) => {
598                    *pos += consumed;
599                    self.scanned = 0;
600                    self.skip_until = 0;
601                    self.process_message(message, events)?;
602                }
603                Err(rtsp_types::ParseError::Incomplete(needed)) => {
604                    if let Some(more) = needed {
605                        self.skip_until = rest.len().saturating_add(more.get());
606                    }
607                    break;
608                }
609                Err(rtsp_types::ParseError::Error) => {
610                    return Err(Error::MessageParse {
611                        what: MessageKind::Response,
612                        source: Box::new(rtsp_types::ParseError::Error),
613                    });
614                }
615            }
616        }
617        Ok(())
618    }
619
620    fn process_message(
621        &mut self,
622        message: Message<Body>,
623        events: &mut Vec<ClientEvent>,
624    ) -> Result<()> {
625        match message {
626            Message::Response(response) => {
627                // A response with no parseable CSeq can't be correlated to
628                // anything pending. Skip it rather than aborting the whole
629                // `handle_data` call — bytes already drained from `inbound`
630                // (and events already decoded from them earlier in this same
631                // call) must not be discarded for one bad response (RFC 2326
632                // gives no reason to treat this as fatal).
633                let Some(cseq) = header_value(response.header(&headers::CSEQ))
634                    .and_then(|s| s.trim().parse::<u32>().ok())
635                else {
636                    return Ok(());
637                };
638                let status = response.status();
639
640                if let Some(challenge) = header_value(response.header(&headers::WWW_AUTHENTICATE))
641                    .or_else(|| header_value(response.header(&headers::PROXY_AUTHENTICATE)))
642                {
643                    self.last_challenge = Some(challenge.to_string());
644                }
645
646                // 401: attempt a transparent auth retry.
647                if status == StatusCode::Unauthorized
648                    && let Some(retry) = self.try_auth_retry(cseq, &response)?
649                {
650                    events.push(retry);
651                    return Ok(());
652                }
653
654                // An unknown or already-answered CSeq (a duplicate response,
655                // or a reply to a request the caller gave up waiting for) is
656                // likewise skipped rather than fatal, for the same reason.
657                let Some(pending) = self.pending.remove(&cseq) else {
658                    return Ok(());
659                };
660
661                // Capture Session id + timeout (typically from SETUP). Recoverable
662                // problems (a malformed or zero timeout) are kept in
663                // `session_warnings()`; an unusable header is recorded there and ignored.
664                if let Some(session_hdr) = header_value(response.header(&headers::SESSION)) {
665                    match crate::session_header::SessionHeader::parse_with_warnings(session_hdr) {
666                        Ok((parsed, warnings)) => {
667                            for w in warnings {
668                                self.record_session_warning(w);
669                            }
670                            self.session_id = Some(parsed.id);
671                            if let Some(t) = parsed.timeout {
672                                self.session_timeout = Some(t.as_secs());
673                            }
674                        }
675                        Err(e) => {
676                            let w = format!("unusable Session header {session_hdr:?}: {e}");
677                            self.record_session_warning(w);
678                        }
679                    }
680                }
681                // Capture negotiated transport from SETUP response.
682                if pending.method == Method::Setup
683                    && let Some(t) = header_value(response.header(&headers::TRANSPORT))
684                {
685                    self.negotiated_transport = Some(Transport::parse(t)?);
686                }
687
688                if pending.method == Method::Play && status.is_success() {
689                    self.rtp_info = response
690                        .typed_header::<headers::RtpInfos>()
691                        .ok()
692                        .flatten()
693                        .and_then(|infos| infos.try_into_v1().ok())
694                        .map(|infos| match infos {
695                            headers::RtpInfos::V1(list) => list
696                                .into_iter()
697                                .map(|i| RtpInfoEntry {
698                                    uri: i.uri.to_string(),
699                                    seq: i.seq,
700                                    rtptime: i.rtptime,
701                                })
702                                .collect(),
703                            _ => Vec::new(),
704                        })
705                        .unwrap_or_default();
706                }
707
708                self.redirect_location = status
709                    .is_redirection()
710                    .then(|| header_value(response.header(&headers::LOCATION)))
711                    .flatten()
712                    .map(|v| v.trim().to_string());
713
714                if pending.method == Method::Describe && status.is_success() {
715                    self.content_base = header_value(response.header(&headers::CONTENT_BASE))
716                        .or_else(|| header_value(response.header(&headers::CONTENT_LOCATION)))
717                        .map(|v| v.trim().to_string());
718                }
719
720                // State transition.
721                if status.is_success() {
722                    self.state = client_next_state(self.state, &pending.method)?;
723                    // TEARDOWN invalidates the session.
724                    if pending.method == Method::Teardown {
725                        self.session_id = None;
726                        self.session_timeout = None;
727                        self.authenticator = None;
728                    }
729                } else if status.is_redirection() {
730                    self.state = SessionState::Init;
731                }
732                // 4xx (other than the handled 401) / 5xx: no state change.
733
734                events.push(ClientEvent::Response {
735                    cseq,
736                    method: pending.method,
737                    status,
738                    body: response.into_body(),
739                });
740                Ok(())
741            }
742            Message::Data(data) => {
743                events.push(ClientEvent::MediaData {
744                    channel: data.channel_id(),
745                    data: data.into_body(),
746                });
747                Ok(())
748            }
749            Message::Request(_) => {
750                // Server-initiated requests (e.g. S->C OPTIONS, REDIRECT,
751                // ANNOUNCE) are out of scope for this round; ignore.
752                Ok(())
753            }
754        }
755    }
756
757    /// On a 401, build/refresh the authenticator from `WWW-Authenticate` and
758    /// re-send the pending request with an `Authorization` header, unless a
759    /// retry was already attempted (wrong credentials) or none are configured.
760    fn try_auth_retry(
761        &mut self,
762        cseq: u32,
763        response: &rtsp_types::Response<Body>,
764    ) -> Result<Option<ClientEvent>> {
765        let creds = match &self.credentials {
766            Some(c) => c.clone(),
767            None => return Ok(None),
768        };
769        // Only retry if the original request is still pending and hasn't retried.
770        let (method, uri, already) = match self.pending.get(&cseq) {
771            Some(p) => (p.method.clone(), p.uri.clone(), p.auth_retried),
772            None => return Ok(None),
773        };
774
775        let challenge = header_value(response.header(&headers::WWW_AUTHENTICATE))
776            .ok_or(Error::MissingAuthChallenge)?;
777        let stale = crate::headers_util::challenge_is_stale(challenge);
778
779        // Always rebuild the authenticator from THIS challenge. A server can
780        // rotate its nonce per session or per time window without setting
781        // `stale=true` (RFC 2326 §14 doesn't require it); gating the rebuild
782        // on `stale` meant a rotated-but-not-stale nonce got signed with the
783        // old value, failed again, and was then abandoned instead of retried
784        // with the fresh one.
785        self.authenticator = Some(Authenticator::from_challenge(challenge, creds)?);
786        // Guard: if this pending request was already retried once and it's
787        // still a 401 with no `stale=true`, give up so the caller sees it
788        // (wrong credentials). `stale=true` is the server's explicit signal
789        // that the credentials were fine and only the nonce needs refreshing,
790        // so it's allowed one more retry even after an earlier one.
791        if already && !stale {
792            return Ok(None);
793        }
794
795        // Preserve the original body and every non-hop-by-hop header (e.g.
796        // Content-Type for an ANNOUNCE's SDP, Accept/Transport/Range for a
797        // SETUP/PLAY) before we drop the old pending entry, then issue a new
798        // request with a fresh CSeq. RFC 2326 §10.3 requires the retried
799        // ANNOUNCE to carry the same SDP body as the original.
800        let (body, extra) = self.replay_extra(cseq);
801        self.pending.remove(&cseq);
802        let new_cseq = self.next_cseq;
803        let request = self.assemble(method.clone(), &uri, new_cseq, &body, &extra)?;
804        let bytes = serialize(&Message::from(request.clone()))?;
805        self.next_cseq += 1;
806        self.pending.insert(
807            new_cseq,
808            Pending {
809                method: method.clone(),
810                uri,
811                request,
812                auth_retried: true,
813            },
814        );
815        Ok(Some(ClientEvent::AuthRetry {
816            method,
817            cseq: new_cseq,
818            request: bytes,
819        }))
820    }
821
822    /// Re-derives the body and every replayable header (e.g. Content-Type,
823    /// Accept/Transport/Range) for an auth replay from the previously-sent
824    /// request. `CSeq`, `Session` and `Authorization` are excluded because
825    /// `assemble` sets them itself for the retry, and `Content-Length` is
826    /// excluded because `RequestBuilder::build` recomputes it from `body`.
827    fn replay_extra(&self, old_cseq: u32) -> (Vec<u8>, Vec<(headers::HeaderName, String)>) {
828        let Some(p) = self.pending.get(&old_cseq) else {
829            return (Vec::new(), Vec::new());
830        };
831        let hop_by_hop = [
832            headers::CSEQ,
833            headers::SESSION,
834            headers::AUTHORIZATION,
835            headers::USER_AGENT,
836            headers::CONTENT_LENGTH,
837        ];
838        let extra = p
839            .request
840            .headers()
841            .filter(|(name, _)| !hop_by_hop.iter().any(|h| h == *name))
842            .map(|(name, value)| (name.clone(), value.as_str().to_string()))
843            .collect();
844        (p.request.body().clone(), extra)
845    }
846}
847
848/// Extracts the string value of an optional header.
849fn header_value(h: Option<&headers::HeaderValue>) -> Option<&str> {
850    h.map(|v| v.as_str())
851}
852
853/// Serializes an RTSP message to bytes.
854fn serialize(message: &Message<Body>) -> Result<Vec<u8>> {
855    let mut out = Vec::new();
856    message.write(&mut out).map_err(Error::MessageWrite)?;
857    Ok(out)
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863
864    #[test]
865    fn play_in_init_bites() {
866        let mut c = ClientSession::new();
867        assert!(matches!(
868            c.play("rtsp://h/s"),
869            Err(crate::error::Error::MethodNotValidInState { .. })
870        ));
871    }
872
873    #[test]
874    fn setup_allowed_in_init() {
875        let mut c = ClientSession::new();
876        let t = Transport::single(crate::transport::TransportSpec::rtp_avp_tcp_interleaved(
877            0, 1,
878        ));
879        assert!(c.setup("rtsp://h/s", &t).is_ok());
880    }
881
882    #[test]
883    fn cseq_increments() {
884        let mut c = ClientSession::new();
885        let a = c.options("rtsp://h/s").unwrap();
886        let b = c.describe("rtsp://h/s").unwrap();
887        assert!(String::from_utf8_lossy(&a).contains("CSeq: 1"));
888        assert!(String::from_utf8_lossy(&b).contains("CSeq: 2"));
889    }
890
891    #[test]
892    fn announce_emits_request_with_sdp_content_type() {
893        let mut c = ClientSession::new();
894        let sdp = "v=0\r\no=- 0 0 IN IP4 0.0.0.0\r\ns=Test\r\n";
895        let bytes = c.announce("rtsp://h/s", sdp).unwrap();
896        let s = String::from_utf8_lossy(&bytes);
897        assert!(s.contains("ANNOUNCE rtsp://h/s"));
898        assert!(s.contains("Content-Type: application/sdp"));
899        assert!(s.contains(sdp));
900    }
901
902    #[test]
903    fn redirect_location_is_taken_from_a_3xx_and_cleared_by_the_next_response() {
904        let mut c = ClientSession::new();
905        c.describe("rtsp://h/s").unwrap();
906        assert_eq!(c.redirect_location(), None);
907        let _ = c.handle_data(b"RTSP/1.0 302 Found\r\nCSeq: 1\r\nLocation: rtsp://other/s\r\n\r\n");
908        assert_eq!(c.redirect_location(), Some("rtsp://other/s"));
909        c.describe("rtsp://other/s").unwrap();
910        let _ = c.handle_data(b"RTSP/1.0 200 OK\r\nCSeq: 2\r\n\r\n");
911        assert_eq!(c.redirect_location(), None);
912    }
913
914    fn wire_401(challenge: &str) -> Vec<u8> {
915        format!("RTSP/1.0 401 Unauthorized\r\nCSeq: 1\r\nWWW-Authenticate: {challenge}\r\n\r\n")
916            .into_bytes()
917    }
918
919    #[test]
920    fn challenge_is_recorded_without_credentials() {
921        let mut c = ClientSession::new();
922        c.describe("rtsp://h/s").unwrap();
923        assert_eq!(c.last_challenge(), None);
924        let events = c
925            .handle_data_strict(&wire_401("Basic realm=\"cam\""))
926            .unwrap();
927        assert!(matches!(
928            events.as_slice(),
929            [ClientEvent::Response {
930                status: StatusCode::Unauthorized,
931                ..
932            }]
933        ));
934        assert_eq!(c.last_challenge(), Some("Basic realm=\"cam\""));
935    }
936
937    #[test]
938    fn authenticate_with_makes_next_request_authorized() {
939        let mut c = ClientSession::new();
940        c.describe("rtsp://h/s").unwrap();
941        c.handle_data_strict(&wire_401("Basic realm=\"cam\""))
942            .unwrap();
943        let before = c.describe("rtsp://h/s").unwrap();
944        assert!(!String::from_utf8_lossy(&before).contains("Authorization"));
945        c.authenticate_with(Credentials::new("u", "p"), None)
946            .unwrap();
947        let after = String::from_utf8_lossy(&c.describe("rtsp://h/s").unwrap()).into_owned();
948        assert!(after.contains("Authorization: Basic dTpw"), "got: {after}");
949    }
950
951    #[test]
952    fn authenticate_with_explicit_challenge_overrides_recorded_one() {
953        let mut c = ClientSession::new();
954        c.authenticate_with(Credentials::new("u", "p"), Some("Basic realm=\"x\""))
955            .unwrap();
956        let req = String::from_utf8_lossy(&c.options("rtsp://h/s").unwrap()).into_owned();
957        assert!(req.contains("Authorization: Basic dTpw"), "got: {req}");
958    }
959
960    #[test]
961    fn a_401_without_a_challenge_cannot_be_answered() {
962        let mut c = ClientSession::new();
963        c.authenticate_with(Credentials::new("u", "p"), Some("Basic realm=\"x\""))
964            .unwrap();
965        c.describe("rtsp://h/s").unwrap();
966        let err = c
967            .handle_data_strict(b"RTSP/1.0 401 Unauthorized\r\nCSeq: 1\r\n\r\n")
968            .unwrap_err();
969        assert!(matches!(err, Error::MissingAuthChallenge), "{err:?}");
970    }
971
972    #[test]
973    fn a_broadcast_auth_failure_keeps_its_source() {
974        let err = Error::from(broadcast_auth::Error::InvalidBearerToken);
975        assert!(
976            matches!(err, Error::Auth(broadcast_auth::Error::InvalidBearerToken)),
977            "{err:?}"
978        );
979    }
980
981    fn setup_request(c: &mut ClientSession) {
982        let t = Transport::single(crate::transport::TransportSpec::rtp_avp_tcp_interleaved(
983            0, 1,
984        ));
985        c.setup("rtsp://h/s/trackID=1", &t).unwrap();
986    }
987
988    fn feed(c: &mut ClientSession, text: &str) {
989        c.handle_data_strict(text.replace('\n', "\r\n").as_bytes())
990            .unwrap();
991    }
992
993    #[test]
994    fn content_base_is_taken_from_the_describe_response() {
995        let mut c = ClientSession::new();
996        assert_eq!(c.content_base(), None);
997        c.describe("rtsp://h/s").unwrap();
998        feed(
999            &mut c,
1000            "RTSP/1.0 200 OK\nCSeq: 1\nContent-Base: rtsp://h/s/\nContent-Location: rtsp://h/loc/\n\n",
1001        );
1002        assert_eq!(c.content_base(), Some("rtsp://h/s/"));
1003        c.describe("rtsp://h/s").unwrap();
1004        feed(
1005            &mut c,
1006            "RTSP/1.0 200 OK\nCSeq: 2\nContent-Location: rtsp://h/loc/\n\n",
1007        );
1008        assert_eq!(c.content_base(), Some("rtsp://h/loc/"));
1009        c.describe("rtsp://h/s").unwrap();
1010        feed(&mut c, "RTSP/1.0 200 OK\nCSeq: 3\n\n");
1011        assert_eq!(c.content_base(), None);
1012    }
1013
1014    #[test]
1015    fn take_negotiated_transport_only_returns_the_latest_setup_reply() {
1016        let mut c = ClientSession::new();
1017        setup_request(&mut c);
1018        feed(
1019            &mut c,
1020            "RTSP/1.0 200 OK\nCSeq: 1\nSession: A1\nTransport: RTP/AVP/TCP;unicast;interleaved=0-1\n\n",
1021        );
1022        let first = c
1023            .take_negotiated_transport()
1024            .expect("first reply carries one");
1025        assert_eq!(first.first().and_then(|s| s.interleaved), Some((0, 1)));
1026        assert!(c.take_negotiated_transport().is_none(), "taken once");
1027        setup_request(&mut c);
1028        feed(&mut c, "RTSP/1.0 200 OK\nCSeq: 2\nSession: A1\n\n");
1029        assert!(
1030            c.take_negotiated_transport().is_none(),
1031            "a reply without Transport negotiates nothing"
1032        );
1033    }
1034
1035    #[test]
1036    fn take_rtp_info_returns_the_play_response_entries_once() {
1037        let mut c = ClientSession::new();
1038        setup_request(&mut c);
1039        feed(
1040            &mut c,
1041            "RTSP/1.0 200 OK\nCSeq: 1\nSession: A1\nTransport: RTP/AVP/TCP;unicast;interleaved=0-1\n\n",
1042        );
1043        c.play("rtsp://h/s").unwrap();
1044        feed(
1045            &mut c,
1046            "RTSP/1.0 200 OK\nCSeq: 2\nSession: A1\nRTP-Info: url=rtsp://h/s/t1;seq=7;rtptime=100,url=rtsp://h/s/t2;seq=9;rtptime=4294967295\n\n",
1047        );
1048        let info = c.take_rtp_info();
1049        assert_eq!(info.len(), 2);
1050        assert_eq!(info[0].uri, "rtsp://h/s/t1");
1051        assert_eq!(info[0].seq, Some(7));
1052        assert_eq!(info[0].rtptime, Some(100));
1053        assert_eq!(info[1].rtptime, Some(u32::MAX));
1054        assert!(c.take_rtp_info().is_empty(), "taken once");
1055    }
1056
1057    // Regression (audit run-09 W5): one unknown/duplicate CSeq in a batch of
1058    // inbound bytes must not discard events already decoded earlier in the
1059    // same `handle_data` call. Before the fix, `handle_data` returned `Err`
1060    // on the first unmatched CSeq, so a caller lost the CSeq-1 `Response`
1061    // event even though its bytes had already been drained from `inbound`.
1062    #[test]
1063    fn unknown_cseq_is_skipped_without_discarding_earlier_events() {
1064        fn wire(s: &str) -> Vec<u8> {
1065            s.replace('\n', "\r\n").into_bytes()
1066        }
1067        let mut c = ClientSession::new();
1068        c.options("rtsp://h/s").unwrap(); // CSeq 1
1069        c.describe("rtsp://h/s").unwrap(); // CSeq 2
1070
1071        // A valid response (CSeq 1), then a duplicate/unmatched response
1072        // (CSeq 99, never sent), then another valid response (CSeq 2), all
1073        // in one `handle_data` call.
1074        let mut batch = wire("RTSP/1.0 200 OK\nCSeq: 1\n\n");
1075        batch.extend(wire("RTSP/1.0 200 OK\nCSeq: 99\n\n"));
1076        batch.extend(wire("RTSP/1.0 200 OK\nCSeq: 2\n\n"));
1077
1078        let events = c
1079            .handle_data_strict(&batch)
1080            .expect("an unmatched CSeq must not fail the whole batch");
1081        let cseqs: Vec<u32> = events
1082            .into_iter()
1083            .map(|e| match e {
1084                ClientEvent::Response { cseq, .. } => cseq,
1085                other => panic!("unexpected event: {other:?}"),
1086            })
1087            .collect();
1088        assert_eq!(
1089            cseqs,
1090            vec![1, 2],
1091            "expected both real responses despite the unmatched CSeq 99"
1092        );
1093    }
1094
1095    // Audit run-09 O3: many frames in one read are decoded with a cursor (one
1096    // drain per call, not one memmove of the remainder per frame), and a
1097    // partial trailing frame is retained for the next call.
1098    #[test]
1099    fn many_interleaved_frames_in_one_read_decode_in_order_and_keep_the_tail() {
1100        const FRAMES: usize = 600;
1101        const PAYLOAD: usize = 800;
1102        let mut wire = Vec::new();
1103        for i in 0..FRAMES {
1104            let payload = vec![(i % 251) as u8; PAYLOAD];
1105            wire.extend(
1106                interleaved::InterleavedFrame::slice_to_bytes((i % 2) as u8, &payload).unwrap(),
1107            );
1108        }
1109        let split = wire.len() - 100; // last frame arrives in two pieces
1110        let mut c = ClientSession::new();
1111        let first = c.handle_data(&wire[..split]);
1112        assert!(first.error.is_none());
1113        assert_eq!(first.events.len(), FRAMES - 1);
1114        let second = c.handle_data(&wire[split..]);
1115        assert!(second.error.is_none());
1116        assert_eq!(second.events.len(), 1);
1117        for (i, e) in first.events.iter().chain(second.events.iter()).enumerate() {
1118            match e {
1119                ClientEvent::MediaData { channel, data } => {
1120                    assert_eq!(*channel as usize, i % 2);
1121                    assert_eq!(data.len(), PAYLOAD);
1122                    assert!(data.iter().all(|b| *b == (i % 251) as u8));
1123                }
1124                other => panic!("unexpected {other:?}"),
1125            }
1126        }
1127        assert!(!c.has_buffered_input());
1128    }
1129
1130    // Regression (audit run-09 W6): an unterminated header (or a peer that
1131    // drips bytes forever without completing a message) must not grow
1132    // `inbound` without bound.
1133    #[test]
1134    fn inbound_buffer_is_capped_against_an_unterminated_header() {
1135        let mut c = ClientSession::new();
1136        c.options("rtsp://h/s").unwrap();
1137        let chunk = vec![b'A'; 64 * 1024];
1138        let mut last = Ok(Vec::new());
1139        for _ in 0..(MAX_INBOUND_BYTES / chunk.len() + 2) {
1140            last = c.handle_data_strict(&chunk);
1141            if last.is_err() {
1142                break;
1143            }
1144        }
1145        assert!(
1146            matches!(last, Err(crate::error::Error::MessageParse { .. })),
1147            "expected the oversized inbound buffer to be rejected, not grown forever"
1148        );
1149    }
1150
1151    #[test]
1152    fn record_fails_from_init_state() {
1153        let mut c = ClientSession::new();
1154        assert!(matches!(
1155            c.record("rtsp://h/s"),
1156            Err(crate::error::Error::MethodNotValidInState { .. })
1157        ));
1158    }
1159
1160    #[test]
1161    fn record_transitions_to_recording_state() {
1162        // RTSP lines are CRLF-terminated (RFC 2326 §1); convert "\n" -> CRLF.
1163        fn wire(s: &str) -> Vec<u8> {
1164            s.replace('\n', "\r\n").into_bytes()
1165        }
1166        let mut c = ClientSession::new();
1167        // Drive Init -> Ready via a successful SETUP round-trip.
1168        let t = Transport::single(crate::transport::TransportSpec::rtp_avp_tcp_interleaved(
1169            0, 1,
1170        ));
1171        c.setup("rtsp://h/s", &t).unwrap();
1172        c.handle_data_strict(&wire(
1173            "RTSP/1.0 200 OK\nCSeq: 1\nSession: 42\nTransport: RTP/AVP/TCP;interleaved=0-1\n\n",
1174        ))
1175        .unwrap();
1176        assert_eq!(c.state(), SessionState::Ready);
1177
1178        // RECORD is valid from Ready; the request alone does not move state.
1179        c.record("rtsp://h/s").unwrap();
1180        assert_eq!(c.state(), SessionState::Ready);
1181        // A 2xx response advances Ready -> Recording.
1182        c.handle_data_strict(&wire("RTSP/1.0 200 OK\nCSeq: 2\nSession: 42\n\n"))
1183            .unwrap();
1184        assert_eq!(c.state(), SessionState::Recording);
1185    }
1186
1187    // Regression (audit run-09 C1): a 401 auth retry must replay the ORIGINAL
1188    // request body and Content-Type, not an empty body. Before the fix, the
1189    // retried ANNOUNCE carried `Content-Length: 0` and no SDP, so an
1190    // authenticated ANNOUNCE could never reach a real server (RFC 2326 §10.3).
1191    #[test]
1192    fn auth_retry_replays_original_body_and_content_type() {
1193        fn wire(s: &str) -> Vec<u8> {
1194            s.replace('\n', "\r\n").into_bytes()
1195        }
1196        let mut c = ClientSession::new().with_credentials(Credentials::new("admin", "12345"));
1197        let sdp = "v=0\r\no=- 0 0 IN IP4 0.0.0.0\r\ns=Test\r\n";
1198        let first = c.announce("rtsp://h/s", sdp).unwrap();
1199        assert!(String::from_utf8_lossy(&first).contains("CSeq: 1"));
1200
1201        let events = c
1202            .handle_data_strict(&wire(
1203                "RTSP/1.0 401 Unauthorized\nCSeq: 1\nWWW-Authenticate: Digest realm=\"cam\",nonce=\"abc123\",qop=\"auth\"\n\n",
1204            ))
1205            .unwrap();
1206        let retry = events
1207            .into_iter()
1208            .find_map(|e| match e {
1209                ClientEvent::AuthRetry { request, .. } => Some(request),
1210                _ => None,
1211            })
1212            .expect("expected an AuthRetry event");
1213        let retry = String::from_utf8_lossy(&retry);
1214
1215        assert!(retry.contains("ANNOUNCE rtsp://h/s"), "{retry}");
1216        assert!(retry.contains("Authorization: Digest "), "{retry}");
1217        assert!(
1218            retry.contains("Content-Type: application/sdp"),
1219            "Content-Type dropped on auth retry: {retry}"
1220        );
1221        assert!(
1222            retry.contains(sdp),
1223            "SDP body dropped on auth retry: {retry}"
1224        );
1225        let expected_len = format!("Content-Length: {}", sdp.len());
1226        assert!(
1227            retry.contains(&expected_len),
1228            "expected {expected_len:?} in: {retry}"
1229        );
1230    }
1231
1232    // Regression (audit run-09 W4): a server that rotates its Digest nonce
1233    // without `stale=true` must still get a correctly-signed retry on the
1234    // very next request, not one signed with the stale nonce. Before the fix,
1235    // the authenticator was rebuilt only when it was `None`, `already`
1236    // retried, or `stale` was set — none of which hold for a fresh request's
1237    // first 401 when the client had already authenticated earlier in the
1238    // session — so the retry reused the old nonce, failed again, and the
1239    // engine gave up (`already && !stale`) instead of adapting.
1240    #[test]
1241    fn nonce_rotation_without_stale_is_picked_up_on_the_first_retry() {
1242        fn wire(s: &str) -> Vec<u8> {
1243            s.replace('\n', "\r\n").into_bytes()
1244        }
1245        let mut c = ClientSession::new().with_credentials(Credentials::new("admin", "12345"));
1246
1247        // Establish the authenticator via an ordinary first challenge.
1248        c.options("rtsp://h/s").unwrap(); // CSeq 1
1249        let events = c
1250            .handle_data_strict(&wire(
1251                "RTSP/1.0 401 Unauthorized\nCSeq: 1\nWWW-Authenticate: Digest realm=\"cam\",nonce=\"nonce-1\",qop=\"auth\"\n\n",
1252            ))
1253            .unwrap();
1254        assert!(
1255            events
1256                .iter()
1257                .any(|e| matches!(e, ClientEvent::AuthRetry { cseq: 2, .. })),
1258            "{events:?}"
1259        );
1260        c.handle_data_strict(&wire("RTSP/1.0 200 OK\nCSeq: 2\n\n"))
1261            .unwrap();
1262
1263        // A later, unrelated request (e.g. a GET_PARAMETER keepalive) hits a
1264        // rotated nonce with no `stale=true`.
1265        c.options("rtsp://h/s").unwrap(); // CSeq 3
1266        let events = c
1267            .handle_data_strict(&wire(
1268                "RTSP/1.0 401 Unauthorized\nCSeq: 3\nWWW-Authenticate: Digest realm=\"cam\",nonce=\"nonce-2\",qop=\"auth\"\n\n",
1269            ))
1270            .unwrap();
1271        let retry = events
1272            .into_iter()
1273            .find_map(|e| match e {
1274                ClientEvent::AuthRetry {
1275                    cseq: 4, request, ..
1276                } => Some(request),
1277                _ => None,
1278            })
1279            .expect("expected a retry (CSeq 4), not a give-up");
1280        let retry = String::from_utf8_lossy(&retry);
1281        assert!(
1282            retry.contains("nonce=\"nonce-2\""),
1283            "retry signed with the stale nonce instead of the rotated one: {retry}"
1284        );
1285    }
1286
1287    fn established(timeout: Option<u64>) -> ClientSession {
1288        let mut c = ClientSession::new();
1289        let _ = c
1290            .setup(
1291                "rtsp://h/s",
1292                &Transport::single(crate::transport::TransportSpec::rtp_avp_tcp_interleaved(
1293                    0, 1,
1294                )),
1295            )
1296            .unwrap();
1297        let sess = match timeout {
1298            Some(t) => format!("abc;timeout={t}"),
1299            None => "abc".into(),
1300        };
1301        let resp = format!(
1302            "RTSP/1.0 200 OK\r\nCSeq: 1\r\nSession: {sess}\r\nTransport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n\r\n"
1303        );
1304        c.handle_data_strict(resp.as_bytes()).unwrap();
1305        c
1306    }
1307
1308    #[test]
1309    fn the_keepalive_interval_is_floored_and_fires_at_most_once_per_interval() {
1310        // timeout=1 would give a 0.5 s half-timeout; the floor makes it 1 s.
1311        let t0 = Instant::now();
1312        let mut c = established(Some(1));
1313        c.mark_activity(t0);
1314        assert_eq!(c.poll_timeout(), Some(t0 + MIN_KEEPALIVE_INTERVAL));
1315        assert!(
1316            c.handle_timeout(t0 + Duration::from_millis(500))
1317                .unwrap()
1318                .is_none()
1319        );
1320        // hammer one instant: exactly one keepalive per floor interval
1321        let mut sent = 0;
1322        let tick = t0 + MIN_KEEPALIVE_INTERVAL;
1323        for _ in 0..1000 {
1324            if c.handle_timeout(tick).unwrap().is_some() {
1325                sent += 1;
1326            }
1327        }
1328        assert_eq!(sent, 1, "keepalive must not busy-loop");
1329    }
1330
1331    #[test]
1332    fn a_zero_timeout_is_a_warning_and_the_default_not_a_busy_loop() {
1333        let t0 = Instant::now();
1334        let mut c = ClientSession::new();
1335        let _ = c
1336            .setup(
1337                "rtsp://h/s",
1338                &Transport::single(crate::transport::TransportSpec::rtp_avp_tcp_interleaved(
1339                    0, 1,
1340                )),
1341            )
1342            .unwrap();
1343        c.handle_data_strict(
1344            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",
1345        )
1346        .unwrap();
1347        assert_eq!(c.session_warnings().len(), 1, "{:?}", c.session_warnings());
1348        assert!(c.session_warnings()[0].contains("timeout"));
1349        c.mark_activity(t0);
1350        assert_eq!(c.poll_timeout(), Some(t0 + Duration::from_secs(30)));
1351    }
1352
1353    /// Interop (review round 4): every received session id is stored and echoed back
1354    /// verbatim on the next request, as before the own-parser change.
1355    #[test]
1356    fn odd_received_session_ids_are_echoed_verbatim() {
1357        for (hdr, id) in [
1358            ("\"weird\"", "\"weird\""),
1359            ("a b;timeout=30", "a b"),
1360            ("abc,def", "abc,def"),
1361            ("ab\"c", "ab\"c"),
1362        ] {
1363            let mut c = ClientSession::new();
1364            let _ = c
1365                .setup(
1366                    "rtsp://h/s",
1367                    &Transport::single(crate::transport::TransportSpec::rtp_avp_tcp_interleaved(
1368                        0, 1,
1369                    )),
1370                )
1371                .unwrap();
1372            let resp = format!(
1373                "RTSP/1.0 200 OK\r\nCSeq: 1\r\nSession: {hdr}\r\nTransport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n\r\n"
1374            );
1375            c.handle_data_strict(resp.as_bytes()).unwrap();
1376            assert_eq!(c.session_id(), Some(id), "{hdr}");
1377            let play = String::from_utf8(c.play("rtsp://h/s").unwrap()).unwrap();
1378            assert!(
1379                play.contains(&format!("Session: {id}\r\n")),
1380                "{hdr}: {play}"
1381            );
1382        }
1383    }
1384
1385    #[test]
1386    fn session_warnings_are_capped_but_counted() {
1387        let mut c = ClientSession::new();
1388        for i in 1..=(MAX_SESSION_WARNINGS as u32 + 10) {
1389            let _ = c.options("rtsp://h/s").unwrap();
1390            c.handle_data_strict(
1391                format!("RTSP/1.0 200 OK\r\nCSeq: {i}\r\nSession: abc;timeout=x\r\n\r\n")
1392                    .as_bytes(),
1393            )
1394            .unwrap();
1395        }
1396        assert_eq!(c.session_warnings().len(), MAX_SESSION_WARNINGS);
1397        assert_eq!(c.session_warning_count(), MAX_SESSION_WARNINGS + 10);
1398    }
1399
1400    #[test]
1401    fn an_unusable_session_header_is_observable_not_silent() {
1402        let mut c = ClientSession::new();
1403        let _ = c.options("rtsp://h/s").unwrap();
1404        c.handle_data_strict(b"RTSP/1.0 200 OK\r\nCSeq: 1\r\nSession: ;timeout=5\r\n\r\n")
1405            .unwrap();
1406        assert!(c.session_id().is_none());
1407        assert_eq!(c.session_warnings().len(), 1);
1408    }
1409
1410    #[test]
1411    fn keepalive_is_due_at_half_the_session_timeout() {
1412        let t0 = Instant::now();
1413        let mut c = established(Some(20));
1414        assert_eq!(c.poll_timeout(), None, "no activity recorded yet");
1415        c.mark_activity(t0);
1416        assert_eq!(c.poll_timeout(), Some(t0 + Duration::from_secs(10)));
1417        assert!(
1418            c.handle_timeout(t0 + Duration::from_secs(9))
1419                .unwrap()
1420                .is_none()
1421        );
1422        let req = c
1423            .handle_timeout(t0 + Duration::from_secs(10))
1424            .unwrap()
1425            .expect("keepalive due");
1426        let text = String::from_utf8(req).unwrap();
1427        assert!(
1428            text.starts_with("GET_PARAMETER rtsp://h/s RTSP/1.0"),
1429            "{text}"
1430        );
1431        assert!(text.contains("Session: abc"), "{text}");
1432        // re-armed from the moment it was sent
1433        assert_eq!(
1434            c.poll_timeout(),
1435            Some(t0 + Duration::from_secs(10) + Duration::from_secs(10))
1436        );
1437    }
1438
1439    #[test]
1440    fn keepalive_uses_the_rfc_default_timeout_when_none_was_declared() {
1441        let t0 = Instant::now();
1442        let mut c = established(None);
1443        c.mark_activity(t0);
1444        assert_eq!(c.poll_timeout(), Some(t0 + Duration::from_secs(30))); // 60 s / 2
1445    }
1446
1447    #[test]
1448    fn no_keepalive_before_setup_or_after_teardown() {
1449        let t0 = Instant::now();
1450        let mut c = ClientSession::new();
1451        c.mark_activity(t0);
1452        assert_eq!(c.poll_timeout(), None);
1453        assert!(
1454            c.handle_timeout(t0 + Duration::from_secs(3600))
1455                .unwrap()
1456                .is_none()
1457        );
1458    }
1459
1460    #[test]
1461    fn head_cap_holds_for_a_70_kib_run_of_a_and_for_9000_header_lines() {
1462        let mut c = ClientSession::new();
1463        assert!(matches!(
1464            c.handle_data_strict(&vec![b'A'; 70 * 1024]),
1465            Err(Error::MessageParse { .. })
1466        ));
1467        let mut c = ClientSession::new();
1468        let mut junk = b"RTSP/1.0 200 OK\r\n".to_vec();
1469        junk.extend(b"X-A: b\r\n".repeat(9000));
1470        assert!(matches!(
1471            c.handle_data_strict(&junk),
1472            Err(Error::MessageParse { .. })
1473        ));
1474    }
1475
1476    #[test]
1477    fn a_response_fed_one_byte_at_a_time_is_still_decoded() {
1478        let mut c = ClientSession::new();
1479        let _ = c.options("rtsp://h/s").unwrap();
1480        let resp = b"RTSP/1.0 200 OK\r\nCSeq: 1\r\nPublic: OPTIONS\r\n\r\n";
1481        let mut got = 0;
1482        for b in resp {
1483            got += c.handle_data_strict(&[*b]).unwrap().len();
1484        }
1485        assert_eq!(got, 1);
1486    }
1487
1488    #[test]
1489    fn an_unterminated_header_over_the_head_cap_is_rejected() {
1490        let mut c = ClientSession::new();
1491        let mut junk = b"RTSP/1.0 200 OK\r\nX-Pad: ".to_vec();
1492        junk.extend(std::iter::repeat_n(b'a', 70 * 1024)); // no terminator, ever
1493        let err = c
1494            .handle_data_strict(&junk)
1495            .expect_err("header phase is capped at 64 KiB");
1496        assert!(matches!(err, Error::MessageParse { .. }), "{err:?}");
1497    }
1498
1499    #[test]
1500    fn stale_quoted_rechallenge_refreshes_nonce_after_a_prior_retry() {
1501        fn wire(s: &str) -> Vec<u8> {
1502            s.replace('\n', "\r\n").into_bytes()
1503        }
1504        let mut c = ClientSession::new().with_credentials(Credentials::new("admin", "12345"));
1505        c.options("rtsp://h/s").unwrap(); // CSeq 1
1506
1507        // First 401 (no stale) -> normal retry at CSeq 2, marked retried.
1508        c.handle_data_strict(&wire(
1509            "RTSP/1.0 401 Unauthorized\nCSeq: 1\nWWW-Authenticate: Digest realm=\"cam\",nonce=\"nonce-1\",qop=\"auth\"\n\n",
1510        )).unwrap();
1511
1512        // Re-challenge with a quoted `stale="True"` and a fresh nonce: the
1513        // retried request must be re-signed with the NEW nonce, not given up.
1514        let events = c
1515            .handle_data_strict(&wire(
1516                "RTSP/1.0 401 Unauthorized\nCSeq: 2\nWWW-Authenticate: Digest realm=\"cam\",nonce=\"nonce-2\",qop=\"auth\",stale=\"True\"\n\n",
1517            ))
1518            .unwrap();
1519        let retry = events
1520            .into_iter()
1521            .find_map(|e| match e {
1522                ClientEvent::AuthRetry {
1523                    cseq: 3, request, ..
1524                } => Some(request),
1525                _ => None,
1526            })
1527            .expect("a quoted stale=true re-challenge must trigger a retry");
1528        let retry = String::from_utf8_lossy(&retry);
1529        assert!(
1530            retry.contains("nonce=\"nonce-2\""),
1531            "quoted stale=\"True\" was not detected: {retry}"
1532        );
1533    }
1534
1535    // Security-blocker regression (pre-release audit): `ClientSession`
1536    // derives `Debug` and embeds `Option<Credentials>` directly — it must
1537    // inherit `Credentials`'s redacting `Debug`, never the raw secret.
1538    #[test]
1539    fn client_session_debug_does_not_leak_embedded_credentials_secret() {
1540        let c = ClientSession::new()
1541            .with_credentials(Credentials::new("admin", "extremely-secret-password"));
1542        let debug = format!("{c:?}");
1543        assert!(
1544            !debug.contains("extremely-secret-password"),
1545            "leaked via ClientSession Debug: {debug}"
1546        );
1547        assert!(debug.contains("***"), "expected redaction marker: {debug}");
1548    }
1549}