Skip to main content

dvb_ci_runtime/
session.rs

1//! SPDU session layer — a sans-IO mechanism over the transport layer
2//! (ETSI EN 50221 §7.2).
3//!
4//! Multiplexes logical sessions (one per resource in use) over the transport
5//! connection: allocates/tracks `session_nb`s, answers `open_session_request`
6//! for resources the host advertises, opens `create_session` on demand, and
7//! routes `session_number`+APDU to/from the resource bound to a session. It is
8//! mechanism only — *which* resources the host provides is the caller's policy,
9//! supplied as the `provides` predicate to [`SessionLayer::on_spdu`].
10
11use std::collections::BTreeMap;
12
13use crate::resource::ser;
14use broadcast_common::Parse;
15use dvb_ci::resource::ResourceId;
16use dvb_ci::spdu::{
17    CloseSessionRequest, CloseSessionResponse, CreateSessionResponse, OpenSessionRequest,
18    OpenSessionResponse, SessionNumber, SessionStatus, tags,
19};
20
21/// What the session layer wants done after handling one SPDU.
22#[derive(Debug, Default, Clone, PartialEq, Eq)]
23pub struct SessionOut {
24    /// SPDUs to hand down to the transport layer (each becomes a `T_Data_Last`).
25    pub spdus: Vec<Vec<u8>>,
26    /// `(session_nb, apdu_bytes)` to pass up to the resource layer.
27    pub apdus: Vec<(u16, Vec<u8>)>,
28    /// Sessions newly opened (`session_nb`, bound resource).
29    pub opened: Vec<(u16, ResourceId)>,
30    /// `session_nb`s that closed.
31    pub closed: Vec<u16>,
32}
33
34/// The session table + `session_nb` allocator.
35#[derive(Debug, Default)]
36pub struct SessionLayer {
37    sessions: BTreeMap<u16, ResourceId>,
38    next: u16,
39}
40
41impl SessionLayer {
42    /// New, empty session layer.
43    #[must_use]
44    pub fn new() -> Self {
45        Self {
46            sessions: BTreeMap::new(),
47            next: 1, // session_nb 0 is reserved
48        }
49    }
50
51    /// Resource bound to `session_nb`, if open.
52    #[must_use]
53    pub fn resource_of(&self, session_nb: u16) -> Option<ResourceId> {
54        self.sessions.get(&session_nb).copied()
55    }
56
57    /// The lowest open `session_nb` bound to `resource`, if any.
58    ///
59    /// Walks only the open sessions (a handful per slot), where the caller
60    /// used to probe every one of the 65 535 representable numbers through
61    /// [`resource_of`](Self::resource_of) (audit r10-O-9).
62    #[must_use]
63    pub fn session_for(&self, resource: ResourceId) -> Option<u16> {
64        self.sessions
65            .iter()
66            .find(|&(_, &r)| r == resource)
67            .map(|(&n, _)| n)
68    }
69
70    /// All open `(session_nb, resource)` pairs, ascending by `session_nb`.
71    #[must_use]
72    pub fn sessions(&self) -> Vec<(u16, ResourceId)> {
73        self.sessions.iter().map(|(&n, &r)| (n, r)).collect()
74    }
75
76    /// Number of open sessions.
77    #[must_use]
78    pub fn len(&self) -> usize {
79        self.sessions.len()
80    }
81
82    /// Whether there are no open sessions.
83    #[must_use]
84    pub fn is_empty(&self) -> bool {
85        self.sessions.is_empty()
86    }
87
88    /// r10-W-18: probe past any `session_nb` still in `self.sessions` —
89    /// `next` wraps around after 65535 allocations (a long-running slot with
90    /// enough session churn, e.g. repeated MMI dialogues, can reach this),
91    /// and without the probe a wrapped-around number could alias a session
92    /// that is still open, silently corrupting its `resource_of` mapping.
93    fn alloc(&mut self) -> u16 {
94        let start = self.next;
95        loop {
96            let nb = self.next;
97            self.next = self.next.checked_add(1).filter(|&n| n != 0).unwrap_or(1);
98            if !self.sessions.contains_key(&nb) {
99                return nb;
100            }
101            // Every one of the 65535 representable numbers is in use — an
102            // exhaustion this extreme is not realistic for one CI slot's
103            // handful of concurrent resource sessions, but return the
104            // original candidate rather than loop forever.
105            if self.next == start {
106                return nb;
107            }
108        }
109    }
110
111    /// Open a session to a **module-provided** resource (host-initiated):
112    /// returns the `open_session_request` SPDU to send. Sessions are opened the
113    /// same way in both directions (§8.4.1) — the host sends
114    /// `open_session_request`, and the module (the resource provider) assigns the
115    /// `session_nb` in its `open_session_response`. (`create_session`/0x93 is a
116    /// resource-manager-internal primitive; a real CAM rejects it with
117    /// `status=0xF0` — verified live against an AlphaCrypt.) The session is
118    /// recorded once the module's `open_session_response(ok)` arrives.
119    pub fn create_session(&mut self, resource: ResourceId) -> dvb_ci::Result<Vec<u8>> {
120        ser(&OpenSessionRequest { resource })
121    }
122
123    /// Wrap an APDU for sending on `session_nb` (`session_number` + body).
124    pub fn send_apdu(&self, session_nb: u16, apdu: &[u8]) -> dvb_ci::Result<Vec<u8>> {
125        let mut v = ser(&SessionNumber { session_nb })?;
126        v.extend_from_slice(apdu);
127        Ok(v)
128    }
129
130    /// Begin closing `session_nb`: returns the `close_session_request` SPDU.
131    pub fn close(&mut self, session_nb: u16) -> dvb_ci::Result<Vec<u8>> {
132        self.sessions.remove(&session_nb);
133        ser(&CloseSessionRequest { session_nb })
134    }
135
136    /// Bind a module-chosen `session_nb` to `resource` (from
137    /// `open_session_response`/`create_session_response`), returning
138    /// whether the binding is now in effect.
139    ///
140    /// r10-W-18: `session_nb` is the MODULE's own choice, not ours — a
141    /// misbehaving (or hostile) module reusing a number already bound to a
142    /// DIFFERENT resource would otherwise silently alias that resource's
143    /// session, misrouting its APDUs from then on. `0` is also rejected: it
144    /// is the reserved value `alloc` never hands out. Re-asserting the SAME
145    /// resource on an already-open `session_nb` is treated as an idempotent
146    /// success, not a collision (a module may legitimately repeat its own
147    /// prior response).
148    fn try_bind_module_chosen(&mut self, session_nb: u16, resource: ResourceId) -> bool {
149        if session_nb == 0 {
150            return false;
151        }
152        match self.sessions.get(&session_nb) {
153            Some(&existing) => existing == resource,
154            None => {
155                self.sessions.insert(session_nb, resource);
156                true
157            }
158        }
159    }
160
161    /// Handle one inbound SPDU. `provides` answers "does the host provide this
162    /// resource?" for an incoming `open_session_request`.
163    pub fn on_spdu(
164        &mut self,
165        spdu: &[u8],
166        provides: impl Fn(ResourceId) -> bool,
167    ) -> dvb_ci::Result<SessionOut> {
168        let mut out = SessionOut::default();
169        match spdu.first().copied() {
170            // Module wants a host-provided resource.
171            Some(tags::OPEN_SESSION_REQUEST) => {
172                // A malformed CAM SPDU is surfaced (`?` -> Notification::Error),
173                // never dropped silently.
174                let req = OpenSessionRequest::parse(spdu)?;
175                if provides(req.resource) {
176                    let session_nb = self.alloc();
177                    self.sessions.insert(session_nb, req.resource);
178                    out.spdus.push(ser(&OpenSessionResponse {
179                        status: SessionStatus::Ok,
180                        resource: req.resource,
181                        session_nb,
182                    })?);
183                    out.opened.push((session_nb, req.resource));
184                } else {
185                    out.spdus.push(ser(&OpenSessionResponse {
186                        status: SessionStatus::ResourceNonExistent,
187                        resource: req.resource,
188                        session_nb: 0,
189                    })?);
190                }
191            }
192            // Module's reply to our open_session_request (host opened a
193            // module-provided resource); the module assigns the session_nb.
194            // r10-W-18: the assignment is not trusted blindly — see
195            // `try_bind_module_chosen`.
196            Some(tags::OPEN_SESSION_RESPONSE) => {
197                let resp = OpenSessionResponse::parse(spdu)?;
198                if resp.status == SessionStatus::Ok {
199                    out.opened.extend(
200                        self.try_bind_module_chosen(resp.session_nb, resp.resource)
201                            .then_some((resp.session_nb, resp.resource)),
202                    );
203                }
204            }
205            // (Legacy) module's reply to a create_session, if any module uses it.
206            Some(tags::CREATE_SESSION_RESPONSE) => {
207                let resp = CreateSessionResponse::parse(spdu)?;
208                if resp.status == SessionStatus::Ok {
209                    out.opened.extend(
210                        self.try_bind_module_chosen(resp.session_nb, resp.resource)
211                            .then_some((resp.session_nb, resp.resource)),
212                    );
213                }
214            }
215            // Peer closes a session.
216            Some(tags::CLOSE_SESSION_REQUEST) => {
217                let req = CloseSessionRequest::parse(spdu)?;
218                self.sessions.remove(&req.session_nb);
219                out.spdus.push(ser(&CloseSessionResponse {
220                    status: SessionStatus::Ok,
221                    session_nb: req.session_nb,
222                })?);
223                out.closed.push(req.session_nb);
224            }
225            // Ack of a close we initiated.
226            Some(tags::CLOSE_SESSION_RESPONSE) => {
227                let resp = CloseSessionResponse::parse(spdu)?;
228                self.sessions.remove(&resp.session_nb);
229                out.closed.push(resp.session_nb);
230            }
231            // Data: session_number(nb) + APDU body.
232            Some(tags::SESSION_NUMBER) => {
233                let sn = SessionNumber::parse(spdu)?;
234                if spdu.len() > SessionNumber::HEADER_LEN {
235                    out.apdus
236                        .push((sn.session_nb, spdu[SessionNumber::HEADER_LEN..].to_vec()));
237                }
238            }
239            _ => {}
240        }
241        Ok(out)
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use dvb_ci::resource::{APPLICATION_INFORMATION, RESOURCE_MANAGER};
249
250    fn provides_rm(r: ResourceId) -> bool {
251        r == RESOURCE_MANAGER
252    }
253
254    #[test]
255    fn session_for_returns_the_lowest_open_session_of_a_resource() {
256        let mut s = SessionLayer::new();
257        assert_eq!(s.session_for(RESOURCE_MANAGER), None);
258        let rm_req = ser(&OpenSessionRequest {
259            resource: RESOURCE_MANAGER,
260        })
261        .unwrap();
262        let ai_req = ser(&OpenSessionRequest {
263            resource: APPLICATION_INFORMATION,
264        })
265        .unwrap();
266        let all = |_: ResourceId| true;
267        let rm = s.on_spdu(&rm_req, all).unwrap().opened[0].0;
268        let ai = s.on_spdu(&ai_req, all).unwrap().opened[0].0;
269        assert_ne!(rm, ai);
270        assert_eq!(s.session_for(RESOURCE_MANAGER), Some(rm));
271        assert_eq!(s.session_for(APPLICATION_INFORMATION), Some(ai));
272        // Closing the session removes it from the lookup.
273        s.close(rm).unwrap();
274        assert_eq!(s.session_for(RESOURCE_MANAGER), None);
275    }
276
277    #[test]
278    fn open_request_for_provided_resource_grants_and_tracks() {
279        let mut s = SessionLayer::new();
280        let req = ser(&OpenSessionRequest {
281            resource: RESOURCE_MANAGER,
282        })
283        .unwrap();
284        let out = s.on_spdu(&req, provides_rm).unwrap();
285        assert_eq!(out.opened.len(), 1);
286        let (nb, res) = out.opened[0];
287        assert_eq!(res, RESOURCE_MANAGER);
288        assert_eq!(s.resource_of(nb), Some(RESOURCE_MANAGER));
289        // reply is an open_session_response with status ok
290        let resp = OpenSessionResponse::parse(&out.spdus[0]).unwrap();
291        assert_eq!(resp.status, SessionStatus::Ok);
292        assert_eq!(resp.session_nb, nb);
293    }
294
295    #[test]
296    fn open_request_for_absent_resource_denied() {
297        let mut s = SessionLayer::new();
298        let req = ser(&OpenSessionRequest {
299            resource: APPLICATION_INFORMATION,
300        })
301        .unwrap();
302        let out = s.on_spdu(&req, provides_rm).unwrap();
303        assert!(out.opened.is_empty());
304        let resp = OpenSessionResponse::parse(&out.spdus[0]).unwrap();
305        assert_eq!(resp.status, SessionStatus::ResourceNonExistent);
306        assert!(s.is_empty());
307    }
308
309    #[test]
310    fn create_session_tracked_on_ok_response() {
311        let mut s = SessionLayer::new();
312        let _spdu = s.create_session(APPLICATION_INFORMATION).unwrap();
313        // module replies ok for session 1
314        let resp = ser(&CreateSessionResponse {
315            status: SessionStatus::Ok,
316            resource: APPLICATION_INFORMATION,
317            session_nb: 1,
318        })
319        .unwrap();
320        let out = s.on_spdu(&resp, |_| false).unwrap();
321        assert_eq!(out.opened, vec![(1, APPLICATION_INFORMATION)]);
322        assert_eq!(s.resource_of(1), Some(APPLICATION_INFORMATION));
323    }
324
325    /// r10-W-18: a module-chosen `session_nb` that already maps to a
326    /// DIFFERENT resource must be rejected, not silently overwritten —
327    /// otherwise a misbehaving module aliases an existing session and its
328    /// APDUs are misrouted from then on.
329    #[test]
330    fn module_chosen_session_nb_colliding_with_a_different_resource_is_rejected() {
331        let mut s = SessionLayer::new();
332        // Session 1 is already RESOURCE_MANAGER (opened via the ordinary path).
333        let open = ser(&OpenSessionRequest {
334            resource: RESOURCE_MANAGER,
335        })
336        .unwrap();
337        let opened = s.on_spdu(&open, provides_rm).unwrap().opened;
338        let nb = opened[0].0;
339        assert_eq!(s.resource_of(nb), Some(RESOURCE_MANAGER));
340
341        // The module now claims that SAME session_nb for a totally
342        // different resource via create_session_response.
343        let colliding = ser(&CreateSessionResponse {
344            status: SessionStatus::Ok,
345            resource: APPLICATION_INFORMATION,
346            session_nb: nb,
347        })
348        .unwrap();
349        let out = s.on_spdu(&colliding, |_| false).unwrap();
350        assert!(
351            out.opened.is_empty(),
352            "a colliding module-chosen session_nb must not be reported as opened"
353        );
354        assert_eq!(
355            s.resource_of(nb),
356            Some(RESOURCE_MANAGER),
357            "the original binding must survive the collision attempt"
358        );
359    }
360
361    /// r10-W-18: `session_nb = 0` is reserved (`alloc` never hands it out)
362    /// and must never be accepted as a module-chosen binding.
363    #[test]
364    fn module_chosen_session_nb_zero_is_rejected() {
365        let mut s = SessionLayer::new();
366        let resp = ser(&OpenSessionResponse {
367            status: SessionStatus::Ok,
368            resource: APPLICATION_INFORMATION,
369            session_nb: 0,
370        })
371        .unwrap();
372        let out = s.on_spdu(&resp, |_| false).unwrap();
373        assert!(out.opened.is_empty());
374        assert_eq!(s.resource_of(0), None);
375    }
376
377    /// r10-W-18: `alloc` must skip any `session_nb` still open, even across
378    /// the 65535 wraparound — otherwise a long-running slot with enough
379    /// session churn eventually reissues a number that aliases a session
380    /// that never closed.
381    #[test]
382    fn alloc_probes_past_a_still_open_session_across_wraparound() {
383        let mut s = SessionLayer::new();
384        // Force the allocator right up to the wraparound boundary.
385        s.next = 65535;
386        let first = s.alloc();
387        assert_eq!(first, 65535);
388        s.sessions.insert(first, APPLICATION_INFORMATION);
389        // Next candidate would be `65535 -> checked_add overflows -> 1`.
390        // Occupy 1 directly (as if it were opened earlier and never closed).
391        s.sessions.insert(1, RESOURCE_MANAGER);
392        let second = s.alloc();
393        assert_ne!(
394            second, 1,
395            "alloc must not hand out a session_nb that is still open"
396        );
397        assert_eq!(s.resource_of(1), Some(RESOURCE_MANAGER), "untouched");
398    }
399
400    #[test]
401    fn session_number_routes_apdu_up() {
402        let mut s = SessionLayer::new();
403        let apdu = [0x9F, 0x80, 0x21, 0x00];
404        let mut spdu = ser(&SessionNumber { session_nb: 7 }).unwrap();
405        spdu.extend_from_slice(&apdu);
406        let out = s.on_spdu(&spdu, |_| false).unwrap();
407        assert_eq!(out.apdus, vec![(7, apdu.to_vec())]);
408    }
409
410    #[test]
411    fn close_request_acks_and_removes() {
412        let mut s = SessionLayer::new();
413        // open one first
414        let req = ser(&OpenSessionRequest {
415            resource: RESOURCE_MANAGER,
416        })
417        .unwrap();
418        let nb = s.on_spdu(&req, provides_rm).unwrap().opened[0].0;
419        // peer closes it
420        let close = ser(&CloseSessionRequest { session_nb: nb }).unwrap();
421        let out = s.on_spdu(&close, |_| false).unwrap();
422        assert_eq!(out.closed, vec![nb]);
423        assert!(s.is_empty());
424        // reply is a close_session_response
425        assert_eq!(out.spdus[0][0], tags::CLOSE_SESSION_RESPONSE);
426    }
427
428    #[test]
429    fn send_apdu_prefixes_session_number() {
430        let s = SessionLayer::new();
431        let wire = s.send_apdu(3, &[0xAA, 0xBB]).unwrap();
432        let sn = SessionNumber::parse(&wire).unwrap();
433        assert_eq!(sn.session_nb, 3);
434        assert_eq!(&wire[SessionNumber::HEADER_LEN..], &[0xAA, 0xBB]);
435    }
436}