Skip to main content

canic_core/ids/component_deployment/
mod.rs

1//! Module: ids::component_deployment
2//!
3//! Responsibility: identify declared Component composition and concrete group placements.
4//! Does not own: group compilation, placement policy, service authority, or runtime parentage.
5//! Boundary: source identities and member paths are bounded canonical values at decode time.
6
7use 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    /// App-scoped identity of one reusable Component Group declaration.
97    ComponentGroupSpecId,
98    "Component Group Spec ID"
99);
100
101bounded_deployment_name!(
102    /// App-scoped identity of one independently scalable Component Group deployment.
103    ComponentGroupDeploymentId,
104    "Component Group deployment ID"
105);
106
107bounded_deployment_name!(
108    /// Member name unique within one Component Group declaration.
109    ComponentGroupMemberId,
110    "Component Group member ID"
111);
112
113bounded_deployment_name!(
114    /// App-scoped identity of one declared Fleet service endpoint set.
115    FleetServiceId,
116    "Fleet Service ID"
117);
118
119/// Durable Fleet-scoped identity of one materialized Component Group deployment copy.
120#[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/// Canonical inclusion path identifying one flattened Component occurrence.
132#[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/// Typed rejection for an invalid Component deployment identifier.
188#[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/// Typed rejection for an invalid flattened Component Group member path.
205#[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}