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        if apdu.len() < APDU_TAG_LEN {
148            return Err(dvb_ci::Error::InvalidLength("short APDU"));
149        }
150        let mut out = ResourceOut::default();
151        match peek_tag(apdu) {
152            // Module asks for the host's profile → reply with our resource list.
153            Some(t) if t == tag::PROFILE_ENQ => {
154                out.apdus.push(ser(&Profile {
155                    length_form: dvb_ci::length::LengthForm::Minimal,
156                    resources: self.host_resources.clone(),
157                })?);
158            }
159            // Module's profile → record its resources.
160            Some(t) if t == tag::PROFILE => {
161                // A CAM-originated APDU that fails to parse is a protocol error to
162                // surface, not to drop silently (audit #1092 interop).
163                let p = Profile::parse(apdu)?;
164                self.module_resources = p.resources;
165                self.module_profiled = true;
166            }
167            // Resource set changed → re-enquire.
168            Some(t) if t == tag::PROFILE_CHANGE => {
169                out.apdus.push(ser(&ProfileEnq::default())?);
170                self.module_profiled = false;
171                self.ready = false;
172            }
173            _ => {}
174        }
175        // Once we have the module's profile, the host sends `profile_change`
176        // (§8.4.1.1) — the gate the module waits on; until it arrives the module
177        // idles after its `profile` reply (#340 round 1).
178        //
179        // The host does NOT open application_information / conditional_access /
180        // mmi itself. Confirmed on hardware (#340, live AlphaCrypt): the module
181        // ignores a host `open_session_request` for them and rejects a
182        // `create_session` (`status=0xF0`). Those resources are **host-provided**
183        // — the host advertises them in its `profile` reply (see
184        // `CiStack::host_provided`), and the *module* opens sessions to them
185        // (module → host `open_session_request`), exactly as it does for
186        // resource_manager / date_time. The host just accepts. Each session's
187        // `on_open` then drives its enquiry (app_info_enq, ca_info_enq).
188        if self.module_profiled && !self.ready {
189            self.ready = true;
190            out.apdus.push(ser(&ProfileChange::default())?);
191            out.notify.push(Notification::CamReady);
192        }
193        Ok(out)
194    }
195
196    /// r10-W-19: without this, `ready`/`module_profiled` stay latched from a
197    /// prior handshake, so a second `profile` (e.g. after a hot-plug
198    /// re-insert routed through a fresh `Init`) never re-opens the
199    /// `profile_change`/`CamReady` gate — the module then idles forever
200    /// (`on_apdu`'s doc comment above).
201    fn reset(&mut self) {
202        self.module_resources.clear();
203        self.module_profiled = false;
204        self.ready = false;
205    }
206}
207
208/// Application Information (§8.4.2) — module-provided. On open, enquires the
209/// module's application info; surfaces it as [`Notification::ApplicationInfo`].
210#[derive(Debug, Default)]
211pub struct ApplicationInformation;
212
213impl Resource for ApplicationInformation {
214    fn id(&self) -> ResourceId {
215        APPLICATION_INFORMATION
216    }
217
218    fn on_open(&mut self) -> dvb_ci::Result<ResourceOut> {
219        Ok(ResourceOut {
220            apdus: vec![ser(&ApplicationInfoEnq::default())?],
221            ..ResourceOut::default()
222        })
223    }
224
225    fn on_apdu(&mut self, apdu: &[u8]) -> dvb_ci::Result<ResourceOut> {
226        if apdu.len() < APDU_TAG_LEN {
227            return Err(dvb_ci::Error::InvalidLength("short APDU"));
228        }
229        let mut out = ResourceOut::default();
230        if peek_tag(apdu) == Some(tag::APPLICATION_INFO) {
231            let ai = ApplicationInfo::parse(apdu)?;
232            out.notify.push(Notification::ApplicationInfo {
233                application_type: ai.application_type.to_u8(),
234                manufacturer: ai.application_manufacturer,
235                code: ai.manufacturer_code,
236                menu: String::from_utf8_lossy(ai.menu_string).into_owned(),
237            });
238        }
239        Ok(out)
240    }
241    // No state carried between calls — the default `reset()` no-op applies.
242}
243
244/// Conditional Access Support (§8.4.3) — module-provided. On open, enquires the
245/// module's supported `CA_system_id`s ([`Notification::CaInfo`]); decodes
246/// `ca_pmt_reply` ([`Notification::CaPmtReply`]). The host sends `ca_pmt` via
247/// [`HostRequest::SendCaPmt`](crate::event::HostRequest::SendCaPmt).
248#[derive(Debug, Default)]
249pub struct ConditionalAccess;
250
251impl Resource for ConditionalAccess {
252    fn id(&self) -> ResourceId {
253        CONDITIONAL_ACCESS_SUPPORT
254    }
255
256    fn on_open(&mut self) -> dvb_ci::Result<ResourceOut> {
257        Ok(ResourceOut {
258            apdus: vec![ser(&CaInfoEnq::default())?],
259            ..ResourceOut::default()
260        })
261    }
262
263    fn on_apdu(&mut self, apdu: &[u8]) -> dvb_ci::Result<ResourceOut> {
264        if apdu.len() < APDU_TAG_LEN {
265            return Err(dvb_ci::Error::InvalidLength("short APDU"));
266        }
267        let mut out = ResourceOut::default();
268        match peek_tag(apdu) {
269            Some(t) if t == tag::CA_INFO => {
270                // A CAM-originated APDU that fails to parse is a protocol error to
271                // surface, not to drop silently (audit #1092 interop).
272                let ci = CaInfo::parse(apdu)?;
273                out.notify.push(Notification::CaInfo {
274                    ca_system_ids: ci.ca_system_ids,
275                });
276            }
277            Some(t) if t == tag::CA_PMT_REPLY => {
278                // A CAM-originated APDU that fails to parse is a protocol error to
279                // surface, not to drop silently (audit #1092 interop).
280                let r = CaPmtReply::parse(apdu)?;
281                // EN 50221 §8.4.3.5 Table 26: programme-level `CA_enable`.
282                // Plumb the object's own `Option<CaEnable>` straight
283                // through — `None` means the programme
284                // `CA_enable_flag` bit was clear (no programme-level
285                // status given), which is distinct from a genuine
286                // flag-set reserved code and must not be collapsed to a
287                // sentinel.
288                let descrambling_ok = matches!(
289                    r.ca_enable,
290                    Some(
291                        CaEnable::Possible
292                            | CaEnable::PossiblePurchaseDialogue
293                            | CaEnable::PossibleTechnicalDialogue
294                    )
295                );
296                out.notify.push(Notification::CaPmtReply {
297                    program_number: r.program_number,
298                    ca_enable: r.ca_enable,
299                    descrambling_ok,
300                });
301            }
302            _ => {}
303        }
304        Ok(out)
305    }
306    // No state carried between calls — the default `reset()` no-op applies.
307}
308
309const SECS_PER_DAY: u64 = 86_400;
310/// Modified Julian Date of the Unix epoch (1970-01-01).
311const MJD_UNIX_EPOCH: u64 = 40_587;
312
313fn bcd(v: u64) -> u8 {
314    (((v / 10) << 4) | (v % 10)) as u8
315}
316
317/// Encode a Unix timestamp as the 5-byte DVB `UTC_time` (MJD `[15:0]` + BCD
318/// HH:MM:SS), per EN 300 468 Annex C.
319fn unix_to_mjd_bcd(unix_secs: u64) -> [u8; UTC_TIME_LEN] {
320    let mjd = (MJD_UNIX_EPOCH + unix_secs / SECS_PER_DAY) as u16;
321    let sod = unix_secs % SECS_PER_DAY;
322    [
323        (mjd >> 8) as u8,
324        mjd as u8,
325        bcd(sod / 3600),
326        bcd((sod % 3600) / 60),
327        bcd(sod % 60),
328    ]
329}
330
331fn system_utc() -> [u8; UTC_TIME_LEN] {
332    let secs = std::time::SystemTime::now()
333        .duration_since(std::time::UNIX_EPOCH)
334        .map(|d| d.as_secs())
335        .unwrap_or(0);
336    unix_to_mjd_bcd(secs)
337}
338
339/// Date-Time (§8.5.2) — host-provided. On `date_time_enq` replies with the
340/// current UTC; if the enquiry's `response_interval` is non-zero, re-sends every
341/// `response_interval` seconds (driven by [`tick`](Resource::tick)).
342pub struct DateTime {
343    clock: fn() -> [u8; UTC_TIME_LEN],
344    interval: u8,
345    since: Duration,
346}
347
348impl Default for DateTime {
349    fn default() -> Self {
350        Self::new()
351    }
352}
353
354impl DateTime {
355    /// New handler using the system clock.
356    #[must_use]
357    pub fn new() -> Self {
358        Self {
359            clock: system_utc,
360            interval: 0,
361            since: Duration::ZERO,
362        }
363    }
364
365    /// New handler with an injected clock (for tests / a host-supplied source).
366    #[must_use]
367    pub fn with_clock(clock: fn() -> [u8; UTC_TIME_LEN]) -> Self {
368        Self {
369            clock,
370            interval: 0,
371            since: Duration::ZERO,
372        }
373    }
374
375    fn reply(&self) -> dvb_ci::Result<Vec<u8>> {
376        ser(&CiDateTime {
377            length_form: dvb_ci::length::LengthForm::Minimal,
378            utc_time: (self.clock)(),
379            local_offset: None,
380        })
381    }
382}
383
384impl Resource for DateTime {
385    fn id(&self) -> ResourceId {
386        DATE_TIME
387    }
388
389    fn on_apdu(&mut self, apdu: &[u8]) -> dvb_ci::Result<ResourceOut> {
390        if apdu.len() < APDU_TAG_LEN {
391            return Err(dvb_ci::Error::InvalidLength("short APDU"));
392        }
393        let mut out = ResourceOut::default();
394        if peek_tag(apdu) == Some(tag::DATE_TIME_ENQ) {
395            let enq = DateTimeEnq::parse(apdu)?;
396            self.interval = enq.response_interval;
397            self.since = Duration::ZERO;
398            out.apdus.push(self.reply()?);
399        }
400        Ok(out)
401    }
402
403    fn tick(&mut self, elapsed: Duration) -> dvb_ci::Result<ResourceOut> {
404        let mut out = ResourceOut::default();
405        if self.interval > 0 {
406            self.since += elapsed;
407            if self.since >= Duration::from_secs(u64::from(self.interval)) {
408                self.since = Duration::ZERO;
409                out.apdus.push(self.reply()?);
410            }
411        }
412        Ok(out)
413    }
414
415    /// r10-W-19: without this, a stale `interval`/`since` from a previous
416    /// module survives into the next connection, so the resend cadence
417    /// negotiated with the OLD CAM keeps firing (or a leftover partial
418    /// `since` fires early) before the new module ever sends its own
419    /// `date_time_enq`.
420    fn reset(&mut self) {
421        self.interval = 0;
422        self.since = Duration::ZERO;
423    }
424}
425
426/// MMI (§8.6) — module-provided. Surfaces the module's menus/enquiries and the
427/// close as [`Notification::Mmi`] events for the application to display, and
428/// answers the module's `display_control` mode negotiation. The host drives the
429/// dialog back through [`Driver::mmi_menu_answer`](crate::Driver::mmi_menu_answer)
430/// / [`mmi_enquiry_answer`](crate::Driver::mmi_enquiry_answer) /
431/// [`mmi_cancel`](crate::Driver::mmi_cancel) (sent by [`CiStack`](crate::CiStack)
432/// on the open MMI session).
433#[derive(Debug, Default)]
434pub struct Mmi {
435    chain: Option<MmiChain>,
436}
437
438/// Cap on a chained `menu`/`list` body (EN 50221 §8.3.2); a module that never
439/// sends the `_last` block cannot grow host memory past this.
440pub const MAX_MMI_CHAIN_LEN: usize = 0xFFFF;
441
442const APDU_TAG_LEN: usize = 3;
443
444#[derive(Debug)]
445struct MmiChain {
446    last: ApduTag,
447    body: Vec<u8>,
448}
449
450impl Mmi {
451    fn chain_more(&mut self, apdu: &[u8], last: ApduTag) -> dvb_ci::Result<()> {
452        let tail = apdu
453            .get(APDU_TAG_LEN..)
454            .ok_or(dvb_ci::Error::InvalidLength("short APDU"))?;
455        let (len, hdr) = dvb_ci::length::decode(tail)?;
456        let start = APDU_TAG_LEN + hdr;
457        let data = apdu
458            .get(start..start + len)
459            .ok_or(dvb_ci::Error::LengthTooLarge(len))?;
460        let mut chain = match self.chain.take() {
461            Some(c) if c.last == last => c,
462            _ => MmiChain {
463                last,
464                body: Vec::new(),
465            },
466        };
467        if chain.body.len() + data.len() > MAX_MMI_CHAIN_LEN {
468            return Err(dvb_ci::Error::LengthTooLarge(chain.body.len() + data.len()));
469        }
470        chain.body.extend_from_slice(data);
471        self.chain = Some(chain);
472        Ok(())
473    }
474
475    /// The complete `_last` APDU, with any chained `_more` bodies prepended to
476    /// its data field (EN 50221 §8.3.2: the receiver concatenates the data
477    /// fields once the last block arrives).
478    fn complete(&mut self, apdu: &[u8], last: ApduTag) -> dvb_ci::Result<Vec<u8>> {
479        let Some(chain) = self.chain.take().filter(|c| c.last == last) else {
480            return Ok(apdu.to_vec());
481        };
482        let tail = apdu
483            .get(APDU_TAG_LEN..)
484            .ok_or(dvb_ci::Error::InvalidLength("short APDU"))?;
485        let (len, hdr) = dvb_ci::length::decode(tail)?;
486        let start = APDU_TAG_LEN + hdr;
487        let data = apdu
488            .get(start..start + len)
489            .ok_or(dvb_ci::Error::LengthTooLarge(len))?;
490        let total = chain.body.len() + data.len();
491        let mut buf = vec![0u8; APDU_TAG_LEN + dvb_ci::length::encoded_len(total) + total];
492        buf[..APDU_TAG_LEN].copy_from_slice(&last.to_bytes());
493        let n = dvb_ci::length::encode_into(total, &mut buf[APDU_TAG_LEN..])?;
494        let body_at = APDU_TAG_LEN + n;
495        buf[body_at..body_at + chain.body.len()].copy_from_slice(&chain.body);
496        buf[body_at + chain.body.len()..].copy_from_slice(data);
497        Ok(buf)
498    }
499}
500
501impl Resource for Mmi {
502    fn id(&self) -> ResourceId {
503        MMI
504    }
505
506    fn on_apdu(&mut self, apdu: &[u8]) -> dvb_ci::Result<ResourceOut> {
507        if apdu.len() < APDU_TAG_LEN {
508            return Err(dvb_ci::Error::InvalidLength("short APDU"));
509        }
510        let mut out = ResourceOut::default();
511        match peek_tag(apdu) {
512            Some(t) if t == tag::ENQ => {
513                // A CAM-originated APDU that fails to parse is a protocol error to
514                // surface, not to drop silently (audit #1092 interop).
515                let e = Enq::parse(apdu)?;
516                out.notify.push(Notification::Mmi(MmiEvent::Enquiry {
517                    prompt: text(e.text_chars),
518                    blind: e.blind_answer,
519                    answer_len: e.answer_text_length,
520                }));
521            }
522            Some(t) if t == tag::MENU_MORE => self.chain_more(apdu, tag::MENU_LAST)?,
523            Some(t) if t == tag::LIST_MORE => self.chain_more(apdu, tag::LIST_LAST)?,
524            Some(t) if t == tag::MENU_LAST => {
525                // A CAM-originated APDU that fails to parse is a protocol error to
526                // surface, not to drop silently (audit #1092 interop).
527                let full = self.complete(apdu, tag::MENU_LAST)?;
528                let m = Menu::parse(&full)?;
529                out.notify
530                    .push(Notification::Mmi(MmiEvent::Menu(to_menu(&m))));
531            }
532            Some(t) if t == tag::LIST_LAST => {
533                // A CAM-originated APDU that fails to parse is a protocol error to
534                // surface, not to drop silently (audit #1092 interop).
535                let full = self.complete(apdu, tag::LIST_LAST)?;
536                let l = List::parse(&full)?;
537                out.notify
538                    .push(Notification::Mmi(MmiEvent::List(to_menu(&l.0))));
539            }
540            Some(t) if t == tag::CLOSE_MMI => {
541                dvb_ci::objects::mmi_close::CloseMmi::parse(apdu)?;
542                out.notify.push(Notification::Mmi(MmiEvent::Close));
543            }
544            // High-level MMI mode negotiation (§8.6.1): the module opens an MMI
545            // session and sends `display_control`. The host MUST answer
546            // `display_reply` or the module aborts the MMI — verified live: a
547            // real AlphaCrypt opens MMI after `ca_pmt` and, with no reply, closes
548            // the session and never descrambles (an Enigma2 box answers it).
549            Some(t) if t == tag::DISPLAY_CONTROL => {
550                // A CAM-originated APDU that fails to parse is a protocol error to
551                // surface, not to drop silently (audit #1092 interop).
552                let dc = DisplayControl::parse(apdu)?;
553                let reply = match dc.cmd {
554                    // Acknowledge the requested MMI mode (echo it back).
555                    DisplayControlCmd::SetMmiMode => DisplayReply {
556                        length_form: dvb_ci::length::LengthForm::Minimal,
557                        reply_id: DisplayReplyId::MmiModeAck,
558                        body: DisplayReplyBody::MmiModeAck(
559                            dc.mmi_mode.unwrap_or(MmiMode::HighLevel),
560                        ),
561                    },
562                    // We don't implement the character-table / graphics
563                    // queries; tell the module so per Table 35.
564                    _ => DisplayReply {
565                        length_form: dvb_ci::length::LengthForm::Minimal,
566                        reply_id: DisplayReplyId::UnknownDisplayControlCmd,
567                        body: DisplayReplyBody::None,
568                    },
569                };
570                out.apdus.push(ser(&reply)?);
571            }
572            _ => {}
573        }
574        Ok(out)
575    }
576    fn reset(&mut self) {
577        self.chain = None;
578    }
579}
580
581/// Host Control (§8.5.1) — host-provided. The module opens a host_control
582/// session and issues `tune` / `replace` / `clear_replace` / `ask_release`
583/// objects; this handler decodes each and surfaces it as
584/// [`Notification::HostControl`]. The host acts on the request out of band (it
585/// retunes / replaces PIDs itself) — the runtime does not re-tune, so there is
586/// no reply APDU.
587#[derive(Debug, Default)]
588pub struct HostControl;
589
590impl Resource for HostControl {
591    fn id(&self) -> ResourceId {
592        HOST_CONTROL
593    }
594
595    fn on_apdu(&mut self, apdu: &[u8]) -> dvb_ci::Result<ResourceOut> {
596        if apdu.len() < APDU_TAG_LEN {
597            return Err(dvb_ci::Error::InvalidLength("short APDU"));
598        }
599        let mut out = ResourceOut::default();
600        // A CAM-originated host-control APDU that fails to parse (e.g. a padded
601        // `tune`) is a protocol error: `?` surfaces it as `Notification::Error`
602        // through the stack instead of dropping it silently.
603        let event = match peek_tag(apdu) {
604            Some(t) if t == tag::TUNE => {
605                let t = Tune::parse(apdu)?;
606                Some(HostControlEvent::Tune {
607                    network_id: t.network_id,
608                    original_network_id: t.original_network_id,
609                    transport_stream_id: t.transport_stream_id,
610                    service_id: t.service_id,
611                })
612            }
613            Some(t) if t == tag::REPLACE => {
614                let r = Replace::parse(apdu)?;
615                Some(HostControlEvent::Replace {
616                    replacement_ref: r.replacement_ref,
617                    replaced_pid: r.replaced_pid,
618                    replacement_pid: r.replacement_pid,
619                })
620            }
621            Some(t) if t == tag::CLEAR_REPLACE => {
622                let c = ClearReplace::parse(apdu)?;
623                Some(HostControlEvent::ClearReplace {
624                    replacement_ref: c.replacement_ref,
625                })
626            }
627            Some(t) if t == tag::ASK_RELEASE => {
628                AskRelease::parse(apdu)?;
629                Some(HostControlEvent::AskRelease)
630            }
631            _ => None,
632        };
633        if let Some(event) = event {
634            out.notify.push(Notification::HostControl(event));
635        }
636        Ok(out)
637    }
638    // No state carried between calls — the default `reset()` no-op applies.
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644    use dvb_ci::objects::resource_manager::Profile;
645    use mpeg_ts::pid::Pid;
646
647    /// Local test helper: unwrap a serialize that must succeed for the
648    /// fixed/bounded values these tests construct.
649    fn s<S: Serialize<Error = dvb_ci::Error>>(v: &S) -> Vec<u8> {
650        ser(v).expect("test value must serialize")
651    }
652
653    #[test]
654    fn on_open_sends_profile_enq() {
655        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
656        let out = rm.on_open().unwrap();
657        assert_eq!(out.apdus, vec![s(&ProfileEnq::default())]);
658    }
659
660    #[test]
661    fn module_profile_triggers_profile_change_and_camready() {
662        // #340: after the module's `profile`, the host fires CamReady and sends
663        // `profile_change` (the §8.4.1.1 gate) — and nothing else. It does NOT
664        // open application_information / conditional_access / mmi itself: those
665        // are host-provided resources the module opens sessions to (verified on
666        // a live AlphaCrypt, which rejects/ignores host-initiated opens).
667        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
668        rm.on_open().unwrap();
669        let empty_profile = s(&Profile {
670            length_form: dvb_ci::length::LengthForm::Minimal,
671            resources: vec![],
672        });
673        let o = rm.on_apdu(&empty_profile).unwrap();
674        assert!(o.notify.contains(&Notification::CamReady));
675        assert_eq!(o.apdus.len(), 1, "host sends profile_change");
676        assert_eq!(peek_tag(&o.apdus[0]), Some(tag::PROFILE_CHANGE));
677        assert!(o.open.is_empty(), "host opens no sessions itself");
678    }
679
680    #[test]
681    fn answers_a_module_profile_enquiry_without_re_readying() {
682        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
683        rm.on_open().unwrap();
684        rm.on_apdu(&s(&Profile {
685            length_form: dvb_ci::length::LengthForm::Minimal,
686            resources: vec![APPLICATION_INFORMATION],
687        }))
688        .unwrap();
689        // A later module profile_enq → reply with our profile, no second CamReady.
690        let o = rm.on_apdu(&s(&ProfileEnq::default())).unwrap();
691        assert_eq!(o.apdus.len(), 1);
692        assert_eq!(peek_tag(&o.apdus[0]), Some(tag::PROFILE));
693        assert!(!o.notify.contains(&Notification::CamReady));
694    }
695
696    /// r10-W-19: `reset()` must clear every piece of the RM's latched state
697    /// (module resources, `module_profiled`, `ready`) so a fresh handshake
698    /// after a re-init behaves exactly like a brand-new `ResourceManager` —
699    /// in particular so `ready` does not stay latched and silently suppress
700    /// the next `CamReady`.
701    #[test]
702    fn resource_manager_reset_clears_latched_handshake_state() {
703        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
704        rm.on_open().unwrap();
705        let o = rm
706            .on_apdu(&s(&Profile {
707                length_form: dvb_ci::length::LengthForm::Minimal,
708                resources: vec![APPLICATION_INFORMATION],
709            }))
710            .unwrap();
711        assert!(o.notify.contains(&Notification::CamReady), "dirtied: ready");
712        assert!(
713            !rm.module_resources().is_empty(),
714            "dirtied: module_resources"
715        );
716
717        rm.reset();
718        assert!(
719            rm.module_resources().is_empty(),
720            "reset must clear module_resources"
721        );
722
723        // Pre-fix (no reset()), `ready` stayed latched `true` and this second
724        // profile exchange would NOT re-fire CamReady.
725        rm.on_open().unwrap();
726        let o2 = rm
727            .on_apdu(&s(&Profile {
728                length_form: dvb_ci::length::LengthForm::Minimal,
729                resources: vec![],
730            }))
731            .unwrap();
732        assert!(
733            o2.notify.contains(&Notification::CamReady),
734            "a fresh handshake after reset() must fire CamReady again"
735        );
736    }
737
738    #[test]
739    fn mmi_surfaces_enquiry_and_close() {
740        let mut h = Mmi::default();
741        // enquiry
742        let enq = s(&Enq {
743            reserved: Default::default(),
744            length_form: dvb_ci::length::LengthForm::Minimal,
745            blind_answer: true,
746            answer_text_length: 4,
747            text_chars: b"PIN?",
748        });
749        assert_eq!(
750            h.on_apdu(&enq).unwrap().notify,
751            vec![Notification::Mmi(MmiEvent::Enquiry {
752                prompt: "PIN?".to_string(),
753                blind: true,
754                answer_len: 4,
755            })]
756        );
757        // close_mmi (tag 9F 88 00) — surfaced as Close
758        let close = [0x9F, 0x88, 0x00, 0x01, 0x00];
759        assert_eq!(
760            h.on_apdu(&close).unwrap().notify,
761            vec![Notification::Mmi(MmiEvent::Close)]
762        );
763    }
764
765    #[test]
766    fn mmi_surfaces_structured_menu_and_list() {
767        use dvb_ci::objects::mmi_high::{List, Menu, Text};
768        let txt = |s: &'static [u8]| Text {
769            length_form: dvb_ci::length::LengthForm::Minimal,
770            more: false,
771            text_chars: s,
772        };
773        let mut h = Mmi::default();
774        // A `menu()` → MmiEvent::Menu with header lines and choices kept distinct.
775        let menu = s(&Menu {
776            length_form: dvb_ci::length::LengthForm::Minimal,
777            more: false,
778            choice_nb: 2,
779            title: txt(b"AlphaCrypt"),
780            subtitle: txt(b"Module Mainmenu"),
781            bottom: txt(b"Select item and press OK"),
782            choices: vec![txt(b"Smartcard"), txt(b"Quit")],
783        });
784        assert_eq!(
785            h.on_apdu(&menu).unwrap().notify,
786            vec![Notification::Mmi(MmiEvent::Menu(MmiMenu {
787                title: "AlphaCrypt".to_string(),
788                subtitle: "Module Mainmenu".to_string(),
789                bottom: "Select item and press OK".to_string(),
790                choices: vec!["Smartcard".to_string(), "Quit".to_string()],
791            }))]
792        );
793        // A `list()` (same body) → MmiEvent::List.
794        let list = s(&List(Menu {
795            length_form: dvb_ci::length::LengthForm::Minimal,
796            more: false,
797            choice_nb: 0xFF,
798            title: txt(b"Entitlements"),
799            subtitle: txt(b""),
800            bottom: txt(b""),
801            choices: vec![txt(b"ORF AUT")],
802        }));
803        assert_eq!(
804            h.on_apdu(&list).unwrap().notify,
805            vec![Notification::Mmi(MmiEvent::List(MmiMenu {
806                title: "Entitlements".to_string(),
807                subtitle: String::new(),
808                bottom: String::new(),
809                choices: vec!["ORF AUT".to_string()],
810            }))]
811        );
812    }
813
814    fn chained(more: ApduTag, last: ApduTag, whole_last_apdu: &[u8], split: usize) -> [Vec<u8>; 2] {
815        let (len, hdr) = dvb_ci::length::decode(&whole_last_apdu[3..]).unwrap();
816        let body = &whole_last_apdu[3 + hdr..3 + hdr + len];
817        let part = |t: ApduTag, b: &[u8]| {
818            let mut v = t.to_bytes().to_vec();
819            let mut l = [0u8; 3];
820            let n = dvb_ci::length::encode_into(b.len(), &mut l).unwrap();
821            v.extend_from_slice(&l[..n]);
822            v.extend_from_slice(b);
823            v
824        };
825        [part(more, &body[..split]), part(last, &body[split..])]
826    }
827
828    #[test]
829    fn mmi_reassembles_a_menu_chained_over_menu_more_and_menu_last() {
830        use dvb_ci::objects::mmi_high::{Menu, Text};
831        let txt = |s: &'static [u8]| Text {
832            length_form: dvb_ci::length::LengthForm::Minimal,
833            more: false,
834            text_chars: s,
835        };
836        let whole = s(&Menu {
837            length_form: dvb_ci::length::LengthForm::Minimal,
838            more: false,
839            choice_nb: 2,
840            title: txt(b"AlphaCrypt"),
841            subtitle: txt(b"Module Mainmenu"),
842            bottom: txt(b"Select item and press OK"),
843            choices: vec![txt(b"Smartcard"), txt(b"Quit")],
844        });
845        let [first, second] = chained(tag::MENU_MORE, tag::MENU_LAST, &whole, 20);
846        let mut h = Mmi::default();
847        assert!(h.on_apdu(&first).unwrap().notify.is_empty());
848        assert_eq!(
849            h.on_apdu(&second).unwrap().notify,
850            vec![Notification::Mmi(MmiEvent::Menu(MmiMenu {
851                title: "AlphaCrypt".to_string(),
852                subtitle: "Module Mainmenu".to_string(),
853                bottom: "Select item and press OK".to_string(),
854                choices: vec!["Smartcard".to_string(), "Quit".to_string()],
855            }))]
856        );
857    }
858
859    #[test]
860    fn mmi_reassembles_a_list_chained_over_list_more_and_list_last() {
861        use dvb_ci::objects::mmi_high::{List, Menu, Text};
862        let txt = |s: &'static [u8]| Text {
863            length_form: dvb_ci::length::LengthForm::Minimal,
864            more: false,
865            text_chars: s,
866        };
867        let whole = s(&List(Menu {
868            length_form: dvb_ci::length::LengthForm::Minimal,
869            more: false,
870            choice_nb: 2,
871            title: txt(b"Entitlements"),
872            subtitle: txt(b""),
873            bottom: txt(b""),
874            choices: vec![txt(b"ORF AUT"), txt(b"ZDF")],
875        }));
876        let [first, second] = chained(tag::LIST_MORE, tag::LIST_LAST, &whole, 9);
877        let mut h = Mmi::default();
878        assert!(h.on_apdu(&first).unwrap().notify.is_empty());
879        assert_eq!(
880            h.on_apdu(&second).unwrap().notify,
881            vec![Notification::Mmi(MmiEvent::List(MmiMenu {
882                title: "Entitlements".to_string(),
883                subtitle: String::new(),
884                bottom: String::new(),
885                choices: vec!["ORF AUT".to_string(), "ZDF".to_string()],
886            }))]
887        );
888    }
889
890    #[test]
891    fn mmi_discards_a_partial_chain_on_reset() {
892        let mut h = Mmi::default();
893        let first = [0x9F, 0x88, 0x0A, 0x01, 0x02];
894        assert!(h.on_apdu(&first).unwrap().notify.is_empty());
895        h.reset();
896        assert!(h.chain.is_none());
897    }
898
899    #[test]
900    fn mmi_answers_display_control_set_mmi_mode() {
901        use dvb_ci::objects::mmi_display::{DisplayControl, DisplayControlCmd, MmiMode};
902        let mut h = Mmi::default();
903        let dc = s(&DisplayControl {
904            length_form: dvb_ci::length::LengthForm::Minimal,
905            cmd: DisplayControlCmd::SetMmiMode,
906            mmi_mode: Some(MmiMode::HighLevel),
907        });
908        let out = h.on_apdu(&dc).unwrap();
909        // mmi_mode_ack(high_level): 9F 88 02 02 01 01.
910        assert_eq!(out.apdus, vec![vec![0x9F, 0x88, 0x02, 0x02, 0x01, 0x01]]);
911        assert!(out.notify.is_empty());
912    }
913
914    #[test]
915    fn profile_change_re_enquires() {
916        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
917        let out = rm
918            .on_apdu(&s(
919                &dvb_ci::objects::resource_manager::ProfileChange::default(),
920            ))
921            .unwrap();
922        assert_eq!(out.apdus, vec![s(&ProfileEnq::default())]);
923    }
924
925    #[test]
926    fn application_information_surfaces_notification() {
927        use dvb_ci::objects::application_info::ApplicationType;
928        let mut h = ApplicationInformation;
929        assert_eq!(
930            h.on_open().unwrap().apdus,
931            vec![s(&ApplicationInfoEnq::default())]
932        );
933        let ai = s(&ApplicationInfo {
934            length_form: dvb_ci::length::LengthForm::Minimal,
935            application_type: ApplicationType::ConditionalAccess,
936            application_manufacturer: 0x1234,
937            manufacturer_code: 0x5678,
938            menu_string: b"Acme CAM",
939        });
940        let out = h.on_apdu(&ai).unwrap();
941        assert_eq!(
942            out.notify,
943            vec![Notification::ApplicationInfo {
944                application_type: 0x01,
945                manufacturer: 0x1234,
946                code: 0x5678,
947                menu: "Acme CAM".to_string(),
948            }]
949        );
950    }
951
952    #[test]
953    fn host_control_surfaces_tune_replace_clear_and_ask_release() {
954        let mut h = HostControl;
955        assert_eq!(h.id(), HOST_CONTROL);
956
957        // tune() → HostControlEvent::Tune with the four 16-bit identifiers.
958        let tune = s(&Tune {
959            length_form: dvb_ci::length::LengthForm::Minimal,
960            network_id: 0x1122,
961            original_network_id: 0x3344,
962            transport_stream_id: 0x5566,
963            service_id: 0x7788,
964        });
965        assert_eq!(
966            h.on_apdu(&tune).unwrap().notify,
967            vec![Notification::HostControl(HostControlEvent::Tune {
968                network_id: 0x1122,
969                original_network_id: 0x3344,
970                transport_stream_id: 0x5566,
971                service_id: 0x7788,
972            })]
973        );
974
975        // replace() → HostControlEvent::Replace with the 13-bit PIDs decoded.
976        let replace = s(&Replace {
977            length_form: dvb_ci::length::LengthForm::Minimal,
978            replacement_ref: 0x07,
979            replaced_pid: Pid::new(0x0123),
980            replacement_pid: Pid::new(0x01FF),
981            replaced_reserved: Default::default(),
982            replacement_reserved: Default::default(),
983        });
984        assert_eq!(
985            h.on_apdu(&replace).unwrap().notify,
986            vec![Notification::HostControl(HostControlEvent::Replace {
987                replacement_ref: 0x07,
988                replaced_pid: Pid::new(0x0123),
989                replacement_pid: Pid::new(0x01FF),
990            })]
991        );
992
993        // clear_replace() → HostControlEvent::ClearReplace.
994        let clear = s(&ClearReplace {
995            length_form: dvb_ci::length::LengthForm::Minimal,
996            replacement_ref: 0x42,
997        });
998        assert_eq!(
999            h.on_apdu(&clear).unwrap().notify,
1000            vec![Notification::HostControl(HostControlEvent::ClearReplace {
1001                replacement_ref: 0x42,
1002            })]
1003        );
1004
1005        // ask_release() → HostControlEvent::AskRelease (header-only).
1006        let ask = s(&AskRelease::default());
1007        assert_eq!(
1008            h.on_apdu(&ask).unwrap().notify,
1009            vec![Notification::HostControl(HostControlEvent::AskRelease)]
1010        );
1011
1012        // The host acts out of band: no reply APDU is produced.
1013        assert!(h.on_apdu(&tune).unwrap().apdus.is_empty());
1014    }
1015
1016    #[test]
1017    fn mjd_bcd_encoding_is_correct() {
1018        // Unix epoch 1970-01-01 00:00:00 → MJD 40587 (0x9E8B), 00:00:00.
1019        assert_eq!(unix_to_mjd_bcd(0), [0x9E, 0x8B, 0x00, 0x00, 0x00]);
1020        // 1970-01-02 13:45:09 → MJD 40588 (0x9E8C), BCD 13 45 09.
1021        let secs = SECS_PER_DAY + 13 * 3600 + 45 * 60 + 9;
1022        assert_eq!(unix_to_mjd_bcd(secs), [0x9E, 0x8C, 0x13, 0x45, 0x09]);
1023    }
1024
1025    #[test]
1026    fn date_time_replies_to_enq_and_resends_on_interval() {
1027        let fixed = || [0x9E, 0x7B, 0x00, 0x00, 0x00];
1028        let mut h = DateTime::with_clock(fixed);
1029        // enquiry with a 5s response interval → immediate reply
1030        let enq = s(&DateTimeEnq {
1031            length_form: dvb_ci::length::LengthForm::Minimal,
1032            response_interval: 5,
1033        });
1034        let out = h.on_apdu(&enq).unwrap();
1035        assert_eq!(out.apdus.len(), 1);
1036        assert_eq!(peek_tag(&out.apdus[0]), Some(tag::DATE_TIME));
1037        // before the interval: no resend
1038        assert!(h.tick(Duration::from_secs(3)).unwrap().apdus.is_empty());
1039        // crossing the interval: resend
1040        assert_eq!(h.tick(Duration::from_secs(3)).unwrap().apdus.len(), 1);
1041    }
1042
1043    #[test]
1044    fn date_time_interval_zero_does_not_resend() {
1045        let mut h = DateTime::with_clock(|| [0u8; UTC_TIME_LEN]);
1046        h.on_apdu(&s(&DateTimeEnq {
1047            length_form: dvb_ci::length::LengthForm::Minimal,
1048            response_interval: 0,
1049        }))
1050        .unwrap();
1051        assert!(h.tick(Duration::from_secs(60)).unwrap().apdus.is_empty());
1052    }
1053
1054    /// r10-W-19: `reset()` must clear the resend timer (`interval`/`since`)
1055    /// so a stale cadence negotiated with a PREVIOUS module does not keep
1056    /// firing (or fire early on a leftover partial `since`) before the new
1057    /// module ever sends its own `date_time_enq`.
1058    #[test]
1059    fn date_time_reset_clears_interval_and_resend_timer() {
1060        let mut h = DateTime::with_clock(|| [0u8; UTC_TIME_LEN]);
1061        h.on_apdu(&s(&DateTimeEnq {
1062            length_form: dvb_ci::length::LengthForm::Minimal,
1063            response_interval: 5,
1064        }))
1065        .unwrap();
1066        // Dirty: interval = 5, partway through the resend window.
1067        assert!(h.tick(Duration::from_secs(3)).unwrap().apdus.is_empty());
1068
1069        h.reset();
1070        // Pre-fix, the leftover `since` (3s) plus this tick would cross the
1071        // still-latched 5s interval and resend; post-reset, interval is 0 so
1072        // nothing fires no matter how much time passes.
1073        assert!(
1074            h.tick(Duration::from_secs(60)).unwrap().apdus.is_empty(),
1075            "reset must clear the resend cadence from the previous module"
1076        );
1077    }
1078
1079    #[test]
1080    fn conditional_access_surfaces_ca_info_and_pmt_reply() {
1081        let mut h = ConditionalAccess;
1082        assert_eq!(h.on_open().unwrap().apdus, vec![s(&CaInfoEnq::default())]);
1083        // ca_info -> CaInfo notification
1084        let ci = s(&CaInfo {
1085            length_form: dvb_ci::length::LengthForm::Minimal,
1086            ca_system_ids: vec![0x0B00, 0x1800],
1087        });
1088        assert_eq!(
1089            h.on_apdu(&ci).unwrap().notify,
1090            vec![Notification::CaInfo {
1091                ca_system_ids: vec![0x0B00, 0x1800],
1092            }]
1093        );
1094        // ca_pmt_reply (descrambling possible) -> CaPmtReply notification
1095        let reply = s(&CaPmtReply {
1096            reserved_ca_enable: Default::default(),
1097            reserved_version: Default::default(),
1098            length_form: dvb_ci::length::LengthForm::Minimal,
1099            program_number: 0x0042,
1100            version_number: 0,
1101            current_next_indicator: true,
1102            ca_enable: Some(CaEnable::Possible),
1103            streams: vec![],
1104        });
1105        assert_eq!(
1106            h.on_apdu(&reply).unwrap().notify,
1107            vec![Notification::CaPmtReply {
1108                program_number: 0x0042,
1109                ca_enable: Some(CaEnable::Possible),
1110                descrambling_ok: true,
1111            }]
1112        );
1113    }
1114
1115    #[test]
1116    fn conditional_access_ca_pmt_reply_flag_clear_surfaces_none() {
1117        let mut h = ConditionalAccess;
1118        h.on_open().unwrap();
1119        // Programme `CA_enable_flag` clear -> no programme-level status given.
1120        let reply = s(&CaPmtReply {
1121            reserved_ca_enable: Default::default(),
1122            reserved_version: Default::default(),
1123            length_form: dvb_ci::length::LengthForm::Minimal,
1124            program_number: 0x0007,
1125            version_number: 0,
1126            current_next_indicator: true,
1127            ca_enable: None,
1128            streams: vec![],
1129        });
1130        assert_eq!(
1131            h.on_apdu(&reply).unwrap().notify,
1132            vec![Notification::CaPmtReply {
1133                program_number: 0x0007,
1134                ca_enable: None,
1135                descrambling_ok: false,
1136            }]
1137        );
1138    }
1139
1140    #[test]
1141    fn short_apdu_returns_err() {
1142        for len in 0..3usize {
1143            let buf = vec![0u8; len];
1144            assert!(Mmi::default().on_apdu(&buf).is_err(), "Mmi len {len}");
1145            assert!(
1146                ResourceManager::new(vec![]).on_apdu(&buf).is_err(),
1147                "RM len {len}"
1148            );
1149            assert!(
1150                ApplicationInformation.on_apdu(&buf).is_err(),
1151                "AI len {len}"
1152            );
1153            assert!(ConditionalAccess.on_apdu(&buf).is_err(), "CA len {len}");
1154            assert!(DateTime::default().on_apdu(&buf).is_err(), "DT len {len}");
1155            assert!(HostControl.on_apdu(&buf).is_err(), "HC len {len}");
1156        }
1157    }
1158}