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