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 fuer Group-Membership). Wird in Heartbeats mit
5//! GroupInfo-Flag transportiert (`HeartbeatSubmessage.writer_set` /
6//! `secure_writer_set`), um Reader die Konsistenz der gemeinsamen
7//! Writer-/Reader-Gruppe pro Tick zu beweisen.
8//!
9//! # Spec-Layout
10//!
11//! ```text
12//! struct GroupDigest_t {
13//!     octet[16] value;  // MD5 ueber {GuidPrefix*}
14//! };
15//! ```
16//!
17//! Der Hash-Algorithmus ist MD5 (RFC 1321) ueber die konkatenierten
18//! 12-Byte-`GuidPrefix`-Werte aller Gruppenmitglieder, sortiert
19//! aufsteigend. Spec laesst die Sortierung offen, fixiert sie aber
20//! pro Vendor; ZeroDDS sortiert lexikographisch (Stable +
21//! deterministisch), damit der Hash byte-identisch zu Cyclone DDS
22//! ist (bis Cyclone-Vendor-Hashing dokumentiert wird, dann
23//! ggf. 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-Wert.
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` wenn der Wert dem `UNKNOWN`-Sentinel entspricht.
43    #[must_use]
44    pub fn is_unknown(self) -> bool {
45        self.0 == [0u8; 16]
46    }
47
48    /// Berechnet den Digest aus einer Liste von GuidPrefixes. Sortiert
49    /// die Eingabe vor dem Hashing — das Ergebnis ist also unabhaengig
50    /// von der Iter-Reihenfolge des Callers.
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 (Spec gibt keine Endianness vor — der Wert ist eine
63    /// reine Byte-Sequenz aus dem Hash).
64    #[must_use]
65    pub fn to_bytes(self) -> [u8; 16] {
66        self.0
67    }
68
69    /// Roundtrip-Identitaet.
70    #[must_use]
71    pub fn from_bytes(bytes: [u8; 16]) -> Self {
72        Self(bytes)
73    }
74
75    /// Decoded aus einem Slice. Truncated → Error.
76    ///
77    /// # Errors
78    /// `UnexpectedEof` wenn `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 ist hier
93/// nicht-security-relevant — nur fuer Group-Digest-Konsistenz mit
94/// Cyclone DDS und anderen 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-Pfad). Input ist
147        // UPPER-cased zuerst, dann lowercase, dann 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-Pfad. Stellt
163        // sicher, dass GroupDigest auch fuer groessere Gruppen
164        // korrekt arbeitet.
165        let prefixes: alloc::vec::Vec<GuidPrefix> =
166            (1u8..=6).map(|b| GuidPrefix::from_bytes([b; 12])).collect();
167        let d = GroupDigest::from_prefixes(&prefixes);
168        // Erwarteter Output deterministisch (fuer Regression).
169        assert!(!d.is_unknown());
170        // Order-Independenz auch bei 6 Prefixes.
171        let mut reversed = prefixes.clone();
172        reversed.reverse();
173        let d2 = GroupDigest::from_prefixes(&reversed);
174        assert_eq!(d, d2);
175    }
176
177    #[test]
178    fn group_digest_unknown_is_zero() {
179        assert!(GroupDigest::UNKNOWN.is_unknown());
180        assert_eq!(GroupDigest::UNKNOWN.0, [0u8; 16]);
181    }
182
183    #[test]
184    fn group_digest_from_empty_prefixes_is_md5_of_empty() {
185        let d = GroupDigest::from_prefixes(&[]);
186        // MD5("") als 16 byte
187        assert_eq!(
188            d.0,
189            [
190                0xd4, 0x1d, 0x8c, 0xd9, 0x8f, 0x00, 0xb2, 0x04, 0xe9, 0x80, 0x09, 0x98, 0xec, 0xf8,
191                0x42, 0x7e
192            ]
193        );
194    }
195
196    #[test]
197    fn group_digest_independent_of_input_order() {
198        let p1 = GuidPrefix::from_bytes([1; 12]);
199        let p2 = GuidPrefix::from_bytes([2; 12]);
200        let p3 = GuidPrefix::from_bytes([3; 12]);
201        let a = GroupDigest::from_prefixes(&[p1, p2, p3]);
202        let b = GroupDigest::from_prefixes(&[p3, p2, p1]);
203        assert_eq!(a, b);
204    }
205
206    #[test]
207    fn group_digest_distinguishes_different_groups() {
208        let g1 = GroupDigest::from_prefixes(&[GuidPrefix::from_bytes([1; 12])]);
209        let g2 = GroupDigest::from_prefixes(&[GuidPrefix::from_bytes([2; 12])]);
210        assert_ne!(g1, g2);
211    }
212
213    #[test]
214    fn group_digest_roundtrip() {
215        let d = GroupDigest::from_prefixes(&[
216            GuidPrefix::from_bytes([7; 12]),
217            GuidPrefix::from_bytes([8; 12]),
218        ]);
219        let bytes = d.to_bytes();
220        assert_eq!(GroupDigest::from_bytes(bytes), d);
221        assert_eq!(GroupDigest::read_from(&bytes).unwrap(), d);
222    }
223
224    #[test]
225    fn group_digest_read_from_truncated_rejects() {
226        let r = GroupDigest::read_from(&[0u8; 8]);
227        assert!(matches!(r, Err(WireError::UnexpectedEof { .. })));
228    }
229}