cts_common/
crn.rs

1use crate::{AwsRegion, Region, WorkspaceId};
2use miette::Diagnostic;
3use nom::{
4    branch::alt,
5    bytes::complete::{tag, take_while1, take_while_m_n},
6    combinator::{all_consuming, opt},
7    sequence::{preceded, separated_pair},
8    IResult, Parser,
9};
10use serde::{Deserialize, Serialize};
11use std::{fmt::Display, str::FromStr};
12use thiserror::Error;
13
14#[derive(Error, Debug, Diagnostic)]
15pub enum InvalidCrn {
16    #[error("Invalid CRN: {0}")]
17    #[diagnostic(help = "CRN format: `crn:<region>:<workspace_id>[:<service_name>]`")]
18    InvalidFormat(String),
19
20    #[error(transparent)]
21    #[diagnostic(transparent)]
22    InvalidRegion(#[from] crate::region::RegionError),
23
24    #[error(transparent)]
25    #[diagnostic(transparent)]
26    InvalidWorkspaceId(#[from] crate::workspace::InvalidWorkspaceId),
27}
28
29impl InvalidCrn {
30    pub fn invalid_format(input: &str) -> Self {
31        Self::InvalidFormat(input.to_string())
32    }
33}
34
35pub trait AsCrn {
36    /// Converts the implementing type to a CRN
37    fn as_crn(&self) -> Crn;
38}
39
40// TODO: Make some inner type variants of this to handle when a service name is present or not (and for other extensions)
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43/// Represents CRNs (CipherStash Resource Names)
44pub struct Crn {
45    /// The workspace ID
46    pub workspace_id: WorkspaceId,
47
48    /// The region
49    pub region: Region,
50
51    /// An optional service name
52    pub service_name: Option<String>,
53}
54
55impl Crn {
56    /// Creates a new CRN
57    pub fn new(region: Region, workspace_id: WorkspaceId) -> Self {
58        Self {
59            workspace_id,
60            region,
61            service_name: None,
62        }
63    }
64
65    pub fn with_service_name(mut self, service_name: &str) -> Self {
66        self.service_name = Some(service_name.into());
67        self
68    }
69}
70
71impl Serialize for Crn {
72    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
73    where
74        S: serde::Serializer,
75    {
76        let s = self.to_string();
77        serializer.serialize_str(&s)
78    }
79}
80
81impl<'de> Deserialize<'de> for Crn {
82    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
83    where
84        D: serde::Deserializer<'de>,
85    {
86        let s = String::deserialize(deserializer)?;
87        Crn::try_from(s.as_str()).map_err(serde::de::Error::custom)
88    }
89}
90
91impl Display for Crn {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        write!(f, "crn:{}:{}", self.region, self.workspace_id)?;
94        if let Some(service_name) = &self.service_name {
95            write!(f, ":{}", service_name)?;
96        }
97        Ok(())
98    }
99}
100
101impl TryFrom<&str> for Crn {
102    type Error = InvalidCrn;
103
104    fn try_from(value: &str) -> Result<Self, Self::Error> {
105        parse_crn(value)
106    }
107}
108
109impl FromStr for Crn {
110    type Err = InvalidCrn;
111
112    fn from_str(value: &str) -> Result<Self, Self::Err> {
113        Self::try_from(value)
114    }
115}
116
117// TODO: Move all of this into a submodule
118
119/// Parse the "geo" part of the region (e.g. "us-east-1")
120fn region_geo(input: &str) -> IResult<&str, &str> {
121    alt((
122        tag("ap-southeast-2"),
123        tag("eu-central-1"),
124        tag("eu-west-1"),
125        tag("us-east-1"),
126        tag("us-east-2"),
127        tag("us-west-1"),
128        tag("us-west-2"),
129    ))
130    .parse(input)
131}
132
133/// Parse the "vendor" part of the region (e.g. "aws")
134/// Only AWS is supported for now.
135#[inline]
136fn region_vendor(input: &str) -> IResult<&str, &str> {
137    tag("aws")(input)
138}
139
140/// Parse the region (e.g. "us-east-1.aws")
141#[inline]
142fn region(input: &str) -> IResult<&str, Region, nom::error::Error<&str>> {
143    // parse the region
144    separated_pair(region_geo, tag("."), region_vendor)
145        .parse(input)
146        .map(|(rest, (geo, _))| {
147            // Hard code the vendor to "aws" for now
148            // SAFETY: Unwrapping here is safe because the geo is already validated
149            (
150                rest,
151                Region::Aws(AwsRegion::try_from(geo).expect("Invalid geo")),
152            )
153        })
154}
155
156/// Parse the workspace ID (e.g. "ZVATKW3VHMFG27DY")
157/// The workspace ID must be 20 alphanumeric characters
158#[inline]
159fn workspace_id(input: &str) -> IResult<&str, WorkspaceId, nom::error::Error<&str>> {
160    // parse the workspace ID
161    take_while_m_n(16, 16, |c: char| c.is_alphanumeric())(input).map(|(rest, id)| {
162        // Convert the ID to a WorkspaceId
163        // SAFETY: The ID is already validated to be 16 alphanumeric characters
164        // TODO: use the parse method on the inner ArrayString
165        let id = WorkspaceId::try_from(id).expect("Invalid workspace ID");
166        (rest, id)
167    })
168}
169
170fn service_name_chars(input: &str) -> IResult<&str, &str> {
171    // parse the service name
172    let (rest, service_name) =
173        take_while1(|c: char| c.is_alphanumeric() || c == '-' || c == '_').parse(input)?;
174    Ok((rest, service_name))
175}
176
177fn parse_crn(input: &str) -> Result<Crn, InvalidCrn> {
178    let (_, (region, workspace_id, service_name)) = all_consuming((
179        preceded(tag("crn:"), region),
180        preceded(tag(":"), workspace_id),
181        opt(preceded(tag(":"), service_name_chars)),
182    ))
183    .parse(input)
184    .map_err(|_| InvalidCrn::invalid_format(input))?;
185
186    Ok(Crn {
187        region,
188        workspace_id,
189        service_name: service_name.map(String::from),
190    })
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::AwsRegion;
197
198    mod try_from_str {
199        use super::*;
200
201        #[test]
202        fn success_valid_with_service() {
203            let region = Region::new("us-east-1.aws").unwrap();
204            let workspace_id = WorkspaceId::try_from("ZVATKW3VHMFG27DY").unwrap();
205
206            assert_eq!(
207                Crn::try_from("crn:us-east-1.aws:ZVATKW3VHMFG27DY:service_name").unwrap(),
208                Crn::new(region, workspace_id).with_service_name("service_name")
209            );
210
211            assert_eq!(
212                Crn::try_from("crn:us-east-1.aws:ZVATKW3VHMFG27DY:service-name").unwrap(),
213                Crn::new(region, workspace_id).with_service_name("service-name")
214            );
215        }
216
217        #[test]
218        fn success_valid_without_service() {
219            let crn_str = "crn:us-east-1.aws:ZVATKW3VHMFG27DY";
220            let crn = Crn::try_from(crn_str).unwrap();
221            assert_eq!(crn.region, Region::Aws(AwsRegion::UsEast1));
222            assert_eq!(crn.workspace_id.to_string(), "ZVATKW3VHMFG27DY");
223            assert!(crn.service_name.is_none());
224        }
225
226        #[test]
227        fn test_invalid_crn() {
228            assert!(Crn::try_from("invalid_crn").is_err());
229            assert!(Crn::try_from("crn:invalid_crn").is_err());
230            // Trailing colon
231            assert!(Crn::try_from("crn:us-east-1.aws:ZVATKW3VHMFG27DY:").is_err());
232            // Extra parts
233            assert!(
234                Crn::try_from("crn:us-east-1.aws:ZVATKW3VHMFG27DY:service_name:extra").is_err()
235            );
236            // Extra extra parts
237            assert!(
238                Crn::try_from("crn:us-east-1.aws:ZVATKW3VHMFG27DY:service_name:extra:extra")
239                    .is_err()
240            );
241            // Invalid workspace ID
242            assert!(Crn::try_from("crn:us-east-1.aws:ZVATKW3VH").is_err());
243            // Invalid region
244            assert!(Crn::try_from("crn:us-east-1:ZVATKW3VHMFG27DY").is_err());
245            // Missing CRN prefix
246            assert!(Crn::try_from("us-east-1.aws:ZVATKW3VHMFG27DY:service_name").is_err());
247        }
248    }
249
250    mod display {
251        use super::*;
252
253        #[test]
254        fn test_with_workspace_id() {
255            let workspace_id = WorkspaceId::generate().unwrap();
256            let crn = Crn::new(Region::new("us-east-1.aws").unwrap(), workspace_id);
257            assert_eq!(crn.to_string(), format!("crn:us-east-1.aws:{workspace_id}"));
258        }
259
260        #[test]
261        fn test_with_workspace_id_and_service() {
262            let workspace_id = WorkspaceId::generate().unwrap();
263            let crn = Crn::new(Region::new("us-east-1.aws").unwrap(), workspace_id)
264                .with_service_name("zerokms");
265            assert_eq!(
266                crn.to_string(),
267                format!("crn:us-east-1.aws:{workspace_id}:zerokms")
268            );
269        }
270    }
271}