Skip to main content

dvb_si/carousel/
messages.rs

1//! DSM-CC U-N download protocol messages — ISO/IEC 13818-6 §7.2/§7.3.
2//!
3//! Layouts per `docs/iso_13818_6_carousel.md` (hand-transcribed; ISO/IEC
4//! 13818-6 is not freely redistributable), cross-checked against the vendored
5//! TR 101 202 §4.6/§4.7.5 + TS 102 006 Table 15, and pinned live against the
6//! `m6-single.ts` capture by the `carousel_fixture` integration test.
7//!
8//! Control messages (DSI/DII) are the payload of DSM-CC sections with
9//! table_id 0x3B; data messages (DDB) of table_id 0x3C — see
10//! [`crate::tables::dsmcc`] for the section framing.
11//!
12//! # SSU DSI `privateData`
13//!
14//! When a DSI is part of a DVB System Software Update carousel
15//! (TS 102 006 §8.1.1), its `private_data` field carries a
16//! [`GroupInfoIndication`] (Table 6). Parse it with
17//! `GroupInfoIndication::parse(dsi.private_data)`.
18
19use crate::compatibility::CompatibilityDescriptor;
20use crate::error::{Error, Result};
21use alloc::vec::Vec;
22use dvb_common::{Parse, Serialize};
23
24/// `protocolDiscriminator` — always 0x11 for MPEG-2 DSM-CC.
25pub const PROTOCOL_DISCRIMINATOR: u8 = 0x11;
26/// `dsmccType` for U-N download messages (§7.2: 0x03).
27pub const DSMCC_TYPE_UN_DOWNLOAD: u8 = 0x03;
28/// `messageId` of DownloadInfoIndication.
29pub const MESSAGE_ID_DII: u16 = 0x1002;
30/// `messageId` of DownloadDataBlock.
31pub const MESSAGE_ID_DDB: u16 = 0x1003;
32/// `messageId` of DownloadServerInitiate.
33pub const MESSAGE_ID_DSI: u16 = 0x1006;
34
35/// Bytes of dsmccMessageHeader / dsmccDownloadDataHeader before the
36/// adaptation header: pd(1) + type(1) + messageId(2) + transactionId-or-
37/// downloadId(4) + reserved(1) + adaptationLength(1) + messageLength(2).
38const MESSAGE_HEADER_LEN: usize = 12;
39/// serverId is a fixed 20-byte field in the DSI (DVB: all 0xFF).
40const SERVER_ID_LEN: usize = 20;
41/// 16-bit privateDataLength field.
42const PRIVATE_LEN_FIELD: usize = 2;
43/// Fixed DII body bytes before the compatibilityDescriptor: downloadId(4) +
44/// blockSize(2) + windowSize(1) + ackPeriod(1) + tCDownloadWindow(4) +
45/// tCDownloadScenario(4).
46const DII_FIXED_LEN: usize = 16;
47/// Per-module fixed bytes: moduleId(2) + moduleSize(4) + moduleVersion(1) +
48/// moduleInfoLength(1).
49const MODULE_HEADER_LEN: usize = 8;
50/// DDB body bytes before blockData: moduleId(2) + moduleVersion(1) +
51/// reserved(1) + blockNumber(2).
52const DDB_FIXED_LEN: usize = 6;
53
54// ── GroupInfoIndication layout constants (TS 102 006 Table 6) ────────────────
55/// NumberOfGroups field: 2 bytes.
56const GII_NUMBER_OF_GROUPS_LEN: usize = 2;
57/// GroupId field: 4 bytes.
58const GII_GROUP_ID_LEN: usize = 4;
59/// GroupSize field: 4 bytes.
60const GII_GROUP_SIZE_LEN: usize = 4;
61/// GroupInfoLength field: 2 bytes.
62const GII_GROUP_INFO_LEN_FIELD: usize = 2;
63/// PrivateDataLength field: 2 bytes.
64const GII_PRIVATE_DATA_LEN_FIELD: usize = 2;
65
66/// One group entry in a [`GroupInfoIndication`] — TS 102 006 Table 6.
67#[derive(Debug, Clone, PartialEq, Eq)]
68#[cfg_attr(feature = "serde", derive(serde::Serialize))]
69pub struct GroupInfo<'a> {
70    /// `GroupId` — 4-byte group identifier.
71    pub group_id: u32,
72    /// `GroupSize` — total size of the SSU update group in bytes.
73    pub group_size: u32,
74    /// `GroupCompatibility` — compatibility descriptor for this group
75    /// (TS 102 006 Table 7 / Table 15).
76    pub group_compatibility: CompatibilityDescriptor<'a>,
77    /// `GroupInfoByte` loop — application-specific per-group data.
78    #[cfg_attr(feature = "serde", serde(borrow))]
79    pub group_info: &'a [u8],
80    /// `PrivateDataByte` loop — private extension bytes.
81    #[cfg_attr(feature = "serde", serde(borrow))]
82    pub private_data: &'a [u8],
83}
84
85/// GroupInfoIndication — TS 102 006 §8.1.1 Table 6.
86///
87/// Carried as the `privateData` payload of a DSI message
88/// ([`Dsi::private_data`]) when the carousel is an SSU update carousel
89/// (`data_broadcast_id = 0x000A`). It lists one entry per update group
90/// delivered in the carousel, with its compatibility requirements
91/// (hardware/software OUI constraints) and size.
92///
93/// Wire layout (bytes):
94///
95/// ```text
96/// GroupInfoIndication() {
97///   NumberOfGroups           2
98///   for (i=0; i<N; i++) {
99///     GroupId                4
100///     GroupSize              4
101///     GroupCompatibility     variable  (CompatibilityDescriptor — Table 7)
102///     GroupInfoLength        2
103///     for (j=0; j<M; j++) GroupInfoByte   1
104///     PrivateDataLength      2
105///     for (j=0; j<M; j++) PrivateDataByte 1
106///   }
107/// }
108/// ```
109#[derive(Debug, Clone, PartialEq, Eq)]
110#[cfg_attr(feature = "serde", derive(serde::Serialize))]
111pub struct GroupInfoIndication<'a> {
112    /// Group entries in wire order.
113    pub groups: Vec<GroupInfo<'a>>,
114}
115
116impl<'a> Parse<'a> for GroupInfoIndication<'a> {
117    type Error = crate::error::Error;
118
119    fn parse(bytes: &'a [u8]) -> Result<Self> {
120        let (b, _) = bytes
121            .split_first_chunk::<2>()
122            .ok_or(Error::BufferTooShort {
123                need: GII_NUMBER_OF_GROUPS_LEN,
124                have: bytes.len(),
125                what: "GroupInfoIndication NumberOfGroups",
126            })?;
127        let number_of_groups = u16::from_be_bytes(*b) as usize;
128        let mut pos = GII_NUMBER_OF_GROUPS_LEN;
129        let end = bytes.len();
130        let mut groups = Vec::with_capacity(number_of_groups.min(256));
131
132        for _ in 0..number_of_groups {
133            // GroupId (4 bytes) + GroupSize (4 bytes)
134            let fixed = GII_GROUP_ID_LEN + GII_GROUP_SIZE_LEN;
135            let (hdr, _) =
136                bytes[pos..end]
137                    .split_first_chunk::<8>()
138                    .ok_or(Error::BufferTooShort {
139                        need: pos + fixed,
140                        have: end,
141                        what: "GroupInfo GroupId/GroupSize",
142                    })?;
143            let group_id = u32::from_be_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]);
144            let group_size = u32::from_be_bytes([hdr[4], hdr[5], hdr[6], hdr[7]]);
145            pos += fixed;
146
147            // GroupCompatibility — CompatibilityDescriptor (Table 7).
148            // CompatibilityDescriptor::parse consumes exactly the declared
149            // compatibilityDescriptorLength + 2-byte length prefix.
150            use crate::compatibility::COMPAT_DESC_LEN_FIELD;
151            let (bc, _) =
152                bytes[pos..end]
153                    .split_first_chunk::<2>()
154                    .ok_or(Error::BufferTooShort {
155                        need: pos + COMPAT_DESC_LEN_FIELD,
156                        have: end,
157                        what: "GroupCompatibility length field",
158                    })?;
159            let compat_len = u16::from_be_bytes(*bc) as usize;
160            let compat_total = COMPAT_DESC_LEN_FIELD + compat_len;
161            if pos + compat_total > end {
162                return Err(Error::SectionLengthOverflow {
163                    declared: compat_len,
164                    available: end - pos - COMPAT_DESC_LEN_FIELD,
165                });
166            }
167            let group_compatibility =
168                CompatibilityDescriptor::parse(&bytes[pos..pos + compat_total])?;
169            pos += compat_total;
170
171            // GroupInfoLength + GroupInfoByte loop.
172            let (bgi, _) =
173                bytes[pos..end]
174                    .split_first_chunk::<2>()
175                    .ok_or(Error::BufferTooShort {
176                        need: pos + GII_GROUP_INFO_LEN_FIELD,
177                        have: end,
178                        what: "GroupInfo GroupInfoLength",
179                    })?;
180            let group_info_len = u16::from_be_bytes(*bgi) as usize;
181            pos += GII_GROUP_INFO_LEN_FIELD;
182            if pos + group_info_len > end {
183                return Err(Error::SectionLengthOverflow {
184                    declared: group_info_len,
185                    available: end - pos,
186                });
187            }
188            let group_info = &bytes[pos..pos + group_info_len];
189            pos += group_info_len;
190
191            // PrivateDataLength + PrivateDataByte loop.
192            let (bpd, _) =
193                bytes[pos..end]
194                    .split_first_chunk::<2>()
195                    .ok_or(Error::BufferTooShort {
196                        need: pos + GII_PRIVATE_DATA_LEN_FIELD,
197                        have: end,
198                        what: "GroupInfo PrivateDataLength",
199                    })?;
200            let private_data_len = u16::from_be_bytes(*bpd) as usize;
201            pos += GII_PRIVATE_DATA_LEN_FIELD;
202            if pos + private_data_len > end {
203                return Err(Error::SectionLengthOverflow {
204                    declared: private_data_len,
205                    available: end - pos,
206                });
207            }
208            let private_data = &bytes[pos..pos + private_data_len];
209            pos += private_data_len;
210
211            groups.push(GroupInfo {
212                group_id,
213                group_size,
214                group_compatibility,
215                group_info,
216                private_data,
217            });
218        }
219
220        Ok(GroupInfoIndication { groups })
221    }
222}
223
224impl Serialize for GroupInfoIndication<'_> {
225    type Error = crate::error::Error;
226
227    fn serialized_len(&self) -> usize {
228        GII_NUMBER_OF_GROUPS_LEN
229            + self
230                .groups
231                .iter()
232                .map(|g| {
233                    GII_GROUP_ID_LEN
234                        + GII_GROUP_SIZE_LEN
235                        + g.group_compatibility.serialized_len()
236                        + GII_GROUP_INFO_LEN_FIELD
237                        + g.group_info.len()
238                        + GII_PRIVATE_DATA_LEN_FIELD
239                        + g.private_data.len()
240                })
241                .sum::<usize>()
242    }
243
244    fn serialize_into(&self, buf: &mut [u8]) -> Result<usize> {
245        let len = self.serialized_len();
246        if buf.len() < len {
247            return Err(Error::OutputBufferTooSmall {
248                need: len,
249                have: buf.len(),
250            });
251        }
252        if self.groups.len() > u16::MAX as usize {
253            return Err(Error::SectionLengthOverflow {
254                declared: self.groups.len(),
255                available: u16::MAX as usize,
256            });
257        }
258        buf[0..2].copy_from_slice(&(self.groups.len() as u16).to_be_bytes());
259        let mut pos = GII_NUMBER_OF_GROUPS_LEN;
260
261        for g in &self.groups {
262            buf[pos..pos + 4].copy_from_slice(&g.group_id.to_be_bytes());
263            buf[pos + 4..pos + 8].copy_from_slice(&g.group_size.to_be_bytes());
264            pos += GII_GROUP_ID_LEN + GII_GROUP_SIZE_LEN;
265
266            let written = g.group_compatibility.serialize_into(&mut buf[pos..])?;
267            pos += written;
268
269            if g.group_info.len() > u16::MAX as usize {
270                return Err(Error::SectionLengthOverflow {
271                    declared: g.group_info.len(),
272                    available: u16::MAX as usize,
273                });
274            }
275            buf[pos..pos + 2].copy_from_slice(&(g.group_info.len() as u16).to_be_bytes());
276            pos += GII_GROUP_INFO_LEN_FIELD;
277            buf[pos..pos + g.group_info.len()].copy_from_slice(g.group_info);
278            pos += g.group_info.len();
279
280            if g.private_data.len() > u16::MAX as usize {
281                return Err(Error::SectionLengthOverflow {
282                    declared: g.private_data.len(),
283                    available: u16::MAX as usize,
284                });
285            }
286            buf[pos..pos + 2].copy_from_slice(&(g.private_data.len() as u16).to_be_bytes());
287            pos += GII_PRIVATE_DATA_LEN_FIELD;
288            buf[pos..pos + g.private_data.len()].copy_from_slice(g.private_data);
289            pos += g.private_data.len();
290        }
291
292        Ok(len)
293    }
294}
295
296/// DownloadServerInitiate (§7.3.6, messageId 0x1006).
297#[derive(Debug, Clone, PartialEq, Eq)]
298#[cfg_attr(feature = "serde", derive(serde::Serialize))]
299pub struct Dsi<'a> {
300    /// 32-bit transactionId. DVB (TR 101 202 §4.7.9): the 2 LSBs are 0x0000
301    /// for a DSI; bit 31 toggles on update.
302    pub transaction_id: u32,
303    /// Raw dsmccAdaptationHeader bytes (usually empty).
304    pub adaptation: &'a [u8],
305    /// 20-byte serverId — all 0xFF under the DVB profile.
306    pub server_id: [u8; SERVER_ID_LEN],
307    /// compatibilityDescriptor() — TS 102 006 Table 15 / ISO/IEC 13818-6.
308    pub compatibility_descriptor: CompatibilityDescriptor<'a>,
309    /// privateData, raw. SSU: GroupInfoIndication (TS 102 006 Table 6);
310    /// object carousel: ServiceGatewayInfo (TR 101 202 Table 4.15).
311    pub private_data: &'a [u8],
312}
313
314/// One module entry in a DII (§7.3.3).
315#[derive(Debug, Clone, PartialEq, Eq)]
316#[cfg_attr(feature = "serde", derive(serde::Serialize))]
317pub struct DiiModule<'a> {
318    /// moduleId referenced by DDB messages.
319    pub module_id: u16,
320    /// Total module size in bytes.
321    pub module_size: u32,
322    /// moduleVersion; DDBs must match.
323    pub module_version: u8,
324    /// moduleInfo, raw (object carousel: BIOP::ModuleInfo, TR 101 202
325    /// Table 4.14).
326    pub module_info: &'a [u8],
327}
328
329/// DownloadInfoIndication (§7.3.3, messageId 0x1002).
330#[derive(Debug, Clone, PartialEq, Eq)]
331#[cfg_attr(feature = "serde", derive(serde::Serialize))]
332pub struct Dii<'a> {
333    /// 32-bit transactionId (TR 101 202 Table 4.1 sub-fields).
334    pub transaction_id: u32,
335    /// Raw dsmccAdaptationHeader bytes (usually empty).
336    pub adaptation: &'a [u8],
337    /// downloadId — links this DII to its DDB messages.
338    pub download_id: u32,
339    /// Bytes per DDB block (every block except possibly the last).
340    pub block_size: u16,
341    /// windowSize — 0 under the DVB profile.
342    pub window_size: u8,
343    /// ackPeriod — 0 under the DVB profile.
344    pub ack_period: u8,
345    /// tCDownloadWindow — 0 under the DVB profile.
346    pub t_c_download_window: u32,
347    /// tCDownloadScenario.
348    pub t_c_download_scenario: u32,
349    /// compatibilityDescriptor() — TS 102 006 Table 15 / ISO/IEC 13818-6.
350    pub compatibility_descriptor: CompatibilityDescriptor<'a>,
351    /// Module entries in wire order.
352    pub modules: Vec<DiiModule<'a>>,
353    /// privateData, raw.
354    pub private_data: &'a [u8],
355}
356
357/// A U-N download control message — payload of a table_id 0x3B DSM-CC
358/// section, discriminated by `messageId`.
359#[derive(Debug, Clone, PartialEq, Eq)]
360#[cfg_attr(feature = "serde", derive(serde::Serialize))]
361#[non_exhaustive]
362pub enum UnMessage<'a> {
363    /// DownloadServerInitiate (messageId 0x1006).
364    Dsi(Dsi<'a>),
365    /// DownloadInfoIndication (messageId 0x1002).
366    Dii(Dii<'a>),
367}
368
369/// DownloadDataBlock (§7.3.7.1, messageId 0x1003) — payload of a table_id
370/// 0x3C DSM-CC section, including its dsmccDownloadDataHeader.
371#[derive(Debug, Clone, PartialEq, Eq)]
372#[cfg_attr(feature = "serde", derive(serde::Serialize))]
373pub struct DownloadDataBlock<'a> {
374    /// downloadId from the dsmccDownloadDataHeader — matches the DII.
375    pub download_id: u32,
376    /// Raw dsmccAdaptationHeader bytes (usually empty).
377    pub adaptation: &'a [u8],
378    /// moduleId of the module this block belongs to.
379    pub module_id: u16,
380    /// moduleVersion — must match the DII module entry.
381    pub module_version: u8,
382    /// Block index; byte offset within the module = blockNumber × blockSize.
383    pub block_number: u16,
384    /// The block payload.
385    pub block_data: &'a [u8],
386}
387
388/// Parse the 12-byte dsmccMessageHeader / dsmccDownloadDataHeader common
389/// shape. Returns (messageId, transaction_or_download_id, adaptation,
390/// payload) where `payload` is bounded by `messageLength`.
391fn parse_header<'a>(bytes: &'a [u8], what: &'static str) -> Result<(u16, u32, &'a [u8], &'a [u8])> {
392    let (hdr, _) =
393        bytes
394            .split_first_chunk::<MESSAGE_HEADER_LEN>()
395            .ok_or(Error::BufferTooShort {
396                need: MESSAGE_HEADER_LEN,
397                have: bytes.len(),
398                what,
399            })?;
400    if hdr[0] != PROTOCOL_DISCRIMINATOR {
401        return Err(Error::ReservedBitsViolation {
402            field: "protocolDiscriminator",
403            reason: "must be 0x11 (ISO/IEC 13818-6 §7.2)",
404        });
405    }
406    if hdr[1] != DSMCC_TYPE_UN_DOWNLOAD {
407        return Err(Error::ReservedBitsViolation {
408            field: "dsmccType",
409            reason: "must be 0x03 — U-N download (ISO/IEC 13818-6 §7.2)",
410        });
411    }
412    let message_id = u16::from_be_bytes([hdr[2], hdr[3]]);
413    let id = u32::from_be_bytes([hdr[4], hdr[5], hdr[6], hdr[7]]);
414    let adaptation_length = hdr[9] as usize;
415    let message_length = u16::from_be_bytes([hdr[10], hdr[11]]) as usize;
416    let total = MESSAGE_HEADER_LEN + message_length;
417    if bytes.len() < total {
418        return Err(Error::SectionLengthOverflow {
419            declared: message_length,
420            available: bytes.len() - MESSAGE_HEADER_LEN,
421        });
422    }
423    if adaptation_length > message_length {
424        return Err(Error::SectionLengthOverflow {
425            declared: adaptation_length,
426            available: message_length,
427        });
428    }
429    let adaptation = &bytes[MESSAGE_HEADER_LEN..MESSAGE_HEADER_LEN + adaptation_length];
430    let payload = &bytes[MESSAGE_HEADER_LEN + adaptation_length..total];
431    Ok((message_id, id, adaptation, payload))
432}
433
434/// Serialize the common 12-byte header followed by the adaptation bytes.
435/// `payload_len` is the body length after the adaptation header.
436fn serialize_header(
437    buf: &mut [u8],
438    message_id: u16,
439    id: u32,
440    adaptation: &[u8],
441    payload_len: usize,
442) -> Result<usize> {
443    let message_length = adaptation.len() + payload_len;
444    if adaptation.len() > u8::MAX as usize {
445        return Err(Error::SectionLengthOverflow {
446            declared: adaptation.len(),
447            available: u8::MAX as usize,
448        });
449    }
450    if message_length > u16::MAX as usize {
451        return Err(Error::SectionLengthOverflow {
452            declared: message_length,
453            available: u16::MAX as usize,
454        });
455    }
456    buf[0] = PROTOCOL_DISCRIMINATOR;
457    buf[1] = DSMCC_TYPE_UN_DOWNLOAD;
458    buf[2..4].copy_from_slice(&message_id.to_be_bytes());
459    buf[4..8].copy_from_slice(&id.to_be_bytes());
460    buf[8] = 0xFF; // reserved
461    buf[9] = adaptation.len() as u8;
462    buf[10..12].copy_from_slice(&(message_length as u16).to_be_bytes());
463    buf[MESSAGE_HEADER_LEN..MESSAGE_HEADER_LEN + adaptation.len()].copy_from_slice(adaptation);
464    Ok(MESSAGE_HEADER_LEN + adaptation.len())
465}
466
467/// Read a 16-bit-length-prefixed slice at `pos`, bounds-checked against `end`.
468fn length_prefixed(bytes: &[u8], pos: usize, end: usize) -> Result<(&[u8], usize)> {
469    let (b, _) = bytes[pos..end]
470        .split_first_chunk::<2>()
471        .ok_or(Error::BufferTooShort {
472            need: pos + 2,
473            have: end,
474            what: "DSM-CC 16-bit length field",
475        })?;
476    let len = u16::from_be_bytes(*b) as usize;
477    let start = pos + 2;
478    if start + len > end {
479        return Err(Error::SectionLengthOverflow {
480            declared: len,
481            available: end - start,
482        });
483    }
484    Ok((&bytes[start..start + len], start + len))
485}
486
487/// Parse a compatibilityDescriptor() block at `offset` inside `payload` that
488/// ends at `end`. Returns the parsed descriptor and the position just past it.
489fn parse_compat_block<'a>(
490    payload: &'a [u8],
491    offset: usize,
492    end: usize,
493) -> Result<(CompatibilityDescriptor<'a>, usize)> {
494    use crate::compatibility::COMPAT_DESC_LEN_FIELD;
495    let (b, _) = payload[offset..]
496        .split_first_chunk::<2>()
497        .ok_or(Error::BufferTooShort {
498            need: offset + COMPAT_DESC_LEN_FIELD,
499            have: end,
500            what: "compatibilityDescriptor in DSM-CC message",
501        })?;
502    let compat_desc_len = u16::from_be_bytes(*b) as usize;
503    let compat_end = offset + COMPAT_DESC_LEN_FIELD + compat_desc_len;
504    if compat_end > end {
505        return Err(Error::SectionLengthOverflow {
506            declared: compat_desc_len,
507            available: end - offset - COMPAT_DESC_LEN_FIELD,
508        });
509    }
510    let cd = CompatibilityDescriptor::parse(&payload[offset..compat_end])?;
511    Ok((cd, compat_end))
512}
513
514impl<'a> Parse<'a> for UnMessage<'a> {
515    type Error = crate::error::Error;
516
517    fn parse(bytes: &'a [u8]) -> Result<Self> {
518        let (message_id, transaction_id, adaptation, payload) =
519            parse_header(bytes, "UnMessage header")?;
520        let end = payload.len();
521        match message_id {
522            MESSAGE_ID_DSI => {
523                if end < SERVER_ID_LEN {
524                    return Err(Error::BufferTooShort {
525                        need: SERVER_ID_LEN,
526                        have: end,
527                        what: "Dsi body",
528                    });
529                }
530                let mut server_id = [0u8; SERVER_ID_LEN];
531                server_id.copy_from_slice(&payload[..SERVER_ID_LEN]);
532                let (compatibility_descriptor, pos) =
533                    parse_compat_block(payload, SERVER_ID_LEN, end)?;
534                let (private_data, _pos) = length_prefixed(payload, pos, end)?;
535                Ok(UnMessage::Dsi(Dsi {
536                    transaction_id,
537                    adaptation,
538                    server_id,
539                    compatibility_descriptor,
540                    private_data,
541                }))
542            }
543            MESSAGE_ID_DII => {
544                let (dii_hdr, _) =
545                    payload
546                        .split_first_chunk::<DII_FIXED_LEN>()
547                        .ok_or(Error::BufferTooShort {
548                            need: DII_FIXED_LEN,
549                            have: end,
550                            what: "Dii body",
551                        })?;
552                let download_id =
553                    u32::from_be_bytes([dii_hdr[0], dii_hdr[1], dii_hdr[2], dii_hdr[3]]);
554                let block_size = u16::from_be_bytes([dii_hdr[4], dii_hdr[5]]);
555                let window_size = dii_hdr[6];
556                let ack_period = dii_hdr[7];
557                let t_c_download_window =
558                    u32::from_be_bytes([dii_hdr[8], dii_hdr[9], dii_hdr[10], dii_hdr[11]]);
559                let t_c_download_scenario =
560                    u32::from_be_bytes([dii_hdr[12], dii_hdr[13], dii_hdr[14], dii_hdr[15]]);
561                let (compatibility_descriptor, mut pos) =
562                    parse_compat_block(payload, DII_FIXED_LEN, end)?;
563                let (bnm, _) = payload
564                    .get(pos..)
565                    .and_then(|s| s.split_first_chunk::<2>())
566                    .ok_or(Error::BufferTooShort {
567                        need: pos + 2,
568                        have: end,
569                        what: "Dii numberOfModules",
570                    })?;
571                let number_of_modules = u16::from_be_bytes(*bnm) as usize;
572                pos += 2;
573                let mut modules = Vec::with_capacity(number_of_modules.min(256));
574                for _ in 0..number_of_modules {
575                    let (mhdr, _) = payload
576                        .get(pos..)
577                        .and_then(|s| s.split_first_chunk::<MODULE_HEADER_LEN>())
578                        .ok_or(Error::BufferTooShort {
579                            need: pos + MODULE_HEADER_LEN,
580                            have: end,
581                            what: "Dii module entry",
582                        })?;
583                    let module_id = u16::from_be_bytes([mhdr[0], mhdr[1]]);
584                    let module_size = u32::from_be_bytes([mhdr[2], mhdr[3], mhdr[4], mhdr[5]]);
585                    let module_version = mhdr[6];
586                    let module_info_length = mhdr[7] as usize;
587                    let info_start = pos + MODULE_HEADER_LEN;
588                    if info_start + module_info_length > end {
589                        return Err(Error::SectionLengthOverflow {
590                            declared: module_info_length,
591                            available: end - info_start,
592                        });
593                    }
594                    modules.push(DiiModule {
595                        module_id,
596                        module_size,
597                        module_version,
598                        module_info: &payload[info_start..info_start + module_info_length],
599                    });
600                    pos = info_start + module_info_length;
601                }
602                let (private_data, _pos) = length_prefixed(payload, pos, end)?;
603                Ok(UnMessage::Dii(Dii {
604                    transaction_id,
605                    adaptation,
606                    download_id,
607                    block_size,
608                    window_size,
609                    ack_period,
610                    t_c_download_window,
611                    t_c_download_scenario,
612                    compatibility_descriptor,
613                    modules,
614                    private_data,
615                }))
616            }
617            _ => Err(Error::ReservedBitsViolation {
618                field: "messageId",
619                reason: "expected 0x1002 (DII) or 0x1006 (DSI) on table_id 0x3B \
620                         (ISO/IEC 13818-6 §7.3)",
621            }),
622        }
623    }
624}
625
626impl Serialize for UnMessage<'_> {
627    type Error = crate::error::Error;
628
629    fn serialized_len(&self) -> usize {
630        match self {
631            UnMessage::Dsi(dsi) => {
632                MESSAGE_HEADER_LEN
633                    + dsi.adaptation.len()
634                    + SERVER_ID_LEN
635                    + dsi.compatibility_descriptor.serialized_len()
636                    + PRIVATE_LEN_FIELD
637                    + dsi.private_data.len()
638            }
639            UnMessage::Dii(dii) => {
640                MESSAGE_HEADER_LEN
641                    + dii.adaptation.len()
642                    + DII_FIXED_LEN
643                    + dii.compatibility_descriptor.serialized_len()
644                    + 2 // numberOfModules
645                    + dii
646                        .modules
647                        .iter()
648                        .map(|m| MODULE_HEADER_LEN + m.module_info.len())
649                        .sum::<usize>()
650                    + PRIVATE_LEN_FIELD
651                    + dii.private_data.len()
652            }
653        }
654    }
655
656    fn serialize_into(&self, buf: &mut [u8]) -> Result<usize> {
657        let len = self.serialized_len();
658        if buf.len() < len {
659            return Err(Error::OutputBufferTooSmall {
660                need: len,
661                have: buf.len(),
662            });
663        }
664        match self {
665            UnMessage::Dsi(dsi) => {
666                let payload_len = len - MESSAGE_HEADER_LEN - dsi.adaptation.len();
667                let mut pos = serialize_header(
668                    buf,
669                    MESSAGE_ID_DSI,
670                    dsi.transaction_id,
671                    dsi.adaptation,
672                    payload_len,
673                )?;
674                buf[pos..pos + SERVER_ID_LEN].copy_from_slice(&dsi.server_id);
675                pos += SERVER_ID_LEN;
676                let written = dsi
677                    .compatibility_descriptor
678                    .serialize_into(&mut buf[pos..])?;
679                pos += written;
680                put_length_prefixed(buf, pos, dsi.private_data)?;
681            }
682            UnMessage::Dii(dii) => {
683                let payload_len = len - MESSAGE_HEADER_LEN - dii.adaptation.len();
684                let mut pos = serialize_header(
685                    buf,
686                    MESSAGE_ID_DII,
687                    dii.transaction_id,
688                    dii.adaptation,
689                    payload_len,
690                )?;
691                buf[pos..pos + 4].copy_from_slice(&dii.download_id.to_be_bytes());
692                buf[pos + 4..pos + 6].copy_from_slice(&dii.block_size.to_be_bytes());
693                buf[pos + 6] = dii.window_size;
694                buf[pos + 7] = dii.ack_period;
695                buf[pos + 8..pos + 12].copy_from_slice(&dii.t_c_download_window.to_be_bytes());
696                buf[pos + 12..pos + 16].copy_from_slice(&dii.t_c_download_scenario.to_be_bytes());
697                pos += DII_FIXED_LEN;
698                let written = dii
699                    .compatibility_descriptor
700                    .serialize_into(&mut buf[pos..])?;
701                pos += written;
702                if dii.modules.len() > u16::MAX as usize {
703                    return Err(Error::SectionLengthOverflow {
704                        declared: dii.modules.len(),
705                        available: u16::MAX as usize,
706                    });
707                }
708                buf[pos..pos + 2].copy_from_slice(&(dii.modules.len() as u16).to_be_bytes());
709                pos += 2;
710                for m in &dii.modules {
711                    if m.module_info.len() > u8::MAX as usize {
712                        return Err(Error::SectionLengthOverflow {
713                            declared: m.module_info.len(),
714                            available: u8::MAX as usize,
715                        });
716                    }
717                    buf[pos..pos + 2].copy_from_slice(&m.module_id.to_be_bytes());
718                    buf[pos + 2..pos + 6].copy_from_slice(&m.module_size.to_be_bytes());
719                    buf[pos + 6] = m.module_version;
720                    buf[pos + 7] = m.module_info.len() as u8;
721                    pos += MODULE_HEADER_LEN;
722                    buf[pos..pos + m.module_info.len()].copy_from_slice(m.module_info);
723                    pos += m.module_info.len();
724                }
725                put_length_prefixed(buf, pos, dii.private_data)?;
726            }
727        }
728        Ok(len)
729    }
730}
731
732/// Write a 16-bit length then the slice; returns the new position.
733fn put_length_prefixed(buf: &mut [u8], pos: usize, data: &[u8]) -> Result<usize> {
734    if data.len() > u16::MAX as usize {
735        return Err(Error::SectionLengthOverflow {
736            declared: data.len(),
737            available: u16::MAX as usize,
738        });
739    }
740    buf[pos..pos + 2].copy_from_slice(&(data.len() as u16).to_be_bytes());
741    buf[pos + 2..pos + 2 + data.len()].copy_from_slice(data);
742    Ok(pos + 2 + data.len())
743}
744
745impl<'a> Parse<'a> for DownloadDataBlock<'a> {
746    type Error = crate::error::Error;
747
748    fn parse(bytes: &'a [u8]) -> Result<Self> {
749        let (message_id, download_id, adaptation, payload) =
750            parse_header(bytes, "DownloadDataBlock header")?;
751        if message_id != MESSAGE_ID_DDB {
752            return Err(Error::ReservedBitsViolation {
753                field: "messageId",
754                reason: "expected 0x1003 (DDB) on table_id 0x3C (ISO/IEC 13818-6 §7.3.7)",
755            });
756        }
757        let (ddb_hdr, _) =
758            payload
759                .split_first_chunk::<DDB_FIXED_LEN>()
760                .ok_or(Error::BufferTooShort {
761                    need: DDB_FIXED_LEN,
762                    have: payload.len(),
763                    what: "DownloadDataBlock body",
764                })?;
765        Ok(DownloadDataBlock {
766            download_id,
767            adaptation,
768            module_id: u16::from_be_bytes([ddb_hdr[0], ddb_hdr[1]]),
769            module_version: ddb_hdr[2],
770            block_number: u16::from_be_bytes([ddb_hdr[4], ddb_hdr[5]]),
771            block_data: &payload[DDB_FIXED_LEN..],
772        })
773    }
774}
775
776impl Serialize for DownloadDataBlock<'_> {
777    type Error = crate::error::Error;
778
779    fn serialized_len(&self) -> usize {
780        MESSAGE_HEADER_LEN + self.adaptation.len() + DDB_FIXED_LEN + self.block_data.len()
781    }
782
783    fn serialize_into(&self, buf: &mut [u8]) -> Result<usize> {
784        let len = self.serialized_len();
785        if buf.len() < len {
786            return Err(Error::OutputBufferTooSmall {
787                need: len,
788                have: buf.len(),
789            });
790        }
791        let payload_len = DDB_FIXED_LEN + self.block_data.len();
792        let pos = serialize_header(
793            buf,
794            MESSAGE_ID_DDB,
795            self.download_id,
796            self.adaptation,
797            payload_len,
798        )?;
799        buf[pos..pos + 2].copy_from_slice(&self.module_id.to_be_bytes());
800        buf[pos + 2] = self.module_version;
801        buf[pos + 3] = 0xFF; // reserved
802        buf[pos + 4..pos + 6].copy_from_slice(&self.block_number.to_be_bytes());
803        buf[pos + DDB_FIXED_LEN..pos + DDB_FIXED_LEN + self.block_data.len()]
804            .copy_from_slice(self.block_data);
805        Ok(len)
806    }
807}
808
809#[cfg(test)]
810mod tests {
811    use super::*;
812
813    fn sample_dsi() -> UnMessage<'static> {
814        UnMessage::Dsi(Dsi {
815            transaction_id: 0x8000_0000,
816            adaptation: &[],
817            server_id: [0xFF; 20],
818            compatibility_descriptor: CompatibilityDescriptor {
819                descriptors: vec![],
820            },
821            private_data: &[0x0A, 0x0B],
822        })
823    }
824
825    fn sample_dii() -> UnMessage<'static> {
826        UnMessage::Dii(Dii {
827            transaction_id: 0x8002_0002,
828            adaptation: &[],
829            download_id: 0x0000_00AB,
830            block_size: 4066,
831            window_size: 0,
832            ack_period: 0,
833            t_c_download_window: 0,
834            t_c_download_scenario: 0,
835            compatibility_descriptor: CompatibilityDescriptor {
836                descriptors: vec![],
837            },
838            modules: vec![
839                DiiModule {
840                    module_id: 1,
841                    module_size: 8000,
842                    module_version: 3,
843                    module_info: &[0xDE, 0xAD],
844                },
845                DiiModule {
846                    module_id: 2,
847                    module_size: 100,
848                    module_version: 1,
849                    module_info: &[],
850                },
851            ],
852            private_data: &[],
853        })
854    }
855
856    #[test]
857    fn dsi_round_trip() {
858        let msg = sample_dsi();
859        let mut buf = vec![0u8; msg.serialized_len()];
860        msg.serialize_into(&mut buf).unwrap();
861        assert_eq!(UnMessage::parse(&buf).unwrap(), msg);
862    }
863
864    #[test]
865    fn dii_round_trip() {
866        let msg = sample_dii();
867        let mut buf = vec![0u8; msg.serialized_len()];
868        msg.serialize_into(&mut buf).unwrap();
869        assert_eq!(UnMessage::parse(&buf).unwrap(), msg);
870    }
871
872    /// A non-empty `compatibilityDescriptor()` carrying one entry with a
873    /// sub-descriptor — exercises the full compat block in a DSI, not just the
874    /// empty `0x00 0x00` form the other tests use.
875    fn nonempty_compat() -> CompatibilityDescriptor<'static> {
876        CompatibilityDescriptor {
877            descriptors: vec![crate::compatibility::CompatibilityDescriptorEntry {
878                descriptor_type: crate::compatibility::DescriptorType::SystemHardware,
879                specifier_type: crate::compatibility::SpecifierType::IeeeOui,
880                specifier_data: [0x00, 0x15, 0x0A],
881                model: 0x1234,
882                version: 0x0001,
883                sub_descriptors: vec![crate::compatibility::SubDescriptor {
884                    sub_descriptor_type: crate::compatibility::SubDescriptorType::Unallocated(0x05),
885                    data: &[0xAA, 0xBB],
886                }],
887            }],
888        }
889    }
890
891    #[test]
892    fn dsi_with_compat_round_trip() {
893        let msg = UnMessage::Dsi(Dsi {
894            transaction_id: 0x8000_0000,
895            adaptation: &[],
896            server_id: [0xFF; 20],
897            compatibility_descriptor: nonempty_compat(),
898            private_data: &[0x0A, 0x0B],
899        });
900        let mut buf = vec![0u8; msg.serialized_len()];
901        msg.serialize_into(&mut buf).unwrap();
902        let re = UnMessage::parse(&buf).unwrap();
903        assert_eq!(re, msg);
904        let mut buf2 = vec![0u8; re.serialized_len()];
905        re.serialize_into(&mut buf2).unwrap();
906        assert_eq!(buf, buf2, "byte-exact re-serialize");
907    }
908
909    #[test]
910    fn dii_with_compat_round_trip() {
911        let msg = UnMessage::Dii(Dii {
912            transaction_id: 0x8002_0002,
913            adaptation: &[],
914            download_id: 0x0000_00AB,
915            block_size: 4066,
916            window_size: 0,
917            ack_period: 0,
918            t_c_download_window: 0,
919            t_c_download_scenario: 0,
920            compatibility_descriptor: nonempty_compat(),
921            modules: vec![DiiModule {
922                module_id: 1,
923                module_size: 8000,
924                module_version: 3,
925                module_info: &[0xDE, 0xAD],
926            }],
927            private_data: &[],
928        });
929        let mut buf = vec![0u8; msg.serialized_len()];
930        msg.serialize_into(&mut buf).unwrap();
931        let re = UnMessage::parse(&buf).unwrap();
932        assert_eq!(re, msg);
933        let mut buf2 = vec![0u8; re.serialized_len()];
934        re.serialize_into(&mut buf2).unwrap();
935        assert_eq!(buf, buf2, "byte-exact re-serialize");
936    }
937
938    #[test]
939    fn ddb_round_trip() {
940        let ddb = DownloadDataBlock {
941            download_id: 0xAB,
942            adaptation: &[],
943            module_id: 1,
944            module_version: 3,
945            block_number: 2,
946            block_data: &[0x55; 64],
947        };
948        let mut buf = vec![0u8; ddb.serialized_len()];
949        ddb.serialize_into(&mut buf).unwrap();
950        assert_eq!(DownloadDataBlock::parse(&buf).unwrap(), ddb);
951    }
952
953    #[test]
954    fn header_fields_on_wire() {
955        let msg = sample_dsi();
956        let mut buf = vec![0u8; msg.serialized_len()];
957        msg.serialize_into(&mut buf).unwrap();
958        assert_eq!(buf[0], 0x11); // protocolDiscriminator
959        assert_eq!(buf[1], 0x03); // dsmccType
960        assert_eq!(u16::from_be_bytes([buf[2], buf[3]]), MESSAGE_ID_DSI);
961        assert_eq!(buf[8], 0xFF); // reserved
962                                  // messageLength = bytes after the 12-byte header
963        let ml = u16::from_be_bytes([buf[10], buf[11]]) as usize;
964        assert_eq!(ml, buf.len() - 12);
965    }
966
967    #[test]
968    fn parse_rejects_wrong_protocol_discriminator() {
969        let msg = sample_dsi();
970        let mut buf = vec![0u8; msg.serialized_len()];
971        msg.serialize_into(&mut buf).unwrap();
972        buf[0] = 0x12;
973        assert!(matches!(
974            UnMessage::parse(&buf).unwrap_err(),
975            Error::ReservedBitsViolation {
976                field: "protocolDiscriminator",
977                ..
978            }
979        ));
980    }
981
982    #[test]
983    fn parse_rejects_unknown_message_id() {
984        let msg = sample_dsi();
985        let mut buf = vec![0u8; msg.serialized_len()];
986        msg.serialize_into(&mut buf).unwrap();
987        buf[2] = 0x10;
988        buf[3] = 0x01; // 0x1001 DownloadInfoRequest — not valid broadcast-side
989        assert!(matches!(
990            UnMessage::parse(&buf).unwrap_err(),
991            Error::ReservedBitsViolation {
992                field: "messageId",
993                ..
994            }
995        ));
996    }
997
998    #[test]
999    fn parse_rejects_short_buffer() {
1000        assert!(matches!(
1001            UnMessage::parse(&[0x11, 0x03]).unwrap_err(),
1002            Error::BufferTooShort { .. }
1003        ));
1004    }
1005
1006    #[test]
1007    fn parse_rejects_message_length_overflow() {
1008        let msg = sample_dsi();
1009        let mut buf = vec![0u8; msg.serialized_len()];
1010        msg.serialize_into(&mut buf).unwrap();
1011        buf[10] = 0xFF;
1012        buf[11] = 0xFF; // declared messageLength way past the buffer
1013        assert!(matches!(
1014            UnMessage::parse(&buf).unwrap_err(),
1015            Error::SectionLengthOverflow { .. }
1016        ));
1017    }
1018
1019    #[test]
1020    fn dii_module_info_overflow_rejected() {
1021        let msg = sample_dii();
1022        let mut buf = vec![0u8; msg.serialized_len()];
1023        msg.serialize_into(&mut buf).unwrap();
1024        // First module's moduleInfoLength is at header(12) + fixed(16) +
1025        // compatLen(2) + numberOfModules(2) + moduleHeader-1 = byte 39.
1026        buf[39] = 0xFF;
1027        assert!(matches!(
1028            UnMessage::parse(&buf).unwrap_err(),
1029            Error::SectionLengthOverflow { .. }
1030        ));
1031    }
1032
1033    #[test]
1034    fn ddb_rejects_un_message_id() {
1035        let msg = sample_dsi();
1036        let mut buf = vec![0u8; msg.serialized_len()];
1037        msg.serialize_into(&mut buf).unwrap();
1038        assert!(matches!(
1039            DownloadDataBlock::parse(&buf).unwrap_err(),
1040            Error::ReservedBitsViolation {
1041                field: "messageId",
1042                ..
1043            }
1044        ));
1045    }
1046
1047    #[test]
1048    fn adaptation_bytes_round_trip() {
1049        let ddb = DownloadDataBlock {
1050            download_id: 1,
1051            adaptation: &[0x01, 0x02, 0x03],
1052            module_id: 9,
1053            module_version: 0,
1054            block_number: 0,
1055            block_data: &[0xAA],
1056        };
1057        let mut buf = vec![0u8; ddb.serialized_len()];
1058        ddb.serialize_into(&mut buf).unwrap();
1059        assert_eq!(buf[9], 3); // adaptationLength
1060        assert_eq!(DownloadDataBlock::parse(&buf).unwrap(), ddb);
1061    }
1062
1063    #[cfg(feature = "serde")]
1064    #[test]
1065    fn dii_serializes_to_valid_json() {
1066        let msg = sample_dii();
1067        let j = serde_json::to_string(&msg).unwrap();
1068        assert!(j.contains("\"download_id\":171"));
1069        assert!(j.contains("\"block_size\":4066"));
1070    }
1071
1072    // ── GroupInfoIndication tests ─────────────────────────────────────────────
1073
1074    /// Construct a minimal single-group GII (no group_info, no private_data,
1075    /// empty CompatibilityDescriptor).
1076    ///
1077    /// Hand-built wire layout (all offsets from byte 0 of the GII):
1078    ///
1079    /// ```text
1080    /// [0..2]  NumberOfGroups = 0x00 0x01 (1)
1081    /// [2..6]  GroupId        = 0x00 0x00 0x00 0x01
1082    /// [6..10] GroupSize      = 0x00 0x07 0xA1 0x20  (500 000 bytes)
1083    /// [10..12] CompatibilityDescriptorLength = 0x00 0x00 (empty)
1084    /// [12..14] GroupInfoLength = 0x00 0x02
1085    /// [14..16] GroupInfoByte  = 0xCA 0xFE
1086    /// [16..18] PrivateDataLength = 0x00 0x01
1087    /// [18]     PrivateDataByte   = 0xBB
1088    /// Total = 19 bytes
1089    /// ```
1090    fn sample_gii() -> GroupInfoIndication<'static> {
1091        GroupInfoIndication {
1092            groups: vec![GroupInfo {
1093                group_id: 0x0000_0001,
1094                group_size: 500_000,
1095                group_compatibility: CompatibilityDescriptor {
1096                    descriptors: vec![],
1097                },
1098                group_info: &[0xCA, 0xFE],
1099                private_data: &[0xBB],
1100            }],
1101        }
1102    }
1103
1104    #[test]
1105    fn gii_round_trip() {
1106        let gii = sample_gii();
1107        let mut buf = vec![0u8; gii.serialized_len()];
1108        gii.serialize_into(&mut buf).unwrap();
1109        let re = GroupInfoIndication::parse(&buf).unwrap();
1110        assert_eq!(re, gii);
1111        // Byte-identical re-serialize.
1112        let mut buf2 = vec![0u8; re.serialized_len()];
1113        re.serialize_into(&mut buf2).unwrap();
1114        assert_eq!(buf, buf2, "GII byte-exact re-serialize");
1115    }
1116
1117    /// Verify exact byte positions against the hand-computed layout comment in
1118    /// `sample_gii` — this test will catch a layout bug that a pure
1119    /// serialize→parse round-trip cannot.
1120    #[test]
1121    fn gii_hand_built_byte_anchor() {
1122        // NumberOfGroups=1, GroupId=1, GroupSize=500000=0x0007_A120,
1123        // CompatLen=0, GroupInfoLen=2, bytes CA FE,
1124        // PrivateDataLen=1, byte BB.
1125        #[rustfmt::skip]
1126        let expected: &[u8] = &[
1127            0x00, 0x01,             // NumberOfGroups = 1
1128            0x00, 0x00, 0x00, 0x01, // GroupId = 1
1129            0x00, 0x07, 0xA1, 0x20, // GroupSize = 500 000
1130            0x00, 0x00,             // CompatibilityDescriptorLength = 0 (empty)
1131            0x00, 0x02,             // GroupInfoLength = 2
1132            0xCA, 0xFE,             // GroupInfoByte × 2
1133            0x00, 0x01,             // PrivateDataLength = 1
1134            0xBB,                   // PrivateDataByte
1135        ];
1136        let gii = sample_gii();
1137        let mut buf = vec![0u8; gii.serialized_len()];
1138        gii.serialize_into(&mut buf).unwrap();
1139        assert_eq!(buf.as_slice(), expected);
1140        let re = GroupInfoIndication::parse(expected).unwrap();
1141        assert_eq!(re, gii);
1142    }
1143
1144    #[test]
1145    fn gii_empty_groups() {
1146        let gii = GroupInfoIndication { groups: vec![] };
1147        let mut buf = vec![0u8; gii.serialized_len()];
1148        gii.serialize_into(&mut buf).unwrap();
1149        assert_eq!(buf, &[0x00, 0x00]); // NumberOfGroups = 0
1150        let re = GroupInfoIndication::parse(&buf).unwrap();
1151        assert!(re.groups.is_empty());
1152    }
1153
1154    #[test]
1155    fn gii_with_compat_round_trip() {
1156        let gii = GroupInfoIndication {
1157            groups: vec![GroupInfo {
1158                group_id: 0xDEAD_BEEF,
1159                group_size: 0x0001_0000,
1160                group_compatibility: nonempty_compat(),
1161                group_info: &[0x01, 0x02, 0x03],
1162                private_data: &[],
1163            }],
1164        };
1165        let mut buf = vec![0u8; gii.serialized_len()];
1166        gii.serialize_into(&mut buf).unwrap();
1167        let re = GroupInfoIndication::parse(&buf).unwrap();
1168        assert_eq!(re, gii);
1169        let mut buf2 = vec![0u8; re.serialized_len()];
1170        re.serialize_into(&mut buf2).unwrap();
1171        assert_eq!(buf, buf2, "GII with compat byte-exact re-serialize");
1172    }
1173
1174    #[test]
1175    fn gii_parse_rejects_short_buffer() {
1176        assert!(matches!(
1177            GroupInfoIndication::parse(&[0x00]).unwrap_err(),
1178            Error::BufferTooShort { .. }
1179        ));
1180    }
1181
1182    #[test]
1183    fn gii_parse_rejects_truncated_group() {
1184        // NumberOfGroups=1 but only 3 bytes of body (needs at least 8 for id+size).
1185        let bytes = &[0x00, 0x01, 0x00, 0x00, 0x00];
1186        assert!(matches!(
1187            GroupInfoIndication::parse(bytes).unwrap_err(),
1188            Error::BufferTooShort { .. }
1189        ));
1190    }
1191
1192    #[cfg(feature = "serde")]
1193    #[test]
1194    fn gii_serde_round_trip() {
1195        let gii = sample_gii();
1196        let json = serde_json::to_string(&gii).unwrap();
1197        assert!(json.contains("\"group_id\":1"));
1198        assert!(json.contains("\"group_size\":500000"));
1199        assert!(json.contains("\"group_info\""));
1200    }
1201}