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