1use 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
22pub struct FleetId([u8; 32]);
23
24impl FleetId {
25 #[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#[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#[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#[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#[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#[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}