telepath_wire/cmd_id.rs
1//! Command ID derivation for the Telepath Command Discovery Protocol.
2//!
3//! A `cmd_id` is a stable 16-bit identifier derived from the command's
4//! human-readable signature. The derivation uses FNV-1a 32-bit hashed over
5//! the canonical pre-image, then XOR-folded to 16 bits.
6//!
7//! ## Pre-image
8//!
9//! ```text
10//! pre-image = name || 0x1F || args_type || 0x1F || ret_type
11//! ```
12//!
13//! `0x1F` (ASCII Unit Separator) cannot appear in Rust identifiers or type
14//! paths, so it is collision-free as a field delimiter.
15//!
16//! ## Stand-in for a schema fingerprint
17//!
18//! `args_type` and `ret_type` are the textual Rust type names as seen by the
19//! `#[command]` proc-macro (`syn`-derived token strings). This is a *textual*
20//! canonicalization, not a true postcard schema digest:
21//!
22//! - Renaming `struct Foo { x: u8 }` to `struct Bar { x: u8 }` **changes** the ID.
23//! - Reordering fields inside `Foo` does **not** change the ID.
24//!
25//! The ID encodes textual type names, not a structural postcard-schema digest.
26//! A future cmd_id v2 could incorporate a real schema hash (see issue #3).
27//!
28//! ## 0x0000 reservation
29//!
30//! `CMD_ID_DISCOVERY` (0x0000) is reserved for the Command Discovery Protocol.
31//! If the raw hash collides with it, [`derive_cmd_id`] rehashes with a `0xFF`
32//! salt byte appended, producing a deterministic non-zero fallback.
33
34use crate::{CMD_ID_DISCOVERY, CMD_ID_METRICS};
35
36/// Delimiter byte placed between pre-image fields (ASCII Unit Separator, 0x1F).
37///
38/// This byte cannot appear in Rust identifiers or type paths, making it
39/// collision-free as a separator between the name, args, and return type fields.
40pub const CMD_ID_FIELD_SEP: u8 = 0x1F;
41
42// FNV-1a parameters (32-bit).
43const FNV_OFFSET_BASIS: u32 = 0x811c9dc5;
44const FNV_PRIME: u32 = 0x01000193;
45
46/// Continues an FNV-1a 32-bit hash over `bytes`, starting from `hash`.
47const fn fnv1a_32_continue(mut hash: u32, bytes: &[u8]) -> u32 {
48 let mut i = 0;
49 while i < bytes.len() {
50 hash ^= bytes[i] as u32;
51 hash = hash.wrapping_mul(FNV_PRIME);
52 i += 1;
53 }
54 hash
55}
56
57/// FNV-1a 32-bit hash of `bytes` from the standard offset basis.
58const fn fnv1a_32(bytes: &[u8]) -> u32 {
59 fnv1a_32_continue(FNV_OFFSET_BASIS, bytes)
60}
61
62/// XOR-folds a 32-bit value to 16 bits by XOR-ing the two halves.
63///
64/// Preferred over truncation because it preserves avalanche across the full
65/// input range and reduces low-bit bias inherent in multiplicative hashes.
66const fn xor_fold(h: u32) -> u16 {
67 ((h >> 16) as u16) ^ (h as u16)
68}
69
70/// FNV-1a 32-bit, XOR-folded to 16 bits.
71///
72/// `const fn` — usable from both proc-macro (std) and `no_std` firmware contexts
73/// without any additional dependencies.
74///
75/// Known test vectors (from <http://www.isthe.com/chongo/tech/comp/fnv/>):
76/// - `fnv1a_16(b"") == 0x1cd9`
77/// - `fnv1a_16(b"a") == 0xcd20`
78/// - `fnv1a_16(b"foobar") == 0x46f4`
79pub const fn fnv1a_16(bytes: &[u8]) -> u16 {
80 xor_fold(fnv1a_32(bytes))
81}
82
83/// Derives a stable 16-bit `cmd_id` from the command's textual signature.
84///
85/// Pre-image: `name || 0x1F || args_type || 0x1F || ret_type` (UTF-8 bytes).
86///
87/// The three segments are hashed sequentially without heap allocation, so this
88/// function is safe to call from `no_std` firmware and `const` proc-macro contexts.
89///
90/// If the result would equal [`CMD_ID_DISCOVERY`] (0x0000) or
91/// [`CMD_ID_METRICS`] (0xFFFE), the function loops over descending salt bytes
92/// (`0xFF`, `0xFE`, …) until the result avoids all reserved IDs —
93/// guaranteeing that neither reserved value is ever returned.
94pub const fn derive_cmd_id(name: &str, args_type: &str, ret_type: &str) -> u16 {
95 let h = fnv1a_32_continue(FNV_OFFSET_BASIS, name.as_bytes());
96 let h = fnv1a_32_continue(h, &[CMD_ID_FIELD_SEP]);
97 let h = fnv1a_32_continue(h, args_type.as_bytes());
98 let h = fnv1a_32_continue(h, &[CMD_ID_FIELD_SEP]);
99 let h = fnv1a_32_continue(h, ret_type.as_bytes());
100 let mut id = xor_fold(h);
101 let mut h = h;
102 let mut salt = 0xFFu8;
103 while id == CMD_ID_DISCOVERY || id == CMD_ID_METRICS {
104 h = fnv1a_32_continue(h, &[salt]);
105 id = xor_fold(h);
106 if salt > 0 {
107 salt -= 1;
108 } else {
109 return 0x0001;
110 }
111 }
112 id
113}
114
115// ---------------------------------------------------------------------------
116// Tests
117// ---------------------------------------------------------------------------
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use crate::{CMD_ID_DISCOVERY, CMD_ID_METRICS};
123
124 // Known-vector source: http://www.isthe.com/chongo/tech/comp/fnv/
125 // (Landon Curt Noll, FNV reference page — "FNV-1a 32-bit test vectors")
126
127 #[test]
128 fn fnv1a_16_empty() {
129 // fnv1a_32(b"") == 0x811c9dc5 (offset basis, no bytes processed)
130 // XOR fold: 0x811c ^ 0x9dc5 = 0x1cd9
131 assert_eq!(fnv1a_16(b""), 0x1cd9);
132 }
133
134 #[test]
135 fn fnv1a_32_known_vector_a() {
136 assert_eq!(fnv1a_32(b"a"), 0xe40c292c);
137 }
138
139 #[test]
140 fn fnv1a_16_known_vector_a() {
141 // 0xe40c ^ 0x292c = 0xcd20
142 assert_eq!(fnv1a_16(b"a"), 0xcd20);
143 }
144
145 #[test]
146 fn fnv1a_32_known_vector_foobar() {
147 assert_eq!(fnv1a_32(b"foobar"), 0xbf9cf968);
148 }
149
150 #[test]
151 fn fnv1a_16_known_vector_foobar() {
152 // 0xbf9c ^ 0xf968 = 0x46f4
153 assert_eq!(fnv1a_16(b"foobar"), 0x46f4);
154 }
155
156 #[test]
157 fn derive_cmd_id_ping_smoke() {
158 let id = derive_cmd_id("ping", "()", "u32");
159 assert_ne!(id, CMD_ID_DISCOVERY);
160 // Stable across calls.
161 assert_eq!(id, derive_cmd_id("ping", "()", "u32"));
162 }
163
164 #[test]
165 fn derive_cmd_id_differs_on_name() {
166 assert_ne!(
167 derive_cmd_id("ping", "()", "u32"),
168 derive_cmd_id("pong", "()", "u32"),
169 );
170 }
171
172 #[test]
173 fn derive_cmd_id_differs_on_ret() {
174 assert_ne!(
175 derive_cmd_id("f", "()", "u32"),
176 derive_cmd_id("f", "()", "u64"),
177 );
178 }
179
180 #[test]
181 fn derive_cmd_id_differs_on_args() {
182 assert_ne!(
183 derive_cmd_id("f", "(u8,)", "u32"),
184 derive_cmd_id("f", "(u16,)", "u32"),
185 );
186 }
187
188 #[test]
189 fn cmd_id_metrics_value() {
190 assert_eq!(CMD_ID_METRICS, 0xFFFE);
191 }
192
193 #[test]
194 fn derive_cmd_id_never_returns_metrics_id() {
195 let cases = [
196 ("ping", "()", "u32"),
197 ("get_value", "(u8,)", "u32"),
198 ("set_value", "(u8, u16)", "Result<(), ()>"),
199 ("", "", ""),
200 ("a", "b", "c"),
201 ];
202 for (name, args, ret) in cases {
203 assert_ne!(
204 derive_cmd_id(name, args, ret),
205 CMD_ID_METRICS,
206 "({name:?}, {args:?}, {ret:?}) mapped to reserved CMD_ID_METRICS",
207 );
208 }
209 }
210
211 #[test]
212 fn derive_cmd_id_never_returns_discovery_id() {
213 let cases = [
214 ("ping", "()", "u32"),
215 ("get_value", "(u8,)", "u32"),
216 ("set_value", "(u8, u16)", "Result<(), ()>"),
217 ("", "", ""),
218 ("a", "b", "c"),
219 ];
220 for (name, args, ret) in cases {
221 assert_ne!(
222 derive_cmd_id(name, args, ret),
223 CMD_ID_DISCOVERY,
224 "({name:?}, {args:?}, {ret:?}) mapped to reserved CMD_ID_DISCOVERY",
225 );
226 }
227 }
228
229 #[test]
230 fn const_context() {
231 const _: u16 = fnv1a_16(b"x");
232 const _: u16 = derive_cmd_id("f", "()", "u32");
233 }
234
235 /// Searches for an input whose raw pre-guard hash is 0x0000, then verifies
236 /// that [`derive_cmd_id`] returns a non-zero ID via the salt-rehash path.
237 ///
238 /// Finding a collision is probabilistic (~1/65536 per input). If none is
239 /// found within the search budget, the test passes trivially — the policy
240 /// is still exercised by the implementation whenever a real collision occurs.
241 #[test]
242 fn derive_cmd_id_salt_rehash_when_raw_is_zero() {
243 for i in 0u32..200_000 {
244 let mut buf = [0u8; 10];
245 let len = encode_decimal(i, &mut buf);
246
247 // Mirror derive_cmd_id internals to inspect the pre-guard hash.
248 let h = fnv1a_32_continue(FNV_OFFSET_BASIS, &buf[..len]);
249 let h = fnv1a_32_continue(h, &[CMD_ID_FIELD_SEP]);
250 let h = fnv1a_32_continue(h, b"()");
251 let h = fnv1a_32_continue(h, &[CMD_ID_FIELD_SEP]);
252 let h = fnv1a_32_continue(h, b"u32");
253
254 if xor_fold(h) == CMD_ID_DISCOVERY {
255 // The salt rehash must also avoid 0x0000.
256 let salted = xor_fold(fnv1a_32_continue(h, &[0xFF]));
257 assert_ne!(salted, CMD_ID_DISCOVERY);
258 return;
259 }
260 }
261 // No collision found — trivially passing (P(miss) ≈ 37%).
262 }
263
264 /// Encodes `n` as ASCII decimal bytes into `buf`, returning the byte count used.
265 fn encode_decimal(mut n: u32, buf: &mut [u8; 10]) -> usize {
266 if n == 0 {
267 buf[0] = b'0';
268 return 1;
269 }
270 let mut end = 0;
271 while n > 0 {
272 buf[end] = b'0' + (n % 10) as u8;
273 end += 1;
274 n /= 10;
275 }
276 buf[..end].reverse();
277 end
278 }
279}