Skip to main content

dvb_si/carousel/biop/
fs.rs

1//! Virtual filesystem view of a DVB object carousel.
2//!
3//! [`CarouselFs`] is built from a set of `(module_id, &[u8])` pairs (the
4//! reassembled module data from [`crate::carousel::ModuleReassembler`]).
5//! It walks each module's BIOP messages, indexes by `(module_id, object_key)`,
6//! and exposes a path-based resolver so callers can retrieve file content
7//! without understanding the IOR chain.
8//!
9//! Spec: `docs/iso_13818_6_biop.md` (ETSI TR 101 202 §4.7.4).
10
11use super::message::{BiopMessage, DirectoryMessage};
12use alloc::collections::BTreeMap;
13use alloc::vec;
14use alloc::vec::Vec;
15
16// ── CarouselObject ────────────────────────────────────────────────────────────
17
18/// One object parsed from a carousel module.
19#[derive(Debug, Clone, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum CarouselObject {
22    /// A Directory or ServiceGateway object.
23    Directory(DirectoryObjectData),
24    /// A File object.
25    File(FileObjectData),
26}
27
28/// Owned data extracted from a `DirectoryMessage`.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct DirectoryObjectData {
31    /// Bindings in this directory: `(name_bytes_no_nul, module_id, object_key_bytes)`.
32    pub entries: Vec<(Vec<u8>, u16, Vec<u8>)>,
33    /// True if this is a ServiceGateway (the carousel root).
34    pub is_service_gateway: bool,
35}
36
37/// Owned data extracted from a `FileMessage`.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct FileObjectData {
40    /// File content bytes.
41    pub content: Vec<u8>,
42}
43
44// ── Key type ──────────────────────────────────────────────────────────────────
45
46#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
47struct ObjectKey {
48    module_id: u16,
49    object_key: Vec<u8>,
50}
51
52// ── CarouselFs ────────────────────────────────────────────────────────────────
53
54/// A virtual filesystem built from a set of reassembled carousel modules.
55///
56/// # Construction
57///
58/// ```text
59/// // collect (module_id, data) pairs from ModuleReassembler:
60/// let fs = CarouselFs::from_modules(&[(1, data_bytes)]);
61/// ```
62///
63/// # Path resolution
64///
65/// Paths are `&[&str]` slices (e.g. `&["images", "logo.png"]`).
66/// The root is the ServiceGateway object; each step follows a binding name.
67/// Binding names that end in `\0` have the trailing NUL stripped before matching.
68#[derive(Debug, Clone)]
69pub struct CarouselFs {
70    objects: BTreeMap<ObjectKey, CarouselObject>,
71    /// Key of the ServiceGateway (root) object, if found.
72    root_key: Option<ObjectKey>,
73}
74
75impl CarouselFs {
76    /// Build a `CarouselFs` from module `(module_id, data)` pairs.
77    ///
78    /// Each module's bytes are walked via `BiopMessage::parse_at`.
79    /// Unknown message kinds are silently skipped.
80    pub fn from_modules(modules: &[(u16, &[u8])]) -> Self {
81        let mut objects: BTreeMap<ObjectKey, CarouselObject> = BTreeMap::new();
82        let mut root_key: Option<ObjectKey> = None;
83
84        for &(module_id, data) in modules {
85            let mut pos = 0;
86            while pos < data.len() {
87                let remaining = &data[pos..];
88                match BiopMessage::parse_at(remaining) {
89                    Ok((msg, consumed)) => {
90                        // Extract the object key and kind from the message.
91                        let (obj_key_bytes, obj) = extract_object(module_id, &msg);
92                        if let Some(obj) = obj {
93                            let is_sg = matches!(&msg, BiopMessage::ServiceGateway(_));
94                            let key = ObjectKey {
95                                module_id,
96                                object_key: obj_key_bytes,
97                            };
98                            if is_sg && root_key.is_none() {
99                                root_key = Some(key.clone());
100                            }
101                            objects.insert(key, obj);
102                        }
103                        pos += consumed;
104                    }
105                    Err(_) => break,
106                }
107            }
108        }
109
110        CarouselFs { objects, root_key }
111    }
112
113    /// Return the ServiceGateway (root) object, if present.
114    pub fn service_gateway(&self) -> Option<&CarouselObject> {
115        self.root_key.as_ref().and_then(|k| self.objects.get(k))
116    }
117
118    /// Resolve a path `&[&str]` starting from the ServiceGateway root.
119    /// Returns the `CarouselObject` at that path, or `None`.
120    pub fn resolve(&self, path: &[&str]) -> Option<&CarouselObject> {
121        let mut cur_key = self.root_key.clone()?;
122        for &segment in path {
123            let dir = match self.objects.get(&cur_key)? {
124                CarouselObject::Directory(d) => d,
125                CarouselObject::File(_) => return None,
126            };
127            // Find binding with name matching `segment` (strip trailing NUL).
128            let (_, mod_id, key_bytes) = dir.entries.iter().find(|(name, _, _)| {
129                let n = strip_nul(name);
130                n == segment.as_bytes()
131            })?;
132            cur_key = ObjectKey {
133                module_id: *mod_id,
134                object_key: key_bytes.clone(),
135            };
136        }
137        self.objects.get(&cur_key)
138    }
139
140    /// Resolve a path and return the file content bytes, if the target is a File.
141    pub fn file_bytes(&self, path: &[&str]) -> Option<&[u8]> {
142        match self.resolve(path)? {
143            CarouselObject::File(f) => Some(&f.content),
144            CarouselObject::Directory(_) => None,
145        }
146    }
147}
148
149/// Strip a trailing NUL byte from a name slice.
150fn strip_nul(name: &[u8]) -> &[u8] {
151    if name.last() == Some(&0) {
152        &name[..name.len() - 1]
153    } else {
154        name
155    }
156}
157
158/// Extract the object key bytes and a `CarouselObject` from a parsed message.
159/// Returns `(object_key_bytes, Some(obj))` or `(vec![], None)` if not indexable.
160fn extract_object(module_id: u16, msg: &BiopMessage<'_>) -> (Vec<u8>, Option<CarouselObject>) {
161    match msg {
162        BiopMessage::Directory(dm) | BiopMessage::ServiceGateway(dm) => {
163            let key_bytes = dm.object_key.to_vec();
164            let entries = extract_dir_entries(module_id, dm);
165            let is_sg = matches!(msg, BiopMessage::ServiceGateway(_));
166            (
167                key_bytes,
168                Some(CarouselObject::Directory(DirectoryObjectData {
169                    entries,
170                    is_service_gateway: is_sg,
171                })),
172            )
173        }
174        BiopMessage::File(fm) => {
175            let key_bytes = fm.object_key.to_vec();
176            let content = fm.content.to_vec();
177            (
178                key_bytes,
179                Some(CarouselObject::File(FileObjectData { content })),
180            )
181        }
182        BiopMessage::Stream(_) | BiopMessage::StreamEvent(_) => (vec![], None),
183    }
184}
185
186/// Extract binding entries from a DirectoryMessage as `(name, module_id, object_key)`.
187fn extract_dir_entries(
188    _self_module_id: u16,
189    dm: &DirectoryMessage<'_>,
190) -> Vec<(Vec<u8>, u16, Vec<u8>)> {
191    let mut entries = Vec::with_capacity(dm.bindings.len());
192    for binding in &dm.bindings {
193        // DVB: nameComponents_count == 1 per binding.
194        let name = binding
195            .name
196            .first()
197            .map(|nc| nc.id.to_vec())
198            .unwrap_or_default();
199        // Get the module_id and object_key from the IOR BIOP profile.
200        if let Some(bp) = binding.ior.biop_profile() {
201            let mod_id = bp.object_location.module_id;
202            let obj_key = bp.object_location.object_key.to_vec();
203            entries.push((name, mod_id, obj_key));
204        }
205    }
206    entries
207}
208
209// ── Tests ─────────────────────────────────────────────────────────────────────
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::carousel::biop::ior::NameComponent;
215    use crate::carousel::biop::{
216        ior::{BiopProfileBody, ConnBinder, Ior, ObjectLocation, TaggedProfile},
217        message::{Binding, BindingType, BiopMessage, DirectoryMessage, FileMessage},
218    };
219    use dvb_common::Serialize;
220
221    /// Build a simple carousel in memory:
222    ///   Module 1: ServiceGateway dir with one binding "index.html" → module 2, key [2]
223    ///   Module 2: File with content b"hello world"
224    fn build_test_carousel() -> Vec<(u16, Vec<u8>)> {
225        // Build the IOR pointing to module 2, key [0x02]
226        let file_ior = Ior {
227            type_id: b"fil\0",
228            profiles: vec![TaggedProfile::Biop(BiopProfileBody {
229                object_location: ObjectLocation {
230                    carousel_id: 0xAB,
231                    module_id: 2,
232                    version_major: 1,
233                    version_minor: 0,
234                    object_key: &[0x02],
235                },
236                conn_binder: ConnBinder { taps: vec![] },
237                extra: vec![],
238            })],
239        };
240
241        let sgw = BiopMessage::ServiceGateway(DirectoryMessage {
242            object_kind: *b"srg\0",
243            object_key: &[0x01],
244            object_info: &[],
245            service_context: vec![],
246            bindings: vec![Binding {
247                name: vec![NameComponent {
248                    id: b"index.html",
249                    kind: b"fil\0",
250                }],
251                binding_type: BindingType::NObject,
252                ior: file_ior,
253                object_info: &[],
254            }],
255        });
256
257        let file = BiopMessage::File(FileMessage {
258            object_key: &[0x02],
259            content_size: 11,
260            object_info_extra: &[],
261            service_context: vec![],
262            content: b"hello world",
263        });
264
265        let mut mod1 = vec![0u8; sgw.serialized_len()];
266        sgw.serialize_into(&mut mod1).unwrap();
267
268        let mut mod2 = vec![0u8; file.serialized_len()];
269        file.serialize_into(&mut mod2).unwrap();
270
271        vec![(1u16, mod1), (2u16, mod2)]
272    }
273
274    #[test]
275    fn carousel_fs_resolve_file() {
276        let modules = build_test_carousel();
277        let refs: Vec<(u16, &[u8])> = modules
278            .iter()
279            .map(|(id, data)| (*id, data.as_slice()))
280            .collect();
281        let fs = CarouselFs::from_modules(&refs);
282
283        // Service gateway should be present
284        assert!(fs.service_gateway().is_some());
285
286        // File lookup
287        let content = fs.file_bytes(&["index.html"]);
288        assert_eq!(content, Some(b"hello world".as_slice()));
289    }
290
291    #[test]
292    fn carousel_fs_resolve_missing_returns_none() {
293        let modules = build_test_carousel();
294        let refs: Vec<(u16, &[u8])> = modules
295            .iter()
296            .map(|(id, data)| (*id, data.as_slice()))
297            .collect();
298        let fs = CarouselFs::from_modules(&refs);
299        assert!(fs.file_bytes(&["does-not-exist.html"]).is_none());
300    }
301}