1use std::fmt;
2
3use serde::{Deserialize, Deserializer, Serialize};
4use sha2::{Digest, Sha256};
5
6const MAX_HOST_ID_CHARS: usize = 128;
7const MAX_CAPABILITY_CHARS: usize = 128;
8
9#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
11#[serde(transparent)]
12pub struct HostId(String);
13
14impl HostId {
15 pub fn new(value: impl Into<String>) -> Result<Self, IdentityError> {
17 let value = value.into();
18 if valid_token(&value, MAX_HOST_ID_CHARS, false) {
19 Ok(Self(value))
20 } else {
21 Err(IdentityError::InvalidHostId(value))
22 }
23 }
24
25 #[must_use]
27 pub fn as_str(&self) -> &str {
28 &self.0
29 }
30}
31
32impl fmt::Display for HostId {
33 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34 formatter.write_str(&self.0)
35 }
36}
37
38impl<'de> Deserialize<'de> for HostId {
39 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
40 where
41 D: Deserializer<'de>,
42 {
43 let value = String::deserialize(deserializer)?;
44 Self::new(value).map_err(serde::de::Error::custom)
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
50#[serde(transparent)]
51pub struct CapabilityName(String);
52
53impl CapabilityName {
54 pub fn new(value: impl Into<String>) -> Result<Self, IdentityError> {
56 let value = value.into();
57 if valid_token(&value, MAX_CAPABILITY_CHARS, true) && value.contains('.') {
58 Ok(Self(value))
59 } else {
60 Err(IdentityError::InvalidCapability(value))
61 }
62 }
63
64 #[must_use]
66 pub fn as_str(&self) -> &str {
67 &self.0
68 }
69}
70
71impl fmt::Display for CapabilityName {
72 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73 formatter.write_str(&self.0)
74 }
75}
76
77impl<'de> Deserialize<'de> for CapabilityName {
78 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
79 where
80 D: Deserializer<'de>,
81 {
82 let value = String::deserialize(deserializer)?;
83 Self::new(value).map_err(serde::de::Error::custom)
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
89#[serde(transparent)]
90pub struct TopologyRevision(String);
91
92impl TopologyRevision {
93 pub fn new(value: impl Into<String>) -> Result<Self, IdentityError> {
95 let value = value.into();
96 if value.len() == 64
97 && value
98 .bytes()
99 .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
100 {
101 Ok(Self(value))
102 } else {
103 Err(IdentityError::InvalidTopologyRevision)
104 }
105 }
106
107 #[must_use]
109 pub fn from_material(material: impl AsRef<[u8]>) -> Self {
110 let digest = Sha256::digest(material.as_ref());
111 Self(digest.iter().map(|byte| format!("{byte:02x}")).collect())
112 }
113
114 #[must_use]
116 pub fn as_str(&self) -> &str {
117 &self.0
118 }
119}
120
121impl fmt::Display for TopologyRevision {
122 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
123 formatter.write_str(&self.0)
124 }
125}
126
127impl<'de> Deserialize<'de> for TopologyRevision {
128 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
129 where
130 D: Deserializer<'de>,
131 {
132 let value = String::deserialize(deserializer)?;
133 Self::new(value).map_err(serde::de::Error::custom)
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
139pub struct PoolKey {
140 host: HostId,
141 revision: TopologyRevision,
142}
143
144impl PoolKey {
145 #[must_use]
147 pub fn new(host: HostId, revision: TopologyRevision) -> Self {
148 Self { host, revision }
149 }
150
151 #[must_use]
153 pub fn host(&self) -> &HostId {
154 &self.host
155 }
156
157 #[must_use]
159 pub fn revision(&self) -> &TopologyRevision {
160 &self.revision
161 }
162}
163
164impl fmt::Display for PoolKey {
165 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
166 write!(formatter, "{}@{}", self.host, self.revision)
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
172#[non_exhaustive]
173pub enum IdentityError {
174 #[error("invalid host id: {0}")]
176 InvalidHostId(String),
177 #[error("invalid capability name: {0}")]
179 InvalidCapability(String),
180 #[error("invalid topology revision")]
182 InvalidTopologyRevision,
183}
184
185fn valid_token(value: &str, max_chars: usize, allow_dot: bool) -> bool {
186 let count = value.chars().count();
187 if count == 0 || count > max_chars {
188 return false;
189 }
190 value.split('.').all(|segment| {
191 if !allow_dot && value.contains('.') {
192 return false;
193 }
194 let mut chars = segment.chars();
195 matches!(chars.next(), Some('a'..='z' | '0'..='9'))
196 && chars.all(|character| {
197 character.is_ascii_lowercase()
198 || character.is_ascii_digit()
199 || matches!(character, '-' | '_')
200 })
201 && !segment.ends_with(['-', '_'])
202 })
203}
204
205#[cfg(test)]
206#[path = "identity_tests.rs"]
207mod tests;