Skip to main content

canic_core/ids/tree/
mod.rs

1//! Module: ids::tree
2//!
3//! Responsibility: identify declared Tree topology and concrete Fleet-scoped Trees.
4//! Does not own: Tree placement, Registry authority, lifecycle, or ID generation.
5//! Boundary: declared IDs are bounded canonical names; generated IDs use lowercase hex.
6
7use crate::impl_storable_bounded;
8use candid::CandidType;
9use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
10use std::{borrow::Borrow, fmt, str::FromStr};
11use thiserror::Error as ThisError;
12
13const TREE_NAME_MAX_BYTES: usize = 40;
14
15///
16/// TreeSpecId
17///
18/// App-scoped identity of one declared permitted Tree topology.
19///
20
21#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
22#[serde(transparent)]
23pub struct TreeSpecId(String);
24
25impl TreeSpecId {
26    #[must_use]
27    pub const fn as_str(&self) -> &str {
28        self.0.as_str()
29    }
30}
31
32impl fmt::Display for TreeSpecId {
33    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34        formatter.write_str(self.as_str())
35    }
36}
37
38impl AsRef<str> for TreeSpecId {
39    fn as_ref(&self) -> &str {
40        self.as_str()
41    }
42}
43
44impl Borrow<str> for TreeSpecId {
45    fn borrow(&self) -> &str {
46        self.as_str()
47    }
48}
49
50impl CandidType for TreeSpecId {
51    fn _ty() -> candid::types::Type {
52        candid::types::TypeInner::Text.into()
53    }
54
55    fn idl_serialize<S>(&self, serializer: S) -> Result<(), S::Error>
56    where
57        S: candid::types::Serializer,
58    {
59        serializer.serialize_text(self.as_str())
60    }
61}
62
63impl FromStr for TreeSpecId {
64    type Err = TreeSpecIdParseError;
65
66    fn from_str(value: &str) -> Result<Self, Self::Err> {
67        validate_tree_name(value).map_err(TreeSpecIdParseError::from)?;
68        Ok(Self(value.to_string()))
69    }
70}
71
72impl TryFrom<String> for TreeSpecId {
73    type Error = TreeSpecIdParseError;
74
75    fn try_from(value: String) -> Result<Self, Self::Error> {
76        validate_tree_name(&value).map_err(TreeSpecIdParseError::from)?;
77        Ok(Self(value))
78    }
79}
80
81impl<'de> Deserialize<'de> for TreeSpecId {
82    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
83    where
84        D: Deserializer<'de>,
85    {
86        let value = String::deserialize(deserializer)?;
87        Self::try_from(value).map_err(de::Error::custom)
88    }
89}
90
91impl_storable_bounded!(TreeSpecId, 64, false);
92
93///
94/// TreeGroupId
95///
96/// Fleet-scoped identity of one App-declared independently scaled Tree Group.
97///
98
99#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
100#[serde(transparent)]
101pub struct TreeGroupId(String);
102
103impl TreeGroupId {
104    #[must_use]
105    pub const fn as_str(&self) -> &str {
106        self.0.as_str()
107    }
108}
109
110impl fmt::Display for TreeGroupId {
111    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
112        formatter.write_str(self.as_str())
113    }
114}
115
116impl AsRef<str> for TreeGroupId {
117    fn as_ref(&self) -> &str {
118        self.as_str()
119    }
120}
121
122impl Borrow<str> for TreeGroupId {
123    fn borrow(&self) -> &str {
124        self.as_str()
125    }
126}
127
128impl CandidType for TreeGroupId {
129    fn _ty() -> candid::types::Type {
130        candid::types::TypeInner::Text.into()
131    }
132
133    fn idl_serialize<S>(&self, serializer: S) -> Result<(), S::Error>
134    where
135        S: candid::types::Serializer,
136    {
137        serializer.serialize_text(self.as_str())
138    }
139}
140
141impl FromStr for TreeGroupId {
142    type Err = TreeGroupIdParseError;
143
144    fn from_str(value: &str) -> Result<Self, Self::Err> {
145        validate_tree_name(value).map_err(TreeGroupIdParseError::from)?;
146        Ok(Self(value.to_string()))
147    }
148}
149
150impl TryFrom<String> for TreeGroupId {
151    type Error = TreeGroupIdParseError;
152
153    fn try_from(value: String) -> Result<Self, Self::Error> {
154        validate_tree_name(&value).map_err(TreeGroupIdParseError::from)?;
155        Ok(Self(value))
156    }
157}
158
159impl<'de> Deserialize<'de> for TreeGroupId {
160    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
161    where
162        D: Deserializer<'de>,
163    {
164        let value = String::deserialize(deserializer)?;
165        Self::try_from(value).map_err(de::Error::custom)
166    }
167}
168
169impl_storable_bounded!(TreeGroupId, 64, false);
170
171///
172/// TreeId
173///
174/// Generated durable Fleet-scoped identity of one concrete Canister Tree.
175///
176
177#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
178pub struct TreeId([u8; 32]);
179
180impl TreeId {
181    /// Construct an ID from bytes produced by the host's cryptographic generator.
182    ///
183    /// The bytes do not become authoritative until an installation journal
184    /// durably records them before Tree Root creation.
185    #[must_use]
186    pub const fn from_generated_bytes(bytes: [u8; 32]) -> Self {
187        Self(bytes)
188    }
189
190    #[must_use]
191    pub const fn as_bytes(&self) -> &[u8; 32] {
192        &self.0
193    }
194}
195
196impl fmt::Display for TreeId {
197    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
198        for byte in self.0 {
199            write!(formatter, "{byte:02x}")?;
200        }
201        Ok(())
202    }
203}
204
205impl CandidType for TreeId {
206    fn _ty() -> candid::types::Type {
207        candid::types::TypeInner::Text.into()
208    }
209
210    fn idl_serialize<S>(&self, serializer: S) -> Result<(), S::Error>
211    where
212        S: candid::types::Serializer,
213    {
214        serializer.serialize_text(&self.to_string())
215    }
216}
217
218impl FromStr for TreeId {
219    type Err = TreeIdParseError;
220
221    fn from_str(value: &str) -> Result<Self, Self::Err> {
222        if value.len() != 64 {
223            return Err(TreeIdParseError::Length(value.len()));
224        }
225        if !value
226            .bytes()
227            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
228        {
229            return Err(TreeIdParseError::CanonicalHex);
230        }
231
232        let mut bytes = [0; 32];
233        for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
234            bytes[index] = (decode_nibble(pair[0]) << 4) | decode_nibble(pair[1]);
235        }
236        Ok(Self(bytes))
237    }
238}
239
240impl Serialize for TreeId {
241    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
242    where
243        S: Serializer,
244    {
245        serializer.collect_str(self)
246    }
247}
248
249impl<'de> Deserialize<'de> for TreeId {
250    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
251    where
252        D: Deserializer<'de>,
253    {
254        let value = String::deserialize(deserializer)?;
255        value.parse().map_err(de::Error::custom)
256    }
257}
258
259///
260/// TreeSpecIdParseError
261///
262/// Typed rejection for an invalid Tree Spec identifier.
263///
264
265#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
266pub enum TreeSpecIdParseError {
267    #[error("Tree Spec ID must not be empty")]
268    Empty,
269
270    #[error("Tree Spec ID must not exceed {max_bytes} bytes, got {actual_bytes}")]
271    TooLong {
272        max_bytes: usize,
273        actual_bytes: usize,
274    },
275
276    #[error("Tree Spec ID must use only ASCII letters, numbers, '-' or '_'")]
277    InvalidCharacters,
278}
279
280///
281/// TreeGroupIdParseError
282///
283/// Typed rejection for an invalid Tree Group identifier.
284///
285
286#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
287pub enum TreeGroupIdParseError {
288    #[error("Tree Group ID must not be empty")]
289    Empty,
290
291    #[error("Tree Group ID must not exceed {max_bytes} bytes, got {actual_bytes}")]
292    TooLong {
293        max_bytes: usize,
294        actual_bytes: usize,
295    },
296
297    #[error("Tree Group ID must use only ASCII letters, numbers, '-' or '_'")]
298    InvalidCharacters,
299}
300
301///
302/// TreeIdParseError
303///
304/// Typed rejection for a non-canonical concrete Tree identity.
305///
306
307#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
308pub enum TreeIdParseError {
309    #[error("Tree ID must contain exactly 64 characters, got {0}")]
310    Length(usize),
311
312    #[error("Tree ID must contain only lowercase hexadecimal characters")]
313    CanonicalHex,
314}
315
316#[derive(Clone, Copy)]
317enum TreeNameIssue {
318    Empty,
319    TooLong {
320        max_bytes: usize,
321        actual_bytes: usize,
322    },
323    InvalidCharacters,
324}
325
326impl From<TreeNameIssue> for TreeSpecIdParseError {
327    fn from(issue: TreeNameIssue) -> Self {
328        match issue {
329            TreeNameIssue::Empty => Self::Empty,
330            TreeNameIssue::TooLong {
331                max_bytes,
332                actual_bytes,
333            } => Self::TooLong {
334                max_bytes,
335                actual_bytes,
336            },
337            TreeNameIssue::InvalidCharacters => Self::InvalidCharacters,
338        }
339    }
340}
341
342impl From<TreeNameIssue> for TreeGroupIdParseError {
343    fn from(issue: TreeNameIssue) -> Self {
344        match issue {
345            TreeNameIssue::Empty => Self::Empty,
346            TreeNameIssue::TooLong {
347                max_bytes,
348                actual_bytes,
349            } => Self::TooLong {
350                max_bytes,
351                actual_bytes,
352            },
353            TreeNameIssue::InvalidCharacters => Self::InvalidCharacters,
354        }
355    }
356}
357
358fn validate_tree_name(value: &str) -> Result<(), TreeNameIssue> {
359    if value.is_empty() {
360        return Err(TreeNameIssue::Empty);
361    }
362    if value.len() > TREE_NAME_MAX_BYTES {
363        return Err(TreeNameIssue::TooLong {
364            max_bytes: TREE_NAME_MAX_BYTES,
365            actual_bytes: value.len(),
366        });
367    }
368    if !value
369        .bytes()
370        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
371    {
372        return Err(TreeNameIssue::InvalidCharacters);
373    }
374    Ok(())
375}
376
377fn decode_nibble(byte: u8) -> u8 {
378    match byte {
379        b'0'..=b'9' => byte - b'0',
380        b'a'..=b'f' => byte - b'a' + 10,
381        _ => unreachable!("canonical hex was validated before decoding"),
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use crate::cdk::structures::storable::Storable;
388
389    use super::*;
390
391    #[test]
392    fn declared_tree_ids_are_bounded_canonical_names() {
393        let spec = "users".parse::<TreeSpecId>().expect("Tree Spec ID");
394        let group = "users-primary"
395            .parse::<TreeGroupId>()
396            .expect("Tree Group ID");
397
398        assert_eq!(spec.as_str(), "users");
399        assert_eq!(group.as_str(), "users-primary");
400        std::assert_matches!("".parse::<TreeSpecId>(), Err(TreeSpecIdParseError::Empty));
401        std::assert_matches!(
402            "bad/name".parse::<TreeGroupId>(),
403            Err(TreeGroupIdParseError::InvalidCharacters)
404        );
405        std::assert_matches!(
406            "a".repeat(TREE_NAME_MAX_BYTES + 1).parse::<TreeSpecId>(),
407            Err(TreeSpecIdParseError::TooLong { .. })
408        );
409    }
410
411    #[test]
412    fn declared_tree_ids_validate_serde_and_candid_input() {
413        let mut invalid_bytes = Vec::new();
414        ciborium::ser::into_writer("bad/name", &mut invalid_bytes)
415            .expect("serialize invalid Tree Spec ID");
416
417        assert!(ciborium::de::from_reader::<TreeSpecId, _>(invalid_bytes.as_slice()).is_err());
418
419        let candid_bytes = candid::encode_one("projects").expect("encode Tree Group ID");
420        let group = candid::decode_one::<TreeGroupId>(&candid_bytes).expect("decode Tree Group ID");
421        let invalid_candid = candid::encode_one("bad/name").expect("encode invalid Tree Group ID");
422
423        assert_eq!(group.as_str(), "projects");
424        assert!(candid::decode_one::<TreeGroupId>(&invalid_candid).is_err());
425    }
426
427    #[test]
428    fn declared_tree_ids_fit_their_stable_bound() {
429        let spec = "a"
430            .repeat(TREE_NAME_MAX_BYTES)
431            .parse::<TreeSpecId>()
432            .expect("maximum Tree Spec ID");
433        let group = "b"
434            .repeat(TREE_NAME_MAX_BYTES)
435            .parse::<TreeGroupId>()
436            .expect("maximum Tree Group ID");
437        let spec_bytes = spec.to_bytes();
438        let group_bytes = group.to_bytes();
439
440        assert!(spec_bytes.len() <= 64);
441        assert!(group_bytes.len() <= 64);
442        assert_eq!(TreeSpecId::from_bytes(spec_bytes), spec);
443        assert_eq!(TreeGroupId::from_bytes(group_bytes), group);
444    }
445
446    #[test]
447    fn tree_id_uses_exact_canonical_text() {
448        let tree_id = TreeId::from_generated_bytes([0xab; 32]);
449        let text = "ab".repeat(32);
450
451        assert_eq!(tree_id.to_string(), text);
452        assert_eq!(text.parse::<TreeId>(), Ok(tree_id));
453
454        let mut serde_bytes = Vec::new();
455        ciborium::ser::into_writer(&tree_id, &mut serde_bytes).expect("serialize Tree ID");
456        let decoded: TreeId =
457            ciborium::de::from_reader(serde_bytes.as_slice()).expect("decode Tree ID");
458        assert_eq!(decoded, tree_id);
459
460        let candid_bytes = candid::encode_one(tree_id).expect("encode Tree ID");
461        let decoded: TreeId = candid::decode_one(&candid_bytes).expect("decode Tree ID");
462        let invalid_candid =
463            candid::encode_one("A000000000000000000000000000000000000000000000000000000000000000")
464                .expect("encode invalid Tree ID");
465
466        assert_eq!(decoded, tree_id);
467        assert!(candid::decode_one::<TreeId>(&invalid_candid).is_err());
468    }
469
470    #[test]
471    fn tree_id_rejects_noncanonical_text() {
472        std::assert_matches!("ab".parse::<TreeId>(), Err(TreeIdParseError::Length(2)));
473        std::assert_matches!(
474            "A000000000000000000000000000000000000000000000000000000000000000".parse::<TreeId>(),
475            Err(TreeIdParseError::CanonicalHex)
476        );
477    }
478}