pamoja_ros2/typehash.rs
1//! Message type identity: the DDS type name and the RIHS01 type hash.
2//!
3//! Two peers only exchange a message if they agree on its type. ROS 2 pins that agreement two ways:
4//! a DDS type name derived from the interface (`std_msgs/msg/String` becomes
5//! `std_msgs::msg::dds_::String_`), and a structural type hash (REP-2011's RIHS01, a SHA-256 over
6//! the type's description, written `RIHS01_` followed by 64 hex digits). This module derives the
7//! type name and parses and formats the hash. Computing the hash from a type description is part of
8//! the live bridge, where it is checked against the value `rosidl` emits.
9
10use alloc::format;
11use alloc::string::String;
12use core::fmt;
13
14/// The length in bytes of a RIHS01 hash (a SHA-256 digest).
15const HASH_LEN: usize = 32;
16
17/// A parsed ROS 2 type hash in the RIHS01 scheme (REP-2011).
18///
19/// The string form is `RIHS01_` followed by 64 lowercase hex digits, the version prefix plus a
20/// SHA-256 digest of the type's description.
21///
22/// # Examples
23///
24/// ```
25/// use pamoja_ros2::typehash::TypeHash;
26///
27/// // The published hash of std_msgs/msg/String round-trips through parse and display.
28/// let text = "RIHS01_df668c740482bbd48fb39d76a70dfd4bd59db1288021743503259e948f6b1a18";
29/// let hash = TypeHash::parse(text).unwrap();
30/// assert_eq!(hash.to_string(), text);
31/// ```
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub struct TypeHash {
34 digest: [u8; HASH_LEN],
35}
36
37impl TypeHash {
38 /// Parses a RIHS01 hash string.
39 ///
40 /// # Arguments
41 ///
42 /// * `text` - the candidate hash, expected as `RIHS01_` plus 64 lowercase hex digits.
43 ///
44 /// # Returns
45 ///
46 /// `Some(hash)` if `text` is a well-formed RIHS01 string, otherwise `None`.
47 pub fn parse(text: &str) -> Option<Self> {
48 let hex = text.strip_prefix("RIHS01_")?;
49 if hex.len() != HASH_LEN * 2 {
50 return None;
51 }
52 let bytes = hex.as_bytes();
53 let mut digest = [0u8; HASH_LEN];
54 let mut i = 0;
55 while i < HASH_LEN {
56 let hi = hex_value(bytes[i * 2])?;
57 let lo = hex_value(bytes[i * 2 + 1])?;
58 digest[i] = (hi << 4) | lo;
59 i += 1;
60 }
61 Some(Self { digest })
62 }
63
64 /// Returns the raw 32-byte digest.
65 ///
66 /// # Returns
67 ///
68 /// The SHA-256 digest carried by the hash.
69 pub fn digest(&self) -> [u8; HASH_LEN] {
70 self.digest
71 }
72}
73
74impl fmt::Display for TypeHash {
75 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 f.write_str("RIHS01_")?;
77 for byte in self.digest {
78 write!(f, "{byte:02x}")?;
79 }
80 Ok(())
81 }
82}
83
84// Returns the value of a single lowercase-or-digit hex character, or `None` if it is not hex.
85fn hex_value(c: u8) -> Option<u8> {
86 match c {
87 b'0'..=b'9' => Some(c - b'0'),
88 b'a'..=b'f' => Some(c - b'a' + 10),
89 _ => None,
90 }
91}
92
93/// Derives the DDS type name from a ROS 2 interface type.
94///
95/// # Arguments
96///
97/// * `ros_type` - the interface type as `package/namespace/Type`, for example `std_msgs/msg/String`.
98///
99/// # Returns
100///
101/// `Some(dds_name)` such as `std_msgs::msg::dds_::String_`, joining the parts with `::`, inserting
102/// the `dds_` namespace, and suffixing the type with `_`; `None` if `ros_type` is not three
103/// non-empty `/`-separated parts.
104///
105/// # Examples
106///
107/// ```
108/// use pamoja_ros2::typehash::dds_type_name;
109///
110/// assert_eq!(dds_type_name("std_msgs/msg/String").as_deref(), Some("std_msgs::msg::dds_::String_"));
111/// assert_eq!(
112/// dds_type_name("example_interfaces/srv/AddTwoInts").as_deref(),
113/// Some("example_interfaces::srv::dds_::AddTwoInts_"),
114/// );
115/// assert_eq!(dds_type_name("std_msgs/String"), None); // missing the namespace part
116/// ```
117pub fn dds_type_name(ros_type: &str) -> Option<String> {
118 let mut parts = ros_type.split('/');
119 let package = parts.next().filter(|p| !p.is_empty())?;
120 let namespace = parts.next().filter(|p| !p.is_empty())?;
121 let type_name = parts.next().filter(|p| !p.is_empty())?;
122 if parts.next().is_some() {
123 return None;
124 }
125 Some(format!("{package}::{namespace}::dds_::{type_name}_"))
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 #[test]
133 fn parses_and_formats_the_published_hash() {
134 let text = "RIHS01_df668c740482bbd48fb39d76a70dfd4bd59db1288021743503259e948f6b1a18";
135 let hash = TypeHash::parse(text).unwrap();
136 assert_eq!(hash.to_string(), text);
137 assert_eq!(hash.digest()[0], 0xdf);
138 assert_eq!(hash.digest()[31], 0x18);
139 }
140
141 #[test]
142 fn rejects_malformed_hashes() {
143 assert!(TypeHash::parse("RIHS01_tooshort").is_none());
144 assert!(TypeHash::parse("df668c74").is_none()); // missing prefix
145 assert!(TypeHash::parse(
146 "RIHS02_df668c740482bbd48fb39d76a70dfd4bd59db1288021743503259e948f6b1a18"
147 )
148 .is_none());
149 // Uppercase hex is not the canonical lowercase form.
150 assert!(TypeHash::parse(
151 "RIHS01_DF668C740482BBD48FB39D76A70DFD4BD59DB1288021743503259E948F6B1A18"
152 )
153 .is_none());
154 }
155
156 #[test]
157 fn derives_dds_type_names() {
158 assert_eq!(
159 dds_type_name("std_msgs/msg/String").as_deref(),
160 Some("std_msgs::msg::dds_::String_"),
161 );
162 assert_eq!(
163 dds_type_name("geometry_msgs/msg/Twist").as_deref(),
164 Some("geometry_msgs::msg::dds_::Twist_"),
165 );
166 assert_eq!(
167 dds_type_name("example_interfaces/srv/AddTwoInts").as_deref(),
168 Some("example_interfaces::srv::dds_::AddTwoInts_"),
169 );
170 assert_eq!(dds_type_name("std_msgs/String"), None);
171 assert_eq!(dds_type_name("a/b/c/d"), None);
172 }
173}