Skip to main content

soma_fleet/
identity.rs

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/// Stable lowercase identity for one managed host.
10#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
11#[serde(transparent)]
12pub struct HostId(String);
13
14impl HostId {
15    /// Creates and validates a host identity.
16    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    /// Returns the stable host identity.
26    #[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/// Lowercase dotted capability advertised by a host or transport.
49#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
50#[serde(transparent)]
51pub struct CapabilityName(String);
52
53impl CapabilityName {
54    /// Creates and validates a capability name such as `transport.ssh`.
55    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    /// Returns the capability name.
65    #[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/// SHA-256 revision of all transport-affecting topology material.
88#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
89#[serde(transparent)]
90pub struct TopologyRevision(String);
91
92impl TopologyRevision {
93    /// Parses a lowercase 64-character SHA-256 revision.
94    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    /// Derives a deterministic revision from canonical topology material.
108    #[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    /// Returns the lowercase SHA-256 revision.
115    #[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/// Connection-cache key bound to host identity and exact topology revision.
138#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
139pub struct PoolKey {
140    host: HostId,
141    revision: TopologyRevision,
142}
143
144impl PoolKey {
145    /// Creates a revision-bound connection key.
146    #[must_use]
147    pub fn new(host: HostId, revision: TopologyRevision) -> Self {
148        Self { host, revision }
149    }
150
151    /// Returns the host identity.
152    #[must_use]
153    pub fn host(&self) -> &HostId {
154        &self.host
155    }
156
157    /// Returns the topology revision.
158    #[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/// Invalid fleet identity.
171#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
172#[non_exhaustive]
173pub enum IdentityError {
174    /// Host identity was empty, oversized, or not canonical lowercase ASCII.
175    #[error("invalid host id: {0}")]
176    InvalidHostId(String),
177    /// Capability was not a valid lowercase dotted identifier.
178    #[error("invalid capability name: {0}")]
179    InvalidCapability(String),
180    /// Topology revision was not a lowercase SHA-256 digest.
181    #[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;