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;
14/// Maximum member segments in one canonical flattened Component Group path.
15pub 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    /// App-scoped identity of one reusable Component Group declaration.
98    ComponentGroupSpecId,
99    "Component Group Spec ID"
100);
101
102bounded_deployment_name!(
103    /// App-scoped identity of one independently scalable Component Group deployment.
104    ComponentGroupDeploymentId,
105    "Component Group deployment ID"
106);
107
108bounded_deployment_name!(
109    /// Member name unique within one Component Group declaration.
110    ComponentGroupMemberId,
111    "Component Group member ID"
112);
113
114bounded_deployment_name!(
115    /// App-scoped identity of one declared Fleet service endpoint set.
116    FleetServiceId,
117    "Fleet Service ID"
118);
119
120/// SHA-256 identity of one canonical Component deployment configuration.
121#[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/// Durable Fleet-scoped identity of one materialized Component Group deployment copy.
156#[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/// Canonical inclusion path identifying one flattened Component occurrence.
168#[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/// Typed rejection for an invalid Component deployment identifier.
224#[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/// Typed rejection for an invalid flattened Component Group member path.
241#[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}