Skip to main content

tatara_process/
identity.rs

1//! Content-addressable identity — deterministic naming from spec.
2//!
3//! Every Process gets a 128-bit BLAKE3 hash of its canonical spec,
4//! base32-encoded (26 chars) using an unambiguous alphabet (no 0/1/o/l).
5//!
6//! Ported from convergence-controller/src/identity.rs, generalized over
7//! any `Serialize` spec (not just `ConvergenceProcessSpec`).
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12/// Length of the truncated hash in bytes (128 bits of collision space).
13const HASH_BYTES: usize = 16;
14
15/// Crockford base32 alphabet — 32 chars, excludes `i/l/o/u` to remove the
16/// most common visual collisions (1/l/i, 0/o, u/v). Matches Douglas
17/// Crockford's published base32 spec.
18const BASE32_ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz";
19
20/// Resolved identity — human-assigned or content-derived.
21#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
22pub struct Identity {
23    /// The name used in the PID path (e.g., `"seph"` or `"a3f7x9kp2bfhmnqr5tvwxyzabc"`).
24    pub name: String,
25    /// Canonical-JSON BLAKE3 hash, 26-char base32. Always computed, even when overridden.
26    pub content_hash: String,
27    /// True when `name` came from `spec.identity.nameOverride`.
28    pub name_override: bool,
29}
30
31/// Compute the content hash of any serializable spec.
32pub fn content_hash<T: Serialize>(spec: &T) -> String {
33    let canonical = serde_json::to_vec(spec).unwrap_or_default();
34    let digest = blake3::hash(&canonical);
35    base32_encode(&digest.as_bytes()[..HASH_BYTES])
36}
37
38/// Derive an identity from a spec + optional human override.
39///
40/// Override wins when non-empty; the content hash is always computed for integrity.
41pub fn derive_identity<T: Serialize>(spec: &T, name_override: Option<&str>) -> Identity {
42    let hash = content_hash(spec);
43    match name_override.map(str::trim).filter(|s| !s.is_empty()) {
44        Some(name) => Identity {
45            name: name.to_string(),
46            content_hash: hash,
47            name_override: true,
48        },
49        None => Identity {
50            name: hash.clone(),
51            content_hash: hash,
52            name_override: false,
53        },
54    }
55}
56
57/// Format a hierarchical process address: `{identity}.{pid_path}`.
58///
59/// Examples: `"seph.1"`, `"a3f7x9kp.1.1"`, `"seph.1.7.2"`.
60pub fn format_process_address(identity: &Identity, pid_path: &str) -> String {
61    format!("{}.{}", identity.name, pid_path)
62}
63
64fn base32_encode(bytes: &[u8]) -> String {
65    let mut out = String::with_capacity((bytes.len() * 8).div_ceil(5));
66    let mut bits: u64 = 0;
67    let mut n: u32 = 0;
68    for &b in bytes {
69        bits = (bits << 8) | u64::from(b);
70        n += 8;
71        while n >= 5 {
72            n -= 5;
73            out.push(BASE32_ALPHABET[((bits >> n) & 0x1f) as usize] as char);
74        }
75    }
76    if n > 0 {
77        out.push(BASE32_ALPHABET[((bits << (5 - n)) & 0x1f) as usize] as char);
78    }
79    out
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[derive(Serialize)]
87    struct Dummy {
88        a: u32,
89        b: &'static str,
90    }
91
92    #[test]
93    fn content_hash_is_deterministic() {
94        let s = Dummy { a: 1, b: "x" };
95        assert_eq!(content_hash(&s), content_hash(&s));
96    }
97
98    #[test]
99    fn content_hash_differs_for_different_input() {
100        assert_ne!(
101            content_hash(&Dummy { a: 1, b: "x" }),
102            content_hash(&Dummy { a: 2, b: "x" })
103        );
104    }
105
106    #[test]
107    fn content_hash_length_is_26() {
108        assert_eq!(content_hash(&Dummy { a: 0, b: "" }).len(), 26);
109    }
110
111    #[test]
112    fn alphabet_excludes_ambiguous() {
113        // Crockford base32 excludes i/l/o/u to eliminate visual collisions.
114        let h = content_hash(&Dummy {
115            a: u32::MAX,
116            b: "qwertyuiopasdfghjklzxcvbnm",
117        });
118        for c in h.chars() {
119            assert!(!matches!(c, 'i' | 'l' | 'o' | 'u'), "saw {c}");
120        }
121    }
122
123    #[test]
124    fn override_wins() {
125        let id = derive_identity(&Dummy { a: 1, b: "x" }, Some("seph"));
126        assert_eq!(id.name, "seph");
127        assert!(id.name_override);
128        assert_eq!(id.content_hash.len(), 26);
129    }
130
131    #[test]
132    fn empty_override_falls_back_to_hash() {
133        let id = derive_identity(&Dummy { a: 1, b: "x" }, Some("   "));
134        assert!(!id.name_override);
135        assert_eq!(id.name, id.content_hash);
136    }
137
138    #[test]
139    fn address_format() {
140        let id = Identity {
141            name: "seph".into(),
142            content_hash: "a".repeat(26),
143            name_override: true,
144        };
145        assert_eq!(format_process_address(&id, "1.7"), "seph.1.7");
146    }
147}