Skip to main content

cloud_sdk/
identity.rs

1//! Extensible provider and service identities.
2
3use core::fmt;
4
5/// Maximum bytes in a provider identifier.
6pub const MAX_PROVIDER_ID_BYTES: usize = 63;
7
8/// Maximum bytes in a service identifier.
9pub const MAX_SERVICE_ID_BYTES: usize = 63;
10
11/// Invalid provider or service identifier.
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum IdentityError {
14    /// The identifier is empty.
15    Empty,
16    /// The identifier exceeds its domain's byte limit.
17    TooLong,
18    /// The identifier contains a byte outside lowercase ASCII, digits, or `-`.
19    InvalidByte,
20    /// The identifier begins or ends with `-`.
21    InvalidBoundary,
22    /// The identifier contains consecutive `-` separators.
23    RepeatedSeparator,
24}
25
26impl fmt::Display for IdentityError {
27    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
28        formatter.write_str(match self {
29            Self::Empty => "provider or service identifier is empty",
30            Self::TooLong => "provider or service identifier is too long",
31            Self::InvalidByte => "provider or service identifier contains an invalid byte",
32            Self::InvalidBoundary => "provider or service identifier has an invalid boundary",
33            Self::RepeatedSeparator => {
34                "provider or service identifier contains repeated separators"
35            }
36        })
37    }
38}
39
40impl core::error::Error for IdentityError {}
41
42/// Bounded canonical cloud-provider identifier.
43#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
44pub struct ProviderId(&'static str);
45
46impl ProviderId {
47    /// Validates a static provider identifier.
48    ///
49    /// Identifiers use lowercase ASCII letters, digits, and single internal
50    /// hyphens. This keeps equality independent of locale, Unicode
51    /// normalization, and case folding.
52    pub const fn new(value: &'static str) -> Result<Self, IdentityError> {
53        match validate(value, MAX_PROVIDER_ID_BYTES) {
54            Ok(()) => Ok(Self(value)),
55            Err(error) => Err(error),
56        }
57    }
58
59    /// Returns the canonical provider identifier.
60    #[must_use]
61    pub const fn as_str(self) -> &'static str {
62        self.0
63    }
64}
65
66/// Bounded canonical service identifier within a provider namespace.
67#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
68pub struct ServiceId(&'static str);
69
70impl ServiceId {
71    /// Validates a static service identifier.
72    ///
73    /// Identifiers use lowercase ASCII letters, digits, and single internal
74    /// hyphens. Service identity is meaningful only with its provider ID.
75    pub const fn new(value: &'static str) -> Result<Self, IdentityError> {
76        match validate(value, MAX_SERVICE_ID_BYTES) {
77            Ok(()) => Ok(Self(value)),
78            Err(error) => Err(error),
79        }
80    }
81
82    /// Returns the canonical service identifier.
83    #[must_use]
84    pub const fn as_str(self) -> &'static str {
85        self.0
86    }
87}
88
89/// Provider-owned marker for an extensible provider namespace.
90///
91/// Provider crates implement this trait on their own zero-sized marker. The
92/// neutral crate has no provider registry to edit.
93///
94/// ```
95/// use cloud_sdk::{ProviderId, ProviderMarker, provider_id};
96///
97/// enum ExampleProvider {}
98///
99/// impl ProviderMarker for ExampleProvider {
100///     const ID: ProviderId = provider_id!("example-cloud");
101/// }
102///
103/// assert_eq!(ExampleProvider::ID.as_str(), "example-cloud");
104/// ```
105///
106/// The private ID representation prevents bypassing validation.
107///
108/// ```compile_fail
109/// use cloud_sdk::ProviderId;
110///
111/// const FORGED: ProviderId = ProviderId("UPPERCASE");
112/// ```
113pub trait ProviderMarker {
114    /// Canonical provider identifier.
115    const ID: ProviderId;
116}
117
118/// Provider-owned marker for one service namespace.
119///
120/// The associated provider makes service ownership explicit without a central
121/// provider/service enum.
122///
123/// ```compile_fail
124/// use cloud_sdk::{ServiceId, ServiceMarker, service_id};
125///
126/// enum MissingProvider {}
127///
128/// impl ServiceMarker for MissingProvider {
129///     const ID: ServiceId = service_id!("compute");
130/// }
131/// ```
132pub trait ServiceMarker {
133    /// Provider that owns this service.
134    type Provider: ProviderMarker;
135
136    /// Canonical service identifier inside the provider namespace.
137    const ID: ServiceId;
138}
139
140#[doc(hidden)]
141pub const fn validate(value: &str, max_bytes: usize) -> Result<(), IdentityError> {
142    if value.is_empty() {
143        return Err(IdentityError::Empty);
144    }
145    if value.len() > max_bytes {
146        return Err(IdentityError::TooLong);
147    }
148    let mut remaining = value.as_bytes();
149    let mut first = true;
150    let mut previous_separator = false;
151    while let Some((byte, tail)) = remaining.split_first() {
152        let separator = *byte == b'-';
153        if !byte.is_ascii_lowercase() && !byte.is_ascii_digit() && !separator {
154            return Err(IdentityError::InvalidByte);
155        }
156        if separator && first {
157            return Err(IdentityError::InvalidBoundary);
158        }
159        if separator && previous_separator {
160            return Err(IdentityError::RepeatedSeparator);
161        }
162        previous_separator = separator;
163        first = false;
164        remaining = tail;
165    }
166    if previous_separator {
167        return Err(IdentityError::InvalidBoundary);
168    }
169    Ok(())
170}
171
172/// Creates a compile-time validated [`ProviderId`].
173///
174/// Invalid literals fail during compilation, including inside a function body:
175///
176/// ```compile_fail
177/// use cloud_sdk::provider_id;
178///
179/// fn main() {
180///     let _ = provider_id!("Uppercase");
181/// }
182/// ```
183#[macro_export]
184macro_rules! provider_id {
185    ($value:literal) => {{
186        const VALUE: $crate::ProviderId = match $crate::ProviderId::new($value) {
187            Ok(value) => value,
188            Err(_) => panic!("invalid provider identifier literal"),
189        };
190        VALUE
191    }};
192}
193
194/// Creates a compile-time validated [`ServiceId`].
195///
196/// Invalid literals fail during compilation, including inside a function body:
197///
198/// ```compile_fail
199/// use cloud_sdk::service_id;
200///
201/// fn main() {
202///     let _ = service_id!("repeated--separator");
203/// }
204/// ```
205#[macro_export]
206macro_rules! service_id {
207    ($value:literal) => {{
208        const VALUE: $crate::ServiceId = match $crate::ServiceId::new($value) {
209            Ok(value) => value,
210            Err(_) => panic!("invalid service identifier literal"),
211        };
212        VALUE
213    }};
214}
215
216#[cfg(test)]
217mod tests {
218    use super::{
219        IdentityError, MAX_PROVIDER_ID_BYTES, MAX_SERVICE_ID_BYTES, ProviderId, ProviderMarker,
220        ServiceId, ServiceMarker,
221    };
222
223    enum Example {}
224
225    impl ProviderMarker for Example {
226        const ID: ProviderId = provider_id!("example");
227    }
228
229    enum Compute {}
230
231    impl ServiceMarker for Compute {
232        type Provider = Example;
233        const ID: ServiceId = service_id!("compute-v2");
234    }
235
236    #[test]
237    fn markers_define_external_namespaces_without_core_registration() {
238        assert_eq!(Example::ID.as_str(), "example");
239        assert_eq!(Compute::ID.as_str(), "compute-v2");
240        assert_eq!(
241            <<Compute as ServiceMarker>::Provider as ProviderMarker>::ID,
242            Example::ID
243        );
244    }
245
246    #[test]
247    fn accepts_canonical_bounded_identifiers() {
248        assert_eq!(
249            ProviderId::new("provider-9").map(ProviderId::as_str),
250            Ok("provider-9")
251        );
252        assert_eq!(
253            ServiceId::new("object-storage").map(ServiceId::as_str),
254            Ok("object-storage")
255        );
256        assert!(ProviderId::new("a").is_ok());
257    }
258
259    #[test]
260    fn literal_macros_validate_inside_function_bodies() {
261        let provider = provider_id!("example-provider");
262        let service = service_id!("object-storage");
263        assert_eq!(provider.as_str(), "example-provider");
264        assert_eq!(service.as_str(), "object-storage");
265    }
266
267    #[test]
268    fn rejects_every_noncanonical_identifier_class() {
269        for value in [
270            "",
271            "-cloud",
272            "cloud-",
273            "cloud--api",
274            "Cloud",
275            "cloud_api",
276            "c loud",
277            "é",
278        ] {
279            assert!(ProviderId::new(value).is_err(), "{value}");
280            assert!(ServiceId::new(value).is_err(), "{value}");
281        }
282        assert_eq!(ProviderId::new(""), Err(IdentityError::Empty));
283        assert_eq!(
284            ProviderId::new("cloud--api"),
285            Err(IdentityError::RepeatedSeparator)
286        );
287        assert_eq!(
288            ServiceId::new("-storage"),
289            Err(IdentityError::InvalidBoundary)
290        );
291    }
292
293    #[test]
294    fn enforces_domain_length_limits_before_scanning() {
295        const PROVIDER_MAX: &str =
296            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
297        const PROVIDER_LONG: &str =
298            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
299        const SERVICE_MAX: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
300        const SERVICE_LONG: &str =
301            "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
302        assert_eq!(PROVIDER_MAX.len(), MAX_PROVIDER_ID_BYTES);
303        assert_eq!(SERVICE_MAX.len(), MAX_SERVICE_ID_BYTES);
304        assert!(ProviderId::new(PROVIDER_MAX).is_ok());
305        assert!(ServiceId::new(SERVICE_MAX).is_ok());
306        assert_eq!(ProviderId::new(PROVIDER_LONG), Err(IdentityError::TooLong));
307        assert_eq!(ServiceId::new(SERVICE_LONG), Err(IdentityError::TooLong));
308    }
309}