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