Skip to main content

cts_common/
service.rs

1use crate::{Region, RegionError};
2use regex::Regex;
3use std::sync::LazyLock;
4use url::Url;
5
6/// Regex to extract the region from a host FQDN or a URL.
7static REGION_HOST_REGEX: LazyLock<Regex> = LazyLock::new(|| {
8    Regex::new(r"^(?:https://){0,1}([^\.]+)\.([^\.]+)\.viturhosted\.net/?$").expect("Invalid regex")
9});
10
11/// Regex to extract the region from a CTS host FQDN or URL.
12static CTS_REGION_HOST_REGEX: LazyLock<Regex> = LazyLock::new(|| {
13    Regex::new(r"^(?:https://){0,1}([^\.]+)\.([^\.]+)\.cts\.cipherstashmanaged\.net/?$")
14        .expect("Invalid regex")
15});
16
17/// Domain name for ZeroKMS service discovery.
18static DOMAIN_NAME: &str = "viturhosted.net";
19
20/// Domain name for CTS service discovery.
21static CTS_DOMAIN_NAME: &str = "cts.cipherstashmanaged.net";
22
23/// Simple service discover trait that defines the name of the service and the endpoint for a given region.
24pub trait ServiceDiscovery {
25    fn name(&self) -> &'static str;
26    fn fqdn(region: Region) -> String;
27    fn endpoint(region: Region) -> Result<Url, RegionError>;
28}
29
30/// ZeroKms service.
31pub struct ZeroKmsServiceDiscovery;
32
33impl ServiceDiscovery for ZeroKmsServiceDiscovery {
34    fn name(&self) -> &'static str {
35        "zerokms"
36    }
37
38    /// Returns the FQDN for the service in the given region.
39    /// The FQDN is in the format `<region>.<provider>.viturhosted.net`.
40    fn fqdn(region: Region) -> String {
41        format!("{}.{DOMAIN_NAME}", region.identifier())
42    }
43
44    /// Returns the URL for the service in the given region.
45    /// The URL is in the format `https://<region>.<provider>.viturhosted.net/`.
46    ///
47    /// When the `test_utils` feature is enabled, the `ZEROKMS_DISCOVERY_OVERRIDE` environment
48    /// variable can be set to override the endpoint URL.
49    fn endpoint(region: Region) -> Result<Url, RegionError> {
50        #[cfg(feature = "test_utils")]
51        if let Ok(override_url) = std::env::var("ZEROKMS_DISCOVERY_OVERRIDE") {
52            return Url::parse(&override_url).map_err(|e| {
53                RegionError::InvalidRegion(format!(
54                    "Invalid ZEROKMS_DISCOVERY_OVERRIDE URL '{}': {}",
55                    override_url, e
56                ))
57            });
58        }
59
60        Url::parse(&format!("https://{}.{DOMAIN_NAME}/", region.identifier())).map_err(|e| {
61            RegionError::InvalidRegion(format!(
62                "Invalid service URL for ZeroKMS in region {}: {}",
63                region.identifier(),
64                e
65            ))
66        })
67    }
68}
69
70impl ZeroKmsServiceDiscovery {
71    /// Attempt to extract the region from a host FQDN or URL.
72    /// The FQDN must be in the format `<region>.<provider>.viturhosted.net`.
73    /// The URL must be in the format `https://<region>.<provider>.viturhosted.net/`.
74    pub fn region_from_host_fqdn(host_fqdn: &str) -> Result<Region, RegionError> {
75        REGION_HOST_REGEX
76            .captures(host_fqdn)
77            .and_then(|caps| Some((caps.get(1)?, caps.get(2)?)))
78            .map(|(r, p)| format!("{}.{}", r.as_str(), p.as_str()))
79            .ok_or_else(|| RegionError::InvalidHostFqdn(host_fqdn.to_string()))
80            .and_then(|ident| Region::new(&ident))
81    }
82}
83
84/// Domain name for Secrets service discovery (global, not regional).
85static SECRETS_DOMAIN_NAME: &str = "dashboard.cipherstash.com";
86
87/// Secrets service.
88pub struct SecretsServiceDiscovery;
89
90impl ServiceDiscovery for SecretsServiceDiscovery {
91    fn name(&self) -> &'static str {
92        "secrets"
93    }
94
95    /// Returns the FQDN for the Secrets service.
96    ///
97    /// The Secrets API is a global endpoint — the region parameter is ignored.
98    fn fqdn(_region: Region) -> String {
99        SECRETS_DOMAIN_NAME.to_string()
100    }
101
102    /// Returns the URL for the Secrets service.
103    ///
104    /// The Secrets API is a global endpoint — the region parameter is ignored.
105    fn endpoint(_region: Region) -> Result<Url, RegionError> {
106        Url::parse(&format!("https://{SECRETS_DOMAIN_NAME}/"))
107            .map_err(|e| RegionError::InvalidRegion(format!("Invalid Secrets service URL: {e}")))
108    }
109}
110
111/// CTS service.
112pub struct CtsServiceDiscovery;
113
114impl ServiceDiscovery for CtsServiceDiscovery {
115    fn name(&self) -> &'static str {
116        "cts"
117    }
118
119    fn fqdn(region: Region) -> String {
120        format!("{}.{CTS_DOMAIN_NAME}", region.identifier())
121    }
122
123    fn endpoint(region: Region) -> Result<Url, RegionError> {
124        Url::parse(&format!(
125            "https://{}.{CTS_DOMAIN_NAME}/",
126            region.identifier()
127        ))
128        .map_err(|e| {
129            RegionError::InvalidRegion(format!(
130                "Invalid service URL for CTS in region {}: {}",
131                region.identifier(),
132                e
133            ))
134        })
135    }
136}
137
138impl CtsServiceDiscovery {
139    /// Attempt to extract the region from a CTS host FQDN or URL.
140    /// The FQDN must be in the format `<region>.<provider>.cts.cipherstashmanaged.net`.
141    /// The URL must be in the format `https://<region>.<provider>.cts.cipherstashmanaged.net/`.
142    pub fn region_from_host_fqdn(host_fqdn: &str) -> Result<Region, RegionError> {
143        CTS_REGION_HOST_REGEX
144            .captures(host_fqdn)
145            .and_then(|caps| Some((caps.get(1)?, caps.get(2)?)))
146            .map(|(r, p)| format!("{}.{}", r.as_str(), p.as_str()))
147            .ok_or_else(|| RegionError::InvalidHostFqdn(host_fqdn.to_string()))
148            .and_then(|ident| Region::new(&ident))
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn test_region_from_host_fqdn() -> anyhow::Result<()> {
158        let host_fqdn = "us-west-1.aws.viturhosted.net";
159        let region = ZeroKmsServiceDiscovery::region_from_host_fqdn(host_fqdn)?;
160        assert_eq!(region.identifier(), "us-west-1.aws");
161
162        Ok(())
163    }
164
165    #[test]
166    fn test_region_from_host_endpoint() -> anyhow::Result<()> {
167        let host_fqdn = "https://us-west-1.aws.viturhosted.net";
168        let region = ZeroKmsServiceDiscovery::region_from_host_fqdn(host_fqdn)?;
169        assert_eq!(region.identifier(), "us-west-1.aws");
170        Ok(())
171    }
172
173    #[test]
174    fn test_region_from_host_endpoint_with_trailing_slash() -> anyhow::Result<()> {
175        let host_fqdn = "https://us-west-1.aws.viturhosted.net/";
176        let region = ZeroKmsServiceDiscovery::region_from_host_fqdn(host_fqdn)?;
177        assert_eq!(region.identifier(), "us-west-1.aws");
178        Ok(())
179    }
180
181    #[test]
182    fn test_cts_endpoint_ap_southeast_2() -> anyhow::Result<()> {
183        let region = Region::new("ap-southeast-2.aws")?;
184        let url = CtsServiceDiscovery::endpoint(region)?;
185        assert_eq!(
186            url.as_str(),
187            "https://ap-southeast-2.aws.cts.cipherstashmanaged.net/"
188        );
189        Ok(())
190    }
191
192    #[test]
193    fn test_cts_endpoint_us_east_1() -> anyhow::Result<()> {
194        let region = Region::new("us-east-1.aws")?;
195        let url = CtsServiceDiscovery::endpoint(region)?;
196        assert_eq!(
197            url.as_str(),
198            "https://us-east-1.aws.cts.cipherstashmanaged.net/"
199        );
200        Ok(())
201    }
202
203    #[test]
204    fn test_cts_region_from_host_fqdn() -> anyhow::Result<()> {
205        let host_fqdn = "us-east-1.aws.cts.cipherstashmanaged.net";
206        let region = CtsServiceDiscovery::region_from_host_fqdn(host_fqdn)?;
207        assert_eq!(region.identifier(), "us-east-1.aws");
208        Ok(())
209    }
210
211    #[test]
212    fn test_cts_region_from_host_endpoint() -> anyhow::Result<()> {
213        let host_fqdn = "https://ap-southeast-2.aws.cts.cipherstashmanaged.net";
214        let region = CtsServiceDiscovery::region_from_host_fqdn(host_fqdn)?;
215        assert_eq!(region.identifier(), "ap-southeast-2.aws");
216        Ok(())
217    }
218
219    #[test]
220    fn test_cts_region_from_host_endpoint_with_trailing_slash() -> anyhow::Result<()> {
221        let host_fqdn = "https://us-west-2.aws.cts.cipherstashmanaged.net/";
222        let region = CtsServiceDiscovery::region_from_host_fqdn(host_fqdn)?;
223        assert_eq!(region.identifier(), "us-west-2.aws");
224        Ok(())
225    }
226
227    #[test]
228    fn test_cts_fqdn_us_west_2() -> anyhow::Result<()> {
229        let region = Region::new("us-west-2.aws")?;
230        assert_eq!(
231            CtsServiceDiscovery::fqdn(region),
232            "us-west-2.aws.cts.cipherstashmanaged.net"
233        );
234        Ok(())
235    }
236
237    #[test]
238    fn test_zerokms_region_from_invalid_host() {
239        let result = ZeroKmsServiceDiscovery::region_from_host_fqdn("not-a-valid-host");
240        assert!(result.is_err());
241    }
242
243    #[test]
244    fn test_zerokms_region_from_wrong_domain() {
245        let result =
246            ZeroKmsServiceDiscovery::region_from_host_fqdn("us-east-1.aws.wrongdomain.net");
247        assert!(result.is_err());
248    }
249
250    #[test]
251    fn test_zerokms_region_from_missing_region_component() {
252        let result = ZeroKmsServiceDiscovery::region_from_host_fqdn("viturhosted.net");
253        assert!(result.is_err());
254    }
255
256    #[test]
257    fn test_cts_region_from_invalid_host() {
258        let result = CtsServiceDiscovery::region_from_host_fqdn("not-a-valid-host");
259        assert!(result.is_err());
260    }
261
262    #[test]
263    fn test_cts_region_from_wrong_domain() {
264        let result = CtsServiceDiscovery::region_from_host_fqdn("us-east-1.aws.wrongdomain.net");
265        assert!(result.is_err());
266    }
267
268    #[test]
269    fn test_cts_region_from_missing_region_component() {
270        let result = CtsServiceDiscovery::region_from_host_fqdn("cts.cipherstashmanaged.net");
271        assert!(result.is_err());
272    }
273
274    #[test]
275    fn test_secrets_endpoint_returns_global_url() -> anyhow::Result<()> {
276        let region = Region::new("ap-southeast-2.aws")?;
277        let url = SecretsServiceDiscovery::endpoint(region)?;
278        assert_eq!(
279            url.as_str(),
280            "https://dashboard.cipherstash.com/",
281            "secrets endpoint should be the global dashboard URL regardless of region"
282        );
283        Ok(())
284    }
285
286    #[test]
287    fn test_secrets_endpoint_ignores_region() -> anyhow::Result<()> {
288        let url_apse2 = SecretsServiceDiscovery::endpoint(Region::new("ap-southeast-2.aws")?)?;
289        let url_use1 = SecretsServiceDiscovery::endpoint(Region::new("us-east-1.aws")?)?;
290        assert_eq!(
291            url_apse2, url_use1,
292            "secrets endpoint should be the same for all regions"
293        );
294        Ok(())
295    }
296
297    #[test]
298    fn test_secrets_fqdn() -> anyhow::Result<()> {
299        let region = Region::new("us-east-1.aws")?;
300        assert_eq!(
301            SecretsServiceDiscovery::fqdn(region),
302            "dashboard.cipherstash.com"
303        );
304        Ok(())
305    }
306}