Skip to main content

dvb_ci/ci_plus/
mod.rs

1//! CI Plus extensions (ETSI TS 103 205) — the resource-scoped APDU layer.
2//!
3//! Like the TS 101 699 extensions ([`crate::ci_ext`]), the CI Plus resources
4//! defined by TS 103 205 have **their own apdu_tag namespace** that collides
5//! with EN 50221 and TS 101 699 — e.g. the Multi-stream resource's tags live in
6//! `0x9F92xx`, Content Control's in `0x9F90xx`, and the extended CA Support
7//! `ca_pmt`/`ca_pmt_reply` reuse EN 50221's `0x9F8032`/`0x9F8033` verbatim. The
8//! same tag bytes therefore denote different objects depending on which
9//! resource's session they arrive on, so they cannot join the global
10//! [`crate::AnyApdu`]. This module provides a *resource-scoped* dispatch
11//! ([`CiPlusApdu`]): parsing keys on the `resource_identifier()` first
12//! ([`classify`]), then the leading `apdu_tag` selects the object within that
13//! resource.
14//!
15//! Spec: ETSI TS 103 205 V1.4.1 — resource IDs in `docs/ts_103_205/resource-ids.md`;
16//! per-resource layouts cited in their own module docs.
17//!
18//! ## Resources implemented this pass
19//!
20//! - **Multi-stream** (`0x00900041`, [`multistream`]) — `CICAM_multistream_capability`
21//!   / `PID_select_req` / `PID_select_reply`.
22//! - **Content Control multi-stream** (`0x008C1041`, [`content_control`]) — the
23//!   printed-syntax extended APDUs `cc_PIN_reply` / `cc_PIN_event`, plus the SAC
24//!   protocol datatype model.
25//! - **Multi-stream Host Control** (`0x00200081`, base v3 `0x00200043`,
26//!   [`multistream_host_control`]) — the tune APDUs `tune_triplet_req` /
27//!   `tune_lcn_req` / `tune_ip_req` / `tuner_status_req` / `tuner_status_reply`.
28//!   Both resource ids route to the same [`CiPlusResource::MultistreamHostControl`]
29//!   kind, carrying the [`multistream_host_control::HostControlMode`] that selects
30//!   the `tune_ip_req` reserved-bit budget. `tune_broadcast_req` / `tune_reply` /
31//!   `ask_release(_reply)` are deferred to CI Plus V1.3 and not encoded.
32//! - **Sample decryption** (`0x00920041`, [`sample_decryption`]) — `sd_info_req` /
33//!   `sd_info_reply` / `sd_start(_reply)` / `sd_update(_reply)`, with opaque DRM
34//!   metadata / UUID payloads.
35//! - **CICAM Player** (`0x00930041`, [`cicam_player`]) — the full 16-APDU set
36//!   `9FA000`–`9FA00F` (`CICAM_player_verify_req` … `CICAM_player_update_reply`).
37//! - **Auxiliary File System / file retrieval** (`0x00910041`, [`file_retrieval`])
38//!   — `FileSystemOffer` / `FileSystemAck`, plus `FileRequest` / `FileAcknowledge`
39//!   whose bodies defer to CI Plus V1.3 (carried opaquely; tags only established).
40//!
41//! ## Not dispatched here
42//!
43//! - **CA Support multi-stream** ([`ca_support`]) — TS 103 205 does not print a
44//!   resource_id for the `resource_type = 2` variant (it defers to CI Plus V1.3),
45//!   so its extended `ca_pmt`/`ca_pmt_reply` are standalone typed structs,
46//!   directly constructible/parseable but **not** wired into [`CiPlusApdu`].
47//! - **Low-Speed Comms v4 extensions** ([`low_speed_comms_v4`]) — TS 103 205 §10
48//!   prints no LSC v4 resource_id (the LSC resource_id + base `comms_*` APDUs defer
49//!   to CI Plus V1.3), so the v4 `comms_info` / `comms_IP_config` APDUs and the
50//!   hybrid/multicast descriptors are standalone typed structs (tag-dispatch
51//!   helper), not wired into [`CiPlusApdu`].
52//! - **URI v3** ([`uri`]) — the `uri_message` is a SAC datatype (datatype_id 25),
53//!   not an APDU/resource, so [`uri::UriMessage`] is a standalone typed struct used
54//!   from the Content Control SAC layer.
55//! - **CI Plus descriptors** ([`descriptors`]) — Sample-Mode TLV descriptors, not
56//!   APDUs, so they are standalone too.
57
58use crate::error::{Error, Result};
59use crate::resource::ResourceId;
60
61pub mod ca_support;
62pub mod cicam_player;
63pub mod content_control;
64pub mod descriptors;
65pub mod file_retrieval;
66pub mod low_speed_comms_v4;
67pub mod multistream;
68pub mod multistream_host_control;
69pub mod sample_decryption;
70pub mod uri;
71
72// --- Resource identifiers (TS 103 205 resource-summary tables) ---
73
74/// Multi-stream resource — Class 144, Type 1, Version 1 (`0x00900041`).
75/// §6.4.2.1 Table 2.
76pub const MULTISTREAM: ResourceId = ResourceId(0x0090_0041);
77/// Content Control multi-stream resource — Class 140, Type 65, Version 1
78/// (`0x008C1041`). §6.4.3.1 Table 6.
79pub const CONTENT_CONTROL: ResourceId = ResourceId(0x008C_1041);
80/// Multi-stream Host Control resource — Class 32, Type 2, Version 1
81/// (`0x00200081`). §6.4.5, Table 17. Based on DVB Host Control v3.
82pub const MULTISTREAM_HOST_CONTROL: ResourceId = ResourceId(0x0020_0081);
83/// Base DVB Host Control v3 resource — Class 32, Type 1, Version 3
84/// (`0x00200043`). §13. The multi-stream tune APDUs derive from this; both ids
85/// route to [`CiPlusResource::MultistreamHostControl`], distinguished only by the
86/// `tune_ip_req` reserved-bit budget ([`multistream_host_control::HostControlMode`]).
87pub const HOST_CONTROL_V3: ResourceId = ResourceId(0x0020_0043);
88/// Sample decryption resource — Class 146, Type 1, Version 1 (`0x00920041`).
89/// §7.4, Table 30.
90pub const SAMPLE_DECRYPTION: ResourceId = ResourceId(0x0092_0041);
91/// CICAM Player resource — Class 147, Type 1, Version 1 (`0x00930041`).
92/// §8.8, Table 71.
93pub const CICAM_PLAYER: ResourceId = ResourceId(0x0093_0041);
94/// Auxiliary File System resource (CICAM file retrieval) — Class 145, Type 1,
95/// Version 1 (`0x00910041`). §9, Table 75.
96pub const FILE_RETRIEVAL: ResourceId = ResourceId(0x0091_0041);
97
98/// The CI Plus resource a [`ResourceId`] denotes, for the resources dispatched by
99/// [`CiPlusApdu`]. Returns `None` for any other resource (including the
100/// deferred-resource_id CA Support multi-stream type).
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
102#[cfg_attr(feature = "serde", derive(serde::Serialize))]
103#[non_exhaustive]
104pub enum CiPlusResource {
105    /// Multi-stream resource (`0x00900041`).
106    Multistream,
107    /// Content Control multi-stream resource (`0x008C1041`).
108    ContentControl,
109    /// Multi-stream Host Control — `0x00200081`
110    /// ([`MultiStream`](multistream_host_control::HostControlMode::MultiStream))
111    /// or the base DVB Host Control v3 `0x00200043`
112    /// ([`BaseV3`](multistream_host_control::HostControlMode::BaseV3)). The matched
113    /// mode is carried so the `tune_ip_req` reserved-bit budget can be selected.
114    MultistreamHostControl(multistream_host_control::HostControlMode),
115    /// Sample decryption resource (`0x00920041`).
116    SampleDecryption,
117    /// CICAM Player resource (`0x00930041`).
118    CicamPlayer,
119    /// Auxiliary File System / file retrieval resource (`0x00910041`).
120    FileRetrieval,
121}
122
123impl CiPlusResource {
124    /// Diagnostic spec token.
125    #[must_use]
126    pub fn name(&self) -> &'static str {
127        match self {
128            Self::Multistream => "multistream",
129            Self::ContentControl => "content_control",
130            Self::MultistreamHostControl(_) => "multistream_host_control",
131            Self::SampleDecryption => "sample_decryption",
132            Self::CicamPlayer => "cicam_player",
133            Self::FileRetrieval => "file_retrieval",
134        }
135    }
136}
137broadcast_common::impl_spec_display!(CiPlusResource);
138
139/// Map a [`ResourceId`] to the CI Plus resource it denotes, or `None` for any
140/// resource not dispatched by [`CiPlusApdu`].
141#[must_use]
142pub fn classify(id: ResourceId) -> Option<CiPlusResource> {
143    use multistream_host_control::HostControlMode;
144    match id {
145        MULTISTREAM => Some(CiPlusResource::Multistream),
146        CONTENT_CONTROL => Some(CiPlusResource::ContentControl),
147        MULTISTREAM_HOST_CONTROL => Some(CiPlusResource::MultistreamHostControl(
148            HostControlMode::MultiStream,
149        )),
150        HOST_CONTROL_V3 => Some(CiPlusResource::MultistreamHostControl(
151            HostControlMode::BaseV3,
152        )),
153        SAMPLE_DECRYPTION => Some(CiPlusResource::SampleDecryption),
154        CICAM_PLAYER => Some(CiPlusResource::CicamPlayer),
155        FILE_RETRIEVAL => Some(CiPlusResource::FileRetrieval),
156        _ => None,
157    }
158}
159
160/// A parsed CI Plus APDU, scoped to the resource it arrived on.
161///
162/// One variant per resource dispatched this pass; each wraps that resource's own
163/// object enum (which dispatches on the leading `apdu_tag`).
164#[derive(Debug, Clone, PartialEq, Eq)]
165#[cfg_attr(feature = "serde", derive(serde::Serialize))]
166#[non_exhaustive]
167pub enum CiPlusApdu<'a> {
168    /// Multi-stream resource object (`0x00900041`).
169    Multistream(multistream::MultistreamApdu),
170    /// Content Control multi-stream object (`0x008C1041`).
171    ContentControl(content_control::ContentControlApdu),
172    /// Multi-stream Host Control object (`0x00200081` / base v3 `0x00200043`).
173    MultistreamHostControl(
174        #[cfg_attr(feature = "serde", serde(borrow))]
175        multistream_host_control::MultistreamHostControlApdu<'a>,
176    ),
177    /// Sample decryption object (`0x00920041`).
178    SampleDecryption(
179        #[cfg_attr(feature = "serde", serde(borrow))] sample_decryption::SampleDecryptionApdu<'a>,
180    ),
181    /// CICAM Player object (`0x00930041`).
182    CicamPlayer(#[cfg_attr(feature = "serde", serde(borrow))] cicam_player::CicamPlayerApdu<'a>),
183    /// Auxiliary File System object (`0x00910041`).
184    FileRetrieval(
185        #[cfg_attr(feature = "serde", serde(borrow))] file_retrieval::FileRetrievalApdu<'a>,
186    ),
187}
188
189impl<'a> CiPlusApdu<'a> {
190    /// Parse a CI Plus APDU, selecting the resource from `resource_id`
191    /// ([`classify`]) and then delegating to that resource's object dispatch on
192    /// the leading `apdu_tag`.
193    ///
194    /// Errors with [`Error::UnknownResource`] if `resource_id` is not a CI Plus
195    /// resource handled this pass.
196    pub fn parse(resource_id: ResourceId, body: &'a [u8]) -> Result<Self> {
197        match classify(resource_id) {
198            Some(CiPlusResource::Multistream) => Ok(Self::Multistream(
199                multistream::MultistreamApdu::parse(body)?,
200            )),
201            Some(CiPlusResource::ContentControl) => Ok(Self::ContentControl(
202                content_control::ContentControlApdu::parse(body)?,
203            )),
204            Some(CiPlusResource::MultistreamHostControl(mode)) => Ok(Self::MultistreamHostControl(
205                multistream_host_control::MultistreamHostControlApdu::parse_mode(body, mode)?,
206            )),
207            Some(CiPlusResource::SampleDecryption) => Ok(Self::SampleDecryption(
208                sample_decryption::SampleDecryptionApdu::parse(body)?,
209            )),
210            Some(CiPlusResource::CicamPlayer) => Ok(Self::CicamPlayer(
211                cicam_player::CicamPlayerApdu::parse(body)?,
212            )),
213            Some(CiPlusResource::FileRetrieval) => Ok(Self::FileRetrieval(
214                file_retrieval::FileRetrievalApdu::parse(body)?,
215            )),
216            None => Err(Error::UnknownResource {
217                resource_id: resource_id.0,
218            }),
219        }
220    }
221}
222
223impl broadcast_common::Serialize for CiPlusApdu<'_> {
224    type Error = Error;
225    fn serialized_len(&self) -> usize {
226        match self {
227            Self::Multistream(o) => o.serialized_len(),
228            Self::ContentControl(o) => o.serialized_len(),
229            Self::MultistreamHostControl(o) => o.serialized_len(),
230            Self::SampleDecryption(o) => o.serialized_len(),
231            Self::CicamPlayer(o) => o.serialized_len(),
232            Self::FileRetrieval(o) => o.serialized_len(),
233        }
234    }
235    fn serialize_into(&self, buf: &mut [u8]) -> Result<usize> {
236        match self {
237            Self::Multistream(o) => o.serialize_into(buf),
238            Self::ContentControl(o) => o.serialize_into(buf),
239            Self::MultistreamHostControl(o) => o.serialize_into(buf),
240            Self::SampleDecryption(o) => o.serialize_into(buf),
241            Self::CicamPlayer(o) => o.serialize_into(buf),
242            Self::FileRetrieval(o) => o.serialize_into(buf),
243        }
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use broadcast_common::Serialize;
251
252    #[test]
253    fn classify_fixed_ids() {
254        use multistream_host_control::HostControlMode;
255        assert_eq!(classify(MULTISTREAM), Some(CiPlusResource::Multistream));
256        assert_eq!(
257            classify(CONTENT_CONTROL),
258            Some(CiPlusResource::ContentControl)
259        );
260        // The two Host Control ids both map to MultistreamHostControl, carrying
261        // the layout mode.
262        assert_eq!(
263            classify(MULTISTREAM_HOST_CONTROL),
264            Some(CiPlusResource::MultistreamHostControl(
265                HostControlMode::MultiStream
266            ))
267        );
268        assert_eq!(
269            classify(HOST_CONTROL_V3),
270            Some(CiPlusResource::MultistreamHostControl(
271                HostControlMode::BaseV3
272            ))
273        );
274        assert_eq!(
275            classify(SAMPLE_DECRYPTION),
276            Some(CiPlusResource::SampleDecryption)
277        );
278        assert_eq!(classify(CICAM_PLAYER), Some(CiPlusResource::CicamPlayer));
279        assert_eq!(
280            classify(FILE_RETRIEVAL),
281            Some(CiPlusResource::FileRetrieval)
282        );
283        assert_eq!(classify(ResourceId(0xDEAD_BEEF)), None);
284        // The deferred CA Support multi-stream type is intentionally not classified.
285        assert_eq!(classify(ResourceId(0x000C_0041)), None);
286    }
287
288    #[test]
289    fn dispatch_routes_host_control_and_sample_decryption() {
290        // tune_lcn_req under the multi-stream host control resource.
291        let lcn = [0x9F, 0x84, 0x07, 0x03, 0x01, 0x81, 0x23];
292        let hc = CiPlusApdu::parse(MULTISTREAM_HOST_CONTROL, &lcn).unwrap();
293        assert!(matches!(
294            hc,
295            CiPlusApdu::MultistreamHostControl(
296                multistream_host_control::MultistreamHostControlApdu::TuneLcnReq(_)
297            )
298        ));
299        assert_eq!(hc.to_bytes(), lcn);
300
301        // The same tune tag arrives on the base-v3 resource id and still routes.
302        let hc_v3 = CiPlusApdu::parse(HOST_CONTROL_V3, &lcn).unwrap();
303        assert!(matches!(hc_v3, CiPlusApdu::MultistreamHostControl(_)));
304
305        // sd_update_reply under the sample decryption resource.
306        let sd = [0x9F, 0x98, 0x05, 0x02, 0x03, 0x01];
307        let parsed = CiPlusApdu::parse(SAMPLE_DECRYPTION, &sd).unwrap();
308        assert!(matches!(
309            parsed,
310            CiPlusApdu::SampleDecryption(sample_decryption::SampleDecryptionApdu::SdUpdateReply(_))
311        ));
312        assert_eq!(parsed.to_bytes(), sd);
313    }
314
315    #[test]
316    fn tune_tag_9f8409_routes_resource_scoped_not_via_anyapdu() {
317        // 0x9F8409 (tune_triplet_req) is a multi-stream host-control tag. The
318        // EN 50221 Host Control resource owns 0x9F8400 (tune) / 0x9F8403
319        // (ask_release) — distinct values. The resource-scoped CiPlusApdu parses
320        // 0x9F8409 only under a host-control resource, proving it routes
321        // independently of the global AnyApdu (which has no 0x9F8409 member).
322        let triplet = [
323            0x9F, 0x84, 0x09, 0x09, 0x05, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x44, 0x00,
324        ];
325        assert!(matches!(
326            CiPlusApdu::parse(MULTISTREAM_HOST_CONTROL, &triplet).unwrap(),
327            CiPlusApdu::MultistreamHostControl(_)
328        ));
329        // Under an unrelated resource (Multistream PID resource), the same tag is
330        // not a member and is rejected.
331        assert!(matches!(
332            CiPlusApdu::parse(MULTISTREAM, &triplet),
333            Err(Error::UnexpectedApduTag { .. })
334        ));
335        // A global EN 50221 tune tag (0x9F8400) is not a member of the
336        // host-control resource's tune set either (different object namespace).
337        let en_tune = [0x9F, 0x84, 0x00, 0x00];
338        assert!(matches!(
339            CiPlusApdu::parse(MULTISTREAM_HOST_CONTROL, &en_tune),
340            Err(Error::UnexpectedApduTag { .. })
341        ));
342    }
343
344    #[test]
345    fn dispatch_routes_multistream_and_content_control() {
346        // Multistream PID_select_reply (whole-TS).
347        let ms_body = [0x9F, 0x92, 0x02, 0x03, 0x01, 0x00, 0x00];
348        let ms = CiPlusApdu::parse(MULTISTREAM, &ms_body).unwrap();
349        assert!(matches!(
350            ms,
351            CiPlusApdu::Multistream(multistream::MultistreamApdu::PidSelectReply(_))
352        ));
353        assert_eq!(ms.to_bytes(), ms_body);
354
355        // Content Control cc_PIN_reply (unbound).
356        let cc_body = [0x9F, 0x90, 0x14, 0x03, 0x00, 0x00, 0x42];
357        let cc = CiPlusApdu::parse(CONTENT_CONTROL, &cc_body).unwrap();
358        assert!(matches!(
359            cc,
360            CiPlusApdu::ContentControl(content_control::ContentControlApdu::CcPinReply(_))
361        ));
362        assert_eq!(cc.to_bytes(), cc_body);
363    }
364
365    #[test]
366    fn dispatch_routes_cicam_player_and_file_retrieval() {
367        // CICAM_player_update_reply (9F A0 0F) under the CICAM Player resource.
368        let upd = [0x9F, 0xA0, 0x0F, 0x02, 0x06, 0x00];
369        let p = CiPlusApdu::parse(CICAM_PLAYER, &upd).unwrap();
370        assert!(matches!(
371            p,
372            CiPlusApdu::CicamPlayer(cicam_player::CicamPlayerApdu::UpdateReply(_))
373        ));
374        assert_eq!(p.to_bytes(), upd);
375
376        // A 9FA0xx player tag is NOT a member of any other resource — it routes
377        // only under the player resource (resource-scoped invariant).
378        assert!(matches!(
379            CiPlusApdu::parse(MULTISTREAM, &upd),
380            Err(Error::UnexpectedApduTag { .. })
381        ));
382        assert!(matches!(
383            CiPlusApdu::parse(SAMPLE_DECRYPTION, &upd),
384            Err(Error::UnexpectedApduTag { .. })
385        ));
386
387        // FileSystemAck (9F 94 01) under the File Retrieval resource.
388        let ack = [0x9F, 0x94, 0x01, 0x01, 0x01];
389        let f = CiPlusApdu::parse(FILE_RETRIEVAL, &ack).unwrap();
390        assert!(matches!(
391            f,
392            CiPlusApdu::FileRetrieval(file_retrieval::FileRetrievalApdu::FileSystemAck(_))
393        ));
394        assert_eq!(f.to_bytes(), ack);
395        // The file-retrieval tag is not a player tag and vice-versa.
396        assert!(matches!(
397            CiPlusApdu::parse(CICAM_PLAYER, &ack),
398            Err(Error::UnexpectedApduTag { .. })
399        ));
400        assert!(matches!(
401            CiPlusApdu::parse(FILE_RETRIEVAL, &upd),
402            Err(Error::UnexpectedApduTag { .. })
403        ));
404    }
405
406    #[test]
407    fn unknown_resource_errors() {
408        let body = [0x9F, 0x92, 0x00, 0x00];
409        assert!(matches!(
410            CiPlusApdu::parse(ResourceId(0x1234_5678), &body),
411            Err(Error::UnknownResource { .. })
412        ));
413    }
414
415    #[test]
416    fn ca_pmt_tag_collision_routes_per_resource_independently() {
417        // 0x9F8032 (ca_pmt) is an EN 50221 tag AND a CI Plus CA-support tag, AND
418        // a TS 101 699-style 0x9F80xx value. Under the Multistream / Content
419        // Control resources it is simply *not* a member tag, so resource-scoped
420        // dispatch rejects it — proving CiPlusApdu routes independently of the
421        // global AnyApdu (which WOULD parse 0x9F8032 as ca_pmt).
422        let ca_pmt_like = [0x9F, 0x80, 0x32, 0x00];
423        assert!(matches!(
424            CiPlusApdu::parse(MULTISTREAM, &ca_pmt_like),
425            Err(Error::UnexpectedApduTag { .. })
426        ));
427        assert!(matches!(
428            CiPlusApdu::parse(CONTENT_CONTROL, &ca_pmt_like),
429            Err(Error::UnexpectedApduTag { .. })
430        ));
431        // The global EN 50221 dispatch, by contrast, recognizes 0x9F8032.
432        // (We don't construct a full ca_pmt here; the point is the tag space is
433        // shared, so only resource-scoping disambiguates.)
434    }
435
436    #[test]
437    fn tag_9f9200_under_multistream_vs_9f9014_under_content_control() {
438        // 9F9200 is a Multistream tag; under Content Control it is unknown.
439        let cap = [0x9F, 0x92, 0x00, 0x03, 0x04, 0x00, 0x01];
440        assert!(matches!(
441            CiPlusApdu::parse(MULTISTREAM, &cap).unwrap(),
442            CiPlusApdu::Multistream(multistream::MultistreamApdu::CicamMultistreamCapability(_))
443        ));
444        assert!(matches!(
445            CiPlusApdu::parse(CONTENT_CONTROL, &cap),
446            Err(Error::UnexpectedApduTag { .. })
447        ));
448        // And 9F9014 is a Content Control tag; under Multistream it is unknown.
449        let pin = [0x9F, 0x90, 0x14, 0x03, 0x00, 0x00, 0x42];
450        assert!(matches!(
451            CiPlusApdu::parse(CONTENT_CONTROL, &pin).unwrap(),
452            CiPlusApdu::ContentControl(content_control::ContentControlApdu::CcPinReply(_))
453        ));
454        assert!(matches!(
455            CiPlusApdu::parse(MULTISTREAM, &pin),
456            Err(Error::UnexpectedApduTag { .. })
457        ));
458    }
459}