Skip to main content

rtmp_runtime/
server.rs

1//! RTMP ingest server session state machine — `connect` → `createStream` →
2//! `publish` (Adobe RTMP 1.0 §7.2, `NetConnection`/`NetStream` commands).
3//!
4//! See [`docs/rtmp.md`](../docs/rtmp.md) §2 (Handshake), §4 (Protocol Control
5//! Messages), §5.3 (User Control Messages), §7 (RTMP Message Types incl.
6//! Command Message), and [`transmux/docs/codec/flv.md`](../../transmux/docs/codec/flv.md)
7//! (FLV header/tag layout, Adobe FLV v10.1 Annex E) for the wire layouts this
8//! module ties together.
9//!
10//! [`ServerSession`] is the sans-IO **publish ingest** engine: feed it
11//! inbound bytes via [`ServerSession::handle_data`], get back outbound bytes
12//! to write plus a list of typed [`ServerEvent`]s. It drives, in order:
13//!
14//! 1. the [`crate::handshake::Handshake`] sub-FSM (C0/C1/C2 → S0/S1/S2),
15//! 2. the [`crate::chunk::ChunkAssembler`]/[`crate::chunk::ChunkWriter`]
16//!    chunk-stream (de)assembly,
17//! 3. [`crate::message::ProtocolControl`]/[`crate::message::UserControl`]
18//!    interpretation and replies,
19//! 4. [`crate::amf0::Command`] routing for the `connect`/`createStream`/
20//!    `publish` command sequence, and
21//! 5. FLV tag emission for Audio(8)/Video(9)/Data-AMF0(18) messages received
22//!    while publishing.
23//!
24//! # Session state
25//!
26//! Internally tracked as `Init → Connected(app) → Publishing(stream_key) →
27//! Closed`. The handshake phase itself is not duplicated in this enum — it
28//! is tracked by `self.handshake.is_done()` (querying
29//! [`crate::handshake::Handshake`] directly), so there is exactly one source
30//! of truth for "has the handshake finished".
31//!
32//! # Ack accounting
33//!
34//! §5.4.3's Acknowledgement sequence number is a plain **modular `u32`**
35//! (truncating the running total byte count) — the spec is silent on
36//! wraparound behaviour for this field, so this is a documented
37//! implementation choice, not a spec requirement.
38//!
39//! Two further implementation choices, not spec requirements:
40//!
41//! - At most **one** Acknowledgement is emitted per
42//!   [`handle_data`](ServerSession::handle_data) call, even if the input
43//!   buffer crossed `window_ack_size` multiple times over (e.g. a single
44//!   call carrying several times the window in bytes). The threshold check
45//!   runs once, after all messages in that call have been dispatched, not
46//!   once per `window_ack_size`-sized increment.
47//! - Handshake bytes are excluded from the Ack byte count: the running
48//!   total only accumulates post-handshake (chunk-stream) bytes — bytes
49//!   consumed while still inside the C0/C1/C2 ↔ S0/S1/S2 handshake exchange
50//!   never reach the counter.
51//!
52//! # Reply csid convention
53//!
54//! Protocol control and User Control messages MUST/SHOULD use chunk stream
55//! id 2 ([`crate::message::CONTROL_CHUNK_STREAM_ID`]) — enforced already by
56//! [`crate::message::ProtocolControl::to_message`] and
57//! [`crate::message::UserControl::to_message`]. This module's own outbound
58//! AMF0 command replies (`_result`/`onStatus`) use `COMMAND_CHUNK_STREAM_ID`
59//! (3) — a real-world convention (distinct from the reserved control csid),
60//! not a spec-mandated value: §5.3 leaves csid choice to the sender for
61//! anything other than protocol control/user control traffic.
62
63use broadcast_common::Parse;
64
65use crate::RtmpError;
66use crate::amf0::{Amf0Value, Command};
67use crate::chunk::{ChunkAssembler, ChunkWriter, Message};
68use crate::handshake::Handshake;
69use crate::message::{LimitType, ProtocolControl, UserControl, msg_type};
70
71type Result<T> = core::result::Result<T, RtmpError>;
72
73// ── Named constants (no magic numbers) ──────────────────────────────────
74
75/// Default outbound chunk size we advertise via Set Chunk Size on `connect`
76/// (§5.4.1). Larger than the §5.3 wire default (128) to reduce chunk-header
77/// overhead for real audio/video payloads.
78pub const DEFAULT_CHUNK_SIZE: u32 = 4096;
79/// Default Window Acknowledgement Size we advertise on `connect` (§5.4.4),
80/// and the default threshold for our own inbound Ack accounting (§5.4.3).
81pub const DEFAULT_WINDOW_ACK_SIZE: u32 = 2_500_000;
82/// Default Set Peer Bandwidth value we advertise on `connect` (§5.4.5).
83pub const DEFAULT_PEER_BANDWIDTH: u32 = 2_500_000;
84
85/// The first message stream id [`ServerSession`] allocates on `createStream`
86/// (§7.2.2). Message stream id 0 is reserved for the `NetConnection`
87/// (control) channel, so allocation starts at 1.
88const FIRST_STREAM_ID: u32 = 1;
89
90/// Chunk stream id this session uses for its own outbound AMF0 command
91/// replies (`_result`/`onStatus`) — see the module doc's "Reply csid
92/// convention" section.
93const COMMAND_CHUNK_STREAM_ID: u32 = 3;
94
95/// `fmsVer` value advertised in the `connect` `_result` Properties object
96/// (§7.2.1). Not spec-mandated — a conventional placeholder value (the
97/// pattern used by reference server implementations), since real clients
98/// only branch on `NetConnection.Connect.Success`/`level`/`code`, not this
99/// string.
100const FMS_VERSION: &str = "FMS/3,0,1,123";
101/// `capabilities` value advertised in the `connect` `_result` Properties
102/// object (§7.2.1). Not spec-mandated (see [`FMS_VERSION`]).
103const CAPABILITIES: f64 = 31.0;
104
105// ── FLV mapping consts (transmux/docs/codec/flv.md, Annex E) ────────────
106
107/// FLV file header `Signature` field (Annex E.2): `"FLV"`.
108const FLV_SIGNATURE: [u8; 3] = *b"FLV";
109/// FLV file header `Version` field (Annex E.2).
110const FLV_VERSION: u8 = 1;
111/// FLV file header `TypeFlags` field (Annex E.2): bit 0 (audio present) |
112/// bit 2 (video present) — this ingest engine always advertises both, since
113/// it does not know ahead of time which media types a publisher will send.
114const FLV_TYPE_FLAGS_AUDIO_VIDEO: u8 = 0b0000_0101;
115/// FLV file header `DataOffset` field (Annex E.2): header size in bytes.
116const FLV_HEADER_SIZE: u32 = 9;
117/// Byte width of one FLV tag's fixed header (Annex E.4.1): `TagType`(1) +
118/// `DataSize`(3) + `Timestamp`(3) + `TimestampExtended`(1) + `StreamID`(3).
119const FLV_TAG_HEADER_LEN: usize = 11;
120/// Byte width of the `PreviousTagSize` field that follows every FLV tag
121/// (Annex E.4.1), and the file header's `PreviousTagSize0` (Annex E.2).
122const FLV_PREV_TAG_SIZE_LEN: usize = 4;
123/// Largest value the FLV tag's 24-bit `DataSize` field can encode.
124const FLV_MAX_DATA_SIZE: usize = 0x00FF_FFFF;
125
126/// Configuration for a [`ServerSession`].
127///
128/// `#[non_exhaustive]`: fields may grow (e.g. a future `app` gate alongside
129/// `expected_stream_key`). Construct via [`ServerConfig::default`] plus the
130/// `with_*` builder methods rather than a struct literal.
131#[non_exhaustive]
132#[derive(Debug, Clone)]
133#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
134pub struct ServerConfig {
135    /// Outbound chunk size advertised (and adopted) on `connect` (§5.4.1).
136    pub chunk_size: u32,
137    /// Window Acknowledgement Size advertised on `connect` (§5.4.4); also
138    /// the initial threshold for this session's own inbound Ack accounting
139    /// (§5.4.3), until a peer `WindowAckSize`/`SetPeerBandwidth` updates it.
140    pub window_ack_size: u32,
141    /// Set Peer Bandwidth value advertised on `connect` (§5.4.5).
142    pub peer_bandwidth: u32,
143    /// If set, `publish`'s stream key (publishing name) must match this
144    /// value exactly or the publish is rejected: `onStatus`
145    /// `NetStream.Publish.BadName`, and no `Publish`/`Media` events are
146    /// emitted for that connection.
147    pub expected_stream_key: Option<String>,
148}
149
150impl Default for ServerConfig {
151    fn default() -> Self {
152        Self {
153            chunk_size: DEFAULT_CHUNK_SIZE,
154            window_ack_size: DEFAULT_WINDOW_ACK_SIZE,
155            peer_bandwidth: DEFAULT_PEER_BANDWIDTH,
156            expected_stream_key: None,
157        }
158    }
159}
160
161impl ServerConfig {
162    /// Set [`ServerConfig::expected_stream_key`]. `#[non_exhaustive]` forbids
163    /// struct-literal construction of this type from outside the crate, so
164    /// this (plus the other `with_*` builders below) is how a caller
165    /// customises a field starting from [`ServerConfig::default`].
166    #[must_use]
167    pub fn with_expected_stream_key(mut self, expected_stream_key: Option<String>) -> Self {
168        self.expected_stream_key = expected_stream_key;
169        self
170    }
171
172    /// Set [`ServerConfig::chunk_size`].
173    #[must_use]
174    pub fn with_chunk_size(mut self, chunk_size: u32) -> Self {
175        self.chunk_size = chunk_size;
176        self
177    }
178
179    /// Set [`ServerConfig::window_ack_size`].
180    #[must_use]
181    pub fn with_window_ack_size(mut self, window_ack_size: u32) -> Self {
182        self.window_ack_size = window_ack_size;
183        self
184    }
185
186    /// Set [`ServerConfig::peer_bandwidth`].
187    #[must_use]
188    pub fn with_peer_bandwidth(mut self, peer_bandwidth: u32) -> Self {
189        self.peer_bandwidth = peer_bandwidth;
190        self
191    }
192}
193
194/// Typed events [`ServerSession::handle_data`] surfaces to the caller.
195#[non_exhaustive]
196#[derive(Debug, Clone, PartialEq)]
197#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
198pub enum ServerEvent {
199    /// `connect` completed: the publisher's requested `app` name (§7.2.1).
200    Connected {
201        /// The `app` property of the `connect` command object.
202        app: String,
203    },
204    /// `publish` was accepted (stream key matched, or no key was
205    /// configured): the session is now `Publishing`.
206    Publish {
207        /// The `app` name captured at `connect`.
208        app: String,
209        /// The publishing name (stream key) passed to `publish` (§7.2.2.6).
210        stream_key: String,
211        /// The message stream id `publish` was invoked on (allocated by the
212        /// preceding `createStream`).
213        stream_id: u32,
214    },
215    /// One FLV tag run is ready: the payload of a single Audio(8)/Video(9)/
216    /// Data-AMF0(18) message, converted to an FLV tag (+ `PreviousTagSize`).
217    /// The very first `Media` event of a session is prefixed with the FLV
218    /// file header, so concatenating every `Media.flv` in arrival order
219    /// yields a valid FLV byte stream feedable to `transmux::FlvDemux`.
220    Media {
221        /// FLV bytes for this tag (file header prefix on the first event
222        /// only, tag header + payload + `PreviousTagSize` every time).
223        flv: Vec<u8>,
224    },
225    /// The publisher ended the stream (`deleteStream`/`FCUnpublish`).
226    Eof,
227}
228
229/// Session state (`connect`/`publish` progress only — the handshake phase
230/// is tracked separately by `self.handshake.is_done()`, see the module doc).
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232enum State {
233    /// Handshake done (or not yet started); `connect` not yet received.
234    Init,
235    /// `connect` succeeded; `app` captured. `createStream`/`publish` may
236    /// now proceed.
237    Connected,
238    /// `publish` succeeded; Audio/Video/Data-AMF0 messages now produce
239    /// [`ServerEvent::Media`].
240    Publishing,
241    /// `deleteStream`/`FCUnpublish` was received; the session is done.
242    Closed,
243}
244
245/// The sans-IO RTMP **publish ingest** server session (see the module doc).
246///
247/// No sockets or clocks live here: drive it entirely by feeding inbound
248/// bytes to [`handle_data`](Self::handle_data).
249#[derive(Debug)]
250pub struct ServerSession {
251    config: ServerConfig,
252    handshake: Handshake,
253    /// Raw bytes accumulated across calls while the handshake is still in
254    /// progress (the handshake sub-FSM does not buffer partial input
255    /// itself — see [`crate::handshake::Handshake::read`]).
256    handshake_buf: Vec<u8>,
257    assembler: ChunkAssembler,
258    writer: ChunkWriter,
259    state: State,
260    app: Option<String>,
261    next_stream_id: u32,
262    /// The message stream id allocated by the most recent successful
263    /// `createStream` (§7.2.2), or `None` if none has succeeded yet.
264    /// `publish` requires this to be `Some` (in addition to `state ==
265    /// Connected`) — a stream must actually have been created first.
266    created_stream_id: Option<u32>,
267    /// Threshold (in bytes received) at which an Acknowledgement is due
268    /// (§5.4.3/§5.4.4). Starts at `config.window_ack_size`; updated if the
269    /// peer sends its own `WindowAckSize`/`SetPeerBandwidth`.
270    ack_threshold: u32,
271    /// Total bytes received on the chunk stream (post-handshake) so far.
272    bytes_received: u64,
273    /// `bytes_received` value as of the last Acknowledgement sent.
274    bytes_acked: u64,
275    /// Whether the FLV file header has already been prefixed to a `Media`
276    /// event (only the very first one gets it).
277    flv_header_sent: bool,
278}
279
280impl ServerSession {
281    /// A new session with the given configuration.
282    #[must_use]
283    pub fn new(config: ServerConfig) -> Self {
284        let ack_threshold = config.window_ack_size;
285        Self {
286            config,
287            handshake: Handshake::new(),
288            handshake_buf: Vec::new(),
289            assembler: ChunkAssembler::new(),
290            writer: ChunkWriter::new(),
291            state: State::Init,
292            app: None,
293            next_stream_id: FIRST_STREAM_ID,
294            created_stream_id: None,
295            ack_threshold,
296            bytes_received: 0,
297            bytes_acked: 0,
298            flv_header_sent: false,
299        }
300    }
301
302    /// A new session using [`ServerConfig::default`].
303    #[must_use]
304    pub fn with_defaults() -> Self {
305        Self::new(ServerConfig::default())
306    }
307
308    /// Feed inbound bytes to the session. Returns `(bytes to write back,
309    /// events produced)`.
310    ///
311    /// Buffers partial handshake input and partial chunks across calls —
312    /// callers just need to forward whatever bytes arrive off the wire, in
313    /// order, one call per read.
314    ///
315    /// # Errors
316    /// [`RtmpError`] on malformed handshake/chunk/AMF0 input, or a command
317    /// used out of order (e.g. `publish` before `connect`). Never panics on
318    /// truncated or garbage input. On `Err` the session should be considered
319    /// unrecoverable/torn down: internal state may have partially advanced
320    /// past the offending input, so the caller must not keep driving it.
321    pub fn handle_data(&mut self, input: &[u8]) -> Result<(Vec<u8>, Vec<ServerEvent>)> {
322        let mut out = Vec::new();
323        let mut events = Vec::new();
324
325        let chunk_input = match self.drive_handshake(input, &mut out)? {
326            Some(bytes) => bytes,
327            None => return Ok((out, events)),
328        };
329
330        self.bytes_received = self.bytes_received.saturating_add(chunk_input.len() as u64);
331
332        // Dispatch each message as soon as it is parsed (rather than
333        // collecting a full batch from one `push` first): a Set Chunk Size
334        // protocol control message (§5.4.1) must take effect for the very
335        // next chunk that follows it, even when both arrive in the same
336        // `handle_data` call — a real ffmpeg publisher does exactly this
337        // (its own `connect`-time SetChunkSize is immediately followed, in
338        // the same TCP segment, by chunks already framed at the new size).
339        // Collecting the whole batch under one `ChunkAssembler::push` call
340        // would parse those later chunks with the *old* chunk size and
341        // misparse them.
342        self.assembler.feed(&chunk_input);
343        while let Some(msg) = self.assembler.next_message()? {
344            self.dispatch_message(&msg, &mut out, &mut events)?;
345        }
346
347        self.maybe_ack(&mut out);
348
349        Ok((out, events))
350    }
351
352    /// Drive the handshake sub-FSM with `input`, appending any handshake
353    /// reply bytes to `out`. Returns `Some(leftover_bytes)` — the
354    /// post-handshake bytes now ready for the chunk assembler — once the
355    /// handshake has completed; `None` if it is still in progress (the
356    /// caller should return early and wait for more input).
357    fn drive_handshake(&mut self, input: &[u8], out: &mut Vec<u8>) -> Result<Option<Vec<u8>>> {
358        if self.handshake.is_done() {
359            return Ok(Some(input.to_vec()));
360        }
361
362        self.handshake_buf.extend_from_slice(input);
363        loop {
364            match self.handshake.read(&self.handshake_buf) {
365                Ok((reply, consumed, done)) => {
366                    out.extend_from_slice(&reply);
367                    self.handshake_buf.drain(..consumed);
368                    if done {
369                        break;
370                    }
371                }
372                Err(RtmpError::BufferTooShort { .. }) => break,
373                Err(e) => return Err(e),
374            }
375        }
376
377        if self.handshake.is_done() {
378            Ok(Some(core::mem::take(&mut self.handshake_buf)))
379        } else {
380            Ok(None)
381        }
382    }
383
384    /// Send an Acknowledgement (§5.4.3) if `bytes_received` has crossed
385    /// `ack_threshold` since the last one. Sequence number is a plain
386    /// modular `u32` truncation of the running total (see the module doc).
387    fn maybe_ack(&mut self, out: &mut Vec<u8>) {
388        let threshold = u64::from(self.ack_threshold.max(1));
389        if self.bytes_received.saturating_sub(self.bytes_acked) >= threshold {
390            self.bytes_acked = self.bytes_received;
391            let seq = self.bytes_received as u32;
392            let ack_msg = ProtocolControl::Acknowledgement(seq).to_message();
393            out.extend_from_slice(&self.writer.write(&ack_msg));
394        }
395    }
396
397    /// Dispatch one reassembled [`Message`] by `message_type_id`.
398    fn dispatch_message(
399        &mut self,
400        msg: &Message,
401        out: &mut Vec<u8>,
402        events: &mut Vec<ServerEvent>,
403    ) -> Result<()> {
404        if let Some(pc) = ProtocolControl::from_message(msg)? {
405            self.handle_protocol_control(pc);
406            return Ok(());
407        }
408
409        match msg.message_type_id {
410            msg_type::USER_CONTROL => {
411                // Publish-only ingest: no inbound user control event needs
412                // a reply from us. Malformed/unrecognised event types are
413                // tolerated (accepted, not fatal) rather than aborting the
414                // whole session over a benign/unknown control event.
415                let _ = UserControl::parse(&msg.payload);
416                Ok(())
417            }
418            msg_type::COMMAND_AMF0 => {
419                let command = Command::parse(&msg.payload)?;
420                self.handle_command(&command, msg, out, events)
421            }
422            msg_type::AUDIO | msg_type::VIDEO | msg_type::DATA_AMF0 => {
423                self.emit_media_if_publishing(msg.message_type_id, msg, events)
424            }
425            // Command-AMF3(17), Data-AMF3(15), Shared Object(19/16),
426            // Aggregate(22), and anything unrecognised: out of scope for
427            // this ingest engine (see the crate's non-goals) — accepted
428            // and ignored rather than treated as an error.
429            _ => Ok(()),
430        }
431    }
432
433    /// Apply a protocol control message's effect (§5.4). Never errors: a
434    /// malformed payload is rejected earlier, in
435    /// [`ProtocolControl::from_message`].
436    fn handle_protocol_control(&mut self, pc: ProtocolControl) {
437        match pc {
438            ProtocolControl::SetChunkSize(n) => self.assembler.set_chunk_size(n),
439            ProtocolControl::WindowAckSize(w) => self.ack_threshold = w,
440            ProtocolControl::SetPeerBandwidth {
441                ack_window_size, ..
442            } => self.ack_threshold = ack_window_size,
443            ProtocolControl::Abort { .. } | ProtocolControl::Acknowledgement(_) => {}
444        }
445    }
446
447    /// Route a Command Message (§7.1.1) by `command.name` (§7.2).
448    fn handle_command(
449        &mut self,
450        command: &Command,
451        msg: &Message,
452        out: &mut Vec<u8>,
453        events: &mut Vec<ServerEvent>,
454    ) -> Result<()> {
455        match command.name.as_str() {
456            "connect" => self.handle_connect(command, msg, out, events),
457            "releaseStream" | "FCPublish" => {
458                self.reply_result(command, msg, vec![Amf0Value::Undefined], out);
459                Ok(())
460            }
461            "createStream" => self.handle_create_stream(command, msg, out),
462            "publish" => self.handle_publish(command, msg, out, events),
463            "deleteStream" | "FCUnpublish" => {
464                self.state = State::Closed;
465                events.push(ServerEvent::Eof);
466                Ok(())
467            }
468            // Unrecognised command name: ignore rather than error, per the
469            // "never error on OBS/extra commands" design goal.
470            _ => Ok(()),
471        }
472    }
473
474    /// `connect` (§7.2.1, `NetConnection`): capture `app`, reply
475    /// WindowAckSize + SetPeerBandwidth + SetChunkSize + `_result`, emit
476    /// [`ServerEvent::Connected`].
477    ///
478    /// # Errors
479    /// [`RtmpError::Malformed`] if the command has no command-object
480    /// argument (arg0), or arg0 is not an AMF0 Object, or the object has no
481    /// `app` property of type String — a present-but-empty `app` string
482    /// (`""`) is tolerated (it is a valid, if unhelpful, application name).
483    /// [`RtmpError::UnexpectedState`] if the session has already reached
484    /// [`State::Closed`].
485    fn handle_connect(
486        &mut self,
487        command: &Command,
488        msg: &Message,
489        out: &mut Vec<u8>,
490        events: &mut Vec<ServerEvent>,
491    ) -> Result<()> {
492        if self.state == State::Closed {
493            return Err(RtmpError::UnexpectedState {
494                what: "connect received after the session was closed",
495            });
496        }
497
498        let Some(Amf0Value::Object(pairs)) = command.arguments.first() else {
499            return Err(RtmpError::Malformed {
500                what: "connect command object / app",
501            });
502        };
503        let Some(app) = pairs.iter().find_map(|(k, v)| {
504            if k == "app" {
505                match v {
506                    Amf0Value::String(s) => Some(s.clone()),
507                    _ => None,
508                }
509            } else {
510                None
511            }
512        }) else {
513            return Err(RtmpError::Malformed {
514                what: "connect command object / app",
515            });
516        };
517
518        self.app = Some(app.clone());
519        self.state = State::Connected;
520
521        let window_ack = ProtocolControl::WindowAckSize(self.config.window_ack_size).to_message();
522        out.extend_from_slice(&self.writer.write(&window_ack));
523
524        let peer_bandwidth = ProtocolControl::SetPeerBandwidth {
525            ack_window_size: self.config.peer_bandwidth,
526            limit_type: LimitType::Dynamic,
527        }
528        .to_message();
529        out.extend_from_slice(&self.writer.write(&peer_bandwidth));
530
531        let set_chunk_size = ProtocolControl::SetChunkSize(self.config.chunk_size).to_message();
532        out.extend_from_slice(&self.writer.write(&set_chunk_size));
533        self.writer.set_chunk_size(self.config.chunk_size);
534
535        let result = Command {
536            name: "_result".to_string(),
537            transaction_id: command.transaction_id,
538            arguments: vec![
539                Amf0Value::Object(vec![
540                    (
541                        "fmsVer".to_string(),
542                        Amf0Value::String(FMS_VERSION.to_string()),
543                    ),
544                    ("capabilities".to_string(), Amf0Value::Number(CAPABILITIES)),
545                ]),
546                Amf0Value::Object(vec![
547                    ("level".to_string(), Amf0Value::String("status".to_string())),
548                    (
549                        "code".to_string(),
550                        Amf0Value::String("NetConnection.Connect.Success".to_string()),
551                    ),
552                    (
553                        "description".to_string(),
554                        Amf0Value::String("Connection succeeded.".to_string()),
555                    ),
556                ]),
557            ],
558        };
559        out.extend_from_slice(&self.writer.write(&self.command_message(msg, &result)));
560
561        events.push(ServerEvent::Connected { app });
562        Ok(())
563    }
564
565    /// `createStream` (§7.2.2, `NetConnection`): allocate a message stream
566    /// id, reply `_result` with it.
567    ///
568    /// # Errors
569    /// [`RtmpError::UnexpectedState`] unless `state == State::Connected` —
570    /// this rejects `createStream` before a successful `connect`, and also
571    /// after [`State::Closed`] (post-`deleteStream`/`FCUnpublish`).
572    fn handle_create_stream(
573        &mut self,
574        command: &Command,
575        msg: &Message,
576        out: &mut Vec<u8>,
577    ) -> Result<()> {
578        if self.state != State::Connected {
579            return Err(RtmpError::UnexpectedState {
580                what: "createStream received before a successful connect (or after the session was closed)",
581            });
582        }
583
584        let stream_id = self.next_stream_id;
585        self.next_stream_id = self.next_stream_id.saturating_add(1);
586        self.created_stream_id = Some(stream_id);
587
588        let result = Command {
589            name: "_result".to_string(),
590            transaction_id: command.transaction_id,
591            arguments: vec![Amf0Value::Null, Amf0Value::Number(f64::from(stream_id))],
592        };
593        out.extend_from_slice(&self.writer.write(&self.command_message(msg, &result)));
594        Ok(())
595    }
596
597    /// `publish` (§7.2.2.6, `NetStream`): capture the stream key, enforce
598    /// `expected_stream_key` if configured, reply StreamBegin + `onStatus`,
599    /// transition to [`State::Publishing`], emit [`ServerEvent::Publish`].
600    ///
601    /// # Errors
602    /// [`RtmpError::UnexpectedState`] unless `state == State::Connected`
603    /// *and* a stream was actually allocated by a preceding `createStream`
604    /// (`created_stream_id.is_some()`) — this rejects `publish` before
605    /// `connect`, `publish` before `createStream`, and `publish` after
606    /// [`State::Closed`].
607    fn handle_publish(
608        &mut self,
609        command: &Command,
610        msg: &Message,
611        out: &mut Vec<u8>,
612        events: &mut Vec<ServerEvent>,
613    ) -> Result<()> {
614        if self.state != State::Connected || self.created_stream_id.is_none() {
615            return Err(RtmpError::UnexpectedState {
616                what: "publish received before a successful connect+createStream (or after the session was closed)",
617            });
618        }
619        let app = self
620            .app
621            .clone()
622            .expect("state == Connected implies app was captured by connect");
623
624        let stream_key = match command.arguments.get(1) {
625            Some(Amf0Value::String(s)) => s.clone(),
626            _ => {
627                return Err(RtmpError::Malformed {
628                    what: "publish command missing its publishing-name (string) argument",
629                });
630            }
631        };
632        let stream_id = msg.message_stream_id;
633
634        if let Some(expected) = &self.config.expected_stream_key
635            && expected != &stream_key
636        {
637            let on_status = Command {
638                name: "onStatus".to_string(),
639                transaction_id: 0.0,
640                arguments: vec![
641                    Amf0Value::Null,
642                    Amf0Value::Object(vec![
643                        ("level".to_string(), Amf0Value::String("error".to_string())),
644                        (
645                            "code".to_string(),
646                            Amf0Value::String("NetStream.Publish.BadName".to_string()),
647                        ),
648                        (
649                            "description".to_string(),
650                            Amf0Value::String("Stream key mismatch.".to_string()),
651                        ),
652                    ]),
653                ],
654            };
655            out.extend_from_slice(&self.writer.write(&self.command_message(msg, &on_status)));
656            // No Publish/Media events; state unchanged (not Publishing).
657            return Ok(());
658        }
659
660        let stream_begin = UserControl::StreamBegin(stream_id).to_message();
661        out.extend_from_slice(&self.writer.write(&stream_begin));
662
663        let on_status = Command {
664            name: "onStatus".to_string(),
665            transaction_id: 0.0,
666            arguments: vec![
667                Amf0Value::Null,
668                Amf0Value::Object(vec![
669                    ("level".to_string(), Amf0Value::String("status".to_string())),
670                    (
671                        "code".to_string(),
672                        Amf0Value::String("NetStream.Publish.Start".to_string()),
673                    ),
674                    (
675                        "description".to_string(),
676                        Amf0Value::String(format!("{stream_key} is now published.")),
677                    ),
678                ]),
679            ],
680        };
681        out.extend_from_slice(&self.writer.write(&self.command_message(msg, &on_status)));
682
683        self.state = State::Publishing;
684        events.push(ServerEvent::Publish {
685            app,
686            stream_key,
687            stream_id,
688        });
689        Ok(())
690    }
691
692    /// Reply a benign `_result` command echoing `command`'s transaction id
693    /// (used for the OBS extras `releaseStream`/`FCPublish`, which never
694    /// error even when tolerated rather than fully implemented).
695    fn reply_result(
696        &mut self,
697        command: &Command,
698        msg: &Message,
699        arguments: Vec<Amf0Value>,
700        out: &mut Vec<u8>,
701    ) {
702        let result = Command {
703            name: "_result".to_string(),
704            transaction_id: command.transaction_id,
705            arguments,
706        };
707        out.extend_from_slice(&self.writer.write(&self.command_message(msg, &result)));
708    }
709
710    /// Wrap `command` in a [`Message`] on [`COMMAND_CHUNK_STREAM_ID`],
711    /// echoing `request`'s message stream id (replies travel back on the
712    /// same `NetConnection`/`NetStream` channel the request arrived on).
713    fn command_message(&self, request: &Message, command: &Command) -> Message {
714        Message {
715            chunk_stream_id: COMMAND_CHUNK_STREAM_ID,
716            timestamp: 0,
717            message_type_id: msg_type::COMMAND_AMF0,
718            message_stream_id: request.message_stream_id,
719            payload: command.to_body(),
720        }
721    }
722
723    /// Convert an Audio(8)/Video(9)/Data-AMF0(18) message to an FLV tag and
724    /// emit it as [`ServerEvent::Media`], but only while
725    /// [`State::Publishing`] (messages arriving before `publish` succeeds,
726    /// or after a stream-key mismatch, are silently dropped — no event).
727    fn emit_media_if_publishing(
728        &mut self,
729        tag_type: u8,
730        msg: &Message,
731        events: &mut Vec<ServerEvent>,
732    ) -> Result<()> {
733        if self.state != State::Publishing {
734            return Ok(());
735        }
736
737        let mut flv = if self.flv_header_sent {
738            Vec::new()
739        } else {
740            self.flv_header_sent = true;
741            flv_file_header()
742        };
743        flv.extend(flv_tag(tag_type, msg.timestamp, &msg.payload)?);
744        events.push(ServerEvent::Media { flv });
745        Ok(())
746    }
747}
748
749/// Build the 13-byte FLV file header: 9-byte header (Annex E.2) + the first
750/// `PreviousTagSize0` (always `0`).
751fn flv_file_header() -> Vec<u8> {
752    let mut v = Vec::with_capacity(FLV_HEADER_SIZE as usize + FLV_PREV_TAG_SIZE_LEN);
753    v.extend_from_slice(&FLV_SIGNATURE);
754    v.push(FLV_VERSION);
755    v.push(FLV_TYPE_FLAGS_AUDIO_VIDEO);
756    v.extend_from_slice(&FLV_HEADER_SIZE.to_be_bytes());
757    v.extend_from_slice(&0u32.to_be_bytes());
758    v
759}
760
761/// Build one FLV tag (Annex E.4.1): `TagType` + `DataSize` + `Timestamp` +
762/// `TimestampExtended` + `StreamID`(always 0) + `Data` + `PreviousTagSize`.
763///
764/// # Errors
765/// [`RtmpError::Unsupported`] if `payload` is too large for the tag's
766/// 24-bit `DataSize` field.
767fn flv_tag(tag_type: u8, timestamp: u32, payload: &[u8]) -> Result<Vec<u8>> {
768    if payload.len() > FLV_MAX_DATA_SIZE {
769        return Err(RtmpError::Unsupported {
770            what: "flv tag payload exceeds the 24-bit DataSize field",
771        });
772    }
773    let data_size = payload.len() as u32;
774
775    let mut v = Vec::with_capacity(FLV_TAG_HEADER_LEN + payload.len() + FLV_PREV_TAG_SIZE_LEN);
776    v.push(tag_type);
777    v.push((data_size >> 16) as u8);
778    v.push((data_size >> 8) as u8);
779    v.push(data_size as u8);
780    v.push((timestamp >> 16) as u8);
781    v.push((timestamp >> 8) as u8);
782    v.push(timestamp as u8);
783    v.push((timestamp >> 24) as u8);
784    v.extend_from_slice(&[0, 0, 0]); // StreamID, always 0.
785    v.extend_from_slice(payload);
786    let prev_tag_size = (FLV_TAG_HEADER_LEN + payload.len()) as u32;
787    v.extend_from_slice(&prev_tag_size.to_be_bytes());
788    Ok(v)
789}
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794    use crate::handshake::{HANDSHAKE_PACKET_LEN, RTMP_VERSION};
795    use crate::message::CONTROL_CHUNK_STREAM_ID as CTRL_CSID;
796    use broadcast_common::Serialize;
797
798    // ── Test-only wire builders (this crate's own encoders) ─────────────
799
800    const CLIENT_CSID: u32 = 3;
801
802    fn build_c0_c1() -> Vec<u8> {
803        let mut v = vec![0u8; 1 + HANDSHAKE_PACKET_LEN];
804        v[0] = RTMP_VERSION;
805        v
806    }
807
808    fn build_c2() -> Vec<u8> {
809        vec![0u8; HANDSHAKE_PACKET_LEN]
810    }
811
812    fn command_message(
813        csid: u32,
814        stream_id: u32,
815        name: &str,
816        txn: f64,
817        args: Vec<Amf0Value>,
818    ) -> Message {
819        let body = Command {
820            name: name.to_string(),
821            transaction_id: txn,
822            arguments: args,
823        }
824        .to_body();
825        Message {
826            chunk_stream_id: csid,
827            timestamp: 0,
828            message_type_id: msg_type::COMMAND_AMF0,
829            message_stream_id: stream_id,
830            payload: body,
831        }
832    }
833
834    fn connect_bytes(app: &str) -> Vec<u8> {
835        let args = vec![Amf0Value::Object(vec![
836            ("app".to_string(), Amf0Value::String(app.to_string())),
837            (
838                "type".to_string(),
839                Amf0Value::String("nonprivate".to_string()),
840            ),
841        ])];
842        let msg = command_message(CLIENT_CSID, 0, "connect", 1.0, args);
843        ChunkWriter::new().write(&msg)
844    }
845
846    fn connect_bytes_no_args() -> Vec<u8> {
847        let msg = command_message(CLIENT_CSID, 0, "connect", 1.0, vec![]);
848        ChunkWriter::new().write(&msg)
849    }
850
851    fn create_stream_bytes() -> Vec<u8> {
852        let msg = command_message(CLIENT_CSID, 0, "createStream", 2.0, vec![Amf0Value::Null]);
853        ChunkWriter::new().write(&msg)
854    }
855
856    fn publish_bytes(stream_id: u32, stream_key: &str) -> Vec<u8> {
857        let args = vec![
858            Amf0Value::Null,
859            Amf0Value::String(stream_key.to_string()),
860            Amf0Value::String("live".to_string()),
861        ];
862        let msg = command_message(CLIENT_CSID, stream_id, "publish", 3.0, args);
863        ChunkWriter::new().write(&msg)
864    }
865
866    fn av_bytes(
867        stream_id: u32,
868        message_type_id: u8,
869        csid: u32,
870        timestamp: u32,
871        payload: Vec<u8>,
872    ) -> Vec<u8> {
873        let msg = Message {
874            chunk_stream_id: csid,
875            timestamp,
876            message_type_id,
877            message_stream_id: stream_id,
878            payload,
879        };
880        ChunkWriter::new().write(&msg)
881    }
882
883    /// Decode every reassembled [`Message`] out of a reply byte stream.
884    /// Pre-sets a generous chunk size: this session's own `_result`/
885    /// `onStatus` replies are always written with the writer's *current*
886    /// chunk size (128 until `connect`'s `SetChunkSize` control message is
887    /// sent, `config.chunk_size` after) but every individual message in
888    /// these tests fits in a single chunk either way, so decoding under a
889    /// single generous assumption reproduces the same framing without
890    /// needing to replay `SetChunkSize` mid-decode.
891    fn decode_messages(bytes: &[u8]) -> Vec<Message> {
892        let mut assembler = ChunkAssembler::new();
893        assembler.set_chunk_size(65536);
894        assembler.push(bytes).expect("well-formed reply stream")
895    }
896
897    fn decode_commands(bytes: &[u8]) -> Vec<Command> {
898        decode_messages(bytes)
899            .iter()
900            .filter(|m| m.message_type_id == msg_type::COMMAND_AMF0)
901            .map(|m| Command::parse(&m.payload).expect("well-formed command reply"))
902            .collect()
903    }
904
905    fn onstatus_code(cmd: &Command) -> Option<String> {
906        cmd.arguments.iter().find_map(|v| match v {
907            Amf0Value::Object(pairs) => pairs.iter().find_map(|(k, v)| {
908                if k == "code" {
909                    match v {
910                        Amf0Value::String(s) => Some(s.clone()),
911                        _ => None,
912                    }
913                } else {
914                    None
915                }
916            }),
917            _ => None,
918        })
919    }
920
921    /// Drive a session through handshake → connect → createStream →
922    /// publish, returning `(session, all reply bytes, all events)`.
923    fn publish_flow(
924        config: ServerConfig,
925        stream_key: &str,
926    ) -> (ServerSession, Vec<u8>, Vec<ServerEvent>) {
927        let mut session = ServerSession::new(config);
928        // `all_out` accumulates only *post-handshake* (chunk-encoded) reply
929        // bytes: the handshake reply (S0+S1+S2) is a raw fixed-length
930        // packet, not chunk-stream framing, so it must not be fed into a
931        // `ChunkAssembler` alongside the chunk-encoded command replies.
932        let mut all_out = Vec::new();
933        let mut all_events = Vec::new();
934
935        session.handle_data(&build_c0_c1()).unwrap();
936        session.handle_data(&build_c2()).unwrap();
937
938        let (out, events) = session.handle_data(&connect_bytes("live")).unwrap();
939        all_out.extend(out);
940        all_events.extend(events);
941
942        let (out, events) = session.handle_data(&create_stream_bytes()).unwrap();
943        all_out.extend(out);
944        all_events.extend(events);
945
946        // ServerSession always allocates stream ids starting at 1.
947        let (out, events) = session.handle_data(&publish_bytes(1, stream_key)).unwrap();
948        all_out.extend(out);
949        all_events.extend(events);
950
951        (session, all_out, all_events)
952    }
953
954    // ── Handshake ─────────────────────────────────────────────────────────
955
956    #[test]
957    fn handshake_completes_and_reply_contains_s0_s1_s2() {
958        let mut session = ServerSession::with_defaults();
959        let (out1, events1) = session.handle_data(&build_c0_c1()).unwrap();
960        assert_eq!(
961            out1.len(),
962            1 + HANDSHAKE_PACKET_LEN + HANDSHAKE_PACKET_LEN,
963            "S0+S1+S2 must be a single 3073-byte reply"
964        );
965        assert!(events1.is_empty());
966
967        let (out2, events2) = session.handle_data(&build_c2()).unwrap();
968        assert!(out2.is_empty(), "C2 receipt produces no reply bytes itself");
969        assert!(events2.is_empty());
970    }
971
972    #[test]
973    fn handshake_split_across_calls_still_completes() {
974        let mut session = ServerSession::with_defaults();
975        let c0c1 = build_c0_c1();
976        let (out1, _) = session.handle_data(&c0c1[..500]).unwrap();
977        assert!(out1.is_empty(), "partial C0+C1 produces no reply yet");
978        let (out2, _) = session.handle_data(&c0c1[500..]).unwrap();
979        assert_eq!(out2.len(), 1 + 2 * HANDSHAKE_PACKET_LEN);
980        let (_out3, _) = session.handle_data(&build_c2()).unwrap();
981    }
982
983    #[test]
984    fn c2_pipelined_with_connect_chunk_in_one_call_still_parses_connect() {
985        // Real clients commonly send C2 back-to-back with the very next
986        // chunk-encoded message (e.g. `connect`) in the same TCP segment,
987        // so both arrive together in a single `handle_data` call. The
988        // post-handshake leftover bytes from that call must be handed to
989        // the chunk assembler within the SAME call, not merely buffered
990        // for a subsequent one.
991        let mut session = ServerSession::with_defaults();
992        session.handle_data(&build_c0_c1()).unwrap();
993
994        let mut pipelined = build_c2();
995        pipelined.extend_from_slice(&connect_bytes("live"));
996
997        let (_out, events) = session.handle_data(&pipelined).unwrap();
998        assert_eq!(
999            events,
1000            vec![ServerEvent::Connected {
1001                app: "live".to_string()
1002            }],
1003            "C2 pipelined with the connect chunk in one handle_data call must \
1004             still yield Connected from that call (leftover bytes must not be dropped)"
1005        );
1006    }
1007
1008    // ── connect ───────────────────────────────────────────────────────────
1009
1010    #[test]
1011    fn connect_emits_connected_event_and_result_reply() {
1012        let mut session = ServerSession::with_defaults();
1013        session.handle_data(&build_c0_c1()).unwrap();
1014        session.handle_data(&build_c2()).unwrap();
1015
1016        let (out, events) = session.handle_data(&connect_bytes("live")).unwrap();
1017        assert_eq!(
1018            events,
1019            vec![ServerEvent::Connected {
1020                app: "live".to_string()
1021            }]
1022        );
1023
1024        let commands = decode_commands(&out);
1025        assert!(
1026            commands.iter().any(|c| c.name == "_result"),
1027            "connect reply must contain a _result command"
1028        );
1029    }
1030
1031    #[test]
1032    fn connect_without_command_object_is_malformed() {
1033        let mut session = ServerSession::with_defaults();
1034        session.handle_data(&build_c0_c1()).unwrap();
1035        session.handle_data(&build_c2()).unwrap();
1036
1037        // No arguments at all: arg0 (the command object) is missing.
1038        let err = session.handle_data(&connect_bytes_no_args()).unwrap_err();
1039        assert!(
1040            matches!(err, RtmpError::Malformed { .. }),
1041            "connect with no command-object argument must error, not default app to \"\""
1042        );
1043    }
1044
1045    /// Gap 3 regression test (#738): connect with a command object present
1046    /// but missing the `app` key must be rejected.
1047    #[test]
1048    fn connect_with_object_missing_app_key_is_malformed() {
1049        let mut session = ServerSession::with_defaults();
1050        session.handle_data(&build_c0_c1()).unwrap();
1051        session.handle_data(&build_c2()).unwrap();
1052
1053        // Object is present but has no `app` key.
1054        let args = vec![Amf0Value::Object(vec![(
1055            "type".to_string(),
1056            Amf0Value::String("nonprivate".to_string()),
1057        )])];
1058        let msg = command_message(CLIENT_CSID, 0, "connect", 1.0, args);
1059        let bytes = ChunkWriter::new().write(&msg);
1060
1061        let err = session.handle_data(&bytes).unwrap_err();
1062        assert!(
1063            matches!(err, RtmpError::Malformed { .. }),
1064            "connect with object missing 'app' key must error"
1065        );
1066    }
1067
1068    /// Gap 3 regression test (#738): connect with a command object present
1069    /// but `app` value is not a String must be rejected.
1070    #[test]
1071    fn connect_with_app_as_number_is_malformed() {
1072        let mut session = ServerSession::with_defaults();
1073        session.handle_data(&build_c0_c1()).unwrap();
1074        session.handle_data(&build_c2()).unwrap();
1075
1076        // Object has `app` key but value is a Number, not a String.
1077        let args = vec![Amf0Value::Object(vec![
1078            ("app".to_string(), Amf0Value::Number(123.0)),
1079            (
1080                "type".to_string(),
1081                Amf0Value::String("nonprivate".to_string()),
1082            ),
1083        ])];
1084        let msg = command_message(CLIENT_CSID, 0, "connect", 1.0, args);
1085        let bytes = ChunkWriter::new().write(&msg);
1086
1087        let err = session.handle_data(&bytes).unwrap_err();
1088        assert!(
1089            matches!(err, RtmpError::Malformed { .. }),
1090            "connect with app as Number must error, not accept numeric value"
1091        );
1092    }
1093
1094    // ── createStream ──────────────────────────────────────────────────────
1095
1096    #[test]
1097    fn create_stream_replies_result_with_stream_id() {
1098        let mut session = ServerSession::with_defaults();
1099        session.handle_data(&build_c0_c1()).unwrap();
1100        session.handle_data(&build_c2()).unwrap();
1101        session.handle_data(&connect_bytes("live")).unwrap();
1102
1103        let (out, _events) = session.handle_data(&create_stream_bytes()).unwrap();
1104        let commands = decode_commands(&out);
1105        let result = commands
1106            .iter()
1107            .find(|c| c.name == "_result")
1108            .expect("createStream _result reply");
1109        assert_eq!(
1110            result.arguments.get(1),
1111            Some(&Amf0Value::Number(1.0)),
1112            "first allocated stream id must be 1"
1113        );
1114    }
1115
1116    #[test]
1117    fn create_stream_before_connect_is_unexpected_state() {
1118        let mut session = ServerSession::with_defaults();
1119        session.handle_data(&build_c0_c1()).unwrap();
1120        session.handle_data(&build_c2()).unwrap();
1121
1122        let err = session.handle_data(&create_stream_bytes()).unwrap_err();
1123        assert!(matches!(err, RtmpError::UnexpectedState { .. }));
1124    }
1125
1126    // ── publish ───────────────────────────────────────────────────────────
1127
1128    #[test]
1129    fn publish_reaches_publishing_emits_event_and_stream_begin_plus_onstatus() {
1130        let (_session, out, events) = publish_flow(ServerConfig::default(), "testkey");
1131
1132        assert!(events.contains(&ServerEvent::Publish {
1133            app: "live".to_string(),
1134            stream_key: "testkey".to_string(),
1135            stream_id: 1,
1136        }));
1137
1138        let messages = decode_messages(&out);
1139        let has_stream_begin = messages.iter().any(|m| {
1140            m.message_type_id == msg_type::USER_CONTROL
1141                && matches!(
1142                    UserControl::parse(&m.payload),
1143                    Ok(UserControl::StreamBegin(1))
1144                )
1145        });
1146        assert!(
1147            has_stream_begin,
1148            "publish reply must include StreamBegin(1)"
1149        );
1150
1151        let commands = decode_commands(&out);
1152        let on_status = commands
1153            .iter()
1154            .find(|c| c.name == "onStatus")
1155            .expect("onStatus reply to publish");
1156        assert_eq!(
1157            onstatus_code(on_status).as_deref(),
1158            Some("NetStream.Publish.Start")
1159        );
1160    }
1161
1162    #[test]
1163    fn publish_before_connect_is_unexpected_state() {
1164        let mut session = ServerSession::with_defaults();
1165        session.handle_data(&build_c0_c1()).unwrap();
1166        session.handle_data(&build_c2()).unwrap();
1167
1168        let err = session
1169            .handle_data(&publish_bytes(1, "testkey"))
1170            .unwrap_err();
1171        assert!(matches!(err, RtmpError::UnexpectedState { .. }));
1172    }
1173
1174    #[test]
1175    fn publish_without_create_stream_is_unexpected_state() {
1176        let mut session = ServerSession::with_defaults();
1177        session.handle_data(&build_c0_c1()).unwrap();
1178        session.handle_data(&build_c2()).unwrap();
1179        session.handle_data(&connect_bytes("live")).unwrap();
1180
1181        // createStream never called: publish must not succeed on app-only.
1182        let err = session
1183            .handle_data(&publish_bytes(1, "testkey"))
1184            .unwrap_err();
1185        assert!(matches!(err, RtmpError::UnexpectedState { .. }));
1186    }
1187
1188    #[test]
1189    fn create_stream_after_closed_is_unexpected_state() {
1190        let mut session = ServerSession::with_defaults();
1191        session.handle_data(&build_c0_c1()).unwrap();
1192        session.handle_data(&build_c2()).unwrap();
1193        session.handle_data(&connect_bytes("live")).unwrap();
1194        session.handle_data(&create_stream_bytes()).unwrap();
1195        session.handle_data(&publish_bytes(1, "testkey")).unwrap();
1196
1197        let delete_stream = command_message(
1198            CLIENT_CSID,
1199            1,
1200            "deleteStream",
1201            4.0,
1202            vec![Amf0Value::Null, Amf0Value::Number(1.0)],
1203        );
1204        let (_out, events) = session
1205            .handle_data(&ChunkWriter::new().write(&delete_stream))
1206            .unwrap();
1207        assert_eq!(events, vec![ServerEvent::Eof]);
1208
1209        let err = session.handle_data(&create_stream_bytes()).unwrap_err();
1210        assert!(
1211            matches!(err, RtmpError::UnexpectedState { .. }),
1212            "createStream after State::Closed must be rejected, not silently re-allowed"
1213        );
1214    }
1215
1216    // ── Audio/Video → Media / FLV ─────────────────────────────────────────
1217
1218    #[test]
1219    fn audio_and_video_emit_media_first_carries_flv_file_header() {
1220        let (mut session, _out, _events) = publish_flow(ServerConfig::default(), "testkey");
1221
1222        let (_out1, events1) = session
1223            .handle_data(&av_bytes(
1224                1,
1225                msg_type::AUDIO,
1226                4,
1227                0,
1228                vec![0xAF, 0x01, 0xDE, 0xAD],
1229            ))
1230            .unwrap();
1231        assert_eq!(events1.len(), 1);
1232        let ServerEvent::Media { flv } = &events1[0] else {
1233            panic!("expected Media event");
1234        };
1235        assert!(
1236            flv.starts_with(b"FLV"),
1237            "the first Media event must carry the FLV file header"
1238        );
1239
1240        let (_out2, events2) = session
1241            .handle_data(&av_bytes(
1242                1,
1243                msg_type::VIDEO,
1244                6,
1245                40,
1246                vec![0x17, 0x01, 0x00, 0x00, 0x00, 0xDE, 0xAD, 0xBE, 0xEF],
1247            ))
1248            .unwrap();
1249        assert_eq!(events2.len(), 1);
1250        let ServerEvent::Media { flv } = &events2[0] else {
1251            panic!("expected Media event");
1252        };
1253        assert!(
1254            !flv.starts_with(b"FLV"),
1255            "only the first Media event carries the file header"
1256        );
1257    }
1258
1259    #[test]
1260    fn concatenated_media_forms_structurally_valid_flv() {
1261        let (mut session, _out, _events) = publish_flow(ServerConfig::default(), "testkey");
1262
1263        let mut flv_stream = Vec::new();
1264        let (_out1, events1) = session
1265            .handle_data(&av_bytes(
1266                1,
1267                msg_type::AUDIO,
1268                4,
1269                0,
1270                vec![0xAF, 0x01, 1, 2, 3],
1271            ))
1272            .unwrap();
1273        let (_out2, events2) = session
1274            .handle_data(&av_bytes(
1275                1,
1276                msg_type::VIDEO,
1277                6,
1278                33,
1279                vec![0x17, 0x01, 0, 0, 0, 4, 5, 6],
1280            ))
1281            .unwrap();
1282        for e in events1.into_iter().chain(events2) {
1283            if let ServerEvent::Media { flv } = e {
1284                flv_stream.extend(flv);
1285            }
1286        }
1287
1288        // File header (13 bytes): signature/version/flags/data-offset/prevTagSize0.
1289        assert_eq!(&flv_stream[0..3], b"FLV");
1290        assert_eq!(flv_stream[3], 1, "FLV version");
1291        assert_eq!(flv_stream[4], 0b0000_0101, "audio+video TypeFlags");
1292        assert_eq!(
1293            u32::from_be_bytes(flv_stream[5..9].try_into().unwrap()),
1294            9,
1295            "DataOffset (header size)"
1296        );
1297        assert_eq!(
1298            u32::from_be_bytes(flv_stream[9..13].try_into().unwrap()),
1299            0,
1300            "PreviousTagSize0"
1301        );
1302
1303        // First tag (audio): TagType=8, DataSize=5.
1304        let tag1 = &flv_stream[13..];
1305        assert_eq!(tag1[0], msg_type::AUDIO);
1306        let data_size1 =
1307            (u32::from(tag1[1]) << 16) | (u32::from(tag1[2]) << 8) | u32::from(tag1[3]);
1308        assert_eq!(data_size1, 5);
1309        let tag1_total = FLV_TAG_HEADER_LEN + 5 + FLV_PREV_TAG_SIZE_LEN;
1310        let prev_tag_size1 = u32::from_be_bytes(
1311            flv_stream[13 + tag1_total - 4..13 + tag1_total]
1312                .try_into()
1313                .unwrap(),
1314        );
1315        assert_eq!(prev_tag_size1 as usize, FLV_TAG_HEADER_LEN + 5);
1316
1317        // Second tag (video) immediately follows.
1318        let tag2 = &flv_stream[13 + tag1_total..];
1319        assert_eq!(tag2[0], msg_type::VIDEO);
1320        let data_size2 =
1321            (u32::from(tag2[1]) << 16) | (u32::from(tag2[2]) << 8) | u32::from(tag2[3]);
1322        assert_eq!(data_size2, 8);
1323        assert_eq!(
1324            flv_stream.len(),
1325            13 + tag1_total + FLV_TAG_HEADER_LEN + 8 + FLV_PREV_TAG_SIZE_LEN
1326        );
1327    }
1328
1329    #[test]
1330    fn data_amf0_onmetadata_emits_media_with_script_tag_type() {
1331        let (mut session, _out, _events) = publish_flow(ServerConfig::default(), "testkey");
1332
1333        // A representative onMetadata Data-AMF0 payload: handler-name
1334        // string followed by a properties object (§7.1's Data Message
1335        // shape), same as e.g. width/height/framerate metadata a real
1336        // encoder sends.
1337        let mut payload = Amf0Value::String("onMetaData".to_string()).to_bytes();
1338        payload.extend(
1339            Amf0Value::Object(vec![
1340                ("width".to_string(), Amf0Value::Number(1920.0)),
1341                ("height".to_string(), Amf0Value::Number(1080.0)),
1342            ])
1343            .to_bytes(),
1344        );
1345
1346        let (_out, events) = session
1347            .handle_data(&av_bytes(1, msg_type::DATA_AMF0, 4, 0, payload))
1348            .unwrap();
1349
1350        assert_eq!(events.len(), 1);
1351        let ServerEvent::Media { flv } = &events[0] else {
1352            panic!("expected Media event");
1353        };
1354        assert!(
1355            flv.starts_with(b"FLV"),
1356            "the first Media event must carry the FLV file header"
1357        );
1358        let tag_type = flv[FLV_HEADER_SIZE as usize + FLV_PREV_TAG_SIZE_LEN];
1359        assert_eq!(
1360            tag_type,
1361            msg_type::DATA_AMF0,
1362            "Data-AMF0 message must produce a script(18) FLV tag"
1363        );
1364    }
1365
1366    #[test]
1367    fn media_before_publishing_is_silently_dropped() {
1368        let mut session = ServerSession::with_defaults();
1369        session.handle_data(&build_c0_c1()).unwrap();
1370        session.handle_data(&build_c2()).unwrap();
1371        session.handle_data(&connect_bytes("live")).unwrap();
1372        session.handle_data(&create_stream_bytes()).unwrap();
1373
1374        // publish never called: state is Connected, not Publishing.
1375        let (_out, events) = session
1376            .handle_data(&av_bytes(1, msg_type::AUDIO, 4, 0, vec![0xAF, 0x01]))
1377            .unwrap();
1378        assert!(events.is_empty());
1379    }
1380
1381    // ── expected_stream_key mismatch ──────────────────────────────────────
1382
1383    #[test]
1384    fn stream_key_mismatch_suppresses_publish_and_media_events() {
1385        let config = ServerConfig {
1386            expected_stream_key: Some("rightkey".to_string()),
1387            ..ServerConfig::default()
1388        };
1389        let (mut session, out, events) = publish_flow(config, "wrongkey");
1390
1391        assert!(
1392            !events
1393                .iter()
1394                .any(|e| matches!(e, ServerEvent::Publish { .. })),
1395            "mismatched stream key must not emit Publish"
1396        );
1397
1398        let commands = decode_commands(&out);
1399        let on_status = commands
1400            .iter()
1401            .find(|c| c.name == "onStatus")
1402            .expect("onStatus reply on mismatch");
1403        assert_eq!(
1404            onstatus_code(on_status).as_deref(),
1405            Some("NetStream.Publish.BadName")
1406        );
1407
1408        // Session never entered Publishing: subsequent A/V produces no Media.
1409        let (_out2, events2) = session
1410            .handle_data(&av_bytes(1, msg_type::AUDIO, 4, 0, vec![0xAF, 0x01]))
1411            .unwrap();
1412        assert!(
1413            events2.is_empty(),
1414            "no Media may be emitted after a rejected publish"
1415        );
1416    }
1417
1418    // ── Ack accounting ────────────────────────────────────────────────────
1419
1420    #[test]
1421    fn ack_written_once_window_ack_size_is_crossed() {
1422        let config = ServerConfig {
1423            window_ack_size: 32,
1424            ..ServerConfig::default()
1425        };
1426        let mut session = ServerSession::new(config);
1427        session.handle_data(&build_c0_c1()).unwrap();
1428        session.handle_data(&build_c2()).unwrap();
1429
1430        // connect's chunk-encoded bytes comfortably exceed 32 bytes.
1431        let (out, _events) = session.handle_data(&connect_bytes("live")).unwrap();
1432        let messages = decode_messages(&out);
1433        let has_ack = messages.iter().any(|m| {
1434            matches!(
1435                ProtocolControl::from_message(m),
1436                Ok(Some(ProtocolControl::Acknowledgement(_)))
1437            )
1438        });
1439        assert!(
1440            has_ack,
1441            "crossing window_ack_size must produce an Acknowledgement"
1442        );
1443    }
1444
1445    #[test]
1446    fn no_ack_below_window_ack_size() {
1447        let config = ServerConfig {
1448            window_ack_size: 10_000_000,
1449            ..ServerConfig::default()
1450        };
1451        let mut session = ServerSession::new(config);
1452        session.handle_data(&build_c0_c1()).unwrap();
1453        let (out, _events) = session.handle_data(&build_c2()).unwrap();
1454        let messages = decode_messages(&out);
1455        assert!(
1456            !messages.iter().any(|m| matches!(
1457                ProtocolControl::from_message(m),
1458                Ok(Some(ProtocolControl::Acknowledgement(_)))
1459            )),
1460            "no Acknowledgement should be due yet"
1461        );
1462    }
1463
1464    // ── Garbage / truncated input never panics ────────────────────────────
1465
1466    #[test]
1467    fn garbage_command_payload_after_handshake_is_error_not_panic() {
1468        let mut session = ServerSession::with_defaults();
1469        session.handle_data(&build_c0_c1()).unwrap();
1470        session.handle_data(&build_c2()).unwrap();
1471
1472        // A structurally valid chunk envelope (Type0 header, Command-AMF0
1473        // type id) whose payload is not valid AMF0 (0xFF is not a defined
1474        // AMF0 marker) — Command::parse must reject this, not panic.
1475        let bogus = Message {
1476            chunk_stream_id: CLIENT_CSID,
1477            timestamp: 0,
1478            message_type_id: msg_type::COMMAND_AMF0,
1479            message_stream_id: 0,
1480            payload: vec![0xFF, 0xFF, 0xFF, 0xFF],
1481        };
1482        let bytes = ChunkWriter::new().write(&bogus);
1483        let err = session.handle_data(&bytes).unwrap_err();
1484        assert!(matches!(
1485            err,
1486            RtmpError::Unsupported { .. }
1487                | RtmpError::Malformed { .. }
1488                | RtmpError::BufferTooShort { .. }
1489        ));
1490    }
1491
1492    #[test]
1493    fn truncated_post_handshake_bytes_do_not_panic() {
1494        let mut session = ServerSession::with_defaults();
1495        session.handle_data(&build_c0_c1()).unwrap();
1496        session.handle_data(&build_c2()).unwrap();
1497
1498        // A handful of arbitrary bytes with no complete chunk in them.
1499        let (out, events) = session.handle_data(&[0x03, 0x01, 0x02]).unwrap();
1500        assert!(out.is_empty());
1501        assert!(events.is_empty());
1502    }
1503
1504    // ── Mutation-check sentinels ──────────────────────────────────────────
1505
1506    #[test]
1507    fn mutation_check_publish_event_must_echo_actual_stream_key() {
1508        let (_session, _out, events) = publish_flow(ServerConfig::default(), "specific-key-xyz");
1509        let publish_event = events
1510            .iter()
1511            .find_map(|e| match e {
1512                ServerEvent::Publish { stream_key, .. } => Some(stream_key.clone()),
1513                _ => None,
1514            })
1515            .expect("Publish event");
1516        assert_eq!(
1517            publish_event, "specific-key-xyz",
1518            "a hardcoded/ignored stream_key would fail this"
1519        );
1520    }
1521
1522    #[test]
1523    fn mutation_check_flv_file_header_bytes_are_exact() {
1524        let header = flv_file_header();
1525        assert_eq!(
1526            header,
1527            vec![
1528                b'F',
1529                b'L',
1530                b'V',        // Signature
1531                1,           // Version
1532                0b0000_0101, // TypeFlags: audio + video
1533                0,
1534                0,
1535                0,
1536                9, // DataOffset = 9
1537                0,
1538                0,
1539                0,
1540                0, // PreviousTagSize0 = 0
1541            ]
1542        );
1543    }
1544
1545    // ── Regression: client Set Chunk Size mid-buffer (#738 Task 8) ────────
1546
1547    #[test]
1548    fn client_set_chunk_size_takes_effect_before_next_message_in_same_call() {
1549        // Reproduces the exact bug a real `ffmpeg` publish surfaced: the
1550        // client sends its own SetChunkSize (as ffmpeg does, right after
1551        // `connect`) and, in the very same TCP segment / `handle_data` call,
1552        // the next message is already framed at the *new* chunk size. If
1553        // `ServerSession` collected a whole batch of messages from one
1554        // `ChunkAssembler::push` before dispatching any of them (applying
1555        // SetChunkSize's effect only afterwards), that next message would
1556        // be misparsed under the *old* chunk size.
1557        let (mut session, _out, _events) = publish_flow(ServerConfig::default(), "testkey");
1558
1559        const NEW_CHUNK_SIZE: u32 = 4096;
1560        let set_chunk_size_bytes =
1561            ChunkWriter::new().write(&ProtocolControl::SetChunkSize(NEW_CHUNK_SIZE).to_message());
1562
1563        // A video payload bigger than the *default* 128-byte chunk size but
1564        // written by a client-side writer already using the new size, so it
1565        // lands as a single physical chunk — the shape a real client
1566        // produces immediately after raising its chunk size.
1567        let big_payload = vec![0x17u8; 300];
1568        let mut client_writer = ChunkWriter::new();
1569        client_writer.set_chunk_size(NEW_CHUNK_SIZE);
1570        let video_bytes = client_writer.write(&Message {
1571            chunk_stream_id: 6,
1572            timestamp: 0,
1573            message_type_id: msg_type::VIDEO,
1574            message_stream_id: 1,
1575            payload: big_payload.clone(),
1576        });
1577
1578        let mut combined = set_chunk_size_bytes;
1579        combined.extend_from_slice(&video_bytes);
1580
1581        // Both messages arrive in a single `handle_data` call: this is the
1582        // exact shape that broke before the incremental-dispatch fix.
1583        let (_out, events) = session.handle_data(&combined).expect(
1584            "SetChunkSize must take effect before parsing the message that follows it \
1585                     in the same handle_data call, not only on a subsequent call",
1586        );
1587
1588        let media = events
1589            .iter()
1590            .find_map(|e| match e {
1591                ServerEvent::Media { flv } => Some(flv.clone()),
1592                _ => None,
1593            })
1594            .expect("the video message must still be parsed into a Media event");
1595        assert!(
1596            media
1597                .windows(big_payload.len())
1598                .any(|w| w == big_payload.as_slice()),
1599            "the video payload must survive intact through the chunk-size change"
1600        );
1601    }
1602
1603    #[test]
1604    fn control_chunk_stream_id_constant_matches_message_module() {
1605        // Sanity: our reply csid choice for command messages must not
1606        // collide with the reserved control/user-control csid.
1607        assert_ne!(COMMAND_CHUNK_STREAM_ID, CTRL_CSID);
1608    }
1609
1610    #[test]
1611    fn next_stream_id_saturates_instead_of_overflowing() {
1612        // Mutation check: with `next_stream_id` already at `u32::MAX`, a
1613        // bare `+= 1` panics (debug-mode overflow check) or wraps to 0
1614        // (release mode) — either way, the wrong behaviour. `saturating_add`
1615        // must instead keep it pinned at `u32::MAX`.
1616        let mut session = ServerSession::with_defaults();
1617        session.handle_data(&build_c0_c1()).unwrap();
1618        session.handle_data(&build_c2()).unwrap();
1619        session.handle_data(&connect_bytes("live")).unwrap();
1620
1621        session.next_stream_id = u32::MAX;
1622        let (out, _events) = session
1623            .handle_data(&create_stream_bytes())
1624            .expect("createStream must not panic when next_stream_id is already u32::MAX");
1625
1626        let commands = decode_commands(&out);
1627        let result = commands
1628            .iter()
1629            .find(|c| c.name == "_result")
1630            .expect("createStream _result reply");
1631        assert_eq!(
1632            result.arguments.get(1),
1633            Some(&Amf0Value::Number(f64::from(u32::MAX))),
1634            "the stream id allocated at the u32::MAX boundary must still be u32::MAX"
1635        );
1636        assert_eq!(
1637            session.next_stream_id,
1638            u32::MAX,
1639            "next_stream_id must saturate at u32::MAX, not wrap to 0"
1640        );
1641    }
1642
1643    // ── serde (feature "serde") ────────────────────────────────────────────
1644
1645    #[cfg(feature = "serde")]
1646    #[test]
1647    fn server_config_and_server_event_serde_round_trip() {
1648        let config = ServerConfig::default()
1649            .with_chunk_size(8192)
1650            .with_expected_stream_key(Some("k".to_string()));
1651        let json = serde_json::to_string(&config).expect("serialize ServerConfig");
1652        let back: ServerConfig = serde_json::from_str(&json).expect("deserialize ServerConfig");
1653        assert_eq!(back.chunk_size, config.chunk_size);
1654        assert_eq!(back.expected_stream_key, config.expected_stream_key);
1655
1656        let event = ServerEvent::Publish {
1657            app: "live".to_string(),
1658            stream_key: "testkey".to_string(),
1659            stream_id: 1,
1660        };
1661        let json = serde_json::to_string(&event).expect("serialize ServerEvent");
1662        let back: ServerEvent = serde_json::from_str(&json).expect("deserialize ServerEvent");
1663        assert_eq!(back, event);
1664    }
1665}