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