use std::collections::HashMap;
use std::time::{Duration, Instant};
use rtsp_types::{Message, Method, Request, StatusCode, Version, headers};
use crate::auth::{Authenticator, Credentials, RequestContext};
use crate::error::{Error, Result};
use crate::interleaved::{self, MAGIC};
use crate::state::{SessionState, client_next_state};
use crate::transport::Transport;
type Body = Vec<u8>;
const DEFAULT_SESSION_TIMEOUT_SECS: u64 = 60;
const KEEPALIVE_FRACTION_DEN: u32 = 2;
pub const MAX_SESSION_WARNINGS: usize = 16;
pub const MIN_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(1);
const MAX_INBOUND_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone)]
struct Pending {
method: Method,
uri: String,
request: Request<Body>,
auth_retried: bool,
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClientEvent {
Response {
cseq: u32,
method: Method,
status: StatusCode,
body: Vec<u8>,
},
AuthRetry {
method: Method,
cseq: u32,
request: Vec<u8>,
},
MediaData {
channel: u8,
data: Vec<u8>,
},
}
#[derive(Debug)]
pub struct ClientSession {
state: SessionState,
next_cseq: u32,
session_id: Option<String>,
session_timeout: Option<u64>,
credentials: Option<Credentials>,
authenticator: Option<Authenticator>,
negotiated_transport: Option<Transport>,
pending: HashMap<u32, Pending>,
inbound: Vec<u8>,
user_agent: String,
last_activity: Option<Instant>,
last_uri: Option<String>,
session_warnings: Vec<String>,
session_warning_total: usize,
scanned: usize,
skip_until: usize,
}
impl Default for ClientSession {
fn default() -> Self {
Self::new()
}
}
impl ClientSession {
pub fn new() -> Self {
ClientSession {
state: SessionState::Init,
next_cseq: 1,
session_id: None,
session_timeout: None,
credentials: None,
authenticator: None,
negotiated_transport: None,
pending: HashMap::new(),
inbound: Vec::new(),
user_agent: "rtsp-runtime".to_string(),
last_activity: None,
last_uri: None,
session_warnings: Vec::new(),
session_warning_total: 0,
scanned: 0,
skip_until: 0,
}
}
pub fn with_credentials(mut self, credentials: Credentials) -> Self {
self.credentials = Some(credentials);
self
}
pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
self.user_agent = ua.into();
self
}
pub fn state(&self) -> SessionState {
self.state
}
pub fn peek_next_cseq(&self) -> u32 {
self.next_cseq
}
fn record_session_warning(&mut self, w: String) {
self.session_warning_total += 1;
if self.session_warnings.len() >= MAX_SESSION_WARNINGS {
self.session_warnings.remove(0);
}
self.session_warnings.push(w);
}
pub fn session_warning_count(&self) -> usize {
self.session_warning_total
}
pub fn session_warnings(&self) -> &[String] {
&self.session_warnings
}
pub fn has_buffered_input(&self) -> bool {
!self.inbound.is_empty()
}
pub fn mark_activity(&mut self, now: Instant) {
self.last_activity = Some(now);
}
pub fn poll_timeout(&self) -> Option<Instant> {
self.session_id.as_ref()?;
let timeout =
Duration::from_secs(self.session_timeout.unwrap_or(DEFAULT_SESSION_TIMEOUT_SECS));
self.last_activity?
.checked_add((timeout / KEEPALIVE_FRACTION_DEN).max(MIN_KEEPALIVE_INTERVAL))
}
pub fn handle_timeout(&mut self, now: Instant) -> Result<Option<Vec<u8>>> {
match self.poll_timeout() {
Some(deadline) if now >= deadline => {}
_ => return Ok(None),
}
let Some(uri) = self.last_uri.clone() else {
return Ok(None);
};
let bytes = self.get_parameter(&uri, b"")?;
self.last_activity = Some(now);
Ok(Some(bytes))
}
pub fn session_id(&self) -> Option<&str> {
self.session_id.as_deref()
}
pub fn session_timeout(&self) -> Option<u64> {
self.session_timeout
}
pub fn negotiated_transport(&self) -> Option<&Transport> {
self.negotiated_transport.as_ref()
}
pub fn options(&mut self, uri: &str) -> Result<Vec<u8>> {
self.build_request(Method::Options, uri, None, &[])
}
pub fn describe(&mut self, uri: &str) -> Result<Vec<u8>> {
self.build_request(
Method::Describe,
uri,
None,
&[(headers::ACCEPT, "application/sdp".to_string())],
)
}
pub fn setup(&mut self, uri: &str, transport: &Transport) -> Result<Vec<u8>> {
self.build_request(
Method::Setup,
uri,
None,
&[(headers::TRANSPORT, transport.to_header_value()?)],
)
}
pub fn play(&mut self, uri: &str) -> Result<Vec<u8>> {
self.build_request(Method::Play, uri, None, &[])
}
pub fn pause(&mut self, uri: &str) -> Result<Vec<u8>> {
self.build_request(Method::Pause, uri, None, &[])
}
pub fn teardown(&mut self, uri: &str) -> Result<Vec<u8>> {
self.build_request(Method::Teardown, uri, None, &[])
}
pub fn announce(&mut self, uri: &str, sdp: &str) -> Result<Vec<u8>> {
self.build_request_with_body(
Method::Announce,
uri,
sdp.as_bytes(),
&[(headers::CONTENT_TYPE, "application/sdp".to_string())],
)
}
pub fn record(&mut self, uri: &str) -> Result<Vec<u8>> {
self.build_request(Method::Record, uri, None, &[])
}
pub fn get_parameter(&mut self, uri: &str, body: &[u8]) -> Result<Vec<u8>> {
self.build_request_with_body(Method::GetParameter, uri, body, &[])
}
fn build_request(
&mut self,
method: Method,
uri: &str,
_range: Option<&str>,
extra: &[(headers::HeaderName, String)],
) -> Result<Vec<u8>> {
self.build_request_with_body(method, uri, &[], extra)
}
fn build_request_with_body(
&mut self,
method: Method,
uri: &str,
body: &[u8],
extra: &[(headers::HeaderName, String)],
) -> Result<Vec<u8>> {
client_next_state(self.state, &method)?;
let cseq = self.next_cseq;
let request = self.assemble(method.clone(), uri, cseq, body, extra)?;
let bytes = serialize(&Message::from(request.clone()))?;
self.next_cseq += 1;
self.last_uri = Some(uri.to_string());
self.pending.insert(
cseq,
Pending {
method,
uri: uri.to_string(),
request,
auth_retried: false,
},
);
Ok(bytes)
}
fn assemble(
&mut self,
method: Method,
uri: &str,
cseq: u32,
body: &[u8],
extra: &[(headers::HeaderName, String)],
) -> Result<Request<Body>> {
let url = rtsp_types::Url::parse(uri)
.map_err(|e| Error::TransportParse(format!("invalid request URI {uri:?}: {e}")))?;
let mut builder = Request::builder(method.clone(), Version::V1_0)
.request_uri(url)
.header(headers::CSEQ, cseq.to_string())
.header(headers::USER_AGENT, self.user_agent.clone());
if let Some(sid) = &self.session_id {
builder = builder.header(headers::SESSION, sid.clone());
}
for (name, value) in extra {
builder = builder.header(name.clone(), value.clone());
}
if let Some(auth) = &mut self.authenticator {
let ctx = RequestContext::new(<&str>::from(&method), uri);
let value = auth.authorization(&ctx)?;
builder = builder.header(headers::AUTHORIZATION, value);
}
let request = if body.is_empty() {
builder.build(Vec::new())
} else {
builder.build(body.to_vec())
};
Ok(request)
}
pub fn handle_data(&mut self, data: &[u8]) -> Result<Vec<ClientEvent>> {
self.inbound.extend_from_slice(data);
if self.inbound.len() > MAX_INBOUND_BYTES {
return Err(Error::MessageParse(format!(
"inbound buffer of {} bytes exceeds the {MAX_INBOUND_BYTES}-byte maximum \
(unterminated header or an unfulfilled Content-Length)",
self.inbound.len()
)));
}
let mut events = Vec::new();
loop {
if self.inbound.is_empty() {
break;
}
if self.inbound[0] == MAGIC {
match interleaved::InterleavedFrame::parse(&self.inbound)? {
Some((frame, consumed)) => {
events.push(ClientEvent::MediaData {
channel: frame.channel,
data: frame.payload,
});
self.inbound.drain(..consumed);
}
None => break, }
continue;
}
if self.inbound.len() < self.skip_until {
break;
}
if crate::framing::head_end(&self.inbound, self.scanned)?.is_none() {
self.scanned = self.inbound.len();
break;
}
match Message::<Body>::parse(&self.inbound) {
Ok((message, consumed)) => {
self.inbound.drain(..consumed);
self.scanned = 0;
self.skip_until = 0;
self.process_message(message, &mut events)?;
}
Err(rtsp_types::ParseError::Incomplete(needed)) => {
if let Some(more) = needed {
self.skip_until = self.inbound.len().saturating_add(more.get());
}
break;
}
Err(rtsp_types::ParseError::Error) => {
return Err(Error::MessageParse("malformed RTSP message".into()));
}
}
}
Ok(events)
}
fn process_message(
&mut self,
message: Message<Body>,
events: &mut Vec<ClientEvent>,
) -> Result<()> {
match message {
Message::Response(response) => {
let Some(cseq) = header_value(response.header(&headers::CSEQ))
.and_then(|s| s.trim().parse::<u32>().ok())
else {
return Ok(());
};
let status = response.status();
if status == StatusCode::Unauthorized
&& let Some(retry) = self.try_auth_retry(cseq, &response)?
{
events.push(retry);
return Ok(());
}
let Some(pending) = self.pending.remove(&cseq) else {
return Ok(());
};
if let Some(session_hdr) = header_value(response.header(&headers::SESSION)) {
match crate::session_header::SessionHeader::parse_with_warnings(session_hdr) {
Ok((parsed, warnings)) => {
for w in warnings {
log::warn!("RTSP Session header {session_hdr:?}: {w}");
self.record_session_warning(w);
}
self.session_id = Some(parsed.id);
if let Some(t) = parsed.timeout {
self.session_timeout = Some(t.as_secs());
}
}
Err(e) => {
let w = format!("unusable Session header {session_hdr:?}: {e}");
log::warn!("RTSP {w}");
self.record_session_warning(w);
}
}
}
if pending.method == Method::Setup
&& let Some(t) = header_value(response.header(&headers::TRANSPORT))
{
self.negotiated_transport = Some(Transport::parse(t)?);
}
if status.is_success() {
self.state = client_next_state(self.state, &pending.method)?;
if pending.method == Method::Teardown {
self.session_id = None;
self.session_timeout = None;
self.authenticator = None;
}
} else if status.is_redirection() {
self.state = SessionState::Init;
}
events.push(ClientEvent::Response {
cseq,
method: pending.method,
status,
body: response.into_body(),
});
Ok(())
}
Message::Data(data) => {
events.push(ClientEvent::MediaData {
channel: data.channel_id(),
data: data.into_body(),
});
Ok(())
}
Message::Request(_) => {
Ok(())
}
}
}
fn try_auth_retry(
&mut self,
cseq: u32,
response: &rtsp_types::Response<Body>,
) -> Result<Option<ClientEvent>> {
let creds = match &self.credentials {
Some(c) => c.clone(),
None => return Ok(None),
};
let (method, uri, already) = match self.pending.get(&cseq) {
Some(p) => (p.method.clone(), p.uri.clone(), p.auth_retried),
None => return Ok(None),
};
let challenge = header_value(response.header(&headers::WWW_AUTHENTICATE))
.ok_or_else(|| Error::Auth("401 without WWW-Authenticate".into()))?;
let stale = crate::headers_util::challenge_is_stale(challenge);
self.authenticator = Some(Authenticator::from_challenge(challenge, creds)?);
if already && !stale {
return Ok(None);
}
let (body, extra) = self.replay_extra(cseq);
self.pending.remove(&cseq);
let new_cseq = self.next_cseq;
let request = self.assemble(method.clone(), &uri, new_cseq, &body, &extra)?;
let bytes = serialize(&Message::from(request.clone()))?;
self.next_cseq += 1;
self.pending.insert(
new_cseq,
Pending {
method: method.clone(),
uri,
request,
auth_retried: true,
},
);
Ok(Some(ClientEvent::AuthRetry {
method,
cseq: new_cseq,
request: bytes,
}))
}
fn replay_extra(&self, old_cseq: u32) -> (Vec<u8>, Vec<(headers::HeaderName, String)>) {
let Some(p) = self.pending.get(&old_cseq) else {
return (Vec::new(), Vec::new());
};
let hop_by_hop = [
headers::CSEQ,
headers::SESSION,
headers::AUTHORIZATION,
headers::USER_AGENT,
headers::CONTENT_LENGTH,
];
let extra = p
.request
.headers()
.filter(|(name, _)| !hop_by_hop.iter().any(|h| h == *name))
.map(|(name, value)| (name.clone(), value.as_str().to_string()))
.collect();
(p.request.body().clone(), extra)
}
}
fn header_value(h: Option<&headers::HeaderValue>) -> Option<&str> {
h.map(|v| v.as_str())
}
fn serialize(message: &Message<Body>) -> Result<Vec<u8>> {
let mut out = Vec::new();
message
.write(&mut out)
.map_err(|e| Error::MessageWrite(e.to_string()))?;
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn play_in_init_bites() {
let mut c = ClientSession::new();
assert!(c.play("rtsp://h/s").is_err());
}
#[test]
fn setup_allowed_in_init() {
let mut c = ClientSession::new();
let t = Transport::single(crate::transport::TransportSpec::rtp_avp_tcp_interleaved(
0, 1,
));
assert!(c.setup("rtsp://h/s", &t).is_ok());
}
#[test]
fn cseq_increments() {
let mut c = ClientSession::new();
let a = c.options("rtsp://h/s").unwrap();
let b = c.describe("rtsp://h/s").unwrap();
assert!(String::from_utf8_lossy(&a).contains("CSeq: 1"));
assert!(String::from_utf8_lossy(&b).contains("CSeq: 2"));
}
#[test]
fn announce_emits_request_with_sdp_content_type() {
let mut c = ClientSession::new();
let sdp = "v=0\r\no=- 0 0 IN IP4 0.0.0.0\r\ns=Test\r\n";
let bytes = c.announce("rtsp://h/s", sdp).unwrap();
let s = String::from_utf8_lossy(&bytes);
assert!(s.contains("ANNOUNCE rtsp://h/s"));
assert!(s.contains("Content-Type: application/sdp"));
assert!(s.contains(sdp));
}
#[test]
fn unknown_cseq_is_skipped_without_discarding_earlier_events() {
fn wire(s: &str) -> Vec<u8> {
s.replace('\n', "\r\n").into_bytes()
}
let mut c = ClientSession::new();
c.options("rtsp://h/s").unwrap(); c.describe("rtsp://h/s").unwrap();
let mut batch = wire("RTSP/1.0 200 OK\nCSeq: 1\n\n");
batch.extend(wire("RTSP/1.0 200 OK\nCSeq: 99\n\n"));
batch.extend(wire("RTSP/1.0 200 OK\nCSeq: 2\n\n"));
let events = c
.handle_data(&batch)
.expect("an unmatched CSeq must not fail the whole batch");
let cseqs: Vec<u32> = events
.into_iter()
.map(|e| match e {
ClientEvent::Response { cseq, .. } => cseq,
other => panic!("unexpected event: {other:?}"),
})
.collect();
assert_eq!(
cseqs,
vec![1, 2],
"expected both real responses despite the unmatched CSeq 99"
);
}
#[test]
fn inbound_buffer_is_capped_against_an_unterminated_header() {
let mut c = ClientSession::new();
c.options("rtsp://h/s").unwrap();
let chunk = vec![b'A'; 64 * 1024];
let mut last = Ok(Vec::new());
for _ in 0..(MAX_INBOUND_BYTES / chunk.len() + 2) {
last = c.handle_data(&chunk);
if last.is_err() {
break;
}
}
assert!(
last.is_err(),
"expected the oversized inbound buffer to be rejected, not grown forever"
);
}
#[test]
fn record_fails_from_init_state() {
let mut c = ClientSession::new();
assert!(c.record("rtsp://h/s").is_err());
}
#[test]
fn record_transitions_to_recording_state() {
fn wire(s: &str) -> Vec<u8> {
s.replace('\n', "\r\n").into_bytes()
}
let mut c = ClientSession::new();
let t = Transport::single(crate::transport::TransportSpec::rtp_avp_tcp_interleaved(
0, 1,
));
c.setup("rtsp://h/s", &t).unwrap();
c.handle_data(&wire(
"RTSP/1.0 200 OK\nCSeq: 1\nSession: 42\nTransport: RTP/AVP/TCP;interleaved=0-1\n\n",
))
.unwrap();
assert_eq!(c.state(), SessionState::Ready);
c.record("rtsp://h/s").unwrap();
assert_eq!(c.state(), SessionState::Ready);
c.handle_data(&wire("RTSP/1.0 200 OK\nCSeq: 2\nSession: 42\n\n"))
.unwrap();
assert_eq!(c.state(), SessionState::Recording);
}
#[test]
fn auth_retry_replays_original_body_and_content_type() {
fn wire(s: &str) -> Vec<u8> {
s.replace('\n', "\r\n").into_bytes()
}
let mut c = ClientSession::new().with_credentials(Credentials::new("admin", "12345"));
let sdp = "v=0\r\no=- 0 0 IN IP4 0.0.0.0\r\ns=Test\r\n";
let first = c.announce("rtsp://h/s", sdp).unwrap();
assert!(String::from_utf8_lossy(&first).contains("CSeq: 1"));
let events = c
.handle_data(&wire(
"RTSP/1.0 401 Unauthorized\nCSeq: 1\nWWW-Authenticate: Digest realm=\"cam\",nonce=\"abc123\",qop=\"auth\"\n\n",
))
.unwrap();
let retry = events
.into_iter()
.find_map(|e| match e {
ClientEvent::AuthRetry { request, .. } => Some(request),
_ => None,
})
.expect("expected an AuthRetry event");
let retry = String::from_utf8_lossy(&retry);
assert!(retry.contains("ANNOUNCE rtsp://h/s"), "{retry}");
assert!(retry.contains("Authorization: Digest "), "{retry}");
assert!(
retry.contains("Content-Type: application/sdp"),
"Content-Type dropped on auth retry: {retry}"
);
assert!(
retry.contains(sdp),
"SDP body dropped on auth retry: {retry}"
);
let expected_len = format!("Content-Length: {}", sdp.len());
assert!(
retry.contains(&expected_len),
"expected {expected_len:?} in: {retry}"
);
}
#[test]
fn nonce_rotation_without_stale_is_picked_up_on_the_first_retry() {
fn wire(s: &str) -> Vec<u8> {
s.replace('\n', "\r\n").into_bytes()
}
let mut c = ClientSession::new().with_credentials(Credentials::new("admin", "12345"));
c.options("rtsp://h/s").unwrap(); let events = c
.handle_data(&wire(
"RTSP/1.0 401 Unauthorized\nCSeq: 1\nWWW-Authenticate: Digest realm=\"cam\",nonce=\"nonce-1\",qop=\"auth\"\n\n",
))
.unwrap();
assert!(
events
.iter()
.any(|e| matches!(e, ClientEvent::AuthRetry { cseq: 2, .. })),
"{events:?}"
);
c.handle_data(&wire("RTSP/1.0 200 OK\nCSeq: 2\n\n"))
.unwrap();
c.options("rtsp://h/s").unwrap(); let events = c
.handle_data(&wire(
"RTSP/1.0 401 Unauthorized\nCSeq: 3\nWWW-Authenticate: Digest realm=\"cam\",nonce=\"nonce-2\",qop=\"auth\"\n\n",
))
.unwrap();
let retry = events
.into_iter()
.find_map(|e| match e {
ClientEvent::AuthRetry {
cseq: 4, request, ..
} => Some(request),
_ => None,
})
.expect("expected a retry (CSeq 4), not a give-up");
let retry = String::from_utf8_lossy(&retry);
assert!(
retry.contains("nonce=\"nonce-2\""),
"retry signed with the stale nonce instead of the rotated one: {retry}"
);
}
fn established(timeout: Option<u64>) -> ClientSession {
let mut c = ClientSession::new();
let _ = c
.setup(
"rtsp://h/s",
&Transport::single(crate::transport::TransportSpec::rtp_avp_tcp_interleaved(
0, 1,
)),
)
.unwrap();
let sess = match timeout {
Some(t) => format!("abc;timeout={t}"),
None => "abc".into(),
};
let resp = format!(
"RTSP/1.0 200 OK\r\nCSeq: 1\r\nSession: {sess}\r\nTransport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n\r\n"
);
c.handle_data(resp.as_bytes()).unwrap();
c
}
#[test]
fn the_keepalive_interval_is_floored_and_fires_at_most_once_per_interval() {
let t0 = Instant::now();
let mut c = established(Some(1));
c.mark_activity(t0);
assert_eq!(c.poll_timeout(), Some(t0 + MIN_KEEPALIVE_INTERVAL));
assert!(
c.handle_timeout(t0 + Duration::from_millis(500))
.unwrap()
.is_none()
);
let mut sent = 0;
let tick = t0 + MIN_KEEPALIVE_INTERVAL;
for _ in 0..1000 {
if c.handle_timeout(tick).unwrap().is_some() {
sent += 1;
}
}
assert_eq!(sent, 1, "keepalive must not busy-loop");
}
#[test]
fn a_zero_timeout_is_a_warning_and_the_default_not_a_busy_loop() {
let t0 = Instant::now();
let mut c = ClientSession::new();
let _ = c
.setup(
"rtsp://h/s",
&Transport::single(crate::transport::TransportSpec::rtp_avp_tcp_interleaved(
0, 1,
)),
)
.unwrap();
c.handle_data(
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",
)
.unwrap();
assert_eq!(c.session_warnings().len(), 1, "{:?}", c.session_warnings());
assert!(c.session_warnings()[0].contains("timeout"));
c.mark_activity(t0);
assert_eq!(c.poll_timeout(), Some(t0 + Duration::from_secs(30)));
}
#[test]
fn odd_received_session_ids_are_echoed_verbatim() {
for (hdr, id) in [
("\"weird\"", "\"weird\""),
("a b;timeout=30", "a b"),
("abc,def", "abc,def"),
("ab\"c", "ab\"c"),
] {
let mut c = ClientSession::new();
let _ = c
.setup(
"rtsp://h/s",
&Transport::single(crate::transport::TransportSpec::rtp_avp_tcp_interleaved(
0, 1,
)),
)
.unwrap();
let resp = format!(
"RTSP/1.0 200 OK\r\nCSeq: 1\r\nSession: {hdr}\r\nTransport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n\r\n"
);
c.handle_data(resp.as_bytes()).unwrap();
assert_eq!(c.session_id(), Some(id), "{hdr}");
let play = String::from_utf8(c.play("rtsp://h/s").unwrap()).unwrap();
assert!(
play.contains(&format!("Session: {id}\r\n")),
"{hdr}: {play}"
);
}
}
#[test]
fn session_warnings_are_capped_but_counted() {
let mut c = ClientSession::new();
for i in 1..=(MAX_SESSION_WARNINGS as u32 + 10) {
let _ = c.options("rtsp://h/s").unwrap();
c.handle_data(
format!("RTSP/1.0 200 OK\r\nCSeq: {i}\r\nSession: abc;timeout=x\r\n\r\n")
.as_bytes(),
)
.unwrap();
}
assert_eq!(c.session_warnings().len(), MAX_SESSION_WARNINGS);
assert_eq!(c.session_warning_count(), MAX_SESSION_WARNINGS + 10);
}
#[test]
fn an_unusable_session_header_is_observable_not_silent() {
let mut c = ClientSession::new();
let _ = c.options("rtsp://h/s").unwrap();
c.handle_data(b"RTSP/1.0 200 OK\r\nCSeq: 1\r\nSession: ;timeout=5\r\n\r\n")
.unwrap();
assert!(c.session_id().is_none());
assert_eq!(c.session_warnings().len(), 1);
}
#[test]
fn keepalive_is_due_at_half_the_session_timeout() {
let t0 = Instant::now();
let mut c = established(Some(20));
assert_eq!(c.poll_timeout(), None, "no activity recorded yet");
c.mark_activity(t0);
assert_eq!(c.poll_timeout(), Some(t0 + Duration::from_secs(10)));
assert!(
c.handle_timeout(t0 + Duration::from_secs(9))
.unwrap()
.is_none()
);
let req = c
.handle_timeout(t0 + Duration::from_secs(10))
.unwrap()
.expect("keepalive due");
let text = String::from_utf8(req).unwrap();
assert!(
text.starts_with("GET_PARAMETER rtsp://h/s RTSP/1.0"),
"{text}"
);
assert!(text.contains("Session: abc"), "{text}");
assert_eq!(
c.poll_timeout(),
Some(t0 + Duration::from_secs(10) + Duration::from_secs(10))
);
}
#[test]
fn keepalive_uses_the_rfc_default_timeout_when_none_was_declared() {
let t0 = Instant::now();
let mut c = established(None);
c.mark_activity(t0);
assert_eq!(c.poll_timeout(), Some(t0 + Duration::from_secs(30))); }
#[test]
fn no_keepalive_before_setup_or_after_teardown() {
let t0 = Instant::now();
let mut c = ClientSession::new();
c.mark_activity(t0);
assert_eq!(c.poll_timeout(), None);
assert!(
c.handle_timeout(t0 + Duration::from_secs(3600))
.unwrap()
.is_none()
);
}
#[test]
fn head_cap_holds_for_a_70_kib_run_of_a_and_for_9000_header_lines() {
let mut c = ClientSession::new();
assert!(matches!(
c.handle_data(&vec![b'A'; 70 * 1024]),
Err(Error::MessageParse(_))
));
let mut c = ClientSession::new();
let mut junk = b"RTSP/1.0 200 OK\r\n".to_vec();
junk.extend(b"X-A: b\r\n".repeat(9000));
assert!(matches!(c.handle_data(&junk), Err(Error::MessageParse(_))));
}
#[test]
fn a_response_fed_one_byte_at_a_time_is_still_decoded() {
let mut c = ClientSession::new();
let _ = c.options("rtsp://h/s").unwrap();
let resp = b"RTSP/1.0 200 OK\r\nCSeq: 1\r\nPublic: OPTIONS\r\n\r\n";
let mut got = 0;
for b in resp {
got += c.handle_data(&[*b]).unwrap().len();
}
assert_eq!(got, 1);
}
#[test]
fn an_unterminated_header_over_the_head_cap_is_rejected() {
let mut c = ClientSession::new();
let mut junk = b"RTSP/1.0 200 OK\r\nX-Pad: ".to_vec();
junk.extend(std::iter::repeat_n(b'a', 70 * 1024)); let err = c
.handle_data(&junk)
.expect_err("header phase is capped at 64 KiB");
assert!(matches!(err, Error::MessageParse(_)), "{err:?}");
}
#[test]
fn stale_quoted_rechallenge_refreshes_nonce_after_a_prior_retry() {
fn wire(s: &str) -> Vec<u8> {
s.replace('\n', "\r\n").into_bytes()
}
let mut c = ClientSession::new().with_credentials(Credentials::new("admin", "12345"));
c.options("rtsp://h/s").unwrap();
c.handle_data(&wire(
"RTSP/1.0 401 Unauthorized\nCSeq: 1\nWWW-Authenticate: Digest realm=\"cam\",nonce=\"nonce-1\",qop=\"auth\"\n\n",
)).unwrap();
let events = c
.handle_data(&wire(
"RTSP/1.0 401 Unauthorized\nCSeq: 2\nWWW-Authenticate: Digest realm=\"cam\",nonce=\"nonce-2\",qop=\"auth\",stale=\"True\"\n\n",
))
.unwrap();
let retry = events
.into_iter()
.find_map(|e| match e {
ClientEvent::AuthRetry {
cseq: 3, request, ..
} => Some(request),
_ => None,
})
.expect("a quoted stale=true re-challenge must trigger a retry");
let retry = String::from_utf8_lossy(&retry);
assert!(
retry.contains("nonce=\"nonce-2\""),
"quoted stale=\"True\" was not detected: {retry}"
);
}
#[test]
fn client_session_debug_does_not_leak_embedded_credentials_secret() {
let c = ClientSession::new()
.with_credentials(Credentials::new("admin", "extremely-secret-password"));
let debug = format!("{c:?}");
assert!(
!debug.contains("extremely-secret-password"),
"leaked via ClientSession Debug: {debug}"
);
assert!(debug.contains("***"), "expected redaction marker: {debug}");
}
}