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 dvb_ci::objects::application_info::{ApplicationInfo, ApplicationInfoEnq};
13use dvb_ci::objects::ca_info::{CaInfo, CaInfoEnq};
14use dvb_ci::objects::ca_pmt_reply::{CaEnable, CaPmtReply};
15use dvb_ci::objects::date_time::{DateTime as CiDateTime, DateTimeEnq, UTC_TIME_LEN};
16use dvb_ci::objects::mmi_high::{Enq, Menu};
17use dvb_ci::objects::resource_manager::{Profile, ProfileChange, ProfileEnq};
18use dvb_ci::resource::{
19    ResourceId, APPLICATION_INFORMATION, CONDITIONAL_ACCESS_SUPPORT, DATE_TIME, MMI,
20    RESOURCE_MANAGER,
21};
22use dvb_ci::tag::{self, ApduTag};
23use dvb_common::{Parse, Serialize};
24
25use crate::event::{MmiEvent, Notification};
26
27/// Decode MMI `text_char` bytes to a `String` (lossy; full EN 300 468 Annex A
28/// decoding is the application's concern).
29fn text(chars: &[u8]) -> String {
30    String::from_utf8_lossy(chars).into_owned()
31}
32
33pub(crate) fn ser<S: Serialize>(s: &S) -> Vec<u8> {
34    let mut b = vec![0u8; s.serialized_len()];
35    match s.serialize_into(&mut b) {
36        Ok(n) => b.truncate(n),
37        Err(_) => b.clear(),
38    }
39    b
40}
41
42/// The 3-byte `apdu_tag` at the start of an APDU, if present.
43pub(crate) fn peek_tag(apdu: &[u8]) -> Option<ApduTag> {
44    (apdu.len() >= 3).then(|| ApduTag::from_bytes(apdu[0], apdu[1], apdu[2]))
45}
46
47/// What a resource wants done after reacting to an input.
48#[derive(Debug, Default, Clone, PartialEq, Eq)]
49pub struct ResourceOut {
50    /// APDUs to send on this resource's session.
51    pub apdus: Vec<Vec<u8>>,
52    /// Host-facing notifications.
53    pub notify: Vec<Notification>,
54    /// Module-provided resources the host should now open (`create_session`).
55    pub open: Vec<ResourceId>,
56}
57
58/// An EN 50221 application-layer resource.
59pub trait Resource {
60    /// The resource this handler serves.
61    fn id(&self) -> ResourceId;
62    /// The session for this resource just opened.
63    fn on_open(&mut self) -> ResourceOut {
64        ResourceOut::default()
65    }
66    /// An APDU arrived on this resource's session.
67    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut;
68    /// Logical time advanced (for resources with timers, e.g. date_time).
69    fn tick(&mut self, _elapsed: Duration) -> ResourceOut {
70        ResourceOut::default()
71    }
72}
73
74/// Resource Manager (§8.4.1) — host-provided. Drives the profile exchange and,
75/// once complete, reports [`Notification::CamReady`] and asks the host to open
76/// the module-provided resources it understands.
77#[derive(Debug)]
78pub struct ResourceManager {
79    host_resources: Vec<ResourceId>,
80    module_resources: Vec<ResourceId>,
81    module_profiled: bool,
82    ready: bool,
83}
84
85impl ResourceManager {
86    /// New RM advertising `host_resources` in its profile reply.
87    #[must_use]
88    pub fn new(host_resources: Vec<ResourceId>) -> Self {
89        Self {
90            host_resources,
91            module_resources: Vec::new(),
92            module_profiled: false,
93            ready: false,
94        }
95    }
96
97    /// Resources the module advertised (valid once the profile exchange ran).
98    #[must_use]
99    pub fn module_resources(&self) -> &[ResourceId] {
100        &self.module_resources
101    }
102}
103
104impl Resource for ResourceManager {
105    fn id(&self) -> ResourceId {
106        RESOURCE_MANAGER
107    }
108
109    fn on_open(&mut self) -> ResourceOut {
110        // Kick off the handshake: ask the module for its profile.
111        ResourceOut {
112            apdus: vec![ser(&ProfileEnq)],
113            ..ResourceOut::default()
114        }
115    }
116
117    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
118        let mut out = ResourceOut::default();
119        match peek_tag(apdu) {
120            // Module asks for the host's profile → reply with our resource list.
121            Some(t) if t == tag::PROFILE_ENQ => {
122                out.apdus.push(ser(&Profile {
123                    resources: self.host_resources.clone(),
124                }));
125            }
126            // Module's profile → record its resources.
127            Some(t) if t == tag::PROFILE => {
128                if let Ok(p) = Profile::parse(apdu) {
129                    self.module_resources = p.resources;
130                    self.module_profiled = true;
131                }
132            }
133            // Resource set changed → re-enquire.
134            Some(t) if t == tag::PROFILE_CHANGE => {
135                out.apdus.push(ser(&ProfileEnq));
136                self.module_profiled = false;
137                self.ready = false;
138            }
139            _ => {}
140        }
141        // Once we have the module's profile, the host:
142        //   1. sends `profile_change` (§8.4.1.1) — the gate the module waits on;
143        //      until it arrives the module can neither open nor accept sessions
144        //      (it idles after its `profile` reply — #340 round 1), and
145        //   2. opens a session to each **module-provided** resource it engages
146        //      (application_information, conditional_access, mmi) with
147        //      `create_session`. The direction rule (confirmed on hardware,
148        //      #340 round 2): the module opens sessions to *host*-provided
149        //      resources (resource_manager, date_time); the *host* opens sessions
150        //      to *module*-provided resources.
151        if self.module_profiled && !self.ready {
152            self.ready = true;
153            out.apdus.push(ser(&ProfileChange));
154            out.notify.push(Notification::CamReady);
155            // Open the standard module-provided resources **unconditionally**.
156            // A real AlphaCrypt/Irdeto CAM returns an *empty* `profile` (no
157            // resource_identifiers — raw `9F 80 11 00`), so gating on its
158            // enumeration opens nothing (#340 round 4). The module accepts a
159            // `create_session` for the resources it actually provides and refuses
160            // the rest (`create_session_response` status != ok, which the session
161            // layer ignores) — matching libdvben50221.
162            out.open.push(APPLICATION_INFORMATION);
163            out.open.push(CONDITIONAL_ACCESS_SUPPORT);
164            out.open.push(MMI);
165        }
166        out
167    }
168}
169
170/// Application Information (§8.4.2) — module-provided. On open, enquires the
171/// module's application info; surfaces it as [`Notification::ApplicationInfo`].
172#[derive(Debug, Default)]
173pub struct ApplicationInformation;
174
175impl Resource for ApplicationInformation {
176    fn id(&self) -> ResourceId {
177        APPLICATION_INFORMATION
178    }
179
180    fn on_open(&mut self) -> ResourceOut {
181        ResourceOut {
182            apdus: vec![ser(&ApplicationInfoEnq)],
183            ..ResourceOut::default()
184        }
185    }
186
187    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
188        let mut out = ResourceOut::default();
189        if peek_tag(apdu) == Some(tag::APPLICATION_INFO) {
190            if let Ok(ai) = ApplicationInfo::parse(apdu) {
191                out.notify.push(Notification::ApplicationInfo {
192                    application_type: ai.application_type.to_u8(),
193                    manufacturer: ai.application_manufacturer,
194                    code: ai.manufacturer_code,
195                    menu: String::from_utf8_lossy(ai.menu_string).into_owned(),
196                });
197            }
198        }
199        out
200    }
201}
202
203/// Conditional Access Support (§8.4.3) — module-provided. On open, enquires the
204/// module's supported `CA_system_id`s ([`Notification::CaInfo`]); decodes
205/// `ca_pmt_reply` ([`Notification::CaPmtReply`]). The host sends `ca_pmt` via
206/// [`HostRequest::SendCaPmt`](crate::event::HostRequest::SendCaPmt).
207#[derive(Debug, Default)]
208pub struct ConditionalAccess;
209
210impl Resource for ConditionalAccess {
211    fn id(&self) -> ResourceId {
212        CONDITIONAL_ACCESS_SUPPORT
213    }
214
215    fn on_open(&mut self) -> ResourceOut {
216        ResourceOut {
217            apdus: vec![ser(&CaInfoEnq)],
218            ..ResourceOut::default()
219        }
220    }
221
222    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
223        let mut out = ResourceOut::default();
224        match peek_tag(apdu) {
225            Some(t) if t == tag::CA_INFO => {
226                if let Ok(ci) = CaInfo::parse(apdu) {
227                    out.notify.push(Notification::CaInfo {
228                        ca_system_ids: ci.ca_system_ids,
229                    });
230                }
231            }
232            Some(t) if t == tag::CA_PMT_REPLY => {
233                if let Ok(r) = CaPmtReply::parse(apdu) {
234                    let descrambling_ok = r.ca_enable.is_some_and(|e| {
235                        matches!(
236                            e,
237                            CaEnable::Possible
238                                | CaEnable::PossiblePurchaseDialogue
239                                | CaEnable::PossibleTechnicalDialogue
240                        )
241                    });
242                    out.notify.push(Notification::CaPmtReply {
243                        program_number: r.program_number,
244                        descrambling_ok,
245                    });
246                }
247            }
248            _ => {}
249        }
250        out
251    }
252}
253
254const SECS_PER_DAY: u64 = 86_400;
255/// Modified Julian Date of the Unix epoch (1970-01-01).
256const MJD_UNIX_EPOCH: u64 = 40_587;
257
258fn bcd(v: u64) -> u8 {
259    (((v / 10) << 4) | (v % 10)) as u8
260}
261
262/// Encode a Unix timestamp as the 5-byte DVB `UTC_time` (MJD `[15:0]` + BCD
263/// HH:MM:SS), per EN 300 468 Annex C.
264fn unix_to_mjd_bcd(unix_secs: u64) -> [u8; UTC_TIME_LEN] {
265    let mjd = (MJD_UNIX_EPOCH + unix_secs / SECS_PER_DAY) as u16;
266    let sod = unix_secs % SECS_PER_DAY;
267    [
268        (mjd >> 8) as u8,
269        mjd as u8,
270        bcd(sod / 3600),
271        bcd((sod % 3600) / 60),
272        bcd(sod % 60),
273    ]
274}
275
276fn system_utc() -> [u8; UTC_TIME_LEN] {
277    let secs = std::time::SystemTime::now()
278        .duration_since(std::time::UNIX_EPOCH)
279        .map(|d| d.as_secs())
280        .unwrap_or(0);
281    unix_to_mjd_bcd(secs)
282}
283
284/// Date-Time (§8.5.2) — host-provided. On `date_time_enq` replies with the
285/// current UTC; if the enquiry's `response_interval` is non-zero, re-sends every
286/// `response_interval` seconds (driven by [`tick`](Resource::tick)).
287pub struct DateTime {
288    clock: fn() -> [u8; UTC_TIME_LEN],
289    interval: u8,
290    since: Duration,
291}
292
293impl Default for DateTime {
294    fn default() -> Self {
295        Self::new()
296    }
297}
298
299impl DateTime {
300    /// New handler using the system clock.
301    #[must_use]
302    pub fn new() -> Self {
303        Self {
304            clock: system_utc,
305            interval: 0,
306            since: Duration::ZERO,
307        }
308    }
309
310    /// New handler with an injected clock (for tests / a host-supplied source).
311    #[must_use]
312    pub fn with_clock(clock: fn() -> [u8; UTC_TIME_LEN]) -> Self {
313        Self {
314            clock,
315            interval: 0,
316            since: Duration::ZERO,
317        }
318    }
319
320    fn reply(&self) -> Vec<u8> {
321        ser(&CiDateTime {
322            utc_time: (self.clock)(),
323            local_offset: None,
324        })
325    }
326}
327
328impl Resource for DateTime {
329    fn id(&self) -> ResourceId {
330        DATE_TIME
331    }
332
333    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
334        let mut out = ResourceOut::default();
335        if peek_tag(apdu) == Some(tag::DATE_TIME_ENQ) {
336            if let Ok(enq) = DateTimeEnq::parse(apdu) {
337                self.interval = enq.response_interval;
338                self.since = Duration::ZERO;
339                out.apdus.push(self.reply());
340            }
341        }
342        out
343    }
344
345    fn tick(&mut self, elapsed: Duration) -> ResourceOut {
346        let mut out = ResourceOut::default();
347        if self.interval > 0 {
348            self.since += elapsed;
349            if self.since >= Duration::from_secs(u64::from(self.interval)) {
350                self.since = Duration::ZERO;
351                out.apdus.push(self.reply());
352            }
353        }
354        out
355    }
356}
357
358/// MMI (§8.6) — module-provided. Surfaces the module's menus/enquiries and the
359/// close as [`Notification::Mmi`] events for the application to display. (The
360/// module drives the dialog; answering — `menu_answ`/`answ` — is a later
361/// addition.)
362#[derive(Debug, Default)]
363pub struct Mmi;
364
365impl Resource for Mmi {
366    fn id(&self) -> ResourceId {
367        MMI
368    }
369
370    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
371        let mut out = ResourceOut::default();
372        match peek_tag(apdu) {
373            Some(t) if t == tag::ENQ => {
374                if let Ok(e) = Enq::parse(apdu) {
375                    out.notify.push(Notification::Mmi(MmiEvent::Enquiry {
376                        prompt: text(e.text_chars),
377                        blind: e.blind_answer,
378                        answer_len: e.answer_text_length,
379                    }));
380                }
381            }
382            Some(t) if t == tag::MENU_LAST => {
383                if let Ok(m) = Menu::parse(apdu) {
384                    out.notify.push(Notification::Mmi(MmiEvent::Menu {
385                        title: text(m.title.text_chars),
386                        items: m.choices.iter().map(|c| text(c.text_chars)).collect(),
387                    }));
388                }
389            }
390            Some(t) if t == tag::CLOSE_MMI => {
391                out.notify.push(Notification::Mmi(MmiEvent::Close));
392            }
393            _ => {}
394        }
395        out
396    }
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use dvb_ci::objects::resource_manager::Profile;
403
404    #[test]
405    fn on_open_sends_profile_enq() {
406        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
407        let out = rm.on_open();
408        assert_eq!(out.apdus, vec![ser(&ProfileEnq)]);
409    }
410
411    #[test]
412    fn module_profile_triggers_profile_change_camready_and_opens() {
413        // #340: after the module's `profile`, the host (1) fires CamReady, (2)
414        // sends `profile_change` (the §8.4.1.1 gate), and (3) opens the standard
415        // module-provided resources via create_session — **unconditionally**,
416        // even when the module's profile is empty (a real AlphaCrypt returns no
417        // resource_identifiers).
418        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
419        rm.on_open();
420        let empty_profile = ser(&Profile { resources: vec![] });
421        let o = rm.on_apdu(&empty_profile);
422        assert!(o.notify.contains(&Notification::CamReady));
423        assert_eq!(o.apdus.len(), 1, "host sends profile_change");
424        assert_eq!(peek_tag(&o.apdus[0]), Some(tag::PROFILE_CHANGE));
425        assert!(o.open.contains(&APPLICATION_INFORMATION));
426        assert!(o.open.contains(&CONDITIONAL_ACCESS_SUPPORT));
427        assert!(o.open.contains(&MMI));
428    }
429
430    #[test]
431    fn answers_a_module_profile_enquiry_without_re_readying() {
432        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
433        rm.on_open();
434        rm.on_apdu(&ser(&Profile {
435            resources: vec![APPLICATION_INFORMATION],
436        }));
437        // A later module profile_enq → reply with our profile, no second CamReady.
438        let o = rm.on_apdu(&ser(&ProfileEnq));
439        assert_eq!(o.apdus.len(), 1);
440        assert_eq!(peek_tag(&o.apdus[0]), Some(tag::PROFILE));
441        assert!(!o.notify.contains(&Notification::CamReady));
442    }
443
444    #[test]
445    fn mmi_surfaces_enquiry_and_close() {
446        let mut h = Mmi;
447        // enquiry
448        let enq = ser(&Enq {
449            blind_answer: true,
450            answer_text_length: 4,
451            text_chars: b"PIN?",
452        });
453        assert_eq!(
454            h.on_apdu(&enq).notify,
455            vec![Notification::Mmi(MmiEvent::Enquiry {
456                prompt: "PIN?".to_string(),
457                blind: true,
458                answer_len: 4,
459            })]
460        );
461        // close_mmi (tag 9F 88 00) — surfaced as Close
462        let close = [0x9F, 0x88, 0x00, 0x01, 0x00];
463        assert_eq!(
464            h.on_apdu(&close).notify,
465            vec![Notification::Mmi(MmiEvent::Close)]
466        );
467    }
468
469    #[test]
470    fn profile_change_re_enquires() {
471        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
472        let out = rm.on_apdu(&ser(&dvb_ci::objects::resource_manager::ProfileChange));
473        assert_eq!(out.apdus, vec![ser(&ProfileEnq)]);
474    }
475
476    #[test]
477    fn application_information_surfaces_notification() {
478        use dvb_ci::objects::application_info::ApplicationType;
479        let mut h = ApplicationInformation;
480        assert_eq!(h.on_open().apdus, vec![ser(&ApplicationInfoEnq)]);
481        let ai = ser(&ApplicationInfo {
482            application_type: ApplicationType::ConditionalAccess,
483            application_manufacturer: 0x1234,
484            manufacturer_code: 0x5678,
485            menu_string: b"Acme CAM",
486        });
487        let out = h.on_apdu(&ai);
488        assert_eq!(
489            out.notify,
490            vec![Notification::ApplicationInfo {
491                application_type: 0x01,
492                manufacturer: 0x1234,
493                code: 0x5678,
494                menu: "Acme CAM".to_string(),
495            }]
496        );
497    }
498
499    #[test]
500    fn mjd_bcd_encoding_is_correct() {
501        // Unix epoch 1970-01-01 00:00:00 → MJD 40587 (0x9E8B), 00:00:00.
502        assert_eq!(unix_to_mjd_bcd(0), [0x9E, 0x8B, 0x00, 0x00, 0x00]);
503        // 1970-01-02 13:45:09 → MJD 40588 (0x9E8C), BCD 13 45 09.
504        let secs = SECS_PER_DAY + 13 * 3600 + 45 * 60 + 9;
505        assert_eq!(unix_to_mjd_bcd(secs), [0x9E, 0x8C, 0x13, 0x45, 0x09]);
506    }
507
508    #[test]
509    fn date_time_replies_to_enq_and_resends_on_interval() {
510        let fixed = || [0x9E, 0x7B, 0x00, 0x00, 0x00];
511        let mut h = DateTime::with_clock(fixed);
512        // enquiry with a 5s response interval → immediate reply
513        let enq = ser(&DateTimeEnq {
514            response_interval: 5,
515        });
516        let out = h.on_apdu(&enq);
517        assert_eq!(out.apdus.len(), 1);
518        assert_eq!(peek_tag(&out.apdus[0]), Some(tag::DATE_TIME));
519        // before the interval: no resend
520        assert!(h.tick(Duration::from_secs(3)).apdus.is_empty());
521        // crossing the interval: resend
522        assert_eq!(h.tick(Duration::from_secs(3)).apdus.len(), 1);
523    }
524
525    #[test]
526    fn date_time_interval_zero_does_not_resend() {
527        let mut h = DateTime::with_clock(|| [0u8; UTC_TIME_LEN]);
528        h.on_apdu(&ser(&DateTimeEnq {
529            response_interval: 0,
530        }));
531        assert!(h.tick(Duration::from_secs(60)).apdus.is_empty());
532    }
533
534    #[test]
535    fn conditional_access_surfaces_ca_info_and_pmt_reply() {
536        let mut h = ConditionalAccess;
537        assert_eq!(h.on_open().apdus, vec![ser(&CaInfoEnq)]);
538        // ca_info -> CaInfo notification
539        let ci = ser(&CaInfo {
540            ca_system_ids: vec![0x0B00, 0x1800],
541        });
542        assert_eq!(
543            h.on_apdu(&ci).notify,
544            vec![Notification::CaInfo {
545                ca_system_ids: vec![0x0B00, 0x1800],
546            }]
547        );
548        // ca_pmt_reply (descrambling possible) -> CaPmtReply notification
549        let reply = ser(&CaPmtReply {
550            program_number: 0x0042,
551            version_number: 0,
552            current_next_indicator: true,
553            ca_enable: Some(CaEnable::Possible),
554            streams: vec![],
555        });
556        assert_eq!(
557            h.on_apdu(&reply).notify,
558            vec![Notification::CaPmtReply {
559                program_number: 0x0042,
560                descrambling_ok: true,
561            }]
562        );
563    }
564}