Skip to main content

dvb_ci_runtime/
transport.rs

1//! TPDU transport layer — a sans-IO state machine for the single transport
2//! connection per CI slot (ETSI EN 50221 §A.4).
3//!
4//! Connection lifecycle (Figures 6/7): `Idle → Creating → Active`. The host
5//! sends `Create_T_C`; the module answers `C_T_C_Reply` (→ `Active`) or the
6//! host times out back to `Idle`. In `Active` the host **polls regularly** —
7//! per §A.4, a poll is a `T_Data_Last` with an empty data field — and, whenever
8//! a Status Byte reports Data-Available (DA), sends `T_RCV` to receive the
9//! queued message. Chained module messages arrive as `T_Data_More*` then a
10//! final `T_Data_Last` and are reassembled into one SPDU payload.
11//!
12//! Timing: EN 50221 mandates regular polling and a reply-timeout arc but does
13//! not fix the interval, so [`DEFAULT_POLL_INTERVAL`] / [`DEFAULT_REPLY_TIMEOUT`]
14//! are implementation-chosen defaults. All timing is expressed via the sans-IO
15//! [`Tick`](crate::event::Event::Tick)/timer model so it is deterministic and
16//! testable without a clock.
17
18use std::collections::VecDeque;
19use std::time::Duration;
20
21use broadcast_common::{Parse, Serialize};
22use dvb_ci::tpdu::{CommandTpdu, DataBlock, ResponseTpdu, SbValue, TcObject, create_t_c, tags};
23
24/// `length_field` value of a `T_SB` object (`t_c_id` + `SB_value`).
25const SB_LENGTH_VALUE: usize = 2;
26
27/// Parse a `T_SB` object (`0x80 · length_field(=2) · t_c_id · sb_value`) at the
28/// start of `bytes`. The `length_field` is decoded, not assumed to be the
29/// single byte `0x02`: a CAM may code it in a long form (`80 82 00 02 ..`).
30fn parse_sb(bytes: &[u8]) -> Option<(u8, SbValue)> {
31    if bytes.first() != Some(&tags::SB) {
32        return None;
33    }
34    let (len, hdr) = dvb_ci::length::decode(&bytes[1..]).ok()?;
35    if len != SB_LENGTH_VALUE {
36        return None;
37    }
38    let body = bytes.get(1 + hdr..1 + hdr + SB_LENGTH_VALUE)?;
39    Some((body[0], SbValue(body[1])))
40}
41
42/// Conventional host poll interval (implementation-chosen; §A.4 mandates only
43/// "poll regularly").
44pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(100);
45/// Conventional reply timeout for an expected `R_TPDU` (the §A.4 Figure 6/7
46/// "Timeout" arc; value implementation-chosen).
47pub const DEFAULT_REPLY_TIMEOUT: Duration = Duration::from_millis(1000);
48
49/// Transport connection state (EN 50221 §A.4, Figures 6/7).
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51#[non_exhaustive]
52pub enum TcState {
53    /// No connection; nothing sent.
54    Idle,
55    /// `Create_T_C` sent, awaiting `C_T_C_Reply`.
56    Creating,
57    /// Connection up; polling/exchanging data.
58    Active,
59}
60
61/// What the transport layer wants done after handling an input.
62#[derive(Debug, Default, Clone, PartialEq, Eq)]
63pub struct Out {
64    /// Link-layer TPDU frames to write to the device, in order.
65    pub writes: Vec<Vec<u8>>,
66    /// Fully-reassembled SPDU payloads to pass up to the session layer.
67    pub spdus: Vec<Vec<u8>>,
68    /// Requested delay until the next [`Tick`](crate::event::Event::Tick).
69    pub timer: Option<Duration>,
70    /// A transport error (e.g. reply timeout, unexpected tag).
71    pub error: Option<TransportError>,
72}
73
74/// Transport-layer errors.
75#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
76#[non_exhaustive]
77pub enum TransportError {
78    /// No `C_T_C_Reply` within the reply timeout (§A.4 timeout arc).
79    #[error("transport connection setup timed out")]
80    SetupTimeout,
81    /// A C_TPDU (poll or data block) sent while `Active` went unanswered
82    /// past the reply timeout (§A.4 timeout arc, applied to the `Active`
83    /// state rather than only connection setup).
84    #[error("no reply to an in-flight C_TPDU within the reply timeout")]
85    ReplyTimeout,
86    /// A reply arrived for a different `t_c_id` than ours.
87    #[error("unexpected t_c_id {got} (expected {expected})")]
88    WrongTcId {
89        /// The `t_c_id` received.
90        got: u8,
91        /// Our connection's `t_c_id`.
92        expected: u8,
93    },
94    /// The module reported a `T_C_Error`.
95    #[error("module reported T_C_Error")]
96    ModuleError,
97    /// A frame could not be parsed as an `R_TPDU`.
98    #[error("malformed R_TPDU")]
99    Malformed,
100    /// A chained `T_Data_More*` reassembly exceeded [`MAX_REASSEMBLY_LEN`]
101    /// without a terminating `T_Data_Last` — the partial reassembly is
102    /// dropped rather than retained (a well-behaved module chains a bounded
103    /// number of fragments; this guards a stuck or hostile one from growing
104    /// host memory without limit).
105    #[error("chained TPDU reassembly exceeded {MAX_REASSEMBLY_LEN} bytes without a T_Data_Last")]
106    ReassemblyOverflow,
107    /// [`Transport::send_spdu`] was called with [`MAX_OUTBOUND_QUEUE`] SPDUs
108    /// already queued — the caller is producing faster than the half-duplex
109    /// link can drain (one block per module turn); queueing further would
110    /// grow unboundedly. The new SPDU is dropped; the caller should retry
111    /// once earlier sends have drained.
112    #[error("outbound queue is full ({MAX_OUTBOUND_QUEUE} SPDUs already queued)")]
113    OutboundQueueFull,
114    /// [`Transport::send_spdu`] was called with an SPDU longer than
115    /// [`MAX_SPDU_LEN`] — it cannot be wrapped in a single `T_Data_Last`
116    /// `CommandTpdu` (`length_field`'s own max encodable value). Previously
117    /// this was only discovered when the resulting `CommandTpdu` failed to
118    /// serialize, which `Transport::cmd` reported by panicking.
119    #[error(
120        "SPDU of {len} bytes exceeds the {MAX_SPDU_LEN}-byte maximum a single T_Data_Last can carry"
121    )]
122    SpduTooLarge {
123        /// The SPDU's actual length.
124        len: usize,
125    },
126}
127
128/// Defensive cap on a single chained-fragment reassembly (`Transport::on_data`).
129/// Not a spec limit — EN 50221 places no bound on how many `T_Data_More`
130/// fragments may precede a `T_Data_Last` — but an unbounded accumulator lets
131/// a stuck or hostile module grow host memory without limit; 2 MiB is far
132/// beyond any legitimate CI application object this transport carries
133/// (`dvb-ci`'s own largest length-prefixed objects top out at 65535 bytes).
134pub const MAX_REASSEMBLY_LEN: usize = 2 * 1024 * 1024;
135
136/// Defensive cap on [`Transport::send_spdu`]'s queue depth. The link sends
137/// one queued block per module turn (§A.4 half-duplex, issue #337), so a
138/// caller enqueueing faster than that drains would otherwise grow the queue
139/// without limit.
140pub const MAX_OUTBOUND_QUEUE: usize = 64;
141
142/// Largest SPDU [`Transport::send_spdu`] can wrap in one `T_Data_Last`
143/// `CommandTpdu`: `length_field`'s own max encodable value (65535, EN 50221
144/// Table 1) minus the 1-byte `t_c_id` that shares the length-prefixed span.
145pub const MAX_SPDU_LEN: usize = 65_535 - 1;
146
147/// The single transport connection for a slot.
148#[derive(Debug)]
149pub struct Transport {
150    tcid: u8,
151    state: TcState,
152    reassembly: Vec<u8>,
153    poll_interval: Duration,
154    reply_timeout: Duration,
155    /// Time accumulated since the last poll (drives the poll cadence).
156    since_poll: Duration,
157    /// Time accumulated since a command that expects a reply (drives the
158    /// reply timeout); `None` when not awaiting a reply. While `Some`, a host
159    /// C_TPDU is *in flight* (sent, module not yet answered) — the link is
160    /// half-duplex, so no further data block may be sent until it clears.
161    awaiting: Option<Duration>,
162    /// SPDUs queued to send, one `T_Data_Last` per module turn. EN 50221's link
163    /// is polled half-duplex: the host sends a single data block, then must wait
164    /// for the module's `T_SB` before sending the next. Sending two back-to-back
165    /// makes a real CAM drop the second (issue #337).
166    outbound: VecDeque<Vec<u8>>,
167}
168
169impl Default for Transport {
170    fn default() -> Self {
171        Self::new(1)
172    }
173}
174
175impl Transport {
176    /// New transport for `tcid` with default timings.
177    #[must_use]
178    pub fn new(tcid: u8) -> Self {
179        Self {
180            tcid,
181            state: TcState::Idle,
182            reassembly: Vec::new(),
183            poll_interval: DEFAULT_POLL_INTERVAL,
184            reply_timeout: DEFAULT_REPLY_TIMEOUT,
185            since_poll: Duration::ZERO,
186            awaiting: None,
187            outbound: VecDeque::new(),
188        }
189    }
190
191    /// Override the poll interval / reply timeout.
192    #[must_use]
193    pub fn with_timing(mut self, poll: Duration, reply: Duration) -> Self {
194        self.poll_interval = poll;
195        self.reply_timeout = reply;
196        self
197    }
198
199    /// Current connection state.
200    #[must_use]
201    pub fn state(&self) -> TcState {
202        self.state
203    }
204
205    /// `data` must be at most [`MAX_SPDU_LEN`] bytes — `send_spdu` enforces
206    /// this before ever queueing, and every other caller passes empty data,
207    /// so `serialize_into` failing here would mean that invariant broke,
208    /// not a normal runtime condition (r10-W-20: this used to be able to
209    /// panic on caller-controlled data that reached here unchecked).
210    fn cmd(&self, tag: u8, data: &[u8]) -> Vec<u8> {
211        let c = CommandTpdu {
212            length_form: dvb_ci::length::LengthForm::Minimal,
213            tag,
214            t_c_id: self.tcid,
215            data,
216        };
217        let mut buf = vec![0u8; c.serialized_len()];
218        let n = c
219            .serialize_into(&mut buf)
220            .expect("data.len() <= MAX_SPDU_LEN, enforced by every caller");
221        buf.truncate(n);
222        buf
223    }
224
225    fn poll_frame(&self) -> Vec<u8> {
226        // §A.4: poll == T_Data_Last with empty data.
227        self.cmd(tags::DATA_LAST, &[])
228    }
229
230    /// Open the connection: emit `Create_T_C` and arm the reply timeout.
231    pub fn init(&mut self) -> Out {
232        self.state = TcState::Creating;
233        self.awaiting = Some(Duration::ZERO);
234        let obj: TcObject = create_t_c(self.tcid);
235        Out {
236            writes: vec![
237                obj.try_to_bytes()
238                    .expect("Create_T_C is a fixed 5-byte TPDU, serialization cannot fail"),
239            ],
240            timer: Some(self.reply_timeout),
241            ..Out::default()
242        }
243    }
244
245    /// Queue an upper-layer SPDU to send (wrapped in a `T_Data_Last`). The block
246    /// is transmitted now if the link is free, else held until the in-flight
247    /// C_TPDU is answered — one data block per module turn (§A.4 half-duplex).
248    pub fn send_spdu(&mut self, spdu: &[u8]) -> Out {
249        if self.state != TcState::Active {
250            return Out::default();
251        }
252        // r10-W-20: reject up front rather than queueing an SPDU that would
253        // later fail to serialize (previously discovered by `cmd`
254        // panicking deep inside `flush`/`tick`).
255        if spdu.len() > MAX_SPDU_LEN {
256            return Out {
257                error: Some(TransportError::SpduTooLarge { len: spdu.len() }),
258                ..Out::default()
259            };
260        }
261        // r10-W-17: cap queue depth so a caller producing faster than the
262        // half-duplex link drains cannot grow it without limit.
263        if self.outbound.len() >= MAX_OUTBOUND_QUEUE {
264            return Out {
265                error: Some(TransportError::OutboundQueueFull),
266                ..Out::default()
267            };
268        }
269        self.outbound.push_back(spdu.to_vec());
270        self.flush()
271    }
272
273    /// Emit the next queued data block if the link is free (Active and no
274    /// C_TPDU in flight); otherwise nothing (it waits for the module's `T_SB`).
275    fn flush(&mut self) -> Out {
276        if self.state != TcState::Active || self.awaiting.is_some() {
277            return Out::default();
278        }
279        match self.outbound.pop_front() {
280            Some(spdu) => {
281                self.awaiting = Some(Duration::ZERO);
282                self.since_poll = Duration::ZERO;
283                Out {
284                    writes: vec![self.cmd(tags::DATA_LAST, &spdu)],
285                    timer: Some(self.poll_interval),
286                    ..Out::default()
287                }
288            }
289            None => Out::default(),
290        }
291    }
292
293    /// Advance logical time by `elapsed`: poll if due, or time out a pending
294    /// reply.
295    pub fn tick(&mut self, elapsed: Duration) -> Out {
296        match self.state {
297            TcState::Idle => Out::default(),
298            TcState::Creating => {
299                if let Some(w) = self.awaiting.as_mut() {
300                    *w += elapsed;
301                    if *w >= self.reply_timeout {
302                        self.state = TcState::Idle;
303                        self.awaiting = None;
304                        // r10-W-17: SPDUs queued against the failed
305                        // connection attempt must not linger and get sent
306                        // against a later, unrelated re-established
307                        // connection.
308                        self.outbound.clear();
309                        return Out {
310                            error: Some(TransportError::SetupTimeout),
311                            ..Out::default()
312                        };
313                    }
314                }
315                Out {
316                    timer: Some(self.reply_timeout),
317                    ..Out::default()
318                }
319            }
320            TcState::Active => {
321                // r10-W-16: a C_TPDU (poll or data block) we already sent is
322                // half-duplex — the link cannot carry another until the
323                // module answers it. Track how long it's been outstanding
324                // and time it out, exactly as `Creating` does for
325                // `Create_T_C`; only once it clears (cleared by `on_frame`,
326                // or here on timeout) does the poll cadence below apply.
327                // Previously this branch never consulted `awaiting` at all
328                // once `since_poll` crossed `poll_interval`, so it just kept
329                // sending a fresh poll on top of a still-outstanding one.
330                if let Some(w) = self.awaiting.as_mut() {
331                    *w += elapsed;
332                    if *w >= self.reply_timeout {
333                        self.awaiting = None;
334                        return Out {
335                            error: Some(TransportError::ReplyTimeout),
336                            timer: Some(self.poll_interval),
337                            ..Out::default()
338                        };
339                    }
340                    return Out {
341                        timer: Some(self.reply_timeout - *w),
342                        ..Out::default()
343                    };
344                }
345                self.since_poll += elapsed;
346                if self.since_poll >= self.poll_interval {
347                    self.since_poll = Duration::ZERO;
348                    // A queued data block goes out in preference to an empty poll.
349                    if !self.outbound.is_empty() {
350                        return self.flush();
351                    }
352                    self.awaiting = Some(Duration::ZERO);
353                    Out {
354                        writes: vec![self.poll_frame()],
355                        timer: Some(self.poll_interval),
356                        ..Out::default()
357                    }
358                } else {
359                    Out {
360                        timer: Some(self.poll_interval - self.since_poll),
361                        ..Out::default()
362                    }
363                }
364            }
365        }
366    }
367
368    /// Handle one link-layer frame read from the device.
369    ///
370    /// A module frame is a leading object (`C_T_C_Reply` / `T_Data_*` / …)
371    /// followed by an appended `T_SB`, or a standalone `T_SB` (the reply to a
372    /// poll with nothing queued). The `T_SB`'s DA bit drives whether the host
373    /// must `T_RCV` next.
374    pub fn on_frame(&mut self, frame: &[u8]) -> Out {
375        self.awaiting = None;
376        match frame.first().copied() {
377            // C_T_C_Reply (+ appended T_SB): connection becomes Active.
378            Some(tags::C_T_C_REPLY) => match TcObject::parse(frame) {
379                Ok(o) if o.t_c_id == self.tcid => {
380                    self.state = TcState::Active;
381                    self.since_poll = Duration::ZERO;
382                    // The appended T_SB follows the object, whatever form its
383                    // own length_field was coded in.
384                    let da = frame
385                        .get(o.serialized_len()..)
386                        .and_then(parse_sb)
387                        .is_some_and(|(_, sb)| sb.data_available());
388                    self.after_status(da)
389                }
390                Ok(o) => self.wrong_tcid(o.t_c_id),
391                Err(_) => self.malformed(),
392            },
393            // Standalone T_SB — the reply to a poll.
394            Some(tags::SB) => match parse_sb(frame) {
395                Some((tcid, _)) if tcid != self.tcid => self.wrong_tcid(tcid),
396                Some((_, sb)) => self.after_status(sb.data_available()),
397                None => self.malformed(),
398            },
399            Some(tags::T_C_ERROR) => Out {
400                error: Some(TransportError::ModuleError),
401                ..Out::default()
402            },
403            Some(tags::DATA_LAST | tags::DATA_MORE) => self.on_data(frame),
404            _ => self.malformed(),
405        }
406    }
407
408    /// r10-W-17: also clears any in-progress chained-fragment reassembly —
409    /// a malformed frame invalidates it; leaving it in place would silently
410    /// prepend stale bytes to the NEXT chain's data.
411    fn malformed(&mut self) -> Out {
412        self.reassembly.clear();
413        Out {
414            error: Some(TransportError::Malformed),
415            ..Out::default()
416        }
417    }
418
419    /// r10-W-17: also clears reassembly, for the same reason as [`Self::malformed`].
420    fn wrong_tcid(&mut self, got: u8) -> Out {
421        self.reassembly.clear();
422        Out {
423            error: Some(TransportError::WrongTcId {
424                got,
425                expected: self.tcid,
426            }),
427            ..Out::default()
428        }
429    }
430
431    /// React to a Status Byte: if DA, solicit the queued message with `T_RCV`;
432    /// otherwise resume the idle poll cadence.
433    fn after_status(&mut self, data_available: bool) -> Out {
434        if data_available {
435            self.awaiting = Some(Duration::ZERO);
436            Out {
437                writes: vec![self.cmd(tags::RCV, &[])],
438                ..Out::default()
439            }
440        } else {
441            // Module idle: its `T_SB` freed the link, so send the next queued
442            // data block if any (the #337 fix); otherwise resume polling.
443            if !self.outbound.is_empty() {
444                return self.flush();
445            }
446            self.since_poll = Duration::ZERO;
447            Out {
448                timer: Some(self.poll_interval),
449                ..Out::default()
450            }
451        }
452    }
453
454    fn on_data(&mut self, frame: &[u8]) -> Out {
455        let r = match ResponseTpdu::parse(frame) {
456            Ok(r) => r,
457            Err(_) => return self.malformed(),
458        };
459        if r.t_c_id != self.tcid {
460            return self.wrong_tcid(r.t_c_id);
461        }
462        self.reassembly.extend_from_slice(r.data);
463        if self.reassembly.len() > MAX_REASSEMBLY_LEN {
464            // r10-W-17: EN 50221 places no bound on the number of chained
465            // `T_Data_More` fragments — without this cap a stuck or hostile
466            // module could grow host memory without limit.
467            self.reassembly.clear();
468            return Out {
469                error: Some(TransportError::ReassemblyOverflow),
470                ..Out::default()
471            };
472        }
473        match r.block {
474            // More chained fragments: each waits for another T_RCV (§A.4 item 10).
475            Some(DataBlock::More) => {
476                self.awaiting = Some(Duration::ZERO);
477                Out {
478                    writes: vec![self.cmd(tags::RCV, &[])],
479                    ..Out::default()
480                }
481            }
482            // Last (or only) fragment: emit the reassembled SPDU, then let the
483            // appended Status Byte decide whether to receive another message.
484            _ => {
485                let mut out = self.after_status(r.sb_value.data_available());
486                if !self.reassembly.is_empty() {
487                    out.spdus.push(core::mem::take(&mut self.reassembly));
488                }
489                out
490            }
491        }
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use dvb_ci::tpdu::SbValue;
499
500    /// Build an R_TPDU frame (module→host) for tests.
501    fn r_tpdu(tag: u8, tcid: u8, data: &[u8], da: bool) -> Vec<u8> {
502        // tag, length_field(=1+data), tcid, data..., SB(0x80), len=2, tcid, sb_value
503        let mut v = vec![tag];
504        v.push((1 + data.len()) as u8);
505        v.push(tcid);
506        v.extend_from_slice(data);
507        v.extend_from_slice(&[tags::SB, 0x02, tcid, SbValue::new(da).0]);
508        v
509    }
510
511    #[test]
512    fn init_sends_create_tc_and_arms_timeout() {
513        let mut t = Transport::new(1);
514        let out = t.init();
515        assert_eq!(out.writes, vec![vec![tags::CREATE_T_C, 0x01, 0x01]]);
516        assert_eq!(t.state(), TcState::Creating);
517        assert_eq!(out.timer, Some(DEFAULT_REPLY_TIMEOUT));
518    }
519
520    #[test]
521    fn setup_times_out_to_idle() {
522        let mut t = Transport::new(1);
523        t.init();
524        let out = t.tick(DEFAULT_REPLY_TIMEOUT);
525        assert_eq!(out.error, Some(TransportError::SetupTimeout));
526        assert_eq!(t.state(), TcState::Idle);
527    }
528
529    #[test]
530    fn reply_activates_then_polls_on_interval() {
531        let mut t = Transport::new(1);
532        t.init();
533        let out = t.on_frame(&[tags::C_T_C_REPLY, 0x01, 0x01]);
534        assert_eq!(t.state(), TcState::Active);
535        assert!(out.error.is_none());
536        // Before the interval: no poll.
537        let early = t.tick(DEFAULT_POLL_INTERVAL / 2);
538        assert!(early.writes.is_empty());
539        // Crossing the interval: an empty T_Data_Last poll.
540        let due = t.tick(DEFAULT_POLL_INTERVAL);
541        assert_eq!(due.writes, vec![vec![tags::DATA_LAST, 0x01, 0x01]]);
542    }
543
544    #[test]
545    fn reassembles_more_then_last_into_one_spdu() {
546        let mut t = Transport::new(1);
547        t.init();
548        t.on_frame(&[tags::C_T_C_REPLY, 0x01, 0x01]);
549        // MORE: partial data, solicits RCV
550        let o1 = t.on_frame(&r_tpdu(tags::DATA_MORE, 1, &[0xAA, 0xBB], false));
551        assert!(o1.spdus.is_empty());
552        assert_eq!(o1.writes, vec![vec![tags::RCV, 0x01, 0x01]]);
553        // LAST: completes the SPDU
554        let o2 = t.on_frame(&r_tpdu(tags::DATA_LAST, 1, &[0xCC], false));
555        assert_eq!(o2.spdus, vec![vec![0xAA, 0xBB, 0xCC]]);
556    }
557
558    #[test]
559    fn data_available_triggers_rcv() {
560        let mut t = Transport::new(1);
561        t.init();
562        t.on_frame(&[tags::C_T_C_REPLY, 0x01, 0x01]);
563        // LAST with DA set → host must RCV the next queued message.
564        let o = t.on_frame(&r_tpdu(tags::DATA_LAST, 1, &[0x01], true));
565        assert_eq!(o.spdus, vec![vec![0x01]]);
566        assert_eq!(o.writes, vec![vec![tags::RCV, 0x01, 0x01]]);
567    }
568
569    #[test]
570    fn two_sends_serialize_one_block_per_module_turn() {
571        // #337: a real CAM drops a second T_Data_Last sent before it answers the
572        // first. Two send_spdu in one turn must emit only ONE write; the second
573        // goes out after the module's T_SB.
574        let mut t = Transport::new(1);
575        t.init();
576        t.on_frame(&[tags::C_T_C_REPLY, 0x01, 0x01]);
577
578        let first = t.send_spdu(&[0x92, 0x07]); // e.g. open_session_response
579        assert_eq!(first.writes.len(), 1);
580        assert_eq!(first.writes[0][0], tags::DATA_LAST);
581
582        // Queued while the first is in flight → no write yet.
583        let second = t.send_spdu(&[0x9F, 0x80, 0x10, 0x00]); // profile_enq
584        assert!(
585            second.writes.is_empty(),
586            "second block must wait for the SB"
587        );
588
589        // Module acknowledges with a standalone T_SB (data_available = 0).
590        let after_sb = t.on_frame(&[tags::SB, 0x02, 0x01, SbValue::new(false).0]);
591        assert_eq!(
592            after_sb.writes.len(),
593            1,
594            "second block flushes after the SB"
595        );
596        assert_eq!(after_sb.writes[0][0], tags::DATA_LAST);
597        // It carries the profile_enq payload.
598        assert!(
599            after_sb.writes[0]
600                .windows(4)
601                .any(|w| w == [0x9F, 0x80, 0x10, 0x00])
602        );
603    }
604
605    /// r10-W-16: while a C_TPDU sent in `Active` is still awaiting its
606    /// reply, `tick` must not send another one (the link is half-duplex) —
607    /// pre-fix, once `since_poll` crossed `poll_interval` again, `tick` sent
608    /// a fresh poll regardless of `awaiting`.
609    #[test]
610    fn active_tick_does_not_poll_while_a_c_tpdu_is_in_flight() {
611        let mut t = Transport::new(1);
612        t.init();
613        t.on_frame(&[tags::C_T_C_REPLY, 0x01, 0x01]);
614        // Send a data block: awaiting becomes Some, no reply yet.
615        let sent = t.send_spdu(&[0xAA]);
616        assert_eq!(sent.writes.len(), 1, "the data block itself goes out");
617
618        // Advance well past one poll_interval, twice over, with no reply.
619        let tick1 = t.tick(DEFAULT_POLL_INTERVAL);
620        assert!(
621            tick1.writes.is_empty(),
622            "must not poll while a C_TPDU is in flight, got {:?}",
623            tick1.writes
624        );
625        let tick2 = t.tick(DEFAULT_POLL_INTERVAL);
626        assert!(
627            tick2.writes.is_empty(),
628            "must not poll while a C_TPDU is in flight, got {:?}",
629            tick2.writes
630        );
631    }
632
633    /// r10-W-16: an in-flight C_TPDU that the module never answers must
634    /// eventually time out (there was previously no timeout at all for the
635    /// `Active` state's `awaiting` — only `Creating`'s `Create_T_C` had
636    /// one).
637    #[test]
638    fn active_reply_timeout_surfaces_error_and_clears_awaiting() {
639        let mut t = Transport::new(1);
640        t.init();
641        t.on_frame(&[tags::C_T_C_REPLY, 0x01, 0x01]);
642        t.send_spdu(&[0xAA]);
643
644        let out = t.tick(DEFAULT_REPLY_TIMEOUT);
645        assert_eq!(out.error, Some(TransportError::ReplyTimeout));
646        assert_eq!(t.state(), TcState::Active, "stays Active, just resets");
647
648        // Awaiting cleared: a later poll can now go out again.
649        let due = t.tick(DEFAULT_POLL_INTERVAL);
650        assert_eq!(due.writes, vec![vec![tags::DATA_LAST, 0x01, 0x01]]);
651    }
652
653    #[test]
654    fn wrong_tcid_is_flagged() {
655        let mut t = Transport::new(1);
656        t.init();
657        let o = t.on_frame(&[tags::C_T_C_REPLY, 0x01, 0x09]);
658        assert_eq!(
659            o.error,
660            Some(TransportError::WrongTcId {
661                got: 9,
662                expected: 1
663            })
664        );
665    }
666
667    /// r10-W-20: an over-long SPDU must be rejected with `SpduTooLarge`
668    /// instead of `send_spdu` propagating it through `flush` into `cmd`,
669    /// where it used to reach a `.expect()` and panic.
670    #[test]
671    fn send_spdu_rejects_an_oversized_spdu_instead_of_panicking() {
672        let mut t = Transport::new(1);
673        t.init();
674        t.on_frame(&[tags::C_T_C_REPLY, 0x01, 0x01]);
675
676        let oversized = vec![0u8; MAX_SPDU_LEN + 1];
677        let out = t.send_spdu(&oversized);
678        assert_eq!(
679            out.error,
680            Some(TransportError::SpduTooLarge {
681                len: oversized.len()
682            })
683        );
684        assert!(out.writes.is_empty());
685        assert!(
686            t.outbound.is_empty(),
687            "the oversized SPDU must not be queued either"
688        );
689    }
690
691    /// r10-W-17: a malformed frame arriving mid-chain must clear whatever
692    /// partial reassembly was in progress, so it does not silently
693    /// contaminate the NEXT chain's data with stale bytes.
694    #[test]
695    fn reassembly_is_cleared_on_a_malformed_frame_mid_chain() {
696        let mut t = Transport::new(1);
697        t.init();
698        t.on_frame(&[tags::C_T_C_REPLY, 0x01, 0x01]);
699
700        // Start a chain: one MORE fragment with stale bytes.
701        let o1 = t.on_frame(&r_tpdu(tags::DATA_MORE, 1, &[0xDE, 0xAD], false));
702        assert_eq!(o1.writes, vec![vec![tags::RCV, 0x01, 0x01]]);
703        assert_eq!(t.reassembly, vec![0xDE, 0xAD]);
704
705        // A malformed frame interrupts the chain.
706        let o2 = t.on_frame(&[0xFF]);
707        assert_eq!(o2.error, Some(TransportError::Malformed));
708        assert!(
709            t.reassembly.is_empty(),
710            "stale bytes must not survive a malformed frame"
711        );
712
713        // A fresh, unrelated chain must reassemble cleanly, with no leftover
714        // 0xDE 0xAD prefix from before.
715        let o3 = t.on_frame(&r_tpdu(tags::DATA_LAST, 1, &[0x01, 0x02], false));
716        assert_eq!(o3.spdus, vec![vec![0x01, 0x02]]);
717    }
718
719    /// r10-W-17: chained `T_Data_More` reassembly must not grow without
720    /// bound — EN 50221 places no cap on fragment count, so a stuck or
721    /// hostile module could otherwise exhaust host memory.
722    #[test]
723    fn reassembly_overflow_is_rejected_and_cleared() {
724        let mut t = Transport::new(1);
725        t.init();
726        t.on_frame(&[tags::C_T_C_REPLY, 0x01, 0x01]);
727
728        // Feed MORE fragments large enough to cross MAX_REASSEMBLY_LEN.
729        // Each R_TPDU's `data` field is capped at 65534 bytes (length_field
730        // max 65535 minus the 1-byte t_c_id), so a handful of maximal
731        // fragments comfortably crosses the 2 MiB cap. Built via
732        // `ResponseTpdu`'s own `Serialize` (not the small-payload `r_tpdu`
733        // helper, whose length byte can't represent more than 127 bytes).
734        let chunk = vec![0xAAu8; 65_534];
735        let frame = ResponseTpdu {
736            length_form: dvb_ci::length::LengthForm::Minimal,
737            status_length_form: dvb_ci::length::LengthForm::Minimal,
738            tag: tags::DATA_MORE,
739            t_c_id: 1,
740            data: &chunk,
741            sb_value: SbValue::new(false),
742            block: Some(DataBlock::More),
743        }
744        .try_to_bytes()
745        .unwrap();
746        let mut overflowed = false;
747        for _ in 0..40 {
748            let out = t.on_frame(&frame);
749            if let Some(err) = out.error {
750                assert_eq!(err, TransportError::ReassemblyOverflow);
751                overflowed = true;
752                break;
753            }
754        }
755        assert!(overflowed, "40 * 65534 bytes must cross MAX_REASSEMBLY_LEN");
756        assert!(
757            t.reassembly.is_empty(),
758            "overflowed reassembly must be cleared, not retained"
759        );
760    }
761
762    /// r10-W-17: `send_spdu` must not grow the outbound queue without
763    /// bound when the caller produces faster than the half-duplex link
764    /// drains.
765    #[test]
766    fn send_spdu_rejects_once_the_outbound_queue_is_full() {
767        let mut t = Transport::new(1);
768        t.init();
769        t.on_frame(&[tags::C_T_C_REPLY, 0x01, 0x01]);
770
771        // The first send goes out immediately and leaves `awaiting` set, so
772        // every subsequent send just queues (nothing drains it here).
773        t.send_spdu(&[0x00]);
774        for _ in 0..MAX_OUTBOUND_QUEUE {
775            let out = t.send_spdu(&[0x00]);
776            assert!(out.error.is_none());
777        }
778        assert_eq!(t.outbound.len(), MAX_OUTBOUND_QUEUE);
779
780        let full = t.send_spdu(&[0x00]);
781        assert_eq!(full.error, Some(TransportError::OutboundQueueFull));
782        assert_eq!(
783            t.outbound.len(),
784            MAX_OUTBOUND_QUEUE,
785            "the rejected SPDU must not be queued"
786        );
787    }
788
789    /// r10-W-17: SPDUs queued before a connection attempt fails (setup
790    /// timeout) must not linger and get sent against a later, unrelated
791    /// re-established connection.
792    #[test]
793    fn outbound_queue_is_cleared_on_setup_timeout() {
794        let mut t = Transport::new(1);
795        t.init();
796        t.on_frame(&[tags::C_T_C_REPLY, 0x01, 0x01]);
797        t.send_spdu(&[0xAA]); // in flight
798        t.send_spdu(&[0xBB]); // queued behind it
799        assert_eq!(t.outbound.len(), 1);
800
801        // Simulate an external re-init (e.g. the session layer resetting
802        // the connection) while the old queue is still populated, then let
803        // the new attempt time out.
804        t.init();
805        assert_eq!(t.state(), TcState::Creating);
806        let out = t.tick(DEFAULT_REPLY_TIMEOUT);
807        assert_eq!(out.error, Some(TransportError::SetupTimeout));
808        assert!(
809            t.outbound.is_empty(),
810            "stale queued SPDUs must not survive into the next connection"
811        );
812    }
813
814    /// r10-W-4 / #337: the module's `profile_reply` frame as captured raw on
815    /// hardware — the R_TPDU `length_field` is the long form `82 00 09`. The
816    /// SPDU must come out intact (`90 02 00 01 9F 80 11 00`), not offset by the
817    /// two extra length bytes.
818    #[test]
819    fn captured_long_form_r_tpdu_yields_the_spdu_intact() {
820        let mut t = Transport::new(1);
821        t.init();
822        t.on_frame(&[tags::C_T_C_REPLY, 0x01, 0x01]);
823        let frame = [
824            0xA0, 0x82, 0x00, 0x09, 0x01, 0x90, 0x02, 0x00, 0x01, 0x9F, 0x80, 0x11, 0x00, 0x80,
825            0x02, 0x01, 0x00,
826        ];
827        let out = t.on_frame(&frame);
828        assert!(out.error.is_none(), "{:?}", out.error);
829        assert_eq!(
830            out.spdus,
831            vec![vec![0x90, 0x02, 0x00, 0x01, 0x9F, 0x80, 0x11, 0x00]]
832        );
833    }
834
835    /// A `C_T_C_Reply` coded in a long form with the appended `T_SB` (data
836    /// available) also coded in a long form: the connection becomes Active and
837    /// the DA bit is still honoured (the host answers with `T_RCV`). The old
838    /// fixed `frame[3..]` offset read the length octets as the status.
839    #[test]
840    fn long_form_c_t_c_reply_and_status_still_drive_the_link() {
841        let mut t = Transport::new(1);
842        t.init();
843        let out = t.on_frame(&[
844            tags::C_T_C_REPLY,
845            0x82,
846            0x00,
847            0x01,
848            0x01,
849            tags::SB,
850            0x82,
851            0x00,
852            0x02,
853            0x01,
854            SbValue::new(true).0,
855        ]);
856        assert_eq!(t.state(), TcState::Active);
857        assert_eq!(out.writes, vec![vec![tags::RCV, 0x01, 0x01]]);
858    }
859}