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