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