canic_core/ids/component/
mod.rs1use 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#[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
102pub struct ComponentInstanceId([u8; 32]);
103
104impl ComponentInstanceId {
105 #[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 #[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#[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#[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}