Skip to main content

canic_core/ids/component/
mod.rs

1//! Module: ids::component
2//!
3//! Responsibility: identify declared Component topology and concrete Fleet-scoped Components.
4//! Does not own: Component placement, Registry authority, lifecycle, or ID allocation.
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 COMPONENT_NAME_MAX_BYTES: usize = 40;
14
15///
16/// ComponentSpecId
17///
18/// App-scoped identity of one declared permitted Component topology.
19///
20
21#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
22#[serde(transparent)]
23pub struct ComponentSpecId(String);
24
25impl ComponentSpecId {
26    #[must_use]
27    pub const fn as_str(&self) -> &str {
28        self.0.as_str()
29    }
30}
31
32impl fmt::Display for ComponentSpecId {
33    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34        formatter.write_str(self.as_str())
35    }
36}
37
38impl AsRef<str> for ComponentSpecId {
39    fn as_ref(&self) -> &str {
40        self.as_str()
41    }
42}
43
44impl Borrow<str> for ComponentSpecId {
45    fn borrow(&self) -> &str {
46        self.as_str()
47    }
48}
49
50impl CandidType for ComponentSpecId {
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 ComponentSpecId {
64    type Err = ComponentSpecIdParseError;
65
66    fn from_str(value: &str) -> Result<Self, Self::Err> {
67        validate_component_name(value).map_err(ComponentSpecIdParseError::from)?;
68        Ok(Self(value.to_string()))
69    }
70}
71
72impl TryFrom<String> for ComponentSpecId {
73    type Error = ComponentSpecIdParseError;
74
75    fn try_from(value: String) -> Result<Self, Self::Error> {
76        validate_component_name(&value).map_err(ComponentSpecIdParseError::from)?;
77        Ok(Self(value))
78    }
79}
80
81impl<'de> Deserialize<'de> for ComponentSpecId {
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!(ComponentSpecId, 64, false);
92
93///
94/// ComponentInstanceId
95///
96/// Generated durable Fleet-scoped identity of one concrete Component.
97///
98
99#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
100pub struct ComponentInstanceId([u8; 32]);
101
102impl ComponentInstanceId {
103    /// Construct an ID from bytes produced by the owning root's allocator.
104    ///
105    /// The bytes do not become authoritative until the owning Fleet Subnet
106    /// Root durably records the allocation before Component creation.
107    #[must_use]
108    pub const fn from_generated_bytes(bytes: [u8; 32]) -> Self {
109        Self(bytes)
110    }
111
112    #[must_use]
113    pub const fn as_bytes(&self) -> &[u8; 32] {
114        &self.0
115    }
116}
117
118impl fmt::Display for ComponentInstanceId {
119    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
120        for byte in self.0 {
121            write!(formatter, "{byte:02x}")?;
122        }
123        Ok(())
124    }
125}
126
127impl CandidType for ComponentInstanceId {
128    fn _ty() -> candid::types::Type {
129        candid::types::TypeInner::Text.into()
130    }
131
132    fn idl_serialize<S>(&self, serializer: S) -> Result<(), S::Error>
133    where
134        S: candid::types::Serializer,
135    {
136        serializer.serialize_text(&self.to_string())
137    }
138}
139
140impl FromStr for ComponentInstanceId {
141    type Err = ComponentInstanceIdParseError;
142
143    fn from_str(value: &str) -> Result<Self, Self::Err> {
144        if value.len() != 64 {
145            return Err(ComponentInstanceIdParseError::Length(value.len()));
146        }
147        if !value
148            .bytes()
149            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
150        {
151            return Err(ComponentInstanceIdParseError::CanonicalHex);
152        }
153
154        let mut bytes = [0; 32];
155        for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
156            bytes[index] = (decode_nibble(pair[0]) << 4) | decode_nibble(pair[1]);
157        }
158        Ok(Self(bytes))
159    }
160}
161
162impl Serialize for ComponentInstanceId {
163    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
164    where
165        S: Serializer,
166    {
167        serializer.collect_str(self)
168    }
169}
170
171impl<'de> Deserialize<'de> for ComponentInstanceId {
172    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
173    where
174        D: Deserializer<'de>,
175    {
176        let value = String::deserialize(deserializer)?;
177        value.parse().map_err(de::Error::custom)
178    }
179}
180
181impl_storable_bounded!(ComponentInstanceId, 64, false);
182
183///
184/// ComponentSpecIdParseError
185///
186/// Typed rejection for an invalid Component Spec identifier.
187///
188
189#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
190pub enum ComponentSpecIdParseError {
191    #[error("Component Spec ID must not be empty")]
192    Empty,
193
194    #[error("Component Spec ID must not exceed {max_bytes} bytes, got {actual_bytes}")]
195    TooLong {
196        max_bytes: usize,
197        actual_bytes: usize,
198    },
199
200    #[error("Component Spec ID must use only ASCII letters, numbers, '-' or '_'")]
201    InvalidCharacters,
202}
203
204///
205/// ComponentInstanceIdParseError
206///
207/// Typed rejection for a non-canonical concrete Component identity.
208///
209
210#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
211pub enum ComponentInstanceIdParseError {
212    #[error("Component Instance ID must contain exactly 64 characters, got {0}")]
213    Length(usize),
214
215    #[error("Component Instance ID must contain only lowercase hexadecimal characters")]
216    CanonicalHex,
217}
218
219#[derive(Clone, Copy)]
220enum ComponentNameIssue {
221    Empty,
222    TooLong {
223        max_bytes: usize,
224        actual_bytes: usize,
225    },
226    InvalidCharacters,
227}
228
229impl From<ComponentNameIssue> for ComponentSpecIdParseError {
230    fn from(issue: ComponentNameIssue) -> Self {
231        match issue {
232            ComponentNameIssue::Empty => Self::Empty,
233            ComponentNameIssue::TooLong {
234                max_bytes,
235                actual_bytes,
236            } => Self::TooLong {
237                max_bytes,
238                actual_bytes,
239            },
240            ComponentNameIssue::InvalidCharacters => Self::InvalidCharacters,
241        }
242    }
243}
244
245fn validate_component_name(value: &str) -> Result<(), ComponentNameIssue> {
246    if value.is_empty() {
247        return Err(ComponentNameIssue::Empty);
248    }
249    if value.len() > COMPONENT_NAME_MAX_BYTES {
250        return Err(ComponentNameIssue::TooLong {
251            max_bytes: COMPONENT_NAME_MAX_BYTES,
252            actual_bytes: value.len(),
253        });
254    }
255    if !value
256        .bytes()
257        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
258    {
259        return Err(ComponentNameIssue::InvalidCharacters);
260    }
261    Ok(())
262}
263
264fn decode_nibble(byte: u8) -> u8 {
265    match byte {
266        b'0'..=b'9' => byte - b'0',
267        b'a'..=b'f' => byte - b'a' + 10,
268        _ => unreachable!("canonical hex was validated before decoding"),
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use crate::cdk::structures::storable::Storable;
275
276    use super::*;
277
278    #[test]
279    fn component_spec_ids_are_bounded_canonical_names() {
280        let spec = "users"
281            .parse::<ComponentSpecId>()
282            .expect("Component Spec ID");
283
284        assert_eq!(spec.as_str(), "users");
285        std::assert_matches!(
286            "".parse::<ComponentSpecId>(),
287            Err(ComponentSpecIdParseError::Empty)
288        );
289        std::assert_matches!(
290            "bad/name".parse::<ComponentSpecId>(),
291            Err(ComponentSpecIdParseError::InvalidCharacters)
292        );
293        std::assert_matches!(
294            "a".repeat(COMPONENT_NAME_MAX_BYTES + 1)
295                .parse::<ComponentSpecId>(),
296            Err(ComponentSpecIdParseError::TooLong { .. })
297        );
298    }
299
300    #[test]
301    fn component_spec_ids_validate_serde_and_candid_input() {
302        let mut invalid_bytes = Vec::new();
303        ciborium::ser::into_writer("bad/name", &mut invalid_bytes)
304            .expect("serialize invalid Component Spec ID");
305
306        assert!(ciborium::de::from_reader::<ComponentSpecId, _>(invalid_bytes.as_slice()).is_err());
307
308        let candid_bytes = candid::encode_one("projects").expect("encode Component Spec ID");
309        let spec =
310            candid::decode_one::<ComponentSpecId>(&candid_bytes).expect("decode Component Spec ID");
311        let invalid_candid =
312            candid::encode_one("bad/name").expect("encode invalid Component Spec ID");
313
314        assert_eq!(spec.as_str(), "projects");
315        assert!(candid::decode_one::<ComponentSpecId>(&invalid_candid).is_err());
316    }
317
318    #[test]
319    fn component_spec_ids_fit_their_stable_bound() {
320        let spec = "a"
321            .repeat(COMPONENT_NAME_MAX_BYTES)
322            .parse::<ComponentSpecId>()
323            .expect("maximum Component Spec ID");
324        let spec_bytes = spec.to_bytes();
325
326        assert!(spec_bytes.len() <= 64);
327        assert_eq!(ComponentSpecId::from_bytes(spec_bytes), spec);
328    }
329
330    #[test]
331    fn component_instance_id_uses_exact_canonical_text() {
332        let component = ComponentInstanceId::from_generated_bytes([0xab; 32]);
333        let text = "ab".repeat(32);
334
335        assert_eq!(component.to_string(), text);
336        assert_eq!(text.parse::<ComponentInstanceId>(), Ok(component));
337
338        let mut serde_bytes = Vec::new();
339        ciborium::ser::into_writer(&component, &mut serde_bytes)
340            .expect("serialize Component Instance ID");
341        let decoded: ComponentInstanceId = ciborium::de::from_reader(serde_bytes.as_slice())
342            .expect("decode Component Instance ID");
343        assert_eq!(decoded, component);
344
345        let candid_bytes = candid::encode_one(component).expect("encode Component Instance ID");
346        let decoded: ComponentInstanceId =
347            candid::decode_one(&candid_bytes).expect("decode Component Instance ID");
348        let invalid_candid =
349            candid::encode_one("A000000000000000000000000000000000000000000000000000000000000000")
350                .expect("encode invalid Component Instance ID");
351
352        assert_eq!(decoded, component);
353        assert!(candid::decode_one::<ComponentInstanceId>(&invalid_candid).is_err());
354    }
355
356    #[test]
357    fn component_instance_id_rejects_noncanonical_text() {
358        std::assert_matches!(
359            "ab".parse::<ComponentInstanceId>(),
360            Err(ComponentInstanceIdParseError::Length(2))
361        );
362        std::assert_matches!(
363            "A000000000000000000000000000000000000000000000000000000000000000"
364                .parse::<ComponentInstanceId>(),
365            Err(ComponentInstanceIdParseError::CanonicalHex)
366        );
367    }
368}