Skip to main content

dvb_ci_runtime/
resource.rs

1//! The resource layer — application-layer state machines (ETSI EN 50221 §8),
2//! one per resource, driven by the session layer's APDUs.
3//!
4//! Each resource implements [`Resource`]: it reacts to its session opening and
5//! to incoming APDUs, producing APDUs to send back, host [`Notification`]s, and
6//! requests to open further (module-provided) resources. This module ships the
7//! mandatory [`ResourceManager`]; application_information / conditional_access /
8//! date_time / mmi land as further `Resource` impls.
9
10use std::time::Duration;
11
12use broadcast_common::{Parse, Serialize};
13use dvb_ci::objects::application_info::{ApplicationInfo, ApplicationInfoEnq};
14use dvb_ci::objects::ca_info::{CaInfo, CaInfoEnq};
15use dvb_ci::objects::ca_pmt_reply::{CaEnable, CaPmtReply};
16use dvb_ci::objects::date_time::{DateTime as CiDateTime, DateTimeEnq, UTC_TIME_LEN};
17use dvb_ci::objects::host_control::{AskRelease, ClearReplace, Replace, Tune};
18use dvb_ci::objects::mmi_display::{
19    DisplayControl, DisplayControlCmd, DisplayReply, DisplayReplyBody, DisplayReplyId, MmiMode,
20};
21use dvb_ci::objects::mmi_high::{Enq, List, Menu};
22use dvb_ci::objects::resource_manager::{Profile, ProfileChange, ProfileEnq};
23use dvb_ci::resource::{
24    APPLICATION_INFORMATION, CONDITIONAL_ACCESS_SUPPORT, DATE_TIME, HOST_CONTROL, MMI,
25    RESOURCE_MANAGER, ResourceId,
26};
27use dvb_ci::tag::{self, ApduTag};
28
29use crate::event::{HostControlEvent, MmiEvent, MmiMenu, Notification};
30
31/// Decode MMI `text_char` bytes to a `String` (lossy; full EN 300 468 Annex A
32/// decoding is the application's concern).
33fn text(chars: &[u8]) -> String {
34    String::from_utf8_lossy(chars).into_owned()
35}
36
37/// Project a parsed high-level [`Menu`] (also the body of a `list()`) onto the
38/// host-facing [`MmiMenu`] — the three header lines and the choice list kept
39/// distinct for display.
40fn to_menu(m: &Menu<'_>) -> MmiMenu {
41    MmiMenu {
42        title: text(m.title.text_chars),
43        subtitle: text(m.subtitle.text_chars),
44        bottom: text(m.bottom.text_chars),
45        choices: m.choices.iter().map(|c| text(c.text_chars)).collect(),
46    }
47}
48
49/// Serialize an APDU/SPDU object to owned bytes — the one helper the resource,
50/// session and stack layers share (audit r10-O-12: it was copied three times).
51///
52/// r10-W-20: `serialize_into` failing here used to be swallowed into a
53/// silently-empty `Vec` (and, in a later pass, turned into a panic) — either
54/// way the failure never reached the caller as a value it could act on. These
55/// layers react to APDUs that ultimately originate from the CAM/card, so a
56/// serialize error must propagate as `Err`, not corrupt the wire exchange or
57/// crash the driver.
58pub(crate) fn ser<S: Serialize<Error = dvb_ci::Error>>(s: &S) -> dvb_ci::Result<Vec<u8>> {
59    s.try_to_bytes()
60}
61
62/// The 3-byte `apdu_tag` at the start of an APDU, if present.
63pub(crate) fn peek_tag(apdu: &[u8]) -> Option<ApduTag> {
64    (apdu.len() >= 3).then(|| ApduTag::from_bytes(apdu[0], apdu[1], apdu[2]))
65}
66
67/// What a resource wants done after reacting to an input.
68#[derive(Debug, Default, Clone, PartialEq, Eq)]
69pub struct ResourceOut {
70    /// APDUs to send on this resource's session.
71    pub apdus: Vec<Vec<u8>>,
72    /// Host-facing notifications.
73    pub notify: Vec<Notification>,
74    /// Module-provided resources the host should now open (`create_session`).
75    pub open: Vec<ResourceId>,
76}
77
78/// An EN 50221 application-layer resource.
79pub trait Resource {
80    /// The resource this handler serves.
81    fn id(&self) -> ResourceId;
82    /// The session for this resource just opened.
83    fn on_open(&mut self) -> dvb_ci::Result<ResourceOut> {
84        Ok(ResourceOut::default())
85    }
86    /// An APDU arrived on this resource's session.
87    fn on_apdu(&mut self, apdu: &[u8]) -> dvb_ci::Result<ResourceOut>;
88    /// Logical time advanced (for resources with timers, e.g. date_time).
89    fn tick(&mut self, _elapsed: Duration) -> dvb_ci::Result<ResourceOut> {
90        Ok(ResourceOut::default())
91    }
92    /// Reset this resource's internal state (r10-W-19): called on
93    /// [`HostRequest::Init`](crate::event::HostRequest::Init) and
94    /// [`HostRequest::Shutdown`](crate::event::HostRequest::Shutdown) so a
95    /// re-inserted CAM starts a fresh handshake against clean state rather
96    /// than one latched by whatever the previous module left behind (e.g. a
97    /// `ready` flag that never re-opens the `profile_change` gate on a
98    /// second `profile` exchange). The default no-op is only correct for a
99    /// resource that carries no state between calls.
100    fn reset(&mut self) {}
101}
102
103/// Resource Manager (§8.4.1) — host-provided. Drives the profile exchange and,
104/// once complete, reports [`Notification::CamReady`] and asks the host to open
105/// the module-provided resources it understands.
106#[derive(Debug)]
107pub struct ResourceManager {
108    host_resources: Vec<ResourceId>,
109    module_resources: Vec<ResourceId>,
110    module_profiled: bool,
111    ready: bool,
112}
113
114impl ResourceManager {
115    /// New RM advertising `host_resources` in its profile reply.
116    #[must_use]
117    pub fn new(host_resources: Vec<ResourceId>) -> Self {
118        Self {
119            host_resources,
120            module_resources: Vec::new(),
121            module_profiled: false,
122            ready: false,
123        }
124    }
125
126    /// Resources the module advertised (valid once the profile exchange ran).
127    #[must_use]
128    pub fn module_resources(&self) -> &[ResourceId] {
129        &self.module_resources
130    }
131}
132
133impl Resource for ResourceManager {
134    fn id(&self) -> ResourceId {
135        RESOURCE_MANAGER
136    }
137
138    fn on_open(&mut self) -> dvb_ci::Result<ResourceOut> {
139        // Kick off the handshake: ask the module for its profile.
140        Ok(ResourceOut {
141            apdus: vec![ser(&ProfileEnq::default())?],
142            ..ResourceOut::default()
143        })
144    }
145
146    fn on_apdu(&mut self, apdu: &[u8]) -> dvb_ci::Result<ResourceOut> {
147        let mut out = ResourceOut::default();
148        match peek_tag(apdu) {
149            // Module asks for the host's profile → reply with our resource list.
150            Some(t) if t == tag::PROFILE_ENQ => {
151                out.apdus.push(ser(&Profile {
152                    length_form: dvb_ci::length::LengthForm::Minimal,
153                    resources: self.host_resources.clone(),
154                })?);
155            }
156            // Module's profile → record its resources.
157            Some(t) if t == tag::PROFILE => {
158                // A CAM-originated APDU that fails to parse is a protocol error to
159                // surface, not to drop silently (audit #1092 interop).
160                let p = Profile::parse(apdu)?;
161                self.module_resources = p.resources;
162                self.module_profiled = true;
163            }
164            // Resource set changed → re-enquire.
165            Some(t) if t == tag::PROFILE_CHANGE => {
166                out.apdus.push(ser(&ProfileEnq::default())?);
167                self.module_profiled = false;
168                self.ready = false;
169            }
170            _ => {}
171        }
172        // Once we have the module's profile, the host sends `profile_change`
173        // (§8.4.1.1) — the gate the module waits on; until it arrives the module
174        // idles after its `profile` reply (#340 round 1).
175        //
176        // The host does NOT open application_information / conditional_access /
177        // mmi itself. Confirmed on hardware (#340, live AlphaCrypt): the module
178        // ignores a host `open_session_request` for them and rejects a
179        // `create_session` (`status=0xF0`). Those resources are **host-provided**
180        // — the host advertises them in its `profile` reply (see
181        // `CiStack::host_provided`), and the *module* opens sessions to them
182        // (module → host `open_session_request`), exactly as it does for
183        // resource_manager / date_time. The host just accepts. Each session's
184        // `on_open` then drives its enquiry (app_info_enq, ca_info_enq).
185        if self.module_profiled && !self.ready {
186            self.ready = true;
187            out.apdus.push(ser(&ProfileChange::default())?);
188            out.notify.push(Notification::CamReady);
189        }
190        Ok(out)
191    }
192
193    /// r10-W-19: without this, `ready`/`module_profiled` stay latched from a
194    /// prior handshake, so a second `profile` (e.g. after a hot-plug
195    /// re-insert routed through a fresh `Init`) never re-opens the
196    /// `profile_change`/`CamReady` gate — the module then idles forever
197    /// (`on_apdu`'s doc comment above).
198    fn reset(&mut self) {
199        self.module_resources.clear();
200        self.module_profiled = false;
201        self.ready = false;
202    }
203}
204
205/// Application Information (§8.4.2) — module-provided. On open, enquires the
206/// module's application info; surfaces it as [`Notification::ApplicationInfo`].
207#[derive(Debug, Default)]
208pub struct ApplicationInformation;
209
210impl Resource for ApplicationInformation {
211    fn id(&self) -> ResourceId {
212        APPLICATION_INFORMATION
213    }
214
215    fn on_open(&mut self) -> dvb_ci::Result<ResourceOut> {
216        Ok(ResourceOut {
217            apdus: vec![ser(&ApplicationInfoEnq::default())?],
218            ..ResourceOut::default()
219        })
220    }
221
222    fn on_apdu(&mut self, apdu: &[u8]) -> dvb_ci::Result<ResourceOut> {
223        let mut out = ResourceOut::default();
224        if peek_tag(apdu) == Some(tag::APPLICATION_INFO) {
225            let ai = ApplicationInfo::parse(apdu)?;
226            out.notify.push(Notification::ApplicationInfo {
227                application_type: ai.application_type.to_u8(),
228                manufacturer: ai.application_manufacturer,
229                code: ai.manufacturer_code,
230                menu: String::from_utf8_lossy(ai.menu_string).into_owned(),
231            });
232        }
233        Ok(out)
234    }
235    // No state carried between calls — the default `reset()` no-op applies.
236}
237
238/// Conditional Access Support (§8.4.3) — module-provided. On open, enquires the
239/// module's supported `CA_system_id`s ([`Notification::CaInfo`]); decodes
240/// `ca_pmt_reply` ([`Notification::CaPmtReply`]). The host sends `ca_pmt` via
241/// [`HostRequest::SendCaPmt`](crate::event::HostRequest::SendCaPmt).
242#[derive(Debug, Default)]
243pub struct ConditionalAccess;
244
245impl Resource for ConditionalAccess {
246    fn id(&self) -> ResourceId {
247        CONDITIONAL_ACCESS_SUPPORT
248    }
249
250    fn on_open(&mut self) -> dvb_ci::Result<ResourceOut> {
251        Ok(ResourceOut {
252            apdus: vec![ser(&CaInfoEnq::default())?],
253            ..ResourceOut::default()
254        })
255    }
256
257    fn on_apdu(&mut self, apdu: &[u8]) -> dvb_ci::Result<ResourceOut> {
258        let mut out = ResourceOut::default();
259        match peek_tag(apdu) {
260            Some(t) if t == tag::CA_INFO => {
261                // A CAM-originated APDU that fails to parse is a protocol error to
262                // surface, not to drop silently (audit #1092 interop).
263                let ci = CaInfo::parse(apdu)?;
264                out.notify.push(Notification::CaInfo {
265                    ca_system_ids: ci.ca_system_ids,
266                });
267            }
268            Some(t) if t == tag::CA_PMT_REPLY => {
269                // A CAM-originated APDU that fails to parse is a protocol error to
270                // surface, not to drop silently (audit #1092 interop).
271                let r = CaPmtReply::parse(apdu)?;
272                // EN 50221 §8.4.3.5 Table 26: programme-level `CA_enable`.
273                // Plumb the object's own `Option<CaEnable>` straight
274                // through — `None` means the programme
275                // `CA_enable_flag` bit was clear (no programme-level
276                // status given), which is distinct from a genuine
277                // flag-set reserved code and must not be collapsed to a
278                // sentinel.
279                let descrambling_ok = matches!(
280                    r.ca_enable,
281                    Some(
282                        CaEnable::Possible
283                            | CaEnable::PossiblePurchaseDialogue
284                            | CaEnable::PossibleTechnicalDialogue
285                    )
286                );
287                out.notify.push(Notification::CaPmtReply {
288                    program_number: r.program_number,
289                    ca_enable: r.ca_enable,
290                    descrambling_ok,
291                });
292            }
293            _ => {}
294        }
295        Ok(out)
296    }
297    // No state carried between calls — the default `reset()` no-op applies.
298}
299
300const SECS_PER_DAY: u64 = 86_400;
301/// Modified Julian Date of the Unix epoch (1970-01-01).
302const MJD_UNIX_EPOCH: u64 = 40_587;
303
304fn bcd(v: u64) -> u8 {
305    (((v / 10) << 4) | (v % 10)) as u8
306}
307
308/// Encode a Unix timestamp as the 5-byte DVB `UTC_time` (MJD `[15:0]` + BCD
309/// HH:MM:SS), per EN 300 468 Annex C.
310fn unix_to_mjd_bcd(unix_secs: u64) -> [u8; UTC_TIME_LEN] {
311    let mjd = (MJD_UNIX_EPOCH + unix_secs / SECS_PER_DAY) as u16;
312    let sod = unix_secs % SECS_PER_DAY;
313    [
314        (mjd >> 8) as u8,
315        mjd as u8,
316        bcd(sod / 3600),
317        bcd((sod % 3600) / 60),
318        bcd(sod % 60),
319    ]
320}
321
322fn system_utc() -> [u8; UTC_TIME_LEN] {
323    let secs = std::time::SystemTime::now()
324        .duration_since(std::time::UNIX_EPOCH)
325        .map(|d| d.as_secs())
326        .unwrap_or(0);
327    unix_to_mjd_bcd(secs)
328}
329
330/// Date-Time (§8.5.2) — host-provided. On `date_time_enq` replies with the
331/// current UTC; if the enquiry's `response_interval` is non-zero, re-sends every
332/// `response_interval` seconds (driven by [`tick`](Resource::tick)).
333pub struct DateTime {
334    clock: fn() -> [u8; UTC_TIME_LEN],
335    interval: u8,
336    since: Duration,
337}
338
339impl Default for DateTime {
340    fn default() -> Self {
341        Self::new()
342    }
343}
344
345impl DateTime {
346    /// New handler using the system clock.
347    #[must_use]
348    pub fn new() -> Self {
349        Self {
350            clock: system_utc,
351            interval: 0,
352            since: Duration::ZERO,
353        }
354    }
355
356    /// New handler with an injected clock (for tests / a host-supplied source).
357    #[must_use]
358    pub fn with_clock(clock: fn() -> [u8; UTC_TIME_LEN]) -> Self {
359        Self {
360            clock,
361            interval: 0,
362            since: Duration::ZERO,
363        }
364    }
365
366    fn reply(&self) -> dvb_ci::Result<Vec<u8>> {
367        ser(&CiDateTime {
368            length_form: dvb_ci::length::LengthForm::Minimal,
369            utc_time: (self.clock)(),
370            local_offset: None,
371        })
372    }
373}
374
375impl Resource for DateTime {
376    fn id(&self) -> ResourceId {
377        DATE_TIME
378    }
379
380    fn on_apdu(&mut self, apdu: &[u8]) -> dvb_ci::Result<ResourceOut> {
381        let mut out = ResourceOut::default();
382        if peek_tag(apdu) == Some(tag::DATE_TIME_ENQ) {
383            let enq = DateTimeEnq::parse(apdu)?;
384            self.interval = enq.response_interval;
385            self.since = Duration::ZERO;
386            out.apdus.push(self.reply()?);
387        }
388        Ok(out)
389    }
390
391    fn tick(&mut self, elapsed: Duration) -> dvb_ci::Result<ResourceOut> {
392        let mut out = ResourceOut::default();
393        if self.interval > 0 {
394            self.since += elapsed;
395            if self.since >= Duration::from_secs(u64::from(self.interval)) {
396                self.since = Duration::ZERO;
397                out.apdus.push(self.reply()?);
398            }
399        }
400        Ok(out)
401    }
402
403    /// r10-W-19: without this, a stale `interval`/`since` from a previous
404    /// module survives into the next connection, so the resend cadence
405    /// negotiated with the OLD CAM keeps firing (or a leftover partial
406    /// `since` fires early) before the new module ever sends its own
407    /// `date_time_enq`.
408    fn reset(&mut self) {
409        self.interval = 0;
410        self.since = Duration::ZERO;
411    }
412}
413
414/// MMI (§8.6) — module-provided. Surfaces the module's menus/enquiries and the
415/// close as [`Notification::Mmi`] events for the application to display, and
416/// answers the module's `display_control` mode negotiation. The host drives the
417/// dialog back through [`Driver::mmi_menu_answer`](crate::Driver::mmi_menu_answer)
418/// / [`mmi_enquiry_answer`](crate::Driver::mmi_enquiry_answer) /
419/// [`mmi_cancel`](crate::Driver::mmi_cancel) (sent by [`CiStack`](crate::CiStack)
420/// on the open MMI session).
421#[derive(Debug, Default)]
422pub struct Mmi;
423
424impl Resource for Mmi {
425    fn id(&self) -> ResourceId {
426        MMI
427    }
428
429    fn on_apdu(&mut self, apdu: &[u8]) -> dvb_ci::Result<ResourceOut> {
430        let mut out = ResourceOut::default();
431        match peek_tag(apdu) {
432            Some(t) if t == tag::ENQ => {
433                // A CAM-originated APDU that fails to parse is a protocol error to
434                // surface, not to drop silently (audit #1092 interop).
435                let e = Enq::parse(apdu)?;
436                out.notify.push(Notification::Mmi(MmiEvent::Enquiry {
437                    prompt: text(e.text_chars),
438                    blind: e.blind_answer,
439                    answer_len: e.answer_text_length,
440                }));
441            }
442            Some(t) if t == tag::MENU_LAST => {
443                // A CAM-originated APDU that fails to parse is a protocol error to
444                // surface, not to drop silently (audit #1092 interop).
445                let m = Menu::parse(apdu)?;
446                out.notify
447                    .push(Notification::Mmi(MmiEvent::Menu(to_menu(&m))));
448            }
449            Some(t) if t == tag::LIST_LAST => {
450                // A CAM-originated APDU that fails to parse is a protocol error to
451                // surface, not to drop silently (audit #1092 interop).
452                let l = List::parse(apdu)?;
453                out.notify
454                    .push(Notification::Mmi(MmiEvent::List(to_menu(&l.0))));
455            }
456            Some(t) if t == tag::CLOSE_MMI => {
457                dvb_ci::objects::mmi_close::CloseMmi::parse(apdu)?;
458                out.notify.push(Notification::Mmi(MmiEvent::Close));
459            }
460            // High-level MMI mode negotiation (§8.6.1): the module opens an MMI
461            // session and sends `display_control`. The host MUST answer
462            // `display_reply` or the module aborts the MMI — verified live: a
463            // real AlphaCrypt opens MMI after `ca_pmt` and, with no reply, closes
464            // the session and never descrambles (an Enigma2 box answers it).
465            Some(t) if t == tag::DISPLAY_CONTROL => {
466                // A CAM-originated APDU that fails to parse is a protocol error to
467                // surface, not to drop silently (audit #1092 interop).
468                let dc = DisplayControl::parse(apdu)?;
469                let reply = match dc.cmd {
470                    // Acknowledge the requested MMI mode (echo it back).
471                    DisplayControlCmd::SetMmiMode => DisplayReply {
472                        length_form: dvb_ci::length::LengthForm::Minimal,
473                        reply_id: DisplayReplyId::MmiModeAck,
474                        body: DisplayReplyBody::MmiModeAck(
475                            dc.mmi_mode.unwrap_or(MmiMode::HighLevel),
476                        ),
477                    },
478                    // We don't implement the character-table / graphics
479                    // queries; tell the module so per Table 35.
480                    _ => DisplayReply {
481                        length_form: dvb_ci::length::LengthForm::Minimal,
482                        reply_id: DisplayReplyId::UnknownDisplayControlCmd,
483                        body: DisplayReplyBody::None,
484                    },
485                };
486                out.apdus.push(ser(&reply)?);
487            }
488            _ => {}
489        }
490        Ok(out)
491    }
492    // No state carried between calls — the default `reset()` no-op applies.
493}
494
495/// Host Control (§8.5.1) — host-provided. The module opens a host_control
496/// session and issues `tune` / `replace` / `clear_replace` / `ask_release`
497/// objects; this handler decodes each and surfaces it as
498/// [`Notification::HostControl`]. The host acts on the request out of band (it
499/// retunes / replaces PIDs itself) — the runtime does not re-tune, so there is
500/// no reply APDU.
501#[derive(Debug, Default)]
502pub struct HostControl;
503
504impl Resource for HostControl {
505    fn id(&self) -> ResourceId {
506        HOST_CONTROL
507    }
508
509    fn on_apdu(&mut self, apdu: &[u8]) -> dvb_ci::Result<ResourceOut> {
510        let mut out = ResourceOut::default();
511        // A CAM-originated host-control APDU that fails to parse (e.g. a padded
512        // `tune`) is a protocol error: `?` surfaces it as `Notification::Error`
513        // through the stack instead of dropping it silently.
514        let event = match peek_tag(apdu) {
515            Some(t) if t == tag::TUNE => {
516                let t = Tune::parse(apdu)?;
517                Some(HostControlEvent::Tune {
518                    network_id: t.network_id,
519                    original_network_id: t.original_network_id,
520                    transport_stream_id: t.transport_stream_id,
521                    service_id: t.service_id,
522                })
523            }
524            Some(t) if t == tag::REPLACE => {
525                let r = Replace::parse(apdu)?;
526                Some(HostControlEvent::Replace {
527                    replacement_ref: r.replacement_ref,
528                    replaced_pid: r.replaced_pid,
529                    replacement_pid: r.replacement_pid,
530                })
531            }
532            Some(t) if t == tag::CLEAR_REPLACE => {
533                let c = ClearReplace::parse(apdu)?;
534                Some(HostControlEvent::ClearReplace {
535                    replacement_ref: c.replacement_ref,
536                })
537            }
538            Some(t) if t == tag::ASK_RELEASE => {
539                AskRelease::parse(apdu)?;
540                Some(HostControlEvent::AskRelease)
541            }
542            _ => None,
543        };
544        if let Some(event) = event {
545            out.notify.push(Notification::HostControl(event));
546        }
547        Ok(out)
548    }
549    // No state carried between calls — the default `reset()` no-op applies.
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555    use dvb_ci::objects::resource_manager::Profile;
556    use mpeg_ts::pid::Pid;
557
558    /// Local test helper: unwrap a serialize that must succeed for the
559    /// fixed/bounded values these tests construct.
560    fn s<S: Serialize<Error = dvb_ci::Error>>(v: &S) -> Vec<u8> {
561        ser(v).expect("test value must serialize")
562    }
563
564    #[test]
565    fn on_open_sends_profile_enq() {
566        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
567        let out = rm.on_open().unwrap();
568        assert_eq!(out.apdus, vec![s(&ProfileEnq::default())]);
569    }
570
571    #[test]
572    fn module_profile_triggers_profile_change_and_camready() {
573        // #340: after the module's `profile`, the host fires CamReady and sends
574        // `profile_change` (the §8.4.1.1 gate) — and nothing else. It does NOT
575        // open application_information / conditional_access / mmi itself: those
576        // are host-provided resources the module opens sessions to (verified on
577        // a live AlphaCrypt, which rejects/ignores host-initiated opens).
578        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
579        rm.on_open().unwrap();
580        let empty_profile = s(&Profile {
581            length_form: dvb_ci::length::LengthForm::Minimal,
582            resources: vec![],
583        });
584        let o = rm.on_apdu(&empty_profile).unwrap();
585        assert!(o.notify.contains(&Notification::CamReady));
586        assert_eq!(o.apdus.len(), 1, "host sends profile_change");
587        assert_eq!(peek_tag(&o.apdus[0]), Some(tag::PROFILE_CHANGE));
588        assert!(o.open.is_empty(), "host opens no sessions itself");
589    }
590
591    #[test]
592    fn answers_a_module_profile_enquiry_without_re_readying() {
593        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
594        rm.on_open().unwrap();
595        rm.on_apdu(&s(&Profile {
596            length_form: dvb_ci::length::LengthForm::Minimal,
597            resources: vec![APPLICATION_INFORMATION],
598        }))
599        .unwrap();
600        // A later module profile_enq → reply with our profile, no second CamReady.
601        let o = rm.on_apdu(&s(&ProfileEnq::default())).unwrap();
602        assert_eq!(o.apdus.len(), 1);
603        assert_eq!(peek_tag(&o.apdus[0]), Some(tag::PROFILE));
604        assert!(!o.notify.contains(&Notification::CamReady));
605    }
606
607    /// r10-W-19: `reset()` must clear every piece of the RM's latched state
608    /// (module resources, `module_profiled`, `ready`) so a fresh handshake
609    /// after a re-init behaves exactly like a brand-new `ResourceManager` —
610    /// in particular so `ready` does not stay latched and silently suppress
611    /// the next `CamReady`.
612    #[test]
613    fn resource_manager_reset_clears_latched_handshake_state() {
614        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
615        rm.on_open().unwrap();
616        let o = rm
617            .on_apdu(&s(&Profile {
618                length_form: dvb_ci::length::LengthForm::Minimal,
619                resources: vec![APPLICATION_INFORMATION],
620            }))
621            .unwrap();
622        assert!(o.notify.contains(&Notification::CamReady), "dirtied: ready");
623        assert!(
624            !rm.module_resources().is_empty(),
625            "dirtied: module_resources"
626        );
627
628        rm.reset();
629        assert!(
630            rm.module_resources().is_empty(),
631            "reset must clear module_resources"
632        );
633
634        // Pre-fix (no reset()), `ready` stayed latched `true` and this second
635        // profile exchange would NOT re-fire CamReady.
636        rm.on_open().unwrap();
637        let o2 = rm
638            .on_apdu(&s(&Profile {
639                length_form: dvb_ci::length::LengthForm::Minimal,
640                resources: vec![],
641            }))
642            .unwrap();
643        assert!(
644            o2.notify.contains(&Notification::CamReady),
645            "a fresh handshake after reset() must fire CamReady again"
646        );
647    }
648
649    #[test]
650    fn mmi_surfaces_enquiry_and_close() {
651        let mut h = Mmi;
652        // enquiry
653        let enq = s(&Enq {
654            reserved: Default::default(),
655            length_form: dvb_ci::length::LengthForm::Minimal,
656            blind_answer: true,
657            answer_text_length: 4,
658            text_chars: b"PIN?",
659        });
660        assert_eq!(
661            h.on_apdu(&enq).unwrap().notify,
662            vec![Notification::Mmi(MmiEvent::Enquiry {
663                prompt: "PIN?".to_string(),
664                blind: true,
665                answer_len: 4,
666            })]
667        );
668        // close_mmi (tag 9F 88 00) — surfaced as Close
669        let close = [0x9F, 0x88, 0x00, 0x01, 0x00];
670        assert_eq!(
671            h.on_apdu(&close).unwrap().notify,
672            vec![Notification::Mmi(MmiEvent::Close)]
673        );
674    }
675
676    #[test]
677    fn mmi_surfaces_structured_menu_and_list() {
678        use dvb_ci::objects::mmi_high::{List, Menu, Text};
679        let txt = |s: &'static [u8]| Text {
680            length_form: dvb_ci::length::LengthForm::Minimal,
681            more: false,
682            text_chars: s,
683        };
684        let mut h = Mmi;
685        // A `menu()` → MmiEvent::Menu with header lines and choices kept distinct.
686        let menu = s(&Menu {
687            length_form: dvb_ci::length::LengthForm::Minimal,
688            more: false,
689            choice_nb: 2,
690            title: txt(b"AlphaCrypt"),
691            subtitle: txt(b"Module Mainmenu"),
692            bottom: txt(b"Select item and press OK"),
693            choices: vec![txt(b"Smartcard"), txt(b"Quit")],
694        });
695        assert_eq!(
696            h.on_apdu(&menu).unwrap().notify,
697            vec![Notification::Mmi(MmiEvent::Menu(MmiMenu {
698                title: "AlphaCrypt".to_string(),
699                subtitle: "Module Mainmenu".to_string(),
700                bottom: "Select item and press OK".to_string(),
701                choices: vec!["Smartcard".to_string(), "Quit".to_string()],
702            }))]
703        );
704        // A `list()` (same body) → MmiEvent::List.
705        let list = s(&List(Menu {
706            length_form: dvb_ci::length::LengthForm::Minimal,
707            more: false,
708            choice_nb: 0xFF,
709            title: txt(b"Entitlements"),
710            subtitle: txt(b""),
711            bottom: txt(b""),
712            choices: vec![txt(b"ORF AUT")],
713        }));
714        assert_eq!(
715            h.on_apdu(&list).unwrap().notify,
716            vec![Notification::Mmi(MmiEvent::List(MmiMenu {
717                title: "Entitlements".to_string(),
718                subtitle: String::new(),
719                bottom: String::new(),
720                choices: vec!["ORF AUT".to_string()],
721            }))]
722        );
723    }
724
725    #[test]
726    fn mmi_answers_display_control_set_mmi_mode() {
727        use dvb_ci::objects::mmi_display::{DisplayControl, DisplayControlCmd, MmiMode};
728        let mut h = Mmi;
729        let dc = s(&DisplayControl {
730            length_form: dvb_ci::length::LengthForm::Minimal,
731            cmd: DisplayControlCmd::SetMmiMode,
732            mmi_mode: Some(MmiMode::HighLevel),
733        });
734        let out = h.on_apdu(&dc).unwrap();
735        // mmi_mode_ack(high_level): 9F 88 02 02 01 01.
736        assert_eq!(out.apdus, vec![vec![0x9F, 0x88, 0x02, 0x02, 0x01, 0x01]]);
737        assert!(out.notify.is_empty());
738    }
739
740    #[test]
741    fn profile_change_re_enquires() {
742        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
743        let out = rm
744            .on_apdu(&s(
745                &dvb_ci::objects::resource_manager::ProfileChange::default(),
746            ))
747            .unwrap();
748        assert_eq!(out.apdus, vec![s(&ProfileEnq::default())]);
749    }
750
751    #[test]
752    fn application_information_surfaces_notification() {
753        use dvb_ci::objects::application_info::ApplicationType;
754        let mut h = ApplicationInformation;
755        assert_eq!(
756            h.on_open().unwrap().apdus,
757            vec![s(&ApplicationInfoEnq::default())]
758        );
759        let ai = s(&ApplicationInfo {
760            length_form: dvb_ci::length::LengthForm::Minimal,
761            application_type: ApplicationType::ConditionalAccess,
762            application_manufacturer: 0x1234,
763            manufacturer_code: 0x5678,
764            menu_string: b"Acme CAM",
765        });
766        let out = h.on_apdu(&ai).unwrap();
767        assert_eq!(
768            out.notify,
769            vec![Notification::ApplicationInfo {
770                application_type: 0x01,
771                manufacturer: 0x1234,
772                code: 0x5678,
773                menu: "Acme CAM".to_string(),
774            }]
775        );
776    }
777
778    #[test]
779    fn host_control_surfaces_tune_replace_clear_and_ask_release() {
780        let mut h = HostControl;
781        assert_eq!(h.id(), HOST_CONTROL);
782
783        // tune() → HostControlEvent::Tune with the four 16-bit identifiers.
784        let tune = s(&Tune {
785            length_form: dvb_ci::length::LengthForm::Minimal,
786            network_id: 0x1122,
787            original_network_id: 0x3344,
788            transport_stream_id: 0x5566,
789            service_id: 0x7788,
790        });
791        assert_eq!(
792            h.on_apdu(&tune).unwrap().notify,
793            vec![Notification::HostControl(HostControlEvent::Tune {
794                network_id: 0x1122,
795                original_network_id: 0x3344,
796                transport_stream_id: 0x5566,
797                service_id: 0x7788,
798            })]
799        );
800
801        // replace() → HostControlEvent::Replace with the 13-bit PIDs decoded.
802        let replace = s(&Replace {
803            length_form: dvb_ci::length::LengthForm::Minimal,
804            replacement_ref: 0x07,
805            replaced_pid: Pid::new(0x0123),
806            replacement_pid: Pid::new(0x01FF),
807            replaced_reserved: Default::default(),
808            replacement_reserved: Default::default(),
809        });
810        assert_eq!(
811            h.on_apdu(&replace).unwrap().notify,
812            vec![Notification::HostControl(HostControlEvent::Replace {
813                replacement_ref: 0x07,
814                replaced_pid: Pid::new(0x0123),
815                replacement_pid: Pid::new(0x01FF),
816            })]
817        );
818
819        // clear_replace() → HostControlEvent::ClearReplace.
820        let clear = s(&ClearReplace {
821            length_form: dvb_ci::length::LengthForm::Minimal,
822            replacement_ref: 0x42,
823        });
824        assert_eq!(
825            h.on_apdu(&clear).unwrap().notify,
826            vec![Notification::HostControl(HostControlEvent::ClearReplace {
827                replacement_ref: 0x42,
828            })]
829        );
830
831        // ask_release() → HostControlEvent::AskRelease (header-only).
832        let ask = s(&AskRelease::default());
833        assert_eq!(
834            h.on_apdu(&ask).unwrap().notify,
835            vec![Notification::HostControl(HostControlEvent::AskRelease)]
836        );
837
838        // The host acts out of band: no reply APDU is produced.
839        assert!(h.on_apdu(&tune).unwrap().apdus.is_empty());
840    }
841
842    #[test]
843    fn mjd_bcd_encoding_is_correct() {
844        // Unix epoch 1970-01-01 00:00:00 → MJD 40587 (0x9E8B), 00:00:00.
845        assert_eq!(unix_to_mjd_bcd(0), [0x9E, 0x8B, 0x00, 0x00, 0x00]);
846        // 1970-01-02 13:45:09 → MJD 40588 (0x9E8C), BCD 13 45 09.
847        let secs = SECS_PER_DAY + 13 * 3600 + 45 * 60 + 9;
848        assert_eq!(unix_to_mjd_bcd(secs), [0x9E, 0x8C, 0x13, 0x45, 0x09]);
849    }
850
851    #[test]
852    fn date_time_replies_to_enq_and_resends_on_interval() {
853        let fixed = || [0x9E, 0x7B, 0x00, 0x00, 0x00];
854        let mut h = DateTime::with_clock(fixed);
855        // enquiry with a 5s response interval → immediate reply
856        let enq = s(&DateTimeEnq {
857            length_form: dvb_ci::length::LengthForm::Minimal,
858            response_interval: 5,
859        });
860        let out = h.on_apdu(&enq).unwrap();
861        assert_eq!(out.apdus.len(), 1);
862        assert_eq!(peek_tag(&out.apdus[0]), Some(tag::DATE_TIME));
863        // before the interval: no resend
864        assert!(h.tick(Duration::from_secs(3)).unwrap().apdus.is_empty());
865        // crossing the interval: resend
866        assert_eq!(h.tick(Duration::from_secs(3)).unwrap().apdus.len(), 1);
867    }
868
869    #[test]
870    fn date_time_interval_zero_does_not_resend() {
871        let mut h = DateTime::with_clock(|| [0u8; UTC_TIME_LEN]);
872        h.on_apdu(&s(&DateTimeEnq {
873            length_form: dvb_ci::length::LengthForm::Minimal,
874            response_interval: 0,
875        }))
876        .unwrap();
877        assert!(h.tick(Duration::from_secs(60)).unwrap().apdus.is_empty());
878    }
879
880    /// r10-W-19: `reset()` must clear the resend timer (`interval`/`since`)
881    /// so a stale cadence negotiated with a PREVIOUS module does not keep
882    /// firing (or fire early on a leftover partial `since`) before the new
883    /// module ever sends its own `date_time_enq`.
884    #[test]
885    fn date_time_reset_clears_interval_and_resend_timer() {
886        let mut h = DateTime::with_clock(|| [0u8; UTC_TIME_LEN]);
887        h.on_apdu(&s(&DateTimeEnq {
888            length_form: dvb_ci::length::LengthForm::Minimal,
889            response_interval: 5,
890        }))
891        .unwrap();
892        // Dirty: interval = 5, partway through the resend window.
893        assert!(h.tick(Duration::from_secs(3)).unwrap().apdus.is_empty());
894
895        h.reset();
896        // Pre-fix, the leftover `since` (3s) plus this tick would cross the
897        // still-latched 5s interval and resend; post-reset, interval is 0 so
898        // nothing fires no matter how much time passes.
899        assert!(
900            h.tick(Duration::from_secs(60)).unwrap().apdus.is_empty(),
901            "reset must clear the resend cadence from the previous module"
902        );
903    }
904
905    #[test]
906    fn conditional_access_surfaces_ca_info_and_pmt_reply() {
907        let mut h = ConditionalAccess;
908        assert_eq!(h.on_open().unwrap().apdus, vec![s(&CaInfoEnq::default())]);
909        // ca_info -> CaInfo notification
910        let ci = s(&CaInfo {
911            length_form: dvb_ci::length::LengthForm::Minimal,
912            ca_system_ids: vec![0x0B00, 0x1800],
913        });
914        assert_eq!(
915            h.on_apdu(&ci).unwrap().notify,
916            vec![Notification::CaInfo {
917                ca_system_ids: vec![0x0B00, 0x1800],
918            }]
919        );
920        // ca_pmt_reply (descrambling possible) -> CaPmtReply notification
921        let reply = s(&CaPmtReply {
922            reserved_ca_enable: Default::default(),
923            reserved_version: Default::default(),
924            length_form: dvb_ci::length::LengthForm::Minimal,
925            program_number: 0x0042,
926            version_number: 0,
927            current_next_indicator: true,
928            ca_enable: Some(CaEnable::Possible),
929            streams: vec![],
930        });
931        assert_eq!(
932            h.on_apdu(&reply).unwrap().notify,
933            vec![Notification::CaPmtReply {
934                program_number: 0x0042,
935                ca_enable: Some(CaEnable::Possible),
936                descrambling_ok: true,
937            }]
938        );
939    }
940
941    #[test]
942    fn conditional_access_ca_pmt_reply_flag_clear_surfaces_none() {
943        let mut h = ConditionalAccess;
944        h.on_open().unwrap();
945        // Programme `CA_enable_flag` clear -> no programme-level status given.
946        let reply = s(&CaPmtReply {
947            reserved_ca_enable: Default::default(),
948            reserved_version: Default::default(),
949            length_form: dvb_ci::length::LengthForm::Minimal,
950            program_number: 0x0007,
951            version_number: 0,
952            current_next_indicator: true,
953            ca_enable: None,
954            streams: vec![],
955        });
956        assert_eq!(
957            h.on_apdu(&reply).unwrap().notify,
958            vec![Notification::CaPmtReply {
959                program_number: 0x0007,
960                ca_enable: None,
961                descrambling_ok: false,
962            }]
963        );
964    }
965}