Skip to main content

zerodds_types/
hash.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! TypeIdentifier hash computation (XTypes 1.3 §7.3.1.2).
4//!
5//! The `EquivalenceHash` (14 bytes) is formed from the **first 14 bytes** of the
6//! **MD5** digest over the XCDR2-serialized TypeObject representation
7//! (XTypes 1.3 §7.3.1.2.1):
8//!
9//! ```text
10//! EquivalenceHash(T) := MD5(xcdr2_le_bytes(T))[0..14]
11//! ```
12//!
13//! Cyclone DDS and Fast-DDS use MD5 — hence mandatory for live interop.
14//!
15//! - For `MinimalTypeObject T`: `EK_MINIMAL` hash
16//! - For `CompleteTypeObject T`: `EK_COMPLETE` hash
17//!
18//! From the hash a TypeIdentifier of kind `EK_MINIMAL` resp.
19//! `EK_COMPLETE` is built — this is the "strongly-hashed" form of the
20//! TypeObject, as exchanged between peers via SEDP (`PID_TYPE_INFORMATION`) and
21//! the TypeLookup service.
22
23use zerodds_cdr::{BufferWriter, EncodeError, Endianness};
24use zerodds_foundation::md5;
25
26use crate::type_identifier::kinds::{EK_COMPLETE, EK_MINIMAL, EQUIVALENCE_HASH_LEN};
27use crate::type_identifier::{EquivalenceHash, TypeIdentifier};
28use crate::type_object::{CompleteTypeObject, MinimalTypeObject, TypeObject};
29
30/// Computes the 14-byte EquivalenceHash of a TypeObject.
31///
32/// Serializes the TypeObject to XCDR2-LE bytes (incl. the equivalence-
33/// kind discriminator), hashes with MD5 and truncates to 14 bytes.
34///
35/// # Errors
36/// `EncodeError` if serialization overflows.
37pub fn compute_hash(to: &TypeObject) -> Result<EquivalenceHash, EncodeError> {
38    // Route through the per-variant functions so EVERY hash path uses the same
39    // XCDR2 framing (DHEADER + EK + body). `to_bytes_le` is the flat internal
40    // roundtrip form and must NOT be used for hashing — mixing the two makes
41    // the registry store under one hash and look up under another.
42    match to {
43        TypeObject::Minimal(m) => compute_minimal_hash(m),
44        TypeObject::Complete(c) => compute_complete_hash(c),
45    }
46}
47
48/// Like [`compute_hash`], but directly over `MinimalTypeObject` (without
49/// the EquivalenceKind discriminator wrapper clone). Writes the
50/// EK_MINIMAL discriminator before the body itself.
51///
52/// # Errors
53/// `EncodeError`.
54pub fn compute_minimal_hash(t: &MinimalTypeObject) -> Result<EquivalenceHash, EncodeError> {
55    Ok(hash_bytes(&xcdr2_typeobject_stream(EK_MINIMAL, |w| {
56        t.encode_into(w)
57    })?))
58}
59
60/// Like [`compute_minimal_hash`], only for `CompleteTypeObject`.
61///
62/// # Errors
63/// `EncodeError`.
64pub fn compute_complete_hash(t: &CompleteTypeObject) -> Result<EquivalenceHash, EncodeError> {
65    Ok(hash_bytes(&xcdr2_typeobject_stream(EK_COMPLETE, |w| {
66        t.encode_into(w)
67    })?))
68}
69
70/// The exact byte stream fed to MD5 for a `MinimalTypeObject` hash (XCDR2
71/// DHEADER + EK + body). Exposed so conformance harnesses can byte-diff it
72/// against vendor TypeObject goldens.
73///
74/// # Errors
75/// `EncodeError`.
76pub fn minimal_hash_input(t: &MinimalTypeObject) -> Result<alloc::vec::Vec<u8>, EncodeError> {
77    xcdr2_typeobject_stream(EK_MINIMAL, |w| t.encode_into(w))
78}
79
80/// The exact byte stream fed to MD5 for a `CompleteTypeObject` hash.
81///
82/// # Errors
83/// `EncodeError`.
84pub fn complete_hash_input(t: &CompleteTypeObject) -> Result<alloc::vec::Vec<u8>, EncodeError> {
85    xcdr2_typeobject_stream(EK_COMPLETE, |w| t.encode_into(w))
86}
87
88/// XTypes 1.3 §7.3.4.5: the serialized TypeObject that the EquivalenceHash is
89/// computed over is an **XCDR2** delimited stream — a 4-byte DHEADER (the byte
90/// length of the body) followed by the EquivalenceKind discriminator + the
91/// TypeObject body, all under the XCDR2 alignment cap (4). Byte-verified
92/// against CycloneDDS/FastDDS/RTI (`proofs/typeobject`); the old code used a
93/// plain (XCDR1) writer with no DHEADER, which matched no vendor.
94fn xcdr2_typeobject_stream(
95    ek: u8,
96    body: impl FnOnce(&mut BufferWriter) -> Result<(), EncodeError>,
97) -> Result<alloc::vec::Vec<u8>, EncodeError> {
98    let mut inner = BufferWriter::new(Endianness::Little).xcdr2();
99    inner.write_u8(ek)?;
100    body(&mut inner)?;
101    let body_bytes = inner.into_bytes();
102    let dheader = u32::try_from(body_bytes.len()).map_err(|_| EncodeError::ValueOutOfRange {
103        message: "TypeObject body exceeds u32::MAX",
104    })?;
105    let mut out = BufferWriter::new(Endianness::Little).xcdr2();
106    out.write_u32(dheader)?;
107    out.write_bytes(&body_bytes)?;
108    Ok(out.into_bytes())
109}
110
111/// Raw hash function: MD5 + truncate to 14 bytes.
112///
113/// MD5 is **spec-conformant** here, not cryptographic. XTypes 1.3
114/// §7.3.1.2.1 requires MD5 for wire compatibility with other DDS
115/// implementations (Cyclone, Fast-DDS, RTI Connext).
116#[must_use]
117pub fn hash_bytes(data: &[u8]) -> EquivalenceHash {
118    let digest = md5(data);
119    let mut out = [0u8; EQUIVALENCE_HASH_LEN];
120    out.copy_from_slice(&digest[..EQUIVALENCE_HASH_LEN]);
121    EquivalenceHash(out)
122}
123
124/// Shortcut: builds a strongly-hashed TypeIdentifier from a
125/// TypeObject. Wraps [`compute_hash`] in the matching `EquivalenceHash*`
126/// TypeIdentifier variant depending on Minimal/Complete.
127///
128/// # Errors
129/// `EncodeError`.
130pub fn to_hashed_type_identifier(to: &TypeObject) -> Result<TypeIdentifier, EncodeError> {
131    let h = compute_hash(to)?;
132    Ok(match to {
133        TypeObject::Minimal(_) => TypeIdentifier::EquivalenceHashMinimal(h),
134        TypeObject::Complete(_) => TypeIdentifier::EquivalenceHashComplete(h),
135    })
136}
137
138// ============================================================================
139// Tests
140// ============================================================================
141
142#[cfg(test)]
143#[allow(clippy::unwrap_used)]
144mod tests {
145    use super::*;
146
147    use crate::type_identifier::PrimitiveKind;
148    use crate::type_object::common::{CommonStructMember, NameHash};
149    use crate::type_object::flags::{StructMemberFlag, StructTypeFlag};
150    use crate::type_object::minimal::{
151        MinimalStructHeader, MinimalStructMember, MinimalStructType,
152    };
153
154    fn sample_minimal_struct(field_count: u32) -> MinimalTypeObject {
155        MinimalTypeObject::Struct(MinimalStructType {
156            struct_flags: StructTypeFlag(StructTypeFlag::IS_APPENDABLE),
157            header: MinimalStructHeader {
158                base_type: TypeIdentifier::None,
159            },
160            member_seq: (0..field_count)
161                .map(|i| MinimalStructMember {
162                    common: CommonStructMember {
163                        member_id: i + 1,
164                        member_flags: StructMemberFlag::default(),
165                        member_type_id: TypeIdentifier::Primitive(PrimitiveKind::Int64),
166                    },
167                    detail: NameHash([i as u8; 4]),
168                })
169                .collect(),
170        })
171    }
172
173    #[test]
174    fn hash_is_14_bytes_and_deterministic() {
175        let t = sample_minimal_struct(3);
176        let h1 = compute_minimal_hash(&t).unwrap();
177        let h2 = compute_minimal_hash(&t).unwrap();
178        assert_eq!(h1, h2);
179        assert_eq!(h1.0.len(), 14);
180    }
181
182    #[test]
183    fn different_type_objects_have_different_hashes() {
184        let t1 = sample_minimal_struct(3);
185        let t2 = sample_minimal_struct(4);
186        let h1 = compute_minimal_hash(&t1).unwrap();
187        let h2 = compute_minimal_hash(&t2).unwrap();
188        assert_ne!(h1, h2);
189    }
190
191    #[test]
192    fn minimal_and_complete_same_semantic_differ_in_hash() {
193        // Even if Minimal and Complete represent the same "type",
194        // the equivalence-kind discriminator at the start of the
195        // serialized bytes distinguishes them → different hashes.
196        use crate::type_object::common::{
197            AppliedBuiltinMemberAnnotations, AppliedBuiltinTypeAnnotations, CompleteMemberDetail,
198            CompleteTypeDetail, OptionalAppliedAnnotationSeq,
199        };
200        use crate::type_object::complete::{
201            CompleteStructHeader, CompleteStructMember, CompleteStructType,
202        };
203
204        let minimal = sample_minimal_struct(1);
205        let complete = CompleteStructType {
206            struct_flags: StructTypeFlag(StructTypeFlag::IS_APPENDABLE),
207            header: CompleteStructHeader {
208                base_type: TypeIdentifier::None,
209                detail: CompleteTypeDetail {
210                    ann_builtin: AppliedBuiltinTypeAnnotations::default(),
211                    ann_custom: OptionalAppliedAnnotationSeq::default(),
212                    type_name: alloc::string::String::from("::Sample"),
213                },
214            },
215            member_seq: alloc::vec![CompleteStructMember {
216                common: CommonStructMember {
217                    member_id: 1,
218                    member_flags: StructMemberFlag::default(),
219                    member_type_id: TypeIdentifier::Primitive(PrimitiveKind::Int64),
220                },
221                detail: CompleteMemberDetail {
222                    name: alloc::string::String::from("x"),
223                    ann_builtin: AppliedBuiltinMemberAnnotations::default(),
224                    ann_custom: OptionalAppliedAnnotationSeq::default(),
225                },
226            }],
227        };
228
229        let hm = compute_minimal_hash(&minimal).unwrap();
230        let complete_wrapped = CompleteTypeObject::Struct(complete);
231        let hc = compute_complete_hash(&complete_wrapped).unwrap();
232        assert_ne!(hm, hc, "minimal and complete must hash to different values");
233    }
234
235    #[test]
236    fn to_hashed_type_identifier_picks_correct_kind() {
237        let minimal = sample_minimal_struct(2);
238        let ti = to_hashed_type_identifier(&TypeObject::Minimal(minimal.clone())).unwrap();
239        assert!(matches!(ti, TypeIdentifier::EquivalenceHashMinimal(_)));
240
241        // Complete variant with a minimal shape (we only simulate the
242        // dispatch) — here we use an array fixture so that no
243        // name needs to be verified.
244        use crate::type_object::common::{
245            AppliedBuiltinMemberAnnotations, AppliedBuiltinTypeAnnotations, CompleteTypeDetail,
246            OptionalAppliedAnnotationSeq,
247        };
248        use crate::type_object::complete::{CompleteCollectionElement, CompleteSequenceType};
249        use crate::type_object::flags::{CollectionElementFlag, CollectionTypeFlag};
250        use crate::type_object::minimal::CommonCollectionElement;
251
252        let complete_seq = CompleteSequenceType {
253            collection_flag: CollectionTypeFlag::default(),
254            bound: 10,
255            detail: CompleteTypeDetail {
256                ann_builtin: AppliedBuiltinTypeAnnotations::default(),
257                ann_custom: OptionalAppliedAnnotationSeq::default(),
258                type_name: alloc::string::String::from("::Seq"),
259            },
260            element: CompleteCollectionElement {
261                common: CommonCollectionElement {
262                    element_flags: CollectionElementFlag::default(),
263                    type_id: TypeIdentifier::Primitive(PrimitiveKind::Int32),
264                },
265                ann_builtin: AppliedBuiltinMemberAnnotations::default(),
266                ann_custom: OptionalAppliedAnnotationSeq::default(),
267            },
268        };
269        let ti2 = to_hashed_type_identifier(&TypeObject::Complete(CompleteTypeObject::Sequence(
270            complete_seq,
271        )))
272        .unwrap();
273        assert!(matches!(ti2, TypeIdentifier::EquivalenceHashComplete(_)));
274    }
275
276    #[test]
277    fn hash_bytes_matches_md5_truncated_reference() {
278        // MD5("") = d41d8cd98f00b204e9800998ecf8427e
279        // Erste 14 bytes: d4 1d 8c d9 8f 00 b2 04 e9 80 09 98 ec f8
280        let h = hash_bytes(b"");
281        let expected: [u8; 14] = [
282            0xd4, 0x1d, 0x8c, 0xd9, 0x8f, 0x00, 0xb2, 0x04, 0xe9, 0x80, 0x09, 0x98, 0xec, 0xf8,
283        ];
284        assert_eq!(h.0, expected);
285    }
286
287    #[test]
288    fn hash_bytes_deterministic_for_known_input() {
289        let h1 = hash_bytes(b"ZeroDDS");
290        let h2 = hash_bytes(b"ZeroDDS");
291        assert_eq!(h1, h2);
292        let h3 = hash_bytes(b"ZeroDDs"); // case-sensitiv
293        assert_ne!(h1, h3);
294    }
295}