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            if expected != &stream_key {
636                let on_status = Command {
637                    name: "onStatus".to_string(),
638                    transaction_id: 0.0,
639                    arguments: vec![
640                        Amf0Value::Null,
641                        Amf0Value::Object(vec![
642                            ("level".to_string(), Amf0Value::String("error".to_string())),
643                            (
644                                "code".to_string(),
645                                Amf0Value::String("NetStream.Publish.BadName".to_string()),
646                            ),
647                            (
648                                "description".to_string(),
649                                Amf0Value::String("Stream key mismatch.".to_string()),
650                            ),
651                        ]),
652                    ],
653                };
654                out.extend_from_slice(&self.writer.write(&self.command_message(msg, &on_status)));
655                // No Publish/Media events; state unchanged (not Publishing).
656                return Ok(());
657            }
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    // ── createStream ──────────────────────────────────────────────────────
1046
1047    #[test]
1048    fn create_stream_replies_result_with_stream_id() {
1049        let mut session = ServerSession::with_defaults();
1050        session.handle_data(&build_c0_c1()).unwrap();
1051        session.handle_data(&build_c2()).unwrap();
1052        session.handle_data(&connect_bytes("live")).unwrap();
1053
1054        let (out, _events) = session.handle_data(&create_stream_bytes()).unwrap();
1055        let commands = decode_commands(&out);
1056        let result = commands
1057            .iter()
1058            .find(|c| c.name == "_result")
1059            .expect("createStream _result reply");
1060        assert_eq!(
1061            result.arguments.get(1),
1062            Some(&Amf0Value::Number(1.0)),
1063            "first allocated stream id must be 1"
1064        );
1065    }
1066
1067    #[test]
1068    fn create_stream_before_connect_is_unexpected_state() {
1069        let mut session = ServerSession::with_defaults();
1070        session.handle_data(&build_c0_c1()).unwrap();
1071        session.handle_data(&build_c2()).unwrap();
1072
1073        let err = session.handle_data(&create_stream_bytes()).unwrap_err();
1074        assert!(matches!(err, RtmpError::UnexpectedState { .. }));
1075    }
1076
1077    // ── publish ───────────────────────────────────────────────────────────
1078
1079    #[test]
1080    fn publish_reaches_publishing_emits_event_and_stream_begin_plus_onstatus() {
1081        let (_session, out, events) = publish_flow(ServerConfig::default(), "testkey");
1082
1083        assert!(events.contains(&ServerEvent::Publish {
1084            app: "live".to_string(),
1085            stream_key: "testkey".to_string(),
1086            stream_id: 1,
1087        }));
1088
1089        let messages = decode_messages(&out);
1090        let has_stream_begin = messages.iter().any(|m| {
1091            m.message_type_id == msg_type::USER_CONTROL
1092                && matches!(
1093                    UserControl::parse(&m.payload),
1094                    Ok(UserControl::StreamBegin(1))
1095                )
1096        });
1097        assert!(
1098            has_stream_begin,
1099            "publish reply must include StreamBegin(1)"
1100        );
1101
1102        let commands = decode_commands(&out);
1103        let on_status = commands
1104            .iter()
1105            .find(|c| c.name == "onStatus")
1106            .expect("onStatus reply to publish");
1107        assert_eq!(
1108            onstatus_code(on_status).as_deref(),
1109            Some("NetStream.Publish.Start")
1110        );
1111    }
1112
1113    #[test]
1114    fn publish_before_connect_is_unexpected_state() {
1115        let mut session = ServerSession::with_defaults();
1116        session.handle_data(&build_c0_c1()).unwrap();
1117        session.handle_data(&build_c2()).unwrap();
1118
1119        let err = session
1120            .handle_data(&publish_bytes(1, "testkey"))
1121            .unwrap_err();
1122        assert!(matches!(err, RtmpError::UnexpectedState { .. }));
1123    }
1124
1125    #[test]
1126    fn publish_without_create_stream_is_unexpected_state() {
1127        let mut session = ServerSession::with_defaults();
1128        session.handle_data(&build_c0_c1()).unwrap();
1129        session.handle_data(&build_c2()).unwrap();
1130        session.handle_data(&connect_bytes("live")).unwrap();
1131
1132        // createStream never called: publish must not succeed on app-only.
1133        let err = session
1134            .handle_data(&publish_bytes(1, "testkey"))
1135            .unwrap_err();
1136        assert!(matches!(err, RtmpError::UnexpectedState { .. }));
1137    }
1138
1139    #[test]
1140    fn create_stream_after_closed_is_unexpected_state() {
1141        let mut session = ServerSession::with_defaults();
1142        session.handle_data(&build_c0_c1()).unwrap();
1143        session.handle_data(&build_c2()).unwrap();
1144        session.handle_data(&connect_bytes("live")).unwrap();
1145        session.handle_data(&create_stream_bytes()).unwrap();
1146        session.handle_data(&publish_bytes(1, "testkey")).unwrap();
1147
1148        let delete_stream = command_message(
1149            CLIENT_CSID,
1150            1,
1151            "deleteStream",
1152            4.0,
1153            vec![Amf0Value::Null, Amf0Value::Number(1.0)],
1154        );
1155        let (_out, events) = session
1156            .handle_data(&ChunkWriter::new().write(&delete_stream))
1157            .unwrap();
1158        assert_eq!(events, vec![ServerEvent::Eof]);
1159
1160        let err = session.handle_data(&create_stream_bytes()).unwrap_err();
1161        assert!(
1162            matches!(err, RtmpError::UnexpectedState { .. }),
1163            "createStream after State::Closed must be rejected, not silently re-allowed"
1164        );
1165    }
1166
1167    // ── Audio/Video → Media / FLV ─────────────────────────────────────────
1168
1169    #[test]
1170    fn audio_and_video_emit_media_first_carries_flv_file_header() {
1171        let (mut session, _out, _events) = publish_flow(ServerConfig::default(), "testkey");
1172
1173        let (_out1, events1) = session
1174            .handle_data(&av_bytes(
1175                1,
1176                msg_type::AUDIO,
1177                4,
1178                0,
1179                vec![0xAF, 0x01, 0xDE, 0xAD],
1180            ))
1181            .unwrap();
1182        assert_eq!(events1.len(), 1);
1183        let ServerEvent::Media { flv } = &events1[0] else {
1184            panic!("expected Media event");
1185        };
1186        assert!(
1187            flv.starts_with(b"FLV"),
1188            "the first Media event must carry the FLV file header"
1189        );
1190
1191        let (_out2, events2) = session
1192            .handle_data(&av_bytes(
1193                1,
1194                msg_type::VIDEO,
1195                6,
1196                40,
1197                vec![0x17, 0x01, 0x00, 0x00, 0x00, 0xDE, 0xAD, 0xBE, 0xEF],
1198            ))
1199            .unwrap();
1200        assert_eq!(events2.len(), 1);
1201        let ServerEvent::Media { flv } = &events2[0] else {
1202            panic!("expected Media event");
1203        };
1204        assert!(
1205            !flv.starts_with(b"FLV"),
1206            "only the first Media event carries the file header"
1207        );
1208    }
1209
1210    #[test]
1211    fn concatenated_media_forms_structurally_valid_flv() {
1212        let (mut session, _out, _events) = publish_flow(ServerConfig::default(), "testkey");
1213
1214        let mut flv_stream = Vec::new();
1215        let (_out1, events1) = session
1216            .handle_data(&av_bytes(
1217                1,
1218                msg_type::AUDIO,
1219                4,
1220                0,
1221                vec![0xAF, 0x01, 1, 2, 3],
1222            ))
1223            .unwrap();
1224        let (_out2, events2) = session
1225            .handle_data(&av_bytes(
1226                1,
1227                msg_type::VIDEO,
1228                6,
1229                33,
1230                vec![0x17, 0x01, 0, 0, 0, 4, 5, 6],
1231            ))
1232            .unwrap();
1233        for e in events1.into_iter().chain(events2) {
1234            if let ServerEvent::Media { flv } = e {
1235                flv_stream.extend(flv);
1236            }
1237        }
1238
1239        // File header (13 bytes): signature/version/flags/data-offset/prevTagSize0.
1240        assert_eq!(&flv_stream[0..3], b"FLV");
1241        assert_eq!(flv_stream[3], 1, "FLV version");
1242        assert_eq!(flv_stream[4], 0b0000_0101, "audio+video TypeFlags");
1243        assert_eq!(
1244            u32::from_be_bytes(flv_stream[5..9].try_into().unwrap()),
1245            9,
1246            "DataOffset (header size)"
1247        );
1248        assert_eq!(
1249            u32::from_be_bytes(flv_stream[9..13].try_into().unwrap()),
1250            0,
1251            "PreviousTagSize0"
1252        );
1253
1254        // First tag (audio): TagType=8, DataSize=5.
1255        let tag1 = &flv_stream[13..];
1256        assert_eq!(tag1[0], msg_type::AUDIO);
1257        let data_size1 =
1258            (u32::from(tag1[1]) << 16) | (u32::from(tag1[2]) << 8) | u32::from(tag1[3]);
1259        assert_eq!(data_size1, 5);
1260        let tag1_total = FLV_TAG_HEADER_LEN + 5 + FLV_PREV_TAG_SIZE_LEN;
1261        let prev_tag_size1 = u32::from_be_bytes(
1262            flv_stream[13 + tag1_total - 4..13 + tag1_total]
1263                .try_into()
1264                .unwrap(),
1265        );
1266        assert_eq!(prev_tag_size1 as usize, FLV_TAG_HEADER_LEN + 5);
1267
1268        // Second tag (video) immediately follows.
1269        let tag2 = &flv_stream[13 + tag1_total..];
1270        assert_eq!(tag2[0], msg_type::VIDEO);
1271        let data_size2 =
1272            (u32::from(tag2[1]) << 16) | (u32::from(tag2[2]) << 8) | u32::from(tag2[3]);
1273        assert_eq!(data_size2, 8);
1274        assert_eq!(
1275            flv_stream.len(),
1276            13 + tag1_total + FLV_TAG_HEADER_LEN + 8 + FLV_PREV_TAG_SIZE_LEN
1277        );
1278    }
1279
1280    #[test]
1281    fn data_amf0_onmetadata_emits_media_with_script_tag_type() {
1282        let (mut session, _out, _events) = publish_flow(ServerConfig::default(), "testkey");
1283
1284        // A representative onMetadata Data-AMF0 payload: handler-name
1285        // string followed by a properties object (§7.1's Data Message
1286        // shape), same as e.g. width/height/framerate metadata a real
1287        // encoder sends.
1288        let mut payload = Amf0Value::String("onMetaData".to_string()).to_bytes();
1289        payload.extend(
1290            Amf0Value::Object(vec![
1291                ("width".to_string(), Amf0Value::Number(1920.0)),
1292                ("height".to_string(), Amf0Value::Number(1080.0)),
1293            ])
1294            .to_bytes(),
1295        );
1296
1297        let (_out, events) = session
1298            .handle_data(&av_bytes(1, msg_type::DATA_AMF0, 4, 0, payload))
1299            .unwrap();
1300
1301        assert_eq!(events.len(), 1);
1302        let ServerEvent::Media { flv } = &events[0] else {
1303            panic!("expected Media event");
1304        };
1305        assert!(
1306            flv.starts_with(b"FLV"),
1307            "the first Media event must carry the FLV file header"
1308        );
1309        let tag_type = flv[FLV_HEADER_SIZE as usize + FLV_PREV_TAG_SIZE_LEN];
1310        assert_eq!(
1311            tag_type,
1312            msg_type::DATA_AMF0,
1313            "Data-AMF0 message must produce a script(18) FLV tag"
1314        );
1315    }
1316
1317    #[test]
1318    fn media_before_publishing_is_silently_dropped() {
1319        let mut session = ServerSession::with_defaults();
1320        session.handle_data(&build_c0_c1()).unwrap();
1321        session.handle_data(&build_c2()).unwrap();
1322        session.handle_data(&connect_bytes("live")).unwrap();
1323        session.handle_data(&create_stream_bytes()).unwrap();
1324
1325        // publish never called: state is Connected, not Publishing.
1326        let (_out, events) = session
1327            .handle_data(&av_bytes(1, msg_type::AUDIO, 4, 0, vec![0xAF, 0x01]))
1328            .unwrap();
1329        assert!(events.is_empty());
1330    }
1331
1332    // ── expected_stream_key mismatch ──────────────────────────────────────
1333
1334    #[test]
1335    fn stream_key_mismatch_suppresses_publish_and_media_events() {
1336        let config = ServerConfig {
1337            expected_stream_key: Some("rightkey".to_string()),
1338            ..ServerConfig::default()
1339        };
1340        let (mut session, out, events) = publish_flow(config, "wrongkey");
1341
1342        assert!(
1343            !events
1344                .iter()
1345                .any(|e| matches!(e, ServerEvent::Publish { .. })),
1346            "mismatched stream key must not emit Publish"
1347        );
1348
1349        let commands = decode_commands(&out);
1350        let on_status = commands
1351            .iter()
1352            .find(|c| c.name == "onStatus")
1353            .expect("onStatus reply on mismatch");
1354        assert_eq!(
1355            onstatus_code(on_status).as_deref(),
1356            Some("NetStream.Publish.BadName")
1357        );
1358
1359        // Session never entered Publishing: subsequent A/V produces no Media.
1360        let (_out2, events2) = session
1361            .handle_data(&av_bytes(1, msg_type::AUDIO, 4, 0, vec![0xAF, 0x01]))
1362            .unwrap();
1363        assert!(
1364            events2.is_empty(),
1365            "no Media may be emitted after a rejected publish"
1366        );
1367    }
1368
1369    // ── Ack accounting ────────────────────────────────────────────────────
1370
1371    #[test]
1372    fn ack_written_once_window_ack_size_is_crossed() {
1373        let config = ServerConfig {
1374            window_ack_size: 32,
1375            ..ServerConfig::default()
1376        };
1377        let mut session = ServerSession::new(config);
1378        session.handle_data(&build_c0_c1()).unwrap();
1379        session.handle_data(&build_c2()).unwrap();
1380
1381        // connect's chunk-encoded bytes comfortably exceed 32 bytes.
1382        let (out, _events) = session.handle_data(&connect_bytes("live")).unwrap();
1383        let messages = decode_messages(&out);
1384        let has_ack = messages.iter().any(|m| {
1385            matches!(
1386                ProtocolControl::from_message(m),
1387                Ok(Some(ProtocolControl::Acknowledgement(_)))
1388            )
1389        });
1390        assert!(
1391            has_ack,
1392            "crossing window_ack_size must produce an Acknowledgement"
1393        );
1394    }
1395
1396    #[test]
1397    fn no_ack_below_window_ack_size() {
1398        let config = ServerConfig {
1399            window_ack_size: 10_000_000,
1400            ..ServerConfig::default()
1401        };
1402        let mut session = ServerSession::new(config);
1403        session.handle_data(&build_c0_c1()).unwrap();
1404        let (out, _events) = session.handle_data(&build_c2()).unwrap();
1405        let messages = decode_messages(&out);
1406        assert!(
1407            !messages.iter().any(|m| matches!(
1408                ProtocolControl::from_message(m),
1409                Ok(Some(ProtocolControl::Acknowledgement(_)))
1410            )),
1411            "no Acknowledgement should be due yet"
1412        );
1413    }
1414
1415    // ── Garbage / truncated input never panics ────────────────────────────
1416
1417    #[test]
1418    fn garbage_command_payload_after_handshake_is_error_not_panic() {
1419        let mut session = ServerSession::with_defaults();
1420        session.handle_data(&build_c0_c1()).unwrap();
1421        session.handle_data(&build_c2()).unwrap();
1422
1423        // A structurally valid chunk envelope (Type0 header, Command-AMF0
1424        // type id) whose payload is not valid AMF0 (0xFF is not a defined
1425        // AMF0 marker) — Command::parse must reject this, not panic.
1426        let bogus = Message {
1427            chunk_stream_id: CLIENT_CSID,
1428            timestamp: 0,
1429            message_type_id: msg_type::COMMAND_AMF0,
1430            message_stream_id: 0,
1431            payload: vec![0xFF, 0xFF, 0xFF, 0xFF],
1432        };
1433        let bytes = ChunkWriter::new().write(&bogus);
1434        let err = session.handle_data(&bytes).unwrap_err();
1435        assert!(matches!(
1436            err,
1437            RtmpError::Unsupported { .. }
1438                | RtmpError::Malformed { .. }
1439                | RtmpError::BufferTooShort { .. }
1440        ));
1441    }
1442
1443    #[test]
1444    fn truncated_post_handshake_bytes_do_not_panic() {
1445        let mut session = ServerSession::with_defaults();
1446        session.handle_data(&build_c0_c1()).unwrap();
1447        session.handle_data(&build_c2()).unwrap();
1448
1449        // A handful of arbitrary bytes with no complete chunk in them.
1450        let (out, events) = session.handle_data(&[0x03, 0x01, 0x02]).unwrap();
1451        assert!(out.is_empty());
1452        assert!(events.is_empty());
1453    }
1454
1455    // ── Mutation-check sentinels ──────────────────────────────────────────
1456
1457    #[test]
1458    fn mutation_check_publish_event_must_echo_actual_stream_key() {
1459        let (_session, _out, events) = publish_flow(ServerConfig::default(), "specific-key-xyz");
1460        let publish_event = events
1461            .iter()
1462            .find_map(|e| match e {
1463                ServerEvent::Publish { stream_key, .. } => Some(stream_key.clone()),
1464                _ => None,
1465            })
1466            .expect("Publish event");
1467        assert_eq!(
1468            publish_event, "specific-key-xyz",
1469            "a hardcoded/ignored stream_key would fail this"
1470        );
1471    }
1472
1473    #[test]
1474    fn mutation_check_flv_file_header_bytes_are_exact() {
1475        let header = flv_file_header();
1476        assert_eq!(
1477            header,
1478            vec![
1479                b'F',
1480                b'L',
1481                b'V',        // Signature
1482                1,           // Version
1483                0b0000_0101, // TypeFlags: audio + video
1484                0,
1485                0,
1486                0,
1487                9, // DataOffset = 9
1488                0,
1489                0,
1490                0,
1491                0, // PreviousTagSize0 = 0
1492            ]
1493        );
1494    }
1495
1496    // ── Regression: client Set Chunk Size mid-buffer (#738 Task 8) ────────
1497
1498    #[test]
1499    fn client_set_chunk_size_takes_effect_before_next_message_in_same_call() {
1500        // Reproduces the exact bug a real `ffmpeg` publish surfaced: the
1501        // client sends its own SetChunkSize (as ffmpeg does, right after
1502        // `connect`) and, in the very same TCP segment / `handle_data` call,
1503        // the next message is already framed at the *new* chunk size. If
1504        // `ServerSession` collected a whole batch of messages from one
1505        // `ChunkAssembler::push` before dispatching any of them (applying
1506        // SetChunkSize's effect only afterwards), that next message would
1507        // be misparsed under the *old* chunk size.
1508        let (mut session, _out, _events) = publish_flow(ServerConfig::default(), "testkey");
1509
1510        const NEW_CHUNK_SIZE: u32 = 4096;
1511        let set_chunk_size_bytes =
1512            ChunkWriter::new().write(&ProtocolControl::SetChunkSize(NEW_CHUNK_SIZE).to_message());
1513
1514        // A video payload bigger than the *default* 128-byte chunk size but
1515        // written by a client-side writer already using the new size, so it
1516        // lands as a single physical chunk — the shape a real client
1517        // produces immediately after raising its chunk size.
1518        let big_payload = vec![0x17u8; 300];
1519        let mut client_writer = ChunkWriter::new();
1520        client_writer.set_chunk_size(NEW_CHUNK_SIZE);
1521        let video_bytes = client_writer.write(&Message {
1522            chunk_stream_id: 6,
1523            timestamp: 0,
1524            message_type_id: msg_type::VIDEO,
1525            message_stream_id: 1,
1526            payload: big_payload.clone(),
1527        });
1528
1529        let mut combined = set_chunk_size_bytes;
1530        combined.extend_from_slice(&video_bytes);
1531
1532        // Both messages arrive in a single `handle_data` call: this is the
1533        // exact shape that broke before the incremental-dispatch fix.
1534        let (_out, events) = session.handle_data(&combined).expect(
1535            "SetChunkSize must take effect before parsing the message that follows it \
1536                     in the same handle_data call, not only on a subsequent call",
1537        );
1538
1539        let media = events
1540            .iter()
1541            .find_map(|e| match e {
1542                ServerEvent::Media { flv } => Some(flv.clone()),
1543                _ => None,
1544            })
1545            .expect("the video message must still be parsed into a Media event");
1546        assert!(
1547            media
1548                .windows(big_payload.len())
1549                .any(|w| w == big_payload.as_slice()),
1550            "the video payload must survive intact through the chunk-size change"
1551        );
1552    }
1553
1554    #[test]
1555    fn control_chunk_stream_id_constant_matches_message_module() {
1556        // Sanity: our reply csid choice for command messages must not
1557        // collide with the reserved control/user-control csid.
1558        assert_ne!(COMMAND_CHUNK_STREAM_ID, CTRL_CSID);
1559    }
1560
1561    #[test]
1562    fn next_stream_id_saturates_instead_of_overflowing() {
1563        // Mutation check: with `next_stream_id` already at `u32::MAX`, a
1564        // bare `+= 1` panics (debug-mode overflow check) or wraps to 0
1565        // (release mode) — either way, the wrong behaviour. `saturating_add`
1566        // must instead keep it pinned at `u32::MAX`.
1567        let mut session = ServerSession::with_defaults();
1568        session.handle_data(&build_c0_c1()).unwrap();
1569        session.handle_data(&build_c2()).unwrap();
1570        session.handle_data(&connect_bytes("live")).unwrap();
1571
1572        session.next_stream_id = u32::MAX;
1573        let (out, _events) = session
1574            .handle_data(&create_stream_bytes())
1575            .expect("createStream must not panic when next_stream_id is already u32::MAX");
1576
1577        let commands = decode_commands(&out);
1578        let result = commands
1579            .iter()
1580            .find(|c| c.name == "_result")
1581            .expect("createStream _result reply");
1582        assert_eq!(
1583            result.arguments.get(1),
1584            Some(&Amf0Value::Number(f64::from(u32::MAX))),
1585            "the stream id allocated at the u32::MAX boundary must still be u32::MAX"
1586        );
1587        assert_eq!(
1588            session.next_stream_id,
1589            u32::MAX,
1590            "next_stream_id must saturate at u32::MAX, not wrap to 0"
1591        );
1592    }
1593
1594    // ── serde (feature "serde") ────────────────────────────────────────────
1595
1596    #[cfg(feature = "serde")]
1597    #[test]
1598    fn server_config_and_server_event_serde_round_trip() {
1599        let config = ServerConfig::default()
1600            .with_chunk_size(8192)
1601            .with_expected_stream_key(Some("k".to_string()));
1602        let json = serde_json::to_string(&config).expect("serialize ServerConfig");
1603        let back: ServerConfig = serde_json::from_str(&json).expect("deserialize ServerConfig");
1604        assert_eq!(back.chunk_size, config.chunk_size);
1605        assert_eq!(back.expected_stream_key, config.expected_stream_key);
1606
1607        let event = ServerEvent::Publish {
1608            app: "live".to_string(),
1609            stream_key: "testkey".to_string(),
1610            stream_id: 1,
1611        };
1612        let json = serde_json::to_string(&event).expect("serialize ServerEvent");
1613        let back: ServerEvent = serde_json::from_str(&json).expect("deserialize ServerEvent");
1614        assert_eq!(back, event);
1615    }
1616}