Skip to main content

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}