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, 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        // The handshake completes once we have the module's profile — that
142        // alone tells us which module resources to open. Real CAMs
143        // (AlphaCrypt/Irdeto, #337) send their `profile` reply and then idle;
144        // they do not enquire the host's profile, so gating on `host_profiled`
145        // hung RM forever. We still answer a module `profile_enq` if one comes
146        // (so it can later open host-provided resources like date_time).
147        if self.module_profiled && !self.ready {
148            self.ready = true;
149            out.notify.push(Notification::CamReady);
150            // Open the module-provided resources we understand.
151            for r in [APPLICATION_INFORMATION, CONDITIONAL_ACCESS_SUPPORT, MMI] {
152                if self.module_resources.contains(&r) {
153                    out.open.push(r);
154                }
155            }
156        }
157        out
158    }
159}
160
161/// Application Information (§8.4.2) — module-provided. On open, enquires the
162/// module's application info; surfaces it as [`Notification::ApplicationInfo`].
163#[derive(Debug, Default)]
164pub struct ApplicationInformation;
165
166impl Resource for ApplicationInformation {
167    fn id(&self) -> ResourceId {
168        APPLICATION_INFORMATION
169    }
170
171    fn on_open(&mut self) -> ResourceOut {
172        ResourceOut {
173            apdus: vec![ser(&ApplicationInfoEnq)],
174            ..ResourceOut::default()
175        }
176    }
177
178    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
179        let mut out = ResourceOut::default();
180        if peek_tag(apdu) == Some(tag::APPLICATION_INFO) {
181            if let Ok(ai) = ApplicationInfo::parse(apdu) {
182                out.notify.push(Notification::ApplicationInfo {
183                    application_type: ai.application_type.to_u8(),
184                    manufacturer: ai.application_manufacturer,
185                    code: ai.manufacturer_code,
186                    menu: String::from_utf8_lossy(ai.menu_string).into_owned(),
187                });
188            }
189        }
190        out
191    }
192}
193
194/// Conditional Access Support (§8.4.3) — module-provided. On open, enquires the
195/// module's supported `CA_system_id`s ([`Notification::CaInfo`]); decodes
196/// `ca_pmt_reply` ([`Notification::CaPmtReply`]). The host sends `ca_pmt` via
197/// [`HostRequest::SendCaPmt`](crate::event::HostRequest::SendCaPmt).
198#[derive(Debug, Default)]
199pub struct ConditionalAccess;
200
201impl Resource for ConditionalAccess {
202    fn id(&self) -> ResourceId {
203        CONDITIONAL_ACCESS_SUPPORT
204    }
205
206    fn on_open(&mut self) -> ResourceOut {
207        ResourceOut {
208            apdus: vec![ser(&CaInfoEnq)],
209            ..ResourceOut::default()
210        }
211    }
212
213    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
214        let mut out = ResourceOut::default();
215        match peek_tag(apdu) {
216            Some(t) if t == tag::CA_INFO => {
217                if let Ok(ci) = CaInfo::parse(apdu) {
218                    out.notify.push(Notification::CaInfo {
219                        ca_system_ids: ci.ca_system_ids,
220                    });
221                }
222            }
223            Some(t) if t == tag::CA_PMT_REPLY => {
224                if let Ok(r) = CaPmtReply::parse(apdu) {
225                    let descrambling_ok = r.ca_enable.is_some_and(|e| {
226                        matches!(
227                            e,
228                            CaEnable::Possible
229                                | CaEnable::PossiblePurchaseDialogue
230                                | CaEnable::PossibleTechnicalDialogue
231                        )
232                    });
233                    out.notify.push(Notification::CaPmtReply {
234                        program_number: r.program_number,
235                        descrambling_ok,
236                    });
237                }
238            }
239            _ => {}
240        }
241        out
242    }
243}
244
245const SECS_PER_DAY: u64 = 86_400;
246/// Modified Julian Date of the Unix epoch (1970-01-01).
247const MJD_UNIX_EPOCH: u64 = 40_587;
248
249fn bcd(v: u64) -> u8 {
250    (((v / 10) << 4) | (v % 10)) as u8
251}
252
253/// Encode a Unix timestamp as the 5-byte DVB `UTC_time` (MJD `[15:0]` + BCD
254/// HH:MM:SS), per EN 300 468 Annex C.
255fn unix_to_mjd_bcd(unix_secs: u64) -> [u8; UTC_TIME_LEN] {
256    let mjd = (MJD_UNIX_EPOCH + unix_secs / SECS_PER_DAY) as u16;
257    let sod = unix_secs % SECS_PER_DAY;
258    [
259        (mjd >> 8) as u8,
260        mjd as u8,
261        bcd(sod / 3600),
262        bcd((sod % 3600) / 60),
263        bcd(sod % 60),
264    ]
265}
266
267fn system_utc() -> [u8; UTC_TIME_LEN] {
268    let secs = std::time::SystemTime::now()
269        .duration_since(std::time::UNIX_EPOCH)
270        .map(|d| d.as_secs())
271        .unwrap_or(0);
272    unix_to_mjd_bcd(secs)
273}
274
275/// Date-Time (§8.5.2) — host-provided. On `date_time_enq` replies with the
276/// current UTC; if the enquiry's `response_interval` is non-zero, re-sends every
277/// `response_interval` seconds (driven by [`tick`](Resource::tick)).
278pub struct DateTime {
279    clock: fn() -> [u8; UTC_TIME_LEN],
280    interval: u8,
281    since: Duration,
282}
283
284impl Default for DateTime {
285    fn default() -> Self {
286        Self::new()
287    }
288}
289
290impl DateTime {
291    /// New handler using the system clock.
292    #[must_use]
293    pub fn new() -> Self {
294        Self {
295            clock: system_utc,
296            interval: 0,
297            since: Duration::ZERO,
298        }
299    }
300
301    /// New handler with an injected clock (for tests / a host-supplied source).
302    #[must_use]
303    pub fn with_clock(clock: fn() -> [u8; UTC_TIME_LEN]) -> Self {
304        Self {
305            clock,
306            interval: 0,
307            since: Duration::ZERO,
308        }
309    }
310
311    fn reply(&self) -> Vec<u8> {
312        ser(&CiDateTime {
313            utc_time: (self.clock)(),
314            local_offset: None,
315        })
316    }
317}
318
319impl Resource for DateTime {
320    fn id(&self) -> ResourceId {
321        DATE_TIME
322    }
323
324    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
325        let mut out = ResourceOut::default();
326        if peek_tag(apdu) == Some(tag::DATE_TIME_ENQ) {
327            if let Ok(enq) = DateTimeEnq::parse(apdu) {
328                self.interval = enq.response_interval;
329                self.since = Duration::ZERO;
330                out.apdus.push(self.reply());
331            }
332        }
333        out
334    }
335
336    fn tick(&mut self, elapsed: Duration) -> ResourceOut {
337        let mut out = ResourceOut::default();
338        if self.interval > 0 {
339            self.since += elapsed;
340            if self.since >= Duration::from_secs(u64::from(self.interval)) {
341                self.since = Duration::ZERO;
342                out.apdus.push(self.reply());
343            }
344        }
345        out
346    }
347}
348
349/// MMI (§8.6) — module-provided. Surfaces the module's menus/enquiries and the
350/// close as [`Notification::Mmi`] events for the application to display. (The
351/// module drives the dialog; answering — `menu_answ`/`answ` — is a later
352/// addition.)
353#[derive(Debug, Default)]
354pub struct Mmi;
355
356impl Resource for Mmi {
357    fn id(&self) -> ResourceId {
358        MMI
359    }
360
361    fn on_apdu(&mut self, apdu: &[u8]) -> ResourceOut {
362        let mut out = ResourceOut::default();
363        match peek_tag(apdu) {
364            Some(t) if t == tag::ENQ => {
365                if let Ok(e) = Enq::parse(apdu) {
366                    out.notify.push(Notification::Mmi(MmiEvent::Enquiry {
367                        prompt: text(e.text_chars),
368                        blind: e.blind_answer,
369                        answer_len: e.answer_text_length,
370                    }));
371                }
372            }
373            Some(t) if t == tag::MENU_LAST => {
374                if let Ok(m) = Menu::parse(apdu) {
375                    out.notify.push(Notification::Mmi(MmiEvent::Menu {
376                        title: text(m.title.text_chars),
377                        items: m.choices.iter().map(|c| text(c.text_chars)).collect(),
378                    }));
379                }
380            }
381            Some(t) if t == tag::CLOSE_MMI => {
382                out.notify.push(Notification::Mmi(MmiEvent::Close));
383            }
384            _ => {}
385        }
386        out
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use dvb_ci::objects::resource_manager::Profile;
394
395    #[test]
396    fn on_open_sends_profile_enq() {
397        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
398        let out = rm.on_open();
399        assert_eq!(out.apdus, vec![ser(&ProfileEnq)]);
400    }
401
402    #[test]
403    fn handshake_completes_on_module_profile_alone() {
404        // #337: a real CAM sends its `profile` reply then idles — it never
405        // enquires the host. The handshake must complete on the module's profile
406        // alone (gating on a module `profile_enq` hung RM forever).
407        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
408        rm.on_open();
409        let module_profile = ser(&Profile {
410            resources: vec![APPLICATION_INFORMATION, CONDITIONAL_ACCESS_SUPPORT],
411        });
412        let o = rm.on_apdu(&module_profile);
413        // CamReady fires + opens the module resources we understand.
414        assert!(o.notify.contains(&Notification::CamReady));
415        assert!(o.open.contains(&APPLICATION_INFORMATION));
416        assert!(o.open.contains(&CONDITIONAL_ACCESS_SUPPORT));
417        assert!(!o.open.contains(&MMI), "module didn't advertise MMI");
418    }
419
420    #[test]
421    fn answers_a_module_profile_enquiry_without_re_readying() {
422        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
423        rm.on_open();
424        rm.on_apdu(&ser(&Profile {
425            resources: vec![APPLICATION_INFORMATION],
426        }));
427        // A later module profile_enq → reply with our profile, no second CamReady.
428        let o = rm.on_apdu(&ser(&ProfileEnq));
429        assert_eq!(o.apdus.len(), 1);
430        assert_eq!(peek_tag(&o.apdus[0]), Some(tag::PROFILE));
431        assert!(!o.notify.contains(&Notification::CamReady));
432    }
433
434    #[test]
435    fn mmi_surfaces_enquiry_and_close() {
436        let mut h = Mmi;
437        // enquiry
438        let enq = ser(&Enq {
439            blind_answer: true,
440            answer_text_length: 4,
441            text_chars: b"PIN?",
442        });
443        assert_eq!(
444            h.on_apdu(&enq).notify,
445            vec![Notification::Mmi(MmiEvent::Enquiry {
446                prompt: "PIN?".to_string(),
447                blind: true,
448                answer_len: 4,
449            })]
450        );
451        // close_mmi (tag 9F 88 00) — surfaced as Close
452        let close = [0x9F, 0x88, 0x00, 0x01, 0x00];
453        assert_eq!(
454            h.on_apdu(&close).notify,
455            vec![Notification::Mmi(MmiEvent::Close)]
456        );
457    }
458
459    #[test]
460    fn profile_change_re_enquires() {
461        let mut rm = ResourceManager::new(vec![RESOURCE_MANAGER]);
462        let out = rm.on_apdu(&ser(&dvb_ci::objects::resource_manager::ProfileChange));
463        assert_eq!(out.apdus, vec![ser(&ProfileEnq)]);
464    }
465
466    #[test]
467    fn application_information_surfaces_notification() {
468        use dvb_ci::objects::application_info::ApplicationType;
469        let mut h = ApplicationInformation;
470        assert_eq!(h.on_open().apdus, vec![ser(&ApplicationInfoEnq)]);
471        let ai = ser(&ApplicationInfo {
472            application_type: ApplicationType::ConditionalAccess,
473            application_manufacturer: 0x1234,
474            manufacturer_code: 0x5678,
475            menu_string: b"Acme CAM",
476        });
477        let out = h.on_apdu(&ai);
478        assert_eq!(
479            out.notify,
480            vec![Notification::ApplicationInfo {
481                application_type: 0x01,
482                manufacturer: 0x1234,
483                code: 0x5678,
484                menu: "Acme CAM".to_string(),
485            }]
486        );
487    }
488
489    #[test]
490    fn mjd_bcd_encoding_is_correct() {
491        // Unix epoch 1970-01-01 00:00:00 → MJD 40587 (0x9E8B), 00:00:00.
492        assert_eq!(unix_to_mjd_bcd(0), [0x9E, 0x8B, 0x00, 0x00, 0x00]);
493        // 1970-01-02 13:45:09 → MJD 40588 (0x9E8C), BCD 13 45 09.
494        let secs = SECS_PER_DAY + 13 * 3600 + 45 * 60 + 9;
495        assert_eq!(unix_to_mjd_bcd(secs), [0x9E, 0x8C, 0x13, 0x45, 0x09]);
496    }
497
498    #[test]
499    fn date_time_replies_to_enq_and_resends_on_interval() {
500        let fixed = || [0x9E, 0x7B, 0x00, 0x00, 0x00];
501        let mut h = DateTime::with_clock(fixed);
502        // enquiry with a 5s response interval → immediate reply
503        let enq = ser(&DateTimeEnq {
504            response_interval: 5,
505        });
506        let out = h.on_apdu(&enq);
507        assert_eq!(out.apdus.len(), 1);
508        assert_eq!(peek_tag(&out.apdus[0]), Some(tag::DATE_TIME));
509        // before the interval: no resend
510        assert!(h.tick(Duration::from_secs(3)).apdus.is_empty());
511        // crossing the interval: resend
512        assert_eq!(h.tick(Duration::from_secs(3)).apdus.len(), 1);
513    }
514
515    #[test]
516    fn date_time_interval_zero_does_not_resend() {
517        let mut h = DateTime::with_clock(|| [0u8; UTC_TIME_LEN]);
518        h.on_apdu(&ser(&DateTimeEnq {
519            response_interval: 0,
520        }));
521        assert!(h.tick(Duration::from_secs(60)).apdus.is_empty());
522    }
523
524    #[test]
525    fn conditional_access_surfaces_ca_info_and_pmt_reply() {
526        let mut h = ConditionalAccess;
527        assert_eq!(h.on_open().apdus, vec![ser(&CaInfoEnq)]);
528        // ca_info -> CaInfo notification
529        let ci = ser(&CaInfo {
530            ca_system_ids: vec![0x0B00, 0x1800],
531        });
532        assert_eq!(
533            h.on_apdu(&ci).notify,
534            vec![Notification::CaInfo {
535                ca_system_ids: vec![0x0B00, 0x1800],
536            }]
537        );
538        // ca_pmt_reply (descrambling possible) -> CaPmtReply notification
539        let reply = ser(&CaPmtReply {
540            program_number: 0x0042,
541            version_number: 0,
542            current_next_indicator: true,
543            ca_enable: Some(CaEnable::Possible),
544            streams: vec![],
545        });
546        assert_eq!(
547            h.on_apdu(&reply).notify,
548            vec![Notification::CaPmtReply {
549                program_number: 0x0042,
550                descrambling_ok: true,
551            }]
552        );
553    }
554}