canic_core/ids/component_deployment/
mod.rs1use crate::impl_storable_bounded;
8use candid::CandidType;
9use serde::{Deserialize, Deserializer, Serialize, de};
10use std::{borrow::Borrow, fmt, str::FromStr};
11use thiserror::Error as ThisError;
12
13const COMPONENT_DEPLOYMENT_NAME_MAX_BYTES: usize = 40;
14pub const COMPONENT_GROUP_MEMBER_PATH_MAX_SEGMENTS: usize = 16;
16const COMPONENT_GROUP_MEMBER_PATH_MAX_BYTES: usize =
17 8 + COMPONENT_GROUP_MEMBER_PATH_MAX_SEGMENTS * (8 + COMPONENT_DEPLOYMENT_NAME_MAX_BYTES);
18
19macro_rules! bounded_deployment_name {
20 ($(#[$meta:meta])* $name:ident, $kind:literal) => {
21 $(#[$meta])*
22 #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
23 #[serde(transparent)]
24 pub struct $name(String);
25
26 impl $name {
27 #[must_use]
28 pub const fn as_str(&self) -> &str {
29 self.0.as_str()
30 }
31 }
32
33 impl fmt::Display for $name {
34 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
35 formatter.write_str(self.as_str())
36 }
37 }
38
39 impl AsRef<str> for $name {
40 fn as_ref(&self) -> &str {
41 self.as_str()
42 }
43 }
44
45 impl Borrow<str> for $name {
46 fn borrow(&self) -> &str {
47 self.as_str()
48 }
49 }
50
51 impl CandidType for $name {
52 fn _ty() -> candid::types::Type {
53 candid::types::TypeInner::Text.into()
54 }
55
56 fn idl_serialize<S>(&self, serializer: S) -> Result<(), S::Error>
57 where
58 S: candid::types::Serializer,
59 {
60 serializer.serialize_text(self.as_str())
61 }
62 }
63
64 impl FromStr for $name {
65 type Err = ComponentDeploymentIdParseError;
66
67 fn from_str(value: &str) -> Result<Self, Self::Err> {
68 validate_component_deployment_name(value, $kind)?;
69 Ok(Self(value.to_string()))
70 }
71 }
72
73 impl TryFrom<String> for $name {
74 type Error = ComponentDeploymentIdParseError;
75
76 fn try_from(value: String) -> Result<Self, Self::Error> {
77 validate_component_deployment_name(&value, $kind)?;
78 Ok(Self(value))
79 }
80 }
81
82 impl<'de> Deserialize<'de> for $name {
83 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
84 where
85 D: Deserializer<'de>,
86 {
87 let value = String::deserialize(deserializer)?;
88 Self::try_from(value).map_err(de::Error::custom)
89 }
90 }
91
92 impl_storable_bounded!($name, 64, false);
93 };
94}
95
96bounded_deployment_name!(
97 ComponentGroupSpecId,
99 "Component Group Spec ID"
100);
101
102bounded_deployment_name!(
103 ComponentGroupDeploymentId,
105 "Component Group deployment ID"
106);
107
108bounded_deployment_name!(
109 ComponentGroupMemberId,
111 "Component Group member ID"
112);
113
114bounded_deployment_name!(
115 FleetServiceId,
117 "Fleet Service ID"
118);
119
120#[derive(
122 CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
123)]
124#[serde(transparent)]
125pub struct ComponentDeploymentConfigurationDigest([u8; 32]);
126
127impl ComponentDeploymentConfigurationDigest {
128 #[must_use]
129 pub const fn from_bytes(bytes: [u8; 32]) -> Self {
130 Self(bytes)
131 }
132
133 #[must_use]
134 pub const fn as_bytes(&self) -> &[u8; 32] {
135 &self.0
136 }
137
138 #[must_use]
139 pub const fn into_bytes(self) -> [u8; 32] {
140 self.0
141 }
142}
143
144impl fmt::Display for ComponentDeploymentConfigurationDigest {
145 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
146 for byte in self.0 {
147 write!(formatter, "{byte:02x}")?;
148 }
149 Ok(())
150 }
151}
152
153impl_storable_bounded!(ComponentDeploymentConfigurationDigest, 128, false);
154
155#[derive(
157 CandidType, Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
158)]
159#[serde(deny_unknown_fields)]
160pub struct ComponentGroupPlacementId {
161 pub deployment: ComponentGroupDeploymentId,
162 pub ordinal: u32,
163}
164
165impl_storable_bounded!(ComponentGroupPlacementId, 128, false);
166
167#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
169#[serde(transparent)]
170pub struct ComponentGroupMemberPath(Vec<ComponentGroupMemberId>);
171
172impl ComponentGroupMemberPath {
173 #[must_use]
174 pub fn as_slice(&self) -> &[ComponentGroupMemberId] {
175 &self.0
176 }
177
178 #[must_use]
179 pub const fn len(&self) -> usize {
180 self.0.len()
181 }
182
183 #[must_use]
184 pub const fn is_empty(&self) -> bool {
185 self.0.is_empty()
186 }
187}
188
189impl TryFrom<Vec<ComponentGroupMemberId>> for ComponentGroupMemberPath {
190 type Error = ComponentGroupMemberPathError;
191
192 fn try_from(value: Vec<ComponentGroupMemberId>) -> Result<Self, Self::Error> {
193 validate_component_group_member_path(&value)?;
194 Ok(Self(value))
195 }
196}
197
198impl CandidType for ComponentGroupMemberPath {
199 fn _ty() -> candid::types::Type {
200 <Vec<ComponentGroupMemberId> as CandidType>::_ty()
201 }
202
203 fn idl_serialize<S>(&self, serializer: S) -> Result<(), S::Error>
204 where
205 S: candid::types::Serializer,
206 {
207 self.0.idl_serialize(serializer)
208 }
209}
210
211impl<'de> Deserialize<'de> for ComponentGroupMemberPath {
212 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
213 where
214 D: Deserializer<'de>,
215 {
216 let value = Vec::<ComponentGroupMemberId>::deserialize(deserializer)?;
217 Self::try_from(value).map_err(de::Error::custom)
218 }
219}
220
221impl_storable_bounded!(ComponentGroupMemberPath, 1_024, false);
222
223#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
225pub enum ComponentDeploymentIdParseError {
226 #[error("{kind} must not be empty")]
227 Empty { kind: &'static str },
228
229 #[error("{kind} must not exceed {max_bytes} bytes, got {actual_bytes}")]
230 TooLong {
231 kind: &'static str,
232 max_bytes: usize,
233 actual_bytes: usize,
234 },
235
236 #[error("{kind} must use only ASCII letters, numbers, '-' or '_'")]
237 InvalidCharacters { kind: &'static str },
238}
239
240#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
242pub enum ComponentGroupMemberPathError {
243 #[error("Component Group member path must not be empty")]
244 Empty,
245
246 #[error("Component Group member path must not exceed {max} segments, got {actual}")]
247 TooDeep { max: usize, actual: usize },
248
249 #[error("Component Group member path must not exceed {max} canonical bytes, got {actual}")]
250 TooLong { max: usize, actual: usize },
251}
252
253fn validate_component_deployment_name(
254 value: &str,
255 kind: &'static str,
256) -> Result<(), ComponentDeploymentIdParseError> {
257 if value.is_empty() {
258 return Err(ComponentDeploymentIdParseError::Empty { kind });
259 }
260 if value.len() > COMPONENT_DEPLOYMENT_NAME_MAX_BYTES {
261 return Err(ComponentDeploymentIdParseError::TooLong {
262 kind,
263 max_bytes: COMPONENT_DEPLOYMENT_NAME_MAX_BYTES,
264 actual_bytes: value.len(),
265 });
266 }
267 if !value
268 .bytes()
269 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
270 {
271 return Err(ComponentDeploymentIdParseError::InvalidCharacters { kind });
272 }
273 Ok(())
274}
275
276fn validate_component_group_member_path(
277 value: &[ComponentGroupMemberId],
278) -> Result<(), ComponentGroupMemberPathError> {
279 if value.is_empty() {
280 return Err(ComponentGroupMemberPathError::Empty);
281 }
282 if value.len() > COMPONENT_GROUP_MEMBER_PATH_MAX_SEGMENTS {
283 return Err(ComponentGroupMemberPathError::TooDeep {
284 max: COMPONENT_GROUP_MEMBER_PATH_MAX_SEGMENTS,
285 actual: value.len(),
286 });
287 }
288 let encoded_bytes = value
289 .iter()
290 .fold(8_usize, |bytes, member| bytes + 8 + member.as_str().len());
291 if encoded_bytes > COMPONENT_GROUP_MEMBER_PATH_MAX_BYTES {
292 return Err(ComponentGroupMemberPathError::TooLong {
293 max: COMPONENT_GROUP_MEMBER_PATH_MAX_BYTES,
294 actual: encoded_bytes,
295 });
296 }
297 Ok(())
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use crate::cdk::structures::storable::Storable;
304
305 #[test]
306 fn deployment_names_are_bounded_canonical_identifiers() {
307 let group = "project_data-cell"
308 .parse::<ComponentGroupSpecId>()
309 .expect("Component Group Spec ID");
310 let deployment = "project_data_cells"
311 .parse::<ComponentGroupDeploymentId>()
312 .expect("Component Group deployment ID");
313 let member = "project_hub"
314 .parse::<ComponentGroupMemberId>()
315 .expect("Component Group member ID");
316 let service = "project-hubs"
317 .parse::<FleetServiceId>()
318 .expect("Fleet Service ID");
319
320 assert_eq!(group.as_str(), "project_data-cell");
321 assert_eq!(deployment.as_str(), "project_data_cells");
322 assert_eq!(member.as_str(), "project_hub");
323 assert_eq!(service.as_str(), "project-hubs");
324 assert!("".parse::<ComponentGroupSpecId>().is_err());
325 assert!("bad/name".parse::<ComponentGroupDeploymentId>().is_err());
326 assert!("service.name".parse::<FleetServiceId>().is_err());
327 assert!(
328 "a".repeat(COMPONENT_DEPLOYMENT_NAME_MAX_BYTES + 1)
329 .parse::<ComponentGroupMemberId>()
330 .is_err()
331 );
332 }
333
334 #[test]
335 fn deployment_names_validate_serde_and_candid_input() {
336 let invalid_candid = candid::encode_one("bad/name").expect("encode invalid service ID");
337 let invalid_cbor = {
338 let mut bytes = Vec::new();
339 ciborium::ser::into_writer("bad/name", &mut bytes).expect("encode invalid service ID");
340 bytes
341 };
342
343 assert!(candid::decode_one::<FleetServiceId>(&invalid_candid).is_err());
344 assert!(ciborium::de::from_reader::<FleetServiceId, _>(invalid_cbor.as_slice()).is_err());
345 }
346
347 #[test]
348 fn configuration_digest_preserves_exact_bytes_and_hex_boundary() {
349 let digest = ComponentDeploymentConfigurationDigest::from_bytes([0xab; 32]);
350 let encoded = candid::encode_one(digest).expect("encode configuration digest");
351 let decoded: ComponentDeploymentConfigurationDigest =
352 candid::decode_one(&encoded).expect("decode configuration digest");
353
354 assert_eq!(decoded, digest);
355 assert_eq!(digest.as_bytes(), &[0xab; 32]);
356 assert_eq!(digest.to_string(), "ab".repeat(32));
357 assert!(digest.to_bytes().len() <= 128);
358 }
359
360 #[test]
361 fn placement_identity_binds_deployment_and_ordinal() {
362 let placement = ComponentGroupPlacementId {
363 deployment: "project_data_cells"
364 .parse()
365 .expect("Component Group deployment ID"),
366 ordinal: 7,
367 };
368 let candid = candid::encode_one(&placement).expect("encode placement identity");
369 let decoded: ComponentGroupPlacementId =
370 candid::decode_one(&candid).expect("decode placement identity");
371
372 assert_eq!(decoded, placement);
373 assert!(placement.to_bytes().len() <= 128);
374 }
375
376 #[test]
377 fn member_paths_preserve_occurrence_order_and_reject_invalid_depth() {
378 let path = ComponentGroupMemberPath::try_from(vec![
379 "databases".parse().expect("group member"),
380 "database_a".parse().expect("Component member"),
381 ])
382 .expect("member path");
383 let candid = candid::encode_one(&path).expect("encode member path");
384 let decoded: ComponentGroupMemberPath =
385 candid::decode_one(&candid).expect("decode member path");
386
387 assert_eq!(decoded, path);
388 assert_eq!(path.len(), 2);
389 assert_eq!(path.as_slice()[0].as_str(), "databases");
390 assert_eq!(path.as_slice()[1].as_str(), "database_a");
391 assert!(ComponentGroupMemberPath::try_from(Vec::new()).is_err());
392 assert!(
393 ComponentGroupMemberPath::try_from(
394 (0..=COMPONENT_GROUP_MEMBER_PATH_MAX_SEGMENTS)
395 .map(|index| format!("member_{index}").parse().expect("group member"))
396 .collect::<Vec<_>>()
397 )
398 .is_err()
399 );
400 }
401
402 #[test]
403 fn maximum_member_path_fits_its_stable_bound() {
404 let member = "a"
405 .repeat(COMPONENT_DEPLOYMENT_NAME_MAX_BYTES)
406 .parse::<ComponentGroupMemberId>()
407 .expect("maximum group member");
408 let path = ComponentGroupMemberPath::try_from(vec![
409 member;
410 COMPONENT_GROUP_MEMBER_PATH_MAX_SEGMENTS
411 ])
412 .expect("maximum member path");
413 let bytes = path.to_bytes();
414
415 assert!(bytes.len() <= 1_024);
416 assert_eq!(ComponentGroupMemberPath::from_bytes(bytes), path);
417 }
418}