Skip to main content

canic_core/ids/fleet/
mod.rs

1//! Module: ids::fleet
2//!
3//! Responsibility: identify one installed Fleet independently of its display name.
4//! Does not own: Fleet ID generation, host catalog publication, or activation state.
5//! Boundary: IDs use canonical lowercase hexadecimal text; names are validated labels.
6
7use super::{AppId, CanonicalNetworkId};
8use candid::CandidType;
9use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
10use std::{fmt, str::FromStr};
11use thiserror::Error as ThisError;
12
13const FLEET_NAME_MAX_BYTES: usize = 40;
14
15///
16/// FleetId
17///
18/// Generated durable identity for one Fleet.
19///
20
21#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
22pub struct FleetId([u8; 32]);
23
24impl FleetId {
25    /// Construct an ID from bytes produced by the host's cryptographic generator.
26    ///
27    /// The bytes do not become authoritative until the activation journal
28    /// durably records them.
29    #[must_use]
30    pub const fn from_generated_bytes(bytes: [u8; 32]) -> Self {
31        Self(bytes)
32    }
33
34    #[must_use]
35    pub const fn as_bytes(&self) -> &[u8; 32] {
36        &self.0
37    }
38}
39
40impl fmt::Display for FleetId {
41    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
42        for byte in self.0 {
43            write!(formatter, "{byte:02x}")?;
44        }
45        Ok(())
46    }
47}
48
49impl CandidType for FleetId {
50    fn _ty() -> candid::types::Type {
51        candid::types::TypeInner::Text.into()
52    }
53
54    fn idl_serialize<S>(&self, serializer: S) -> Result<(), S::Error>
55    where
56        S: candid::types::Serializer,
57    {
58        serializer.serialize_text(&self.to_string())
59    }
60}
61
62impl FromStr for FleetId {
63    type Err = FleetIdParseError;
64
65    fn from_str(value: &str) -> Result<Self, Self::Err> {
66        if value.len() != 64 {
67            return Err(FleetIdParseError::Length(value.len()));
68        }
69        if !value
70            .bytes()
71            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
72        {
73            return Err(FleetIdParseError::CanonicalHex);
74        }
75
76        let mut bytes = [0; 32];
77        for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
78            bytes[index] = (decode_nibble(pair[0]) << 4) | decode_nibble(pair[1]);
79        }
80        Ok(Self(bytes))
81    }
82}
83
84impl Serialize for FleetId {
85    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
86    where
87        S: Serializer,
88    {
89        serializer.collect_str(self)
90    }
91}
92
93impl<'de> Deserialize<'de> for FleetId {
94    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
95    where
96        D: Deserializer<'de>,
97    {
98        let value = String::deserialize(deserializer)?;
99        value.parse().map_err(de::Error::custom)
100    }
101}
102
103///
104/// FleetName
105///
106/// Immutable operator-facing label for one Fleet.
107///
108
109#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
110#[serde(transparent)]
111pub struct FleetName(String);
112
113impl FleetName {
114    #[must_use]
115    pub const fn as_str(&self) -> &str {
116        self.0.as_str()
117    }
118}
119
120impl fmt::Display for FleetName {
121    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122        formatter.write_str(self.as_str())
123    }
124}
125
126impl AsRef<str> for FleetName {
127    fn as_ref(&self) -> &str {
128        self.as_str()
129    }
130}
131
132impl FromStr for FleetName {
133    type Err = FleetNameParseError;
134
135    fn from_str(value: &str) -> Result<Self, Self::Err> {
136        validate_fleet_name(value)?;
137        Ok(Self(value.to_string()))
138    }
139}
140
141impl TryFrom<String> for FleetName {
142    type Error = FleetNameParseError;
143
144    fn try_from(value: String) -> Result<Self, Self::Error> {
145        validate_fleet_name(&value)?;
146        Ok(Self(value))
147    }
148}
149
150impl<'de> Deserialize<'de> for FleetName {
151    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
152    where
153        D: Deserializer<'de>,
154    {
155        let value = String::deserialize(deserializer)?;
156        Self::try_from(value).map_err(de::Error::custom)
157    }
158}
159
160///
161/// FleetKey
162///
163/// Complete network-qualified Fleet identity.
164///
165
166#[derive(
167    CandidType, Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
168)]
169#[serde(deny_unknown_fields)]
170pub struct FleetKey {
171    pub network: CanonicalNetworkId,
172    pub fleet_id: FleetId,
173}
174
175///
176/// FleetBinding
177///
178/// Immutable binding between one installed Fleet and its source App.
179///
180
181#[derive(CandidType, Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
182#[serde(deny_unknown_fields)]
183pub struct FleetBinding {
184    pub fleet: FleetKey,
185    pub app: AppId,
186}
187
188///
189/// FleetIdParseError
190///
191/// Typed rejection for a non-canonical Fleet identity.
192///
193
194#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
195pub enum FleetIdParseError {
196    #[error("Fleet ID must contain exactly 64 characters, got {0}")]
197    Length(usize),
198
199    #[error("Fleet ID must contain only lowercase hexadecimal characters")]
200    CanonicalHex,
201}
202
203///
204/// FleetNameParseError
205///
206/// Typed rejection for an invalid Fleet label.
207///
208
209#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
210pub enum FleetNameParseError {
211    #[error("Fleet name must not be empty")]
212    Empty,
213
214    #[error("Fleet name must not exceed {max_bytes} bytes, got {actual_bytes}")]
215    TooLong {
216        max_bytes: usize,
217        actual_bytes: usize,
218    },
219
220    #[error("Fleet name must use only ASCII letters, numbers, '-' or '_'")]
221    InvalidCharacters,
222}
223
224fn validate_fleet_name(value: &str) -> Result<(), FleetNameParseError> {
225    if value.is_empty() {
226        return Err(FleetNameParseError::Empty);
227    }
228    if value.len() > FLEET_NAME_MAX_BYTES {
229        return Err(FleetNameParseError::TooLong {
230            max_bytes: FLEET_NAME_MAX_BYTES,
231            actual_bytes: value.len(),
232        });
233    }
234    if !value
235        .bytes()
236        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
237    {
238        return Err(FleetNameParseError::InvalidCharacters);
239    }
240    Ok(())
241}
242
243fn decode_nibble(byte: u8) -> u8 {
244    match byte {
245        b'0'..=b'9' => byte - b'0',
246        b'a'..=b'f' => byte - b'a' + 10,
247        _ => unreachable!("canonical hex was validated before decoding"),
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn fleet_id_uses_exact_canonical_text() {
257        let fleet_id = FleetId::from_generated_bytes([0xab; 32]);
258        let text = "ab".repeat(32);
259
260        assert_eq!(fleet_id.to_string(), text);
261        assert_eq!(text.parse::<FleetId>(), Ok(fleet_id));
262    }
263
264    #[test]
265    fn fleet_id_rejects_noncanonical_text() {
266        std::assert_matches!("ab".parse::<FleetId>(), Err(FleetIdParseError::Length(2)));
267        std::assert_matches!(
268            "A000000000000000000000000000000000000000000000000000000000000000".parse::<FleetId>(),
269            Err(FleetIdParseError::CanonicalHex)
270        );
271    }
272
273    #[test]
274    fn fleet_name_accepts_only_the_existing_canonical_name_shape() {
275        let name = "toko-production"
276            .parse::<FleetName>()
277            .expect("canonical Fleet name");
278
279        assert_eq!(name.as_str(), "toko-production");
280        std::assert_matches!("".parse::<FleetName>(), Err(FleetNameParseError::Empty));
281        std::assert_matches!(
282            "bad/name".parse::<FleetName>(),
283            Err(FleetNameParseError::InvalidCharacters)
284        );
285        std::assert_matches!(
286            "a".repeat(FLEET_NAME_MAX_BYTES + 1).parse::<FleetName>(),
287            Err(FleetNameParseError::TooLong { .. })
288        );
289    }
290
291    #[test]
292    fn fleet_binding_keeps_app_and_network_separate_from_the_label() {
293        let network = CanonicalNetworkId::public_ic();
294        let fleet_id = FleetId::from_generated_bytes([7; 32]);
295        let binding = FleetBinding {
296            fleet: FleetKey { network, fleet_id },
297            app: AppId::from("toko"),
298        };
299
300        assert_eq!(binding.fleet.network, network);
301        assert_eq!(binding.fleet.fleet_id, fleet_id);
302        assert_eq!(binding.app.as_str(), "toko");
303    }
304}