Skip to main content

dvb_ci_runtime/
stack.rs

1//! The CI protocol stack — composes the transport + session layers and the
2//! resource state machines (Resource Manager, application_information,
3//! conditional_access, date_time, mmi, host_control) into one sans-IO core.
4//!
5//! [`CiStack::handle`] is the pure entry point: feed it an [`Event`], get back
6//! the [`Action`]s the driver must perform. No I/O, threads, or clock here.
7
8use crate::event::{Action, Event, HostRequest, Notification};
9use crate::resource::{
10    ApplicationInformation, ConditionalAccess, DateTime, HostControl, Mmi, Resource,
11    ResourceManager, ResourceOut,
12};
13use crate::session::{SessionLayer, SessionOut};
14use crate::transport::{Out as TransportOut, Transport};
15
16use broadcast_common::Parse;
17use dvb_ci::builder::{build_ca_pmt, build_ca_pmt_for_caids};
18use dvb_ci::objects::ca_pmt::{CaPmtCmdId, CaPmtListManagement};
19use dvb_ci::objects::mmi_high::{Answ, AnswId, MenuAnsw};
20use dvb_ci::resource::{
21    APPLICATION_INFORMATION, CONDITIONAL_ACCESS_SUPPORT, DATE_TIME, HOST_CONTROL, MMI,
22    RESOURCE_MANAGER, ResourceId,
23};
24use dvb_si::tables::pmt::PmtSection;
25
26/// Convert a serialize failure into the host-facing error notification the
27/// stack already uses for every other internal error (transport errors,
28/// `build_ca_pmt_bytes` failures): non-fatal and informational, never a panic
29/// or a silently dropped action.
30fn err_action(e: dvb_ci::Error) -> Action {
31    Action::Notify(Notification::Error {
32        detail: e.to_string(),
33    })
34}
35
36/// The composed EN 50221 protocol core.
37pub struct CiStack {
38    transport: Transport,
39    session: SessionLayer,
40    /// Application-layer resource handlers, dispatched by `ResourceId`.
41    resources: Vec<Box<dyn Resource>>,
42    /// Resources the host **provides** — the module opens sessions to these, so
43    /// the host accepts an incoming `open_session_request` for them
44    /// (resource_manager, date_time). Module-provided resources are opened the
45    /// other way, by the host's `create_session` (#340).
46    host_provided: Vec<ResourceId>,
47    /// `CA_system_id`s the CAM advertised in its `ca_info` (the descramble
48    /// filter set; empty until `ca_info` arrives).
49    cam_caids: Vec<u16>,
50}
51
52impl Default for CiStack {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58impl CiStack {
59    /// New stack on transport connection `t_c_id = 1`. The host advertises the
60    /// Resource Manager and registers the RM + application_information +
61    /// conditional_access handlers.
62    #[must_use]
63    pub fn new() -> Self {
64        // The host *provides* all six resources it implements; the module opens
65        // a session to each (module → host `open_session_request`, host accepts),
66        // and this is the list the RM advertises in its `profile` reply. Verified
67        // on hardware (#340, live AlphaCrypt): the module opens sessions only to
68        // resources the host advertises here — so application_information,
69        // conditional_access and mmi MUST be advertised, or the module never
70        // opens them and `ca_info` never arrives. (The earlier host-initiated
71        // `create_session`/`open_session_request` for these was wrong: the module
72        // rejects/ignores it.)
73        let host_provided = vec![
74            RESOURCE_MANAGER,
75            APPLICATION_INFORMATION,
76            CONDITIONAL_ACCESS_SUPPORT,
77            DATE_TIME,
78            MMI,
79            HOST_CONTROL,
80        ];
81        Self {
82            transport: Transport::new(1),
83            session: SessionLayer::new(),
84            resources: vec![
85                Box::new(ResourceManager::new(host_provided.clone())),
86                Box::new(ApplicationInformation),
87                Box::new(ConditionalAccess),
88                Box::new(DateTime::new()),
89                Box::new(Mmi),
90                Box::new(HostControl),
91            ],
92            host_provided,
93            cam_caids: Vec::new(),
94        }
95    }
96
97    /// Register an additional resource handler.
98    pub fn register(&mut self, resource: Box<dyn Resource>) -> &mut Self {
99        self.resources.push(resource);
100        self
101    }
102
103    /// Index of the registered handler for `resource`, if any.
104    fn handler_index(&self, resource: ResourceId) -> Option<usize> {
105        self.resources.iter().position(|r| r.id() == resource)
106    }
107
108    /// The pure sans-IO entry point.
109    pub fn handle(&mut self, event: Event<'_>) -> Vec<Action> {
110        match event {
111            Event::Host(HostRequest::Init) => {
112                // r10-W-19: rebuild every piece of stack-level state Init
113                // is responsible for, not just the transport connection —
114                // otherwise a stale session table / cached CAIDs from
115                // whatever was connected before survive into the new
116                // handshake (a `session_nb` the old CAM had open could
117                // alias one a newly-inserted CAM later opens for a
118                // DIFFERENT resource, since `SessionLayer` has no way to
119                // know the old binding is stale).
120                self.session = SessionLayer::new();
121                self.cam_caids = Vec::new();
122                // r10-W-19: also reset each resource handler's own internal
123                // state (e.g. the Resource Manager's `ready`/`module_profiled`
124                // latch, date_time's resend timer) — otherwise a re-inserted
125                // CAM's fresh handshake runs against state a PREVIOUS module
126                // left behind.
127                for r in &mut self.resources {
128                    r.reset();
129                }
130                let mut actions = vec![Action::Reset, Action::QuerySlot];
131                let out = self.transport.init();
132                actions.extend(self.emit_transport(out));
133                actions
134            }
135            Event::Tick { elapsed } => {
136                let out = self.transport.tick(elapsed);
137                let mut actions = self.emit_transport(out);
138                // Advance each open resource's timers (e.g. date_time resend).
139                for (session_nb, resource) in self.session.sessions() {
140                    if let Some(i) = self.handler_index(resource) {
141                        match self.resources[i].tick(elapsed) {
142                            Ok(out) => actions.extend(self.process_resource_out(session_nb, out)),
143                            Err(e) => actions.push(err_action(e)),
144                        }
145                    }
146                }
147                actions
148            }
149            Event::Readable(frame) => {
150                let out = self.transport.on_frame(frame);
151                self.emit_transport(out)
152            }
153            Event::Host(HostRequest::SendCaPmt(apdu)) => {
154                self.send_to_resource(CONDITIONAL_ACCESS_SUPPORT, apdu)
155            }
156            Event::Host(HostRequest::Descramble(pmt)) => self.descramble(pmt),
157            Event::Host(HostRequest::DescramblePrograms(pmts)) => self.descramble_programs(pmts),
158            Event::Host(HostRequest::AddProgram(pmt)) => self.add_program(pmt),
159            Event::Host(HostRequest::RemoveProgram(pmt)) => self.remove_program(pmt),
160            Event::Host(HostRequest::EnterMenu) => {
161                match crate::resource::ser(&dvb_ci::objects::application_info::EnterMenu::default())
162                {
163                    Ok(apdu) => self.send_to_resource(APPLICATION_INFORMATION, &apdu),
164                    Err(e) => vec![err_action(e)],
165                }
166            }
167            Event::Host(HostRequest::MmiMenuAnswer(choice_ref)) => {
168                match crate::resource::ser(&MenuAnsw {
169                    length_form: dvb_ci::length::LengthForm::Minimal,
170                    choice_ref,
171                }) {
172                    Ok(apdu) => self.send_to_resource(MMI, &apdu),
173                    Err(e) => vec![err_action(e)],
174                }
175            }
176            Event::Host(HostRequest::MmiEnquiryAnswer(text)) => {
177                match crate::resource::ser(&Answ {
178                    length_form: dvb_ci::length::LengthForm::Minimal,
179                    answ_id: AnswId::Answer,
180                    text_chars: text,
181                }) {
182                    Ok(apdu) => self.send_to_resource(MMI, &apdu),
183                    Err(e) => vec![err_action(e)],
184                }
185            }
186            Event::Host(HostRequest::MmiCancel) => match crate::resource::ser(&Answ {
187                length_form: dvb_ci::length::LengthForm::Minimal,
188                answ_id: AnswId::Cancel,
189                text_chars: &[],
190            }) {
191                Ok(apdu) => self.send_to_resource(MMI, &apdu),
192                Err(e) => vec![err_action(e)],
193            },
194            Event::Host(HostRequest::Shutdown) => {
195                // r10-W-19: previously a complete no-op — this is the
196                // symmetric teardown to `Init`'s build-up, so a later
197                // `Init` starts genuinely clean rather than compounding
198                // onto whatever this shutdown should have cleared out
199                // (stale sessions, cached CAIDs, an `Active` transport
200                // connection the module no longer expects to hear from,
201                // and now each resource's own latched state, r10-W-19).
202                self.transport = Transport::new(1);
203                self.session = SessionLayer::new();
204                self.cam_caids = Vec::new();
205                for r in &mut self.resources {
206                    r.reset();
207                }
208                vec![Action::Reset]
209            }
210        }
211    }
212
213    /// React to a CA notification as it is surfaced: cache the CAM's CAIDs from
214    /// `ca_info`, and complete a pending [`HostRequest::Descramble`] by sending
215    /// `ok_descrambling` when the `ca_pmt_reply` says descrambling is possible.
216    fn on_ca_notification(&mut self, note: &Notification) -> Vec<Action> {
217        // Cache the CAM's advertised CAIDs so a later `descramble` can filter the
218        // `ca_pmt` to them. (The `ca_pmt_reply` outcome is surfaced to the host as
219        // `Notification::CaPmtReply`; no follow-up SPDU is needed — we send
220        // `ok_descrambling` up front.)
221        if let Notification::CaInfo { ca_system_ids } = note {
222            self.cam_caids = ca_system_ids.clone();
223        }
224        Vec::new()
225    }
226
227    /// Begin a [`HostRequest::Descramble`]: build a CAID-filtered `ca_pmt` with
228    /// `cmd_id = ok_descrambling` and send it.
229    ///
230    /// We do NOT send a `query` first: a real AlphaCrypt/Irdeto module does not
231    /// reply to a `ca_pmt` query (verified live — the query was sent and the
232    /// module stayed silent, so a query→reply→ok flow stalls forever). The
233    /// module descrambles directly on `ok_descrambling` and reports the outcome
234    /// via `ca_pmt_reply` (surfaced as `Notification::CaPmtReply`). This matches
235    /// what oscam / libdvben50221 do in practice.
236    fn descramble(&mut self, pmt: &[u8]) -> Vec<Action> {
237        self.send_ca_pmt_for(pmt, CaPmtListManagement::Only, CaPmtCmdId::OkDescrambling)
238    }
239
240    /// Descramble a **set** of programmes in one CA-PMT list (§8.4.3.4): the
241    /// first programme is sent `list_management = first` (or `only` if it is the
242    /// sole programme), the interior ones `more`, the last `last`; all with
243    /// `cmd_id = ok_descrambling`. Replaces any previously selected set. The
244    /// per-programme `ca_pmt`s are serialised one-per-module-turn by the
245    /// transport queue.
246    fn descramble_programs(&mut self, pmts: &[&[u8]]) -> Vec<Action> {
247        let mut actions = Vec::new();
248        let n = pmts.len();
249        for (i, pmt) in pmts.iter().enumerate() {
250            let lm = match (n, i) {
251                (1, _) => CaPmtListManagement::Only,
252                (_, 0) => CaPmtListManagement::First,
253                (_, i) if i == n - 1 => CaPmtListManagement::Last,
254                _ => CaPmtListManagement::More,
255            };
256            actions.extend(self.send_ca_pmt_for(pmt, lm, CaPmtCmdId::OkDescrambling));
257        }
258        actions
259    }
260
261    /// Add one programme to the descrambled set without re-listing the rest
262    /// (`list_management = add`, `cmd_id = ok_descrambling`).
263    fn add_program(&mut self, pmt: &[u8]) -> Vec<Action> {
264        self.send_ca_pmt_for(pmt, CaPmtListManagement::Add, CaPmtCmdId::OkDescrambling)
265    }
266
267    /// Remove one programme from the descrambled set (`list_management = update`,
268    /// `cmd_id = not_selected` — tells the CAM to stop descrambling it).
269    fn remove_program(&mut self, pmt: &[u8]) -> Vec<Action> {
270        self.send_ca_pmt_for(pmt, CaPmtListManagement::Update, CaPmtCmdId::NotSelected)
271    }
272
273    /// Build a CAID-filtered `ca_pmt` for `pmt` with the given list-management +
274    /// command id and send it on the conditional-access session.
275    fn send_ca_pmt_for(
276        &mut self,
277        pmt: &[u8],
278        list_management: CaPmtListManagement,
279        cmd_id: CaPmtCmdId,
280    ) -> Vec<Action> {
281        match self.build_ca_pmt_bytes(pmt, list_management, cmd_id) {
282            Ok(bytes) => self.send_to_resource(CONDITIONAL_ACCESS_SUPPORT, &bytes),
283            Err(detail) => vec![Action::Notify(Notification::Error { detail })],
284        }
285    }
286
287    /// Build a CAID-filtered `ca_pmt` APDU for `pmt` with the given
288    /// list-management + command id. Filters to the CAM's advertised CAIDs once
289    /// `ca_info` is known; falls back to all `CA_descriptor`s before then.
290    fn build_ca_pmt_bytes(
291        &self,
292        pmt: &[u8],
293        list_management: CaPmtListManagement,
294        cmd_id: CaPmtCmdId,
295    ) -> Result<Vec<u8>, String> {
296        let parsed = PmtSection::parse(pmt).map_err(|e| format!("invalid PMT: {e}"))?;
297        let built = if self.cam_caids.is_empty() {
298            build_ca_pmt(&parsed, list_management, cmd_id)
299        } else {
300            build_ca_pmt_for_caids(&parsed, &self.cam_caids, list_management, cmd_id)
301        };
302        // r10-W-20: `try_to_bytes`, not `to_bytes` — the projection of a
303        // corrupt (or CAM-influenced) PMT can have no valid `ca_pmt`
304        // encoding; surface it as the stack's `Notification::Error`, never
305        // a panic.
306        built
307            .try_to_bytes()
308            .map_err(|e| format!("ca_pmt serialization failed: {e}"))
309    }
310
311    /// Send an APDU to the open session bound to `resource` (if any).
312    fn send_to_resource(&mut self, resource: ResourceId, apdu: &[u8]) -> Vec<Action> {
313        let nb = self.session.session_for(resource);
314        match nb {
315            Some(nb) => match self.session.send_apdu(nb, apdu) {
316                Ok(spdu) => {
317                    let out = self.transport.send_spdu(&spdu);
318                    self.emit_transport(out)
319                }
320                Err(e) => vec![err_action(e)],
321            },
322            None => vec![Action::Notify(Notification::Error {
323                detail: format!("no open session for resource {}", resource.name()),
324            })],
325        }
326    }
327
328    /// Convert a transport [`Out`](TransportOut) into actions, driving any
329    /// reassembled SPDUs up through the session layer.
330    fn emit_transport(&mut self, out: TransportOut) -> Vec<Action> {
331        let mut actions = Vec::new();
332        for w in out.writes {
333            actions.push(Action::Write(w));
334        }
335        if let Some(after) = out.timer {
336            actions.push(Action::SetTimer { after });
337        }
338        if let Some(err) = out.error {
339            actions.push(Action::Notify(Notification::Error {
340                detail: err.to_string(),
341            }));
342        }
343        for spdu in out.spdus {
344            actions.extend(self.drive_session(&spdu));
345        }
346        actions
347    }
348
349    /// Feed one SPDU to the session layer and convert its output to actions.
350    fn drive_session(&mut self, spdu: &[u8]) -> Vec<Action> {
351        // The module opens sessions to **host-provided** resources
352        // (resource_manager, date_time); the host accepts those. Module-provided
353        // resources (application_information, conditional_access, mmi) are opened
354        // the other way — by the host's `create_session` (#340) — so an incoming
355        // `open_session_request` for them is *not* accepted here.
356        let host_provided = self.host_provided.clone();
357        let SessionOut {
358            spdus,
359            apdus,
360            opened,
361            closed,
362        } = match self.session.on_spdu(spdu, |r| host_provided.contains(&r)) {
363            Ok(out) => out,
364            Err(e) => return vec![err_action(e)],
365        };
366
367        let mut actions = Vec::new();
368        // Session-layer SPDUs (e.g. open_session_response) go down the transport.
369        for s in spdus {
370            actions.extend(self.send_spdu_actions(&s));
371        }
372        for (session_nb, resource) in opened {
373            actions.push(Action::Notify(Notification::SessionOpened { resource }));
374            // Drive the resource handler's on_open (e.g. RM sends profile_enq).
375            if let Some(i) = self.handler_index(resource) {
376                match self.resources[i].on_open() {
377                    Ok(out) => actions.extend(self.process_resource_out(session_nb, out)),
378                    Err(e) => actions.push(err_action(e)),
379                }
380            }
381        }
382        for session_nb in closed {
383            actions.push(Action::Notify(Notification::SessionClosed { session_nb }));
384        }
385        // Route each APDU to the resource handler bound to its session.
386        for (session_nb, apdu) in apdus {
387            if let Some(resource) = self.session.resource_of(session_nb)
388                && let Some(i) = self.handler_index(resource)
389            {
390                match self.resources[i].on_apdu(&apdu) {
391                    Ok(out) => actions.extend(self.process_resource_out(session_nb, out)),
392                    Err(e) => actions.push(err_action(e)),
393                }
394            }
395        }
396        actions
397    }
398
399    /// Wrap an SPDU as a `T_Data_Last` and collect the resulting actions.
400    fn send_spdu_actions(&mut self, spdu: &[u8]) -> Vec<Action> {
401        let t = self.transport.send_spdu(spdu);
402        let mut actions = Vec::new();
403        for w in t.writes {
404            actions.push(Action::Write(w));
405        }
406        if let Some(after) = t.timer {
407            actions.push(Action::SetTimer { after });
408        }
409        actions
410    }
411
412    /// Convert a [`ResourceOut`] into actions: send its APDUs on `session_nb`,
413    /// surface its notifications, and open any module resources it requested.
414    fn process_resource_out(&mut self, session_nb: u16, out: ResourceOut) -> Vec<Action> {
415        let mut actions = Vec::new();
416        for apdu in out.apdus {
417            match self.session.send_apdu(session_nb, &apdu) {
418                Ok(spdu) => actions.extend(self.send_spdu_actions(&spdu)),
419                Err(e) => actions.push(err_action(e)),
420            }
421        }
422        for note in out.notify {
423            // Drive the auto-descramble sequence off the CA notifications.
424            let follow = self.on_ca_notification(&note);
425            actions.push(Action::Notify(note));
426            actions.extend(follow);
427        }
428        for resource in out.open {
429            match self.session.create_session(resource) {
430                Ok(spdu) => actions.extend(self.send_spdu_actions(&spdu)),
431                Err(e) => actions.push(err_action(e)),
432            }
433        }
434        actions
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::transport::DEFAULT_POLL_INTERVAL;
442    use broadcast_common::Serialize;
443    use dvb_ci::resource::RESOURCE_MANAGER;
444    use dvb_ci::spdu::{OpenSessionRequest, tags as spdu_tags};
445    use dvb_ci::tpdu::{SbValue, tags as tpdu_tags};
446
447    fn ser<S: Serialize>(s: &S) -> Vec<u8> {
448        let mut b = vec![0u8; s.serialized_len()];
449        match s.serialize_into(&mut b) {
450            Ok(n) => b.truncate(n),
451            Err(_) => b.clear(),
452        }
453        b
454    }
455
456    /// Wrap an SPDU as a module→host `T_Data_Last` R_TPDU (+ T_SB, DA clear).
457    fn r_data(tcid: u8, spdu: &[u8]) -> Vec<u8> {
458        let mut v = vec![tpdu_tags::DATA_LAST, (1 + spdu.len()) as u8, tcid];
459        v.extend_from_slice(spdu);
460        v.extend_from_slice(&[tpdu_tags::SB, 0x02, tcid, SbValue::new(false).0]);
461        v
462    }
463
464    #[test]
465    fn init_resets_and_opens_transport() {
466        let mut s = CiStack::new();
467        let a = s.handle(Event::Host(HostRequest::Init));
468        assert_eq!(a[0], Action::Reset);
469        assert_eq!(a[1], Action::QuerySlot);
470        assert!(matches!(&a[2], Action::Write(w) if w[0] == tpdu_tags::CREATE_T_C));
471    }
472
473    /// r10-W-19: `Shutdown` was previously a complete no-op — it must now
474    /// reset the device and clear every piece of stack-level state `Init`
475    /// builds up (session table, cached CAIDs, the transport connection).
476    #[test]
477    fn shutdown_resets_device_and_clears_stack_state() {
478        let mut s = CiStack::new();
479        s.handle(Event::Host(HostRequest::Init));
480        s.handle(Event::Readable(&[tpdu_tags::C_T_C_REPLY, 0x01, 0x01]));
481        let osr = ser(&OpenSessionRequest {
482            length_form: dvb_ci::length::LengthForm::Minimal,
483            resource: RESOURCE_MANAGER,
484        });
485        s.handle(Event::Readable(&r_data(1, &osr)));
486        assert!(!s.session.is_empty(), "precondition: a session is open");
487        assert_eq!(s.transport.state(), crate::transport::TcState::Active);
488
489        let actions = s.handle(Event::Host(HostRequest::Shutdown));
490        assert_eq!(actions, vec![Action::Reset]);
491        assert!(
492            s.session.is_empty(),
493            "Shutdown must clear the session table"
494        );
495        assert_eq!(
496            s.transport.state(),
497            crate::transport::TcState::Idle,
498            "Shutdown must tear down the transport connection"
499        );
500    }
501
502    /// r10-W-19: `Init` must rebuild the session table and cached CAIDs,
503    /// not just the transport — otherwise a session_nb the PREVIOUS module
504    /// had open (never explicitly closed, e.g. a hot-plug swap) survives
505    /// into the new handshake and could alias whatever the new module
506    /// opens under the same number for a different resource.
507    #[test]
508    fn init_clears_stale_session_state_from_a_previous_connection() {
509        let mut s = CiStack::new();
510        s.handle(Event::Host(HostRequest::Init));
511        s.handle(Event::Readable(&[tpdu_tags::C_T_C_REPLY, 0x01, 0x01]));
512        let osr = ser(&OpenSessionRequest {
513            length_form: dvb_ci::length::LengthForm::Minimal,
514            resource: RESOURCE_MANAGER,
515        });
516        s.handle(Event::Readable(&r_data(1, &osr)));
517        assert!(
518            !s.session.is_empty(),
519            "precondition: a stale session is open"
520        );
521
522        // Re-init (e.g. a hot-plug re-handshake) without an intervening
523        // Shutdown — the stale state must still not survive.
524        s.handle(Event::Host(HostRequest::Init));
525        assert!(
526            s.session.is_empty(),
527            "Init must not inherit the previous connection's session table"
528        );
529    }
530
531    /// r10-W-19: `Init` must also reset each resource handler's own internal
532    /// state, not just the session table — otherwise the Resource Manager's
533    /// `ready` flag stays latched `true` from the FIRST handshake, and a
534    /// second `profile` (module re-sent after a hot-plug re-insert) never
535    /// re-opens the `profile_change`/`CamReady` gate (`resource.rs`'s
536    /// `ready` field), so the module idles forever after a re-insert.
537    #[test]
538    fn re_init_after_a_completed_handshake_fires_cam_ready_again() {
539        let mut s = CiStack::new();
540        s.handle(Event::Host(HostRequest::Init));
541        s.handle(Event::Readable(&[tpdu_tags::C_T_C_REPLY, 0x01, 0x01]));
542        let osr = ser(&OpenSessionRequest {
543            length_form: dvb_ci::length::LengthForm::Minimal,
544            resource: RESOURCE_MANAGER,
545        });
546        s.handle(Event::Readable(&r_data(1, &osr)));
547        let first = s.handle(Event::Readable(&r_apdu(
548            1,
549            &ser(&dvb_ci::objects::resource_manager::Profile {
550                length_form: dvb_ci::length::LengthForm::Minimal,
551                resources: vec![],
552            }),
553        )));
554        assert!(
555            first
556                .iter()
557                .any(|a| matches!(a, Action::Notify(Notification::CamReady))),
558            "precondition: the first handshake fires CamReady"
559        );
560
561        // Re-init (a hot-plug re-handshake), then re-run the RM handshake
562        // exactly as the first time (the fresh SessionLayer allocates
563        // session_nb 1 again).
564        s.handle(Event::Host(HostRequest::Init));
565        s.handle(Event::Readable(&[tpdu_tags::C_T_C_REPLY, 0x01, 0x01]));
566        let osr2 = ser(&OpenSessionRequest {
567            length_form: dvb_ci::length::LengthForm::Minimal,
568            resource: RESOURCE_MANAGER,
569        });
570        s.handle(Event::Readable(&r_data(1, &osr2)));
571        let second = s.handle(Event::Readable(&r_apdu(
572            1,
573            &ser(&dvb_ci::objects::resource_manager::Profile {
574                length_form: dvb_ci::length::LengthForm::Minimal,
575                resources: vec![],
576            }),
577        )));
578        assert!(
579            second
580                .iter()
581                .any(|a| matches!(a, Action::Notify(Notification::CamReady))),
582            "a fresh handshake after Init must fire CamReady again \
583             (pre-fix: ResourceManager.ready stayed latched from the first \
584             handshake, so profile_change/CamReady never re-fired)"
585        );
586    }
587
588    #[test]
589    fn full_pipeline_opens_a_session_for_a_provided_resource() {
590        let mut s = CiStack::new();
591        s.handle(Event::Host(HostRequest::Init));
592        // module accepts the transport connection
593        s.handle(Event::Readable(&[tpdu_tags::C_T_C_REPLY, 0x01, 0x01]));
594        // module opens a session to the host's resource_manager (carried in an
595        // R_TPDU data block)
596        let osr = ser(&OpenSessionRequest {
597            length_form: dvb_ci::length::LengthForm::Minimal,
598            resource: RESOURCE_MANAGER,
599        });
600        let actions = s.handle(Event::Readable(&r_data(1, &osr)));
601
602        // a SessionOpened notification surfaced...
603        assert!(actions.iter().any(|x| matches!(
604            x,
605            Action::Notify(Notification::SessionOpened {
606                resource
607            }) if *resource == RESOURCE_MANAGER
608        )));
609        // ...and an open_session_response was written back down (inside a TPDU).
610        let wrote_osr = actions.iter().any(|x| match x {
611            Action::Write(w) => w
612                .windows(1)
613                .any(|_| w.contains(&spdu_tags::OPEN_SESSION_RESPONSE)),
614            _ => false,
615        });
616        assert!(wrote_osr, "open_session_response must be sent down");
617
618        // and the session is tracked + a valid response decodes
619        let nb = (1u16..16).find(|&n| s.session.resource_of(n).is_some());
620        assert!(nb.is_some());
621    }
622
623    #[test]
624    fn tick_drives_poll_when_active() {
625        let mut s = CiStack::new();
626        s.handle(Event::Host(HostRequest::Init));
627        s.handle(Event::Readable(&[tpdu_tags::C_T_C_REPLY, 0x01, 0x01]));
628        let a = s.handle(Event::Tick {
629            elapsed: DEFAULT_POLL_INTERVAL,
630        });
631        assert!(
632            a.iter()
633                .any(|x| matches!(x, Action::Write(w) if w.first() == Some(&tpdu_tags::DATA_LAST)))
634        );
635    }
636
637    // --- #334: the auto-descramble (query -> reply -> ok) sequence ---
638
639    /// Feed standalone `T_SB`s (data_available = 0) — the module acking each host
640    /// block — until the stack stops writing, collecting every action. This
641    /// drains the transport's one-block-per-turn outbound queue (#337).
642    fn pump_sbs(s: &mut CiStack) -> Vec<Action> {
643        let mut all = Vec::new();
644        for _ in 0..16 {
645            let a = s.handle(Event::Readable(&[
646                tpdu_tags::SB,
647                0x02,
648                0x01,
649                SbValue::new(false).0,
650            ]));
651            let wrote = a.iter().any(|x| matches!(x, Action::Write(_)));
652            all.extend(a);
653            if !wrote {
654                break;
655            }
656        }
657        all
658    }
659
660    /// Wrap an APDU for delivery on `session_nb` (session_number prefix), then as
661    /// a module→host R_TPDU.
662    fn r_apdu(session_nb: u16, apdu: &[u8]) -> Vec<u8> {
663        use dvb_ci::spdu::SessionNumber;
664        let mut spdu = ser(&SessionNumber {
665            length_form: dvb_ci::length::LengthForm::Minimal,
666            session_nb,
667        });
668        spdu.extend_from_slice(apdu);
669        r_data(1, &spdu)
670    }
671
672    /// Minimal PMT: program_info has one CA_descriptor (CAID 0x0B00) + a non-CA
673    /// descriptor; one clear ES. Mirrors the dvb-ci builder fixture.
674    fn build_pmt() -> Vec<u8> {
675        let prog_ca = [0x09u8, 0x04, 0x0B, 0x00, 0xE1, 0x00];
676        let reg = [0x05u8, 0x04, b'H', b'D', b'M', b'V'];
677        let mut program_info = Vec::new();
678        program_info.extend_from_slice(&prog_ca);
679        program_info.extend_from_slice(&reg);
680        let lang = [0x0Au8, 0x04, b'e', b'n', b'g', 0x00];
681
682        let mut body = Vec::new();
683        body.push(0x02); // table_id
684        body.push(0);
685        body.push(0); // section_length placeholder
686        body.extend_from_slice(&[0x00, 0x01]); // program_number 1
687        body.push(0xC3); // version 1, current_next 1
688        body.push(0x00);
689        body.push(0x00);
690        body.push(0xE0 | 0x02); // PCR_PID 0x0200
691        body.push(0x00);
692        let pil = program_info.len();
693        body.push(0xF0 | ((pil >> 8) as u8 & 0x0F));
694        body.push(pil as u8);
695        body.extend_from_slice(&program_info);
696        // one clear ES
697        body.push(0x03);
698        body.push(0xE0 | 0x02);
699        body.push(0x01);
700        body.push(0xF0 | ((lang.len() >> 8) as u8 & 0x0F));
701        body.push(lang.len() as u8);
702        body.extend_from_slice(&lang);
703
704        let section_length = body.len() - 3 + 4;
705        body[1] = 0xB0 | ((section_length >> 8) as u8 & 0x0F);
706        body[2] = section_length as u8;
707        let crc = broadcast_common::crc32_mpeg2::compute(&body);
708        body.extend_from_slice(&crc.to_be_bytes());
709        body
710    }
711
712    /// Drive the full handshake to open conditional-access + mmi sessions with
713    /// the CAM's CAIDs learned, following the real flow (#340): module opens RM →
714    /// host sends `profile_change` and `create_session`s the module-provided
715    /// resources → module accepts each with `create_session_response`.
716    fn stack_with_ca_session() -> CiStack {
717        use dvb_ci::objects::ca_info::CaInfo;
718        use dvb_ci::objects::resource_manager::Profile;
719        use dvb_ci::resource::{APPLICATION_INFORMATION, CONDITIONAL_ACCESS_SUPPORT, MMI};
720        use dvb_ci::spdu::{CreateSessionResponse, OpenSessionRequest, SessionStatus};
721
722        let mut s = CiStack::new();
723        s.handle(Event::Host(HostRequest::Init));
724        s.handle(Event::Readable(&[tpdu_tags::C_T_C_REPLY, 0x01, 0x01]));
725        // module opens the host's resource_manager → RM session 1
726        s.handle(Event::Readable(&r_data(
727            1,
728            &ser(&OpenSessionRequest {
729                length_form: dvb_ci::length::LengthForm::Minimal,
730                resource: RESOURCE_MANAGER,
731            }),
732        )));
733        // module sends its profile → host: CamReady + profile_change +
734        // create_session for each module-provided resource (alloc nb 2,3,4).
735        s.handle(Event::Readable(&r_apdu(
736            1,
737            &ser(&Profile {
738                length_form: dvb_ci::length::LengthForm::Minimal,
739                resources: vec![APPLICATION_INFORMATION, CONDITIONAL_ACCESS_SUPPORT, MMI],
740            }),
741        )));
742        pump_sbs(&mut s); // flush profile_change + the first create_session
743        // The module accepts each create_session → its session opens (+ on_open
744        // enq); each acceptance frees the link so the next create_session flushes.
745        for (nb, res) in [
746            (2u16, APPLICATION_INFORMATION),
747            (3, CONDITIONAL_ACCESS_SUPPORT),
748            (4, MMI),
749        ] {
750            s.handle(Event::Readable(&r_data(
751                1,
752                &ser(&CreateSessionResponse {
753                    length_form: dvb_ci::length::LengthForm::Minimal,
754                    status: SessionStatus::Ok,
755                    resource: res,
756                    session_nb: nb,
757                }),
758            )));
759            pump_sbs(&mut s);
760        }
761        // module advertises its CAIDs on the CA session
762        let ca_nb = s
763            .session
764            .sessions()
765            .into_iter()
766            .find(|&(_, r)| r == CONDITIONAL_ACCESS_SUPPORT)
767            .map(|(n, _)| n)
768            .expect("CA session open");
769        s.handle(Event::Readable(&r_apdu(
770            ca_nb,
771            &ser(&CaInfo {
772                length_form: dvb_ci::length::LengthForm::Minimal,
773                ca_system_ids: vec![0x0B00, 0x1800],
774            }),
775        )));
776        s
777    }
778
779    /// r10-W-20: a corrupt PMT on the raw `descramble` path must surface
780    /// as `Notification::Error` from the public `CiStack::handle` entry
781    /// point and send nothing — pre-fix `build_ca_pmt_bytes` reached
782    /// `CaPmtBuilt::to_bytes()`, so any serialization failure (dvb-si's
783    /// 12-bit reads clamp a *parseable* loop inside every ca_pmt field,
784    /// but a corrupted reassembly can still fail the re-parse/serialize
785    /// boundary) panicked the driver instead of reporting.
786    #[test]
787    fn descramble_of_a_corrupt_pmt_surfaces_an_error_not_a_panic() {
788        let mut s = stack_with_ca_session();
789        // Truncate a good PMT mid-ES-loop: `bytes_len < 3 +
790        // section_length` — the exact corrupt-reassembly shape.
791        let mut pmt = build_pmt();
792        pmt.truncate(pmt.len() - 8);
793        let actions = s.handle(Event::Host(HostRequest::Descramble(&pmt)));
794        assert!(
795            actions.iter().any(|a| matches!(
796                a,
797                Action::Notify(Notification::Error { detail })
798                    if detail.contains("invalid PMT")
799            )),
800            "expected an invalid-PMT Notification::Error, got {actions:?}"
801        );
802        assert!(
803            !actions.iter().any(|a| matches!(a, Action::Write(_))),
804            "a failed PMT must send nothing"
805        );
806    }
807
808    #[test]
809    fn descramble_sends_ok_descrambling_filtered() {
810        use dvb_ci::objects::ca_pmt::CaPmtCmdId;
811        use dvb_ci::objects::ca_pmt_reply::{CaEnable, CaPmtReply};
812        use dvb_ci::resource::CONDITIONAL_ACCESS_SUPPORT;
813
814        let mut s = stack_with_ca_session();
815        let ca_nb = s
816            .session
817            .sessions()
818            .into_iter()
819            .find(|&(_, r)| r == CONDITIONAL_ACCESS_SUPPORT)
820            .map(|(n, _)| n)
821            .unwrap();
822
823        // descramble() sends ca_pmt with cmd_id = ok_descrambling directly (no
824        // query first — a real CAM doesn't reply to a query; verified live).
825        let pmt = build_pmt();
826        let mut actions = s.handle(Event::Host(HostRequest::Descramble(&pmt)));
827        // Queued behind the in-flight link; the module's SB flushes it (one block
828        // per turn — #337).
829        actions.extend(pump_sbs(&mut s));
830        let c = first_ca_pmt(&actions).expect("ca_pmt sent");
831        assert_eq!(c.cmd_id, CaPmtCmdId::OkDescrambling);
832        // CA descriptors filtered to the CAM's advertised CAIDs.
833        assert_eq!(
834            c.program_ca_descriptors.as_slice(),
835            &[0x09, 0x04, 0x0B, 0x00, 0xE1, 0x00]
836        );
837
838        // The module's ca_pmt_reply is surfaced to the host (no follow-up SPDU).
839        let reply = s.handle(Event::Readable(&r_apdu(
840            ca_nb,
841            &ser(&CaPmtReply {
842                reserved_ca_enable: Default::default(),
843                reserved_version: Default::default(),
844                length_form: dvb_ci::length::LengthForm::Minimal,
845                program_number: 1,
846                version_number: 1,
847                current_next_indicator: true,
848                ca_enable: Some(CaEnable::Possible),
849                streams: vec![],
850            }),
851        )));
852        assert!(reply.iter().any(|a| matches!(
853            a,
854            Action::Notify(Notification::CaPmtReply {
855                descrambling_ok: true,
856                ..
857            })
858        )));
859    }
860
861    /// Whether any written frame carries the 3-byte APDU tag `want`.
862    fn wrote_apdu(actions: &[Action], want: [u8; 3]) -> bool {
863        actions
864            .iter()
865            .any(|a| matches!(a, Action::Write(w) if w.windows(3).any(|x| x == want)))
866    }
867
868    #[test]
869    fn mmi_menu_answer_sends_menu_answ() {
870        let mut s = stack_with_ca_session();
871        let mut acts = s.handle(Event::Host(HostRequest::MmiMenuAnswer(2)));
872        acts.extend(pump_sbs(&mut s));
873        // menu_answ APDU (9F 88 0B) reaches the wire.
874        assert!(wrote_apdu(&acts, [0x9F, 0x88, 0x0B]));
875    }
876
877    #[test]
878    fn mmi_enquiry_answer_sends_answ() {
879        let mut s = stack_with_ca_session();
880        let mut acts = s.handle(Event::Host(HostRequest::MmiEnquiryAnswer(b"1234")));
881        acts.extend(pump_sbs(&mut s));
882        // answ APDU (9F 88 08) reaches the wire.
883        assert!(wrote_apdu(&acts, [0x9F, 0x88, 0x08]));
884    }
885
886    /// Parse every `ca_pmt` (tag `9F 80 32`) found in the written frames,
887    /// returning each one's `cmd_id` + programme CA-descriptor bytes (owned).
888    fn all_ca_pmts(actions: &[Action]) -> Vec<CaPmtSummary> {
889        use broadcast_common::Parse;
890        use dvb_ci::objects::ca_pmt::CaPmt;
891        let tag = [0x9F, 0x80, 0x32];
892        let mut out = Vec::new();
893        for a in actions {
894            if let Action::Write(w) = a
895                && let Some(pos) = w.windows(3).position(|x| x == tag)
896                && let Ok(p) = CaPmt::parse(&w[pos..])
897            {
898                out.push(CaPmtSummary {
899                    list_management: p.list_management,
900                    cmd_id: p.cmd_id.expect("programme cmd_id present"),
901                    program_ca_descriptors: p.program_ca_descriptors.to_vec(),
902                });
903            }
904        }
905        out
906    }
907
908    /// The first `ca_pmt` in the written frames.
909    fn first_ca_pmt(actions: &[Action]) -> Option<CaPmtSummary> {
910        all_ca_pmts(actions).into_iter().next()
911    }
912
913    struct CaPmtSummary {
914        list_management: dvb_ci::objects::ca_pmt::CaPmtListManagement,
915        cmd_id: dvb_ci::objects::ca_pmt::CaPmtCmdId,
916        program_ca_descriptors: Vec<u8>,
917    }
918
919    #[test]
920    fn descramble_programs_emits_first_more_last() {
921        use dvb_ci::objects::ca_pmt::{CaPmtCmdId, CaPmtListManagement};
922
923        let mut s = stack_with_ca_session();
924        let pmt = build_pmt();
925        // Three programmes → first / more / last, all ok_descrambling.
926        let mut acts = s.handle(Event::Host(HostRequest::DescramblePrograms(&[
927            &pmt, &pmt, &pmt,
928        ])));
929        acts.extend(pump_sbs(&mut s));
930        let lms: Vec<_> = all_ca_pmts(&acts)
931            .iter()
932            .map(|c| c.list_management)
933            .collect();
934        assert_eq!(
935            lms,
936            vec![
937                CaPmtListManagement::First,
938                CaPmtListManagement::More,
939                CaPmtListManagement::Last,
940            ]
941        );
942        assert!(
943            all_ca_pmts(&acts)
944                .iter()
945                .all(|c| c.cmd_id == CaPmtCmdId::OkDescrambling)
946        );
947    }
948
949    #[test]
950    fn add_and_remove_program_use_add_update() {
951        use dvb_ci::objects::ca_pmt::{CaPmtCmdId, CaPmtListManagement};
952
953        let mut s = stack_with_ca_session();
954        let pmt = build_pmt();
955
956        let mut add = s.handle(Event::Host(HostRequest::AddProgram(&pmt)));
957        add.extend(pump_sbs(&mut s));
958        let a = first_ca_pmt(&add).expect("add ca_pmt");
959        assert_eq!(a.list_management, CaPmtListManagement::Add);
960        assert_eq!(a.cmd_id, CaPmtCmdId::OkDescrambling);
961
962        let mut rm = s.handle(Event::Host(HostRequest::RemoveProgram(&pmt)));
963        rm.extend(pump_sbs(&mut s));
964        let r = first_ca_pmt(&rm).expect("remove ca_pmt");
965        assert_eq!(r.list_management, CaPmtListManagement::Update);
966        assert_eq!(r.cmd_id, CaPmtCmdId::NotSelected);
967    }
968}