use broadcast_common::Parse;
use crate::RtmpError;
use crate::amf0::{Amf0Value, Command};
use crate::chunk::{ChunkAssembler, ChunkWriter, Message};
use crate::handshake::Handshake;
use crate::message::{LimitType, ProtocolControl, UserControl, msg_type};
type Result<T> = core::result::Result<T, RtmpError>;
pub const DEFAULT_CHUNK_SIZE: u32 = 4096;
pub const DEFAULT_WINDOW_ACK_SIZE: u32 = 2_500_000;
pub const DEFAULT_PEER_BANDWIDTH: u32 = 2_500_000;
const FIRST_STREAM_ID: u32 = 1;
const COMMAND_CHUNK_STREAM_ID: u32 = 3;
const FMS_VERSION: &str = "FMS/3,0,1,123";
const CAPABILITIES: f64 = 31.0;
const FLV_SIGNATURE: [u8; 3] = *b"FLV";
const FLV_VERSION: u8 = 1;
const FLV_TYPE_FLAGS_AUDIO_VIDEO: u8 = 0b0000_0101;
const FLV_HEADER_SIZE: u32 = 9;
const FLV_TAG_HEADER_LEN: usize = 11;
const FLV_PREV_TAG_SIZE_LEN: usize = 4;
const FLV_MAX_DATA_SIZE: usize = 0x00FF_FFFF;
#[non_exhaustive]
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ServerConfig {
pub chunk_size: u32,
pub window_ack_size: u32,
pub peer_bandwidth: u32,
pub expected_stream_key: Option<String>,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
chunk_size: DEFAULT_CHUNK_SIZE,
window_ack_size: DEFAULT_WINDOW_ACK_SIZE,
peer_bandwidth: DEFAULT_PEER_BANDWIDTH,
expected_stream_key: None,
}
}
}
impl ServerConfig {
#[must_use]
pub fn with_expected_stream_key(mut self, expected_stream_key: Option<String>) -> Self {
self.expected_stream_key = expected_stream_key;
self
}
#[must_use]
pub fn with_chunk_size(mut self, chunk_size: u32) -> Self {
self.chunk_size = chunk_size;
self
}
#[must_use]
pub fn with_window_ack_size(mut self, window_ack_size: u32) -> Self {
self.window_ack_size = window_ack_size;
self
}
#[must_use]
pub fn with_peer_bandwidth(mut self, peer_bandwidth: u32) -> Self {
self.peer_bandwidth = peer_bandwidth;
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ServerEvent {
Connected {
app: String,
},
Publish {
app: String,
stream_key: String,
stream_id: u32,
},
Media {
flv: Vec<u8>,
},
Eof,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
Init,
Connected,
Publishing,
Closed,
}
#[derive(Debug)]
pub struct ServerSession {
config: ServerConfig,
handshake: Handshake,
handshake_buf: Vec<u8>,
assembler: ChunkAssembler,
writer: ChunkWriter,
state: State,
app: Option<String>,
next_stream_id: u32,
created_stream_id: Option<u32>,
ack_threshold: u32,
bytes_received: u64,
bytes_acked: u64,
flv_header_sent: bool,
}
impl ServerSession {
#[must_use]
pub fn new(config: ServerConfig) -> Self {
let ack_threshold = config.window_ack_size;
Self {
config,
handshake: Handshake::new(),
handshake_buf: Vec::new(),
assembler: ChunkAssembler::new(),
writer: ChunkWriter::new(),
state: State::Init,
app: None,
next_stream_id: FIRST_STREAM_ID,
created_stream_id: None,
ack_threshold,
bytes_received: 0,
bytes_acked: 0,
flv_header_sent: false,
}
}
#[must_use]
pub fn with_defaults() -> Self {
Self::new(ServerConfig::default())
}
pub fn handle_data(&mut self, input: &[u8]) -> Result<(Vec<u8>, Vec<ServerEvent>)> {
let mut out = Vec::new();
let mut events = Vec::new();
let chunk_input = match self.drive_handshake(input, &mut out)? {
Some(bytes) => bytes,
None => return Ok((out, events)),
};
self.bytes_received = self.bytes_received.saturating_add(chunk_input.len() as u64);
self.assembler.feed(&chunk_input);
while let Some(msg) = self.assembler.next_message()? {
self.dispatch_message(&msg, &mut out, &mut events)?;
}
self.maybe_ack(&mut out);
Ok((out, events))
}
fn drive_handshake(&mut self, input: &[u8], out: &mut Vec<u8>) -> Result<Option<Vec<u8>>> {
if self.handshake.is_done() {
return Ok(Some(input.to_vec()));
}
self.handshake_buf.extend_from_slice(input);
loop {
match self.handshake.read(&self.handshake_buf) {
Ok((reply, consumed, done)) => {
out.extend_from_slice(&reply);
self.handshake_buf.drain(..consumed);
if done {
break;
}
}
Err(RtmpError::BufferTooShort { .. }) => break,
Err(e) => return Err(e),
}
}
if self.handshake.is_done() {
Ok(Some(core::mem::take(&mut self.handshake_buf)))
} else {
Ok(None)
}
}
fn maybe_ack(&mut self, out: &mut Vec<u8>) {
let threshold = u64::from(self.ack_threshold.max(1));
if self.bytes_received.saturating_sub(self.bytes_acked) >= threshold {
self.bytes_acked = self.bytes_received;
let seq = self.bytes_received as u32;
let ack_msg = ProtocolControl::Acknowledgement(seq).to_message();
out.extend_from_slice(&self.writer.write(&ack_msg));
}
}
fn dispatch_message(
&mut self,
msg: &Message,
out: &mut Vec<u8>,
events: &mut Vec<ServerEvent>,
) -> Result<()> {
if let Some(pc) = ProtocolControl::from_message(msg)? {
self.handle_protocol_control(pc);
return Ok(());
}
match msg.message_type_id {
msg_type::USER_CONTROL => {
let _ = UserControl::parse(&msg.payload);
Ok(())
}
msg_type::COMMAND_AMF0 => {
let command = Command::parse(&msg.payload)?;
self.handle_command(&command, msg, out, events)
}
msg_type::AUDIO | msg_type::VIDEO | msg_type::DATA_AMF0 => {
self.emit_media_if_publishing(msg.message_type_id, msg, events)
}
_ => Ok(()),
}
}
fn handle_protocol_control(&mut self, pc: ProtocolControl) {
match pc {
ProtocolControl::SetChunkSize(n) => self.assembler.set_chunk_size(n),
ProtocolControl::WindowAckSize(w) => self.ack_threshold = w,
ProtocolControl::SetPeerBandwidth {
ack_window_size, ..
} => self.ack_threshold = ack_window_size,
ProtocolControl::Abort { .. } | ProtocolControl::Acknowledgement(_) => {}
}
}
fn handle_command(
&mut self,
command: &Command,
msg: &Message,
out: &mut Vec<u8>,
events: &mut Vec<ServerEvent>,
) -> Result<()> {
match command.name.as_str() {
"connect" => self.handle_connect(command, msg, out, events),
"releaseStream" | "FCPublish" => {
self.reply_result(command, msg, vec![Amf0Value::Undefined], out);
Ok(())
}
"createStream" => self.handle_create_stream(command, msg, out),
"publish" => self.handle_publish(command, msg, out, events),
"deleteStream" | "FCUnpublish" => {
self.state = State::Closed;
events.push(ServerEvent::Eof);
Ok(())
}
_ => Ok(()),
}
}
fn handle_connect(
&mut self,
command: &Command,
msg: &Message,
out: &mut Vec<u8>,
events: &mut Vec<ServerEvent>,
) -> Result<()> {
if self.state == State::Closed {
return Err(RtmpError::UnexpectedState {
what: "connect received after the session was closed",
});
}
let Some(Amf0Value::Object(pairs)) = command.arguments.first() else {
return Err(RtmpError::Malformed {
what: "connect command object / app",
});
};
let Some(app) = pairs.iter().find_map(|(k, v)| {
if k == "app" {
match v {
Amf0Value::String(s) => Some(s.clone()),
_ => None,
}
} else {
None
}
}) else {
return Err(RtmpError::Malformed {
what: "connect command object / app",
});
};
self.app = Some(app.clone());
self.state = State::Connected;
let window_ack = ProtocolControl::WindowAckSize(self.config.window_ack_size).to_message();
out.extend_from_slice(&self.writer.write(&window_ack));
let peer_bandwidth = ProtocolControl::SetPeerBandwidth {
ack_window_size: self.config.peer_bandwidth,
limit_type: LimitType::Dynamic,
}
.to_message();
out.extend_from_slice(&self.writer.write(&peer_bandwidth));
let set_chunk_size = ProtocolControl::SetChunkSize(self.config.chunk_size).to_message();
out.extend_from_slice(&self.writer.write(&set_chunk_size));
self.writer.set_chunk_size(self.config.chunk_size);
let result = Command {
name: "_result".to_string(),
transaction_id: command.transaction_id,
arguments: vec![
Amf0Value::Object(vec![
(
"fmsVer".to_string(),
Amf0Value::String(FMS_VERSION.to_string()),
),
("capabilities".to_string(), Amf0Value::Number(CAPABILITIES)),
]),
Amf0Value::Object(vec![
("level".to_string(), Amf0Value::String("status".to_string())),
(
"code".to_string(),
Amf0Value::String("NetConnection.Connect.Success".to_string()),
),
(
"description".to_string(),
Amf0Value::String("Connection succeeded.".to_string()),
),
]),
],
};
out.extend_from_slice(&self.writer.write(&self.command_message(msg, &result)));
events.push(ServerEvent::Connected { app });
Ok(())
}
fn handle_create_stream(
&mut self,
command: &Command,
msg: &Message,
out: &mut Vec<u8>,
) -> Result<()> {
if self.state != State::Connected {
return Err(RtmpError::UnexpectedState {
what: "createStream received before a successful connect (or after the session was closed)",
});
}
let stream_id = self.next_stream_id;
self.next_stream_id = self.next_stream_id.saturating_add(1);
self.created_stream_id = Some(stream_id);
let result = Command {
name: "_result".to_string(),
transaction_id: command.transaction_id,
arguments: vec![Amf0Value::Null, Amf0Value::Number(f64::from(stream_id))],
};
out.extend_from_slice(&self.writer.write(&self.command_message(msg, &result)));
Ok(())
}
fn handle_publish(
&mut self,
command: &Command,
msg: &Message,
out: &mut Vec<u8>,
events: &mut Vec<ServerEvent>,
) -> Result<()> {
if self.state != State::Connected || self.created_stream_id.is_none() {
return Err(RtmpError::UnexpectedState {
what: "publish received before a successful connect+createStream (or after the session was closed)",
});
}
let app = self
.app
.clone()
.expect("state == Connected implies app was captured by connect");
let stream_key = match command.arguments.get(1) {
Some(Amf0Value::String(s)) => s.clone(),
_ => {
return Err(RtmpError::Malformed {
what: "publish command missing its publishing-name (string) argument",
});
}
};
let stream_id = msg.message_stream_id;
if let Some(expected) = &self.config.expected_stream_key {
if expected != &stream_key {
let on_status = Command {
name: "onStatus".to_string(),
transaction_id: 0.0,
arguments: vec![
Amf0Value::Null,
Amf0Value::Object(vec![
("level".to_string(), Amf0Value::String("error".to_string())),
(
"code".to_string(),
Amf0Value::String("NetStream.Publish.BadName".to_string()),
),
(
"description".to_string(),
Amf0Value::String("Stream key mismatch.".to_string()),
),
]),
],
};
out.extend_from_slice(&self.writer.write(&self.command_message(msg, &on_status)));
return Ok(());
}
}
let stream_begin = UserControl::StreamBegin(stream_id).to_message();
out.extend_from_slice(&self.writer.write(&stream_begin));
let on_status = Command {
name: "onStatus".to_string(),
transaction_id: 0.0,
arguments: vec![
Amf0Value::Null,
Amf0Value::Object(vec![
("level".to_string(), Amf0Value::String("status".to_string())),
(
"code".to_string(),
Amf0Value::String("NetStream.Publish.Start".to_string()),
),
(
"description".to_string(),
Amf0Value::String(format!("{stream_key} is now published.")),
),
]),
],
};
out.extend_from_slice(&self.writer.write(&self.command_message(msg, &on_status)));
self.state = State::Publishing;
events.push(ServerEvent::Publish {
app,
stream_key,
stream_id,
});
Ok(())
}
fn reply_result(
&mut self,
command: &Command,
msg: &Message,
arguments: Vec<Amf0Value>,
out: &mut Vec<u8>,
) {
let result = Command {
name: "_result".to_string(),
transaction_id: command.transaction_id,
arguments,
};
out.extend_from_slice(&self.writer.write(&self.command_message(msg, &result)));
}
fn command_message(&self, request: &Message, command: &Command) -> Message {
Message {
chunk_stream_id: COMMAND_CHUNK_STREAM_ID,
timestamp: 0,
message_type_id: msg_type::COMMAND_AMF0,
message_stream_id: request.message_stream_id,
payload: command.to_body(),
}
}
fn emit_media_if_publishing(
&mut self,
tag_type: u8,
msg: &Message,
events: &mut Vec<ServerEvent>,
) -> Result<()> {
if self.state != State::Publishing {
return Ok(());
}
let mut flv = if self.flv_header_sent {
Vec::new()
} else {
self.flv_header_sent = true;
flv_file_header()
};
flv.extend(flv_tag(tag_type, msg.timestamp, &msg.payload)?);
events.push(ServerEvent::Media { flv });
Ok(())
}
}
fn flv_file_header() -> Vec<u8> {
let mut v = Vec::with_capacity(FLV_HEADER_SIZE as usize + FLV_PREV_TAG_SIZE_LEN);
v.extend_from_slice(&FLV_SIGNATURE);
v.push(FLV_VERSION);
v.push(FLV_TYPE_FLAGS_AUDIO_VIDEO);
v.extend_from_slice(&FLV_HEADER_SIZE.to_be_bytes());
v.extend_from_slice(&0u32.to_be_bytes());
v
}
fn flv_tag(tag_type: u8, timestamp: u32, payload: &[u8]) -> Result<Vec<u8>> {
if payload.len() > FLV_MAX_DATA_SIZE {
return Err(RtmpError::Unsupported {
what: "flv tag payload exceeds the 24-bit DataSize field",
});
}
let data_size = payload.len() as u32;
let mut v = Vec::with_capacity(FLV_TAG_HEADER_LEN + payload.len() + FLV_PREV_TAG_SIZE_LEN);
v.push(tag_type);
v.push((data_size >> 16) as u8);
v.push((data_size >> 8) as u8);
v.push(data_size as u8);
v.push((timestamp >> 16) as u8);
v.push((timestamp >> 8) as u8);
v.push(timestamp as u8);
v.push((timestamp >> 24) as u8);
v.extend_from_slice(&[0, 0, 0]); v.extend_from_slice(payload);
let prev_tag_size = (FLV_TAG_HEADER_LEN + payload.len()) as u32;
v.extend_from_slice(&prev_tag_size.to_be_bytes());
Ok(v)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::handshake::{HANDSHAKE_PACKET_LEN, RTMP_VERSION};
use crate::message::CONTROL_CHUNK_STREAM_ID as CTRL_CSID;
use broadcast_common::Serialize;
const CLIENT_CSID: u32 = 3;
fn build_c0_c1() -> Vec<u8> {
let mut v = vec![0u8; 1 + HANDSHAKE_PACKET_LEN];
v[0] = RTMP_VERSION;
v
}
fn build_c2() -> Vec<u8> {
vec![0u8; HANDSHAKE_PACKET_LEN]
}
fn command_message(
csid: u32,
stream_id: u32,
name: &str,
txn: f64,
args: Vec<Amf0Value>,
) -> Message {
let body = Command {
name: name.to_string(),
transaction_id: txn,
arguments: args,
}
.to_body();
Message {
chunk_stream_id: csid,
timestamp: 0,
message_type_id: msg_type::COMMAND_AMF0,
message_stream_id: stream_id,
payload: body,
}
}
fn connect_bytes(app: &str) -> Vec<u8> {
let args = vec![Amf0Value::Object(vec![
("app".to_string(), Amf0Value::String(app.to_string())),
(
"type".to_string(),
Amf0Value::String("nonprivate".to_string()),
),
])];
let msg = command_message(CLIENT_CSID, 0, "connect", 1.0, args);
ChunkWriter::new().write(&msg)
}
fn connect_bytes_no_args() -> Vec<u8> {
let msg = command_message(CLIENT_CSID, 0, "connect", 1.0, vec![]);
ChunkWriter::new().write(&msg)
}
fn create_stream_bytes() -> Vec<u8> {
let msg = command_message(CLIENT_CSID, 0, "createStream", 2.0, vec![Amf0Value::Null]);
ChunkWriter::new().write(&msg)
}
fn publish_bytes(stream_id: u32, stream_key: &str) -> Vec<u8> {
let args = vec![
Amf0Value::Null,
Amf0Value::String(stream_key.to_string()),
Amf0Value::String("live".to_string()),
];
let msg = command_message(CLIENT_CSID, stream_id, "publish", 3.0, args);
ChunkWriter::new().write(&msg)
}
fn av_bytes(
stream_id: u32,
message_type_id: u8,
csid: u32,
timestamp: u32,
payload: Vec<u8>,
) -> Vec<u8> {
let msg = Message {
chunk_stream_id: csid,
timestamp,
message_type_id,
message_stream_id: stream_id,
payload,
};
ChunkWriter::new().write(&msg)
}
fn decode_messages(bytes: &[u8]) -> Vec<Message> {
let mut assembler = ChunkAssembler::new();
assembler.set_chunk_size(65536);
assembler.push(bytes).expect("well-formed reply stream")
}
fn decode_commands(bytes: &[u8]) -> Vec<Command> {
decode_messages(bytes)
.iter()
.filter(|m| m.message_type_id == msg_type::COMMAND_AMF0)
.map(|m| Command::parse(&m.payload).expect("well-formed command reply"))
.collect()
}
fn onstatus_code(cmd: &Command) -> Option<String> {
cmd.arguments.iter().find_map(|v| match v {
Amf0Value::Object(pairs) => pairs.iter().find_map(|(k, v)| {
if k == "code" {
match v {
Amf0Value::String(s) => Some(s.clone()),
_ => None,
}
} else {
None
}
}),
_ => None,
})
}
fn publish_flow(
config: ServerConfig,
stream_key: &str,
) -> (ServerSession, Vec<u8>, Vec<ServerEvent>) {
let mut session = ServerSession::new(config);
let mut all_out = Vec::new();
let mut all_events = Vec::new();
session.handle_data(&build_c0_c1()).unwrap();
session.handle_data(&build_c2()).unwrap();
let (out, events) = session.handle_data(&connect_bytes("live")).unwrap();
all_out.extend(out);
all_events.extend(events);
let (out, events) = session.handle_data(&create_stream_bytes()).unwrap();
all_out.extend(out);
all_events.extend(events);
let (out, events) = session.handle_data(&publish_bytes(1, stream_key)).unwrap();
all_out.extend(out);
all_events.extend(events);
(session, all_out, all_events)
}
#[test]
fn handshake_completes_and_reply_contains_s0_s1_s2() {
let mut session = ServerSession::with_defaults();
let (out1, events1) = session.handle_data(&build_c0_c1()).unwrap();
assert_eq!(
out1.len(),
1 + HANDSHAKE_PACKET_LEN + HANDSHAKE_PACKET_LEN,
"S0+S1+S2 must be a single 3073-byte reply"
);
assert!(events1.is_empty());
let (out2, events2) = session.handle_data(&build_c2()).unwrap();
assert!(out2.is_empty(), "C2 receipt produces no reply bytes itself");
assert!(events2.is_empty());
}
#[test]
fn handshake_split_across_calls_still_completes() {
let mut session = ServerSession::with_defaults();
let c0c1 = build_c0_c1();
let (out1, _) = session.handle_data(&c0c1[..500]).unwrap();
assert!(out1.is_empty(), "partial C0+C1 produces no reply yet");
let (out2, _) = session.handle_data(&c0c1[500..]).unwrap();
assert_eq!(out2.len(), 1 + 2 * HANDSHAKE_PACKET_LEN);
let (_out3, _) = session.handle_data(&build_c2()).unwrap();
}
#[test]
fn c2_pipelined_with_connect_chunk_in_one_call_still_parses_connect() {
let mut session = ServerSession::with_defaults();
session.handle_data(&build_c0_c1()).unwrap();
let mut pipelined = build_c2();
pipelined.extend_from_slice(&connect_bytes("live"));
let (_out, events) = session.handle_data(&pipelined).unwrap();
assert_eq!(
events,
vec![ServerEvent::Connected {
app: "live".to_string()
}],
"C2 pipelined with the connect chunk in one handle_data call must \
still yield Connected from that call (leftover bytes must not be dropped)"
);
}
#[test]
fn connect_emits_connected_event_and_result_reply() {
let mut session = ServerSession::with_defaults();
session.handle_data(&build_c0_c1()).unwrap();
session.handle_data(&build_c2()).unwrap();
let (out, events) = session.handle_data(&connect_bytes("live")).unwrap();
assert_eq!(
events,
vec![ServerEvent::Connected {
app: "live".to_string()
}]
);
let commands = decode_commands(&out);
assert!(
commands.iter().any(|c| c.name == "_result"),
"connect reply must contain a _result command"
);
}
#[test]
fn connect_without_command_object_is_malformed() {
let mut session = ServerSession::with_defaults();
session.handle_data(&build_c0_c1()).unwrap();
session.handle_data(&build_c2()).unwrap();
let err = session.handle_data(&connect_bytes_no_args()).unwrap_err();
assert!(
matches!(err, RtmpError::Malformed { .. }),
"connect with no command-object argument must error, not default app to \"\""
);
}
#[test]
fn create_stream_replies_result_with_stream_id() {
let mut session = ServerSession::with_defaults();
session.handle_data(&build_c0_c1()).unwrap();
session.handle_data(&build_c2()).unwrap();
session.handle_data(&connect_bytes("live")).unwrap();
let (out, _events) = session.handle_data(&create_stream_bytes()).unwrap();
let commands = decode_commands(&out);
let result = commands
.iter()
.find(|c| c.name == "_result")
.expect("createStream _result reply");
assert_eq!(
result.arguments.get(1),
Some(&Amf0Value::Number(1.0)),
"first allocated stream id must be 1"
);
}
#[test]
fn create_stream_before_connect_is_unexpected_state() {
let mut session = ServerSession::with_defaults();
session.handle_data(&build_c0_c1()).unwrap();
session.handle_data(&build_c2()).unwrap();
let err = session.handle_data(&create_stream_bytes()).unwrap_err();
assert!(matches!(err, RtmpError::UnexpectedState { .. }));
}
#[test]
fn publish_reaches_publishing_emits_event_and_stream_begin_plus_onstatus() {
let (_session, out, events) = publish_flow(ServerConfig::default(), "testkey");
assert!(events.contains(&ServerEvent::Publish {
app: "live".to_string(),
stream_key: "testkey".to_string(),
stream_id: 1,
}));
let messages = decode_messages(&out);
let has_stream_begin = messages.iter().any(|m| {
m.message_type_id == msg_type::USER_CONTROL
&& matches!(
UserControl::parse(&m.payload),
Ok(UserControl::StreamBegin(1))
)
});
assert!(
has_stream_begin,
"publish reply must include StreamBegin(1)"
);
let commands = decode_commands(&out);
let on_status = commands
.iter()
.find(|c| c.name == "onStatus")
.expect("onStatus reply to publish");
assert_eq!(
onstatus_code(on_status).as_deref(),
Some("NetStream.Publish.Start")
);
}
#[test]
fn publish_before_connect_is_unexpected_state() {
let mut session = ServerSession::with_defaults();
session.handle_data(&build_c0_c1()).unwrap();
session.handle_data(&build_c2()).unwrap();
let err = session
.handle_data(&publish_bytes(1, "testkey"))
.unwrap_err();
assert!(matches!(err, RtmpError::UnexpectedState { .. }));
}
#[test]
fn publish_without_create_stream_is_unexpected_state() {
let mut session = ServerSession::with_defaults();
session.handle_data(&build_c0_c1()).unwrap();
session.handle_data(&build_c2()).unwrap();
session.handle_data(&connect_bytes("live")).unwrap();
let err = session
.handle_data(&publish_bytes(1, "testkey"))
.unwrap_err();
assert!(matches!(err, RtmpError::UnexpectedState { .. }));
}
#[test]
fn create_stream_after_closed_is_unexpected_state() {
let mut session = ServerSession::with_defaults();
session.handle_data(&build_c0_c1()).unwrap();
session.handle_data(&build_c2()).unwrap();
session.handle_data(&connect_bytes("live")).unwrap();
session.handle_data(&create_stream_bytes()).unwrap();
session.handle_data(&publish_bytes(1, "testkey")).unwrap();
let delete_stream = command_message(
CLIENT_CSID,
1,
"deleteStream",
4.0,
vec![Amf0Value::Null, Amf0Value::Number(1.0)],
);
let (_out, events) = session
.handle_data(&ChunkWriter::new().write(&delete_stream))
.unwrap();
assert_eq!(events, vec![ServerEvent::Eof]);
let err = session.handle_data(&create_stream_bytes()).unwrap_err();
assert!(
matches!(err, RtmpError::UnexpectedState { .. }),
"createStream after State::Closed must be rejected, not silently re-allowed"
);
}
#[test]
fn audio_and_video_emit_media_first_carries_flv_file_header() {
let (mut session, _out, _events) = publish_flow(ServerConfig::default(), "testkey");
let (_out1, events1) = session
.handle_data(&av_bytes(
1,
msg_type::AUDIO,
4,
0,
vec![0xAF, 0x01, 0xDE, 0xAD],
))
.unwrap();
assert_eq!(events1.len(), 1);
let ServerEvent::Media { flv } = &events1[0] else {
panic!("expected Media event");
};
assert!(
flv.starts_with(b"FLV"),
"the first Media event must carry the FLV file header"
);
let (_out2, events2) = session
.handle_data(&av_bytes(
1,
msg_type::VIDEO,
6,
40,
vec![0x17, 0x01, 0x00, 0x00, 0x00, 0xDE, 0xAD, 0xBE, 0xEF],
))
.unwrap();
assert_eq!(events2.len(), 1);
let ServerEvent::Media { flv } = &events2[0] else {
panic!("expected Media event");
};
assert!(
!flv.starts_with(b"FLV"),
"only the first Media event carries the file header"
);
}
#[test]
fn concatenated_media_forms_structurally_valid_flv() {
let (mut session, _out, _events) = publish_flow(ServerConfig::default(), "testkey");
let mut flv_stream = Vec::new();
let (_out1, events1) = session
.handle_data(&av_bytes(
1,
msg_type::AUDIO,
4,
0,
vec![0xAF, 0x01, 1, 2, 3],
))
.unwrap();
let (_out2, events2) = session
.handle_data(&av_bytes(
1,
msg_type::VIDEO,
6,
33,
vec![0x17, 0x01, 0, 0, 0, 4, 5, 6],
))
.unwrap();
for e in events1.into_iter().chain(events2) {
if let ServerEvent::Media { flv } = e {
flv_stream.extend(flv);
}
}
assert_eq!(&flv_stream[0..3], b"FLV");
assert_eq!(flv_stream[3], 1, "FLV version");
assert_eq!(flv_stream[4], 0b0000_0101, "audio+video TypeFlags");
assert_eq!(
u32::from_be_bytes(flv_stream[5..9].try_into().unwrap()),
9,
"DataOffset (header size)"
);
assert_eq!(
u32::from_be_bytes(flv_stream[9..13].try_into().unwrap()),
0,
"PreviousTagSize0"
);
let tag1 = &flv_stream[13..];
assert_eq!(tag1[0], msg_type::AUDIO);
let data_size1 =
(u32::from(tag1[1]) << 16) | (u32::from(tag1[2]) << 8) | u32::from(tag1[3]);
assert_eq!(data_size1, 5);
let tag1_total = FLV_TAG_HEADER_LEN + 5 + FLV_PREV_TAG_SIZE_LEN;
let prev_tag_size1 = u32::from_be_bytes(
flv_stream[13 + tag1_total - 4..13 + tag1_total]
.try_into()
.unwrap(),
);
assert_eq!(prev_tag_size1 as usize, FLV_TAG_HEADER_LEN + 5);
let tag2 = &flv_stream[13 + tag1_total..];
assert_eq!(tag2[0], msg_type::VIDEO);
let data_size2 =
(u32::from(tag2[1]) << 16) | (u32::from(tag2[2]) << 8) | u32::from(tag2[3]);
assert_eq!(data_size2, 8);
assert_eq!(
flv_stream.len(),
13 + tag1_total + FLV_TAG_HEADER_LEN + 8 + FLV_PREV_TAG_SIZE_LEN
);
}
#[test]
fn data_amf0_onmetadata_emits_media_with_script_tag_type() {
let (mut session, _out, _events) = publish_flow(ServerConfig::default(), "testkey");
let mut payload = Amf0Value::String("onMetaData".to_string()).to_bytes();
payload.extend(
Amf0Value::Object(vec![
("width".to_string(), Amf0Value::Number(1920.0)),
("height".to_string(), Amf0Value::Number(1080.0)),
])
.to_bytes(),
);
let (_out, events) = session
.handle_data(&av_bytes(1, msg_type::DATA_AMF0, 4, 0, payload))
.unwrap();
assert_eq!(events.len(), 1);
let ServerEvent::Media { flv } = &events[0] else {
panic!("expected Media event");
};
assert!(
flv.starts_with(b"FLV"),
"the first Media event must carry the FLV file header"
);
let tag_type = flv[FLV_HEADER_SIZE as usize + FLV_PREV_TAG_SIZE_LEN];
assert_eq!(
tag_type,
msg_type::DATA_AMF0,
"Data-AMF0 message must produce a script(18) FLV tag"
);
}
#[test]
fn media_before_publishing_is_silently_dropped() {
let mut session = ServerSession::with_defaults();
session.handle_data(&build_c0_c1()).unwrap();
session.handle_data(&build_c2()).unwrap();
session.handle_data(&connect_bytes("live")).unwrap();
session.handle_data(&create_stream_bytes()).unwrap();
let (_out, events) = session
.handle_data(&av_bytes(1, msg_type::AUDIO, 4, 0, vec![0xAF, 0x01]))
.unwrap();
assert!(events.is_empty());
}
#[test]
fn stream_key_mismatch_suppresses_publish_and_media_events() {
let config = ServerConfig {
expected_stream_key: Some("rightkey".to_string()),
..ServerConfig::default()
};
let (mut session, out, events) = publish_flow(config, "wrongkey");
assert!(
!events
.iter()
.any(|e| matches!(e, ServerEvent::Publish { .. })),
"mismatched stream key must not emit Publish"
);
let commands = decode_commands(&out);
let on_status = commands
.iter()
.find(|c| c.name == "onStatus")
.expect("onStatus reply on mismatch");
assert_eq!(
onstatus_code(on_status).as_deref(),
Some("NetStream.Publish.BadName")
);
let (_out2, events2) = session
.handle_data(&av_bytes(1, msg_type::AUDIO, 4, 0, vec![0xAF, 0x01]))
.unwrap();
assert!(
events2.is_empty(),
"no Media may be emitted after a rejected publish"
);
}
#[test]
fn ack_written_once_window_ack_size_is_crossed() {
let config = ServerConfig {
window_ack_size: 32,
..ServerConfig::default()
};
let mut session = ServerSession::new(config);
session.handle_data(&build_c0_c1()).unwrap();
session.handle_data(&build_c2()).unwrap();
let (out, _events) = session.handle_data(&connect_bytes("live")).unwrap();
let messages = decode_messages(&out);
let has_ack = messages.iter().any(|m| {
matches!(
ProtocolControl::from_message(m),
Ok(Some(ProtocolControl::Acknowledgement(_)))
)
});
assert!(
has_ack,
"crossing window_ack_size must produce an Acknowledgement"
);
}
#[test]
fn no_ack_below_window_ack_size() {
let config = ServerConfig {
window_ack_size: 10_000_000,
..ServerConfig::default()
};
let mut session = ServerSession::new(config);
session.handle_data(&build_c0_c1()).unwrap();
let (out, _events) = session.handle_data(&build_c2()).unwrap();
let messages = decode_messages(&out);
assert!(
!messages.iter().any(|m| matches!(
ProtocolControl::from_message(m),
Ok(Some(ProtocolControl::Acknowledgement(_)))
)),
"no Acknowledgement should be due yet"
);
}
#[test]
fn garbage_command_payload_after_handshake_is_error_not_panic() {
let mut session = ServerSession::with_defaults();
session.handle_data(&build_c0_c1()).unwrap();
session.handle_data(&build_c2()).unwrap();
let bogus = Message {
chunk_stream_id: CLIENT_CSID,
timestamp: 0,
message_type_id: msg_type::COMMAND_AMF0,
message_stream_id: 0,
payload: vec![0xFF, 0xFF, 0xFF, 0xFF],
};
let bytes = ChunkWriter::new().write(&bogus);
let err = session.handle_data(&bytes).unwrap_err();
assert!(matches!(
err,
RtmpError::Unsupported { .. }
| RtmpError::Malformed { .. }
| RtmpError::BufferTooShort { .. }
));
}
#[test]
fn truncated_post_handshake_bytes_do_not_panic() {
let mut session = ServerSession::with_defaults();
session.handle_data(&build_c0_c1()).unwrap();
session.handle_data(&build_c2()).unwrap();
let (out, events) = session.handle_data(&[0x03, 0x01, 0x02]).unwrap();
assert!(out.is_empty());
assert!(events.is_empty());
}
#[test]
fn mutation_check_publish_event_must_echo_actual_stream_key() {
let (_session, _out, events) = publish_flow(ServerConfig::default(), "specific-key-xyz");
let publish_event = events
.iter()
.find_map(|e| match e {
ServerEvent::Publish { stream_key, .. } => Some(stream_key.clone()),
_ => None,
})
.expect("Publish event");
assert_eq!(
publish_event, "specific-key-xyz",
"a hardcoded/ignored stream_key would fail this"
);
}
#[test]
fn mutation_check_flv_file_header_bytes_are_exact() {
let header = flv_file_header();
assert_eq!(
header,
vec![
b'F',
b'L',
b'V', 1, 0b0000_0101, 0,
0,
0,
9, 0,
0,
0,
0, ]
);
}
#[test]
fn client_set_chunk_size_takes_effect_before_next_message_in_same_call() {
let (mut session, _out, _events) = publish_flow(ServerConfig::default(), "testkey");
const NEW_CHUNK_SIZE: u32 = 4096;
let set_chunk_size_bytes =
ChunkWriter::new().write(&ProtocolControl::SetChunkSize(NEW_CHUNK_SIZE).to_message());
let big_payload = vec![0x17u8; 300];
let mut client_writer = ChunkWriter::new();
client_writer.set_chunk_size(NEW_CHUNK_SIZE);
let video_bytes = client_writer.write(&Message {
chunk_stream_id: 6,
timestamp: 0,
message_type_id: msg_type::VIDEO,
message_stream_id: 1,
payload: big_payload.clone(),
});
let mut combined = set_chunk_size_bytes;
combined.extend_from_slice(&video_bytes);
let (_out, events) = session.handle_data(&combined).expect(
"SetChunkSize must take effect before parsing the message that follows it \
in the same handle_data call, not only on a subsequent call",
);
let media = events
.iter()
.find_map(|e| match e {
ServerEvent::Media { flv } => Some(flv.clone()),
_ => None,
})
.expect("the video message must still be parsed into a Media event");
assert!(
media
.windows(big_payload.len())
.any(|w| w == big_payload.as_slice()),
"the video payload must survive intact through the chunk-size change"
);
}
#[test]
fn control_chunk_stream_id_constant_matches_message_module() {
assert_ne!(COMMAND_CHUNK_STREAM_ID, CTRL_CSID);
}
#[test]
fn next_stream_id_saturates_instead_of_overflowing() {
let mut session = ServerSession::with_defaults();
session.handle_data(&build_c0_c1()).unwrap();
session.handle_data(&build_c2()).unwrap();
session.handle_data(&connect_bytes("live")).unwrap();
session.next_stream_id = u32::MAX;
let (out, _events) = session
.handle_data(&create_stream_bytes())
.expect("createStream must not panic when next_stream_id is already u32::MAX");
let commands = decode_commands(&out);
let result = commands
.iter()
.find(|c| c.name == "_result")
.expect("createStream _result reply");
assert_eq!(
result.arguments.get(1),
Some(&Amf0Value::Number(f64::from(u32::MAX))),
"the stream id allocated at the u32::MAX boundary must still be u32::MAX"
);
assert_eq!(
session.next_stream_id,
u32::MAX,
"next_stream_id must saturate at u32::MAX, not wrap to 0"
);
}
#[cfg(feature = "serde")]
#[test]
fn server_config_and_server_event_serde_round_trip() {
let config = ServerConfig::default()
.with_chunk_size(8192)
.with_expected_stream_key(Some("k".to_string()));
let json = serde_json::to_string(&config).expect("serialize ServerConfig");
let back: ServerConfig = serde_json::from_str(&json).expect("deserialize ServerConfig");
assert_eq!(back.chunk_size, config.chunk_size);
assert_eq!(back.expected_stream_key, config.expected_stream_key);
let event = ServerEvent::Publish {
app: "live".to_string(),
stream_key: "testkey".to_string(),
stream_id: 1,
};
let json = serde_json::to_string(&event).expect("serialize ServerEvent");
let back: ServerEvent = serde_json::from_str(&json).expect("deserialize ServerEvent");
assert_eq!(back, event);
}
}