Skip to main content

zerodds_rtps/
group_digest.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! `GroupDigest_t` — DDSI-RTPS 2.5 §8.3.5.10 (16-byte CDR-encoded
4//! 128-bit digest for group membership). Carried in heartbeats with the
5//! GroupInfo flag (`HeartbeatSubmessage.writer_set` /
6//! `secure_writer_set`), to prove to readers the consistency of the shared
7//! writer/reader group per tick.
8//!
9//! # Spec-Layout
10//!
11//! ```text
12//! struct GroupDigest_t {
13//!     octet[16] value;  // MD5 over {GuidPrefix*}
14//! };
15//! ```
16//!
17//! The hash algorithm is MD5 (RFC 1321) over the concatenated
18//! 12-byte `GuidPrefix` values of all group members, sorted
19//! ascending. The spec leaves the sorting open but fixes it
20//! per vendor; ZeroDDS sorts lexicographically (stable +
21//! deterministic), so that the hash is byte-identical to Cyclone DDS
22//! (until Cyclone vendor hashing is documented, then
23//! possibly a vendor switch).
24
25extern crate alloc;
26use alloc::vec::Vec;
27
28use crate::error::WireError;
29use crate::wire_types::GuidPrefix;
30
31/// `GroupDigest_t` (Spec §8.3.5.10). 16-byte value.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
33pub struct GroupDigest(pub [u8; 16]);
34
35impl GroupDigest {
36    /// Wire size: 16 bytes.
37    pub const WIRE_SIZE: usize = 16;
38
39    /// `GROUPDIGEST_UNKNOWN` (Spec): 16 null bytes.
40    pub const UNKNOWN: Self = Self([0; 16]);
41
42    /// `true` if the value equals the `UNKNOWN` sentinel.
43    #[must_use]
44    pub fn is_unknown(self) -> bool {
45        self.0 == [0u8; 16]
46    }
47
48    /// Computes the digest from a list of GuidPrefixes. Sorts
49    /// the input before hashing — the result is thus independent
50    /// of the caller's iteration order.
51    #[must_use]
52    pub fn from_prefixes(prefixes: &[GuidPrefix]) -> Self {
53        let mut sorted: Vec<&GuidPrefix> = prefixes.iter().collect();
54        sorted.sort();
55        let mut concat = Vec::with_capacity(sorted.len() * GuidPrefix::WIRE_SIZE);
56        for p in sorted {
57            concat.extend_from_slice(&p.0);
58        }
59        Self(md5_128(&concat))
60    }
61
62    /// LE bytes (the spec specifies no endianness — the value is a
63    /// pure byte sequence from the hash).
64    #[must_use]
65    pub fn to_bytes(self) -> [u8; 16] {
66        self.0
67    }
68
69    /// Roundtrip identity.
70    #[must_use]
71    pub fn from_bytes(bytes: [u8; 16]) -> Self {
72        Self(bytes)
73    }
74
75    /// Decodes from a slice. Truncated → error.
76    ///
77    /// # Errors
78    /// `UnexpectedEof` if `bytes.len() < 16`.
79    pub fn read_from(bytes: &[u8]) -> Result<Self, WireError> {
80        if bytes.len() < 16 {
81            return Err(WireError::UnexpectedEof {
82                needed: 16,
83                offset: 0,
84            });
85        }
86        let mut out = [0u8; 16];
87        out.copy_from_slice(&bytes[..16]);
88        Ok(Self(out))
89    }
90}
91
92/// MD5 via `zerodds_foundation::md5` (pure-Rust no_std). MD5 here is
93/// not security-relevant — only for group-digest consistency with
94/// Cyclone DDS and other DDS stacks.
95fn md5_128(input: &[u8]) -> [u8; 16] {
96    zerodds_foundation::md5(input)
97}
98
99#[cfg(test)]
100mod tests {
101    #![allow(clippy::expect_used, clippy::unwrap_used)]
102    use super::*;
103
104    #[test]
105    fn md5_empty_input_matches_rfc1321_test_vector() {
106        // RFC 1321 test vector: MD5("") = d41d8cd98f00b204e9800998ecf8427e
107        let h = md5_128(b"");
108        assert_eq!(
109            h,
110            [
111                0xd4, 0x1d, 0x8c, 0xd9, 0x8f, 0x00, 0xb2, 0x04, 0xe9, 0x80, 0x09, 0x98, 0xec, 0xf8,
112                0x42, 0x7e
113            ]
114        );
115    }
116
117    #[test]
118    fn md5_abc_matches_rfc1321_test_vector() {
119        // MD5("abc") = 900150983cd24fb0d6963f7d28e17f72
120        let h = md5_128(b"abc");
121        assert_eq!(
122            h,
123            [
124                0x90, 0x01, 0x50, 0x98, 0x3c, 0xd2, 0x4f, 0xb0, 0xd6, 0x96, 0x3f, 0x7d, 0x28, 0xe1,
125                0x7f, 0x72
126            ]
127        );
128    }
129
130    #[test]
131    fn md5_message_digest_matches_rfc1321_test_vector() {
132        // RFC 1321 test vector 5: MD5("message digest")
133        //   = f96b697d7cb7938d525a2f31aaf161d0
134        let h = md5_128(b"message digest");
135        assert_eq!(
136            h,
137            [
138                0xf9, 0x6b, 0x69, 0x7d, 0x7c, 0xb7, 0x93, 0x8d, 0x52, 0x5a, 0x2f, 0x31, 0xaa, 0xf1,
139                0x61, 0xd0
140            ]
141        );
142    }
143
144    #[test]
145    fn md5_long_input_matches_rfc1321_test_vector() {
146        // RFC 1321 test vector 7 (62 byte, 2-block path). Input is
147        // UPPER-cased first, then lowercase, then digits:
148        //   "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
149        //   = d174ab98d277d9f5a5611c2c9f419d9f
150        let h = md5_128(b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789");
151        assert_eq!(
152            h,
153            [
154                0xd1, 0x74, 0xab, 0x98, 0xd2, 0x77, 0xd9, 0xf5, 0xa5, 0x61, 0x1c, 0x2c, 0x9f, 0x41,
155                0x9d, 0x9f
156            ]
157        );
158    }
159
160    #[test]
161    fn group_digest_handles_more_than_5_prefixes_two_block_md5() {
162        // 6 GuidPrefixes = 72 bytes input → 2-block MD5 path. Ensures
163        // that GroupDigest also works correctly for larger groups.
164        let prefixes: alloc::vec::Vec<GuidPrefix> =
165            (1u8..=6).map(|b| GuidPrefix::from_bytes([b; 12])).collect();
166        let d = GroupDigest::from_prefixes(&prefixes);
167        // Expected output is deterministic (for regression).
168        assert!(!d.is_unknown());
169        // Order independence also for 6 prefixes.
170        let mut reversed = prefixes.clone();
171        reversed.reverse();
172        let d2 = GroupDigest::from_prefixes(&reversed);
173        assert_eq!(d, d2);
174    }
175
176    #[test]
177    fn group_digest_unknown_is_zero() {
178        assert!(GroupDigest::UNKNOWN.is_unknown());
179        assert_eq!(GroupDigest::UNKNOWN.0, [0u8; 16]);
180    }
181
182    #[test]
183    fn group_digest_from_empty_prefixes_is_md5_of_empty() {
184        let d = GroupDigest::from_prefixes(&[]);
185        // MD5("") as 16 bytes
186        assert_eq!(
187            d.0,
188            [
189                0xd4, 0x1d, 0x8c, 0xd9, 0x8f, 0x00, 0xb2, 0x04, 0xe9, 0x80, 0x09, 0x98, 0xec, 0xf8,
190                0x42, 0x7e
191            ]
192        );
193    }
194
195    #[test]
196    fn group_digest_independent_of_input_order() {
197        let p1 = GuidPrefix::from_bytes([1; 12]);
198        let p2 = GuidPrefix::from_bytes([2; 12]);
199        let p3 = GuidPrefix::from_bytes([3; 12]);
200        let a = GroupDigest::from_prefixes(&[p1, p2, p3]);
201        let b = GroupDigest::from_prefixes(&[p3, p2, p1]);
202        assert_eq!(a, b);
203    }
204
205    #[test]
206    fn group_digest_distinguishes_different_groups() {
207        let g1 = GroupDigest::from_prefixes(&[GuidPrefix::from_bytes([1; 12])]);
208        let g2 = GroupDigest::from_prefixes(&[GuidPrefix::from_bytes([2; 12])]);
209        assert_ne!(g1, g2);
210    }
211
212    #[test]
213    fn group_digest_roundtrip() {
214        let d = GroupDigest::from_prefixes(&[
215            GuidPrefix::from_bytes([7; 12]),
216            GuidPrefix::from_bytes([8; 12]),
217        ]);
218        let bytes = d.to_bytes();
219        assert_eq!(GroupDigest::from_bytes(bytes), d);
220        assert_eq!(GroupDigest::read_from(&bytes).unwrap(), d);
221    }
222
223    #[test]
224    fn group_digest_read_from_truncated_rejects() {
225        let r = GroupDigest::read_from(&[0u8; 8]);
226        assert!(matches!(r, Err(WireError::UnexpectedEof { .. })));
227    }
228}