scratchstack_arn/
arn.rs

1use {
2    crate::{
3        utils::{validate_account_id, validate_partition, validate_region, validate_service},
4        ArnError,
5    },
6    serde::{de, Deserialize, Serialize},
7    std::{
8        cmp::Ordering,
9        fmt::{Display, Formatter, Result as FmtResult},
10        hash::Hash,
11        str::FromStr,
12    },
13};
14
15const PARTITION_START: usize = 4;
16
17/// An Amazon Resource Name (ARN) representing an exact resource.
18///
19/// This is used to represent a known resource, such as an S3 bucket, EC2 instance, assumed role instance, etc. This is
20/// _not_ used to represent resource _statements_ in the IAM Aspen policy language, which may contain wildcards.
21///
22/// [Arn] objects are immutable.
23#[derive(Debug, Clone, Eq, Hash, PartialEq)]
24pub struct Arn {
25    arn: String,
26    service_start: usize,
27    region_start: usize,
28    account_id_start: usize,
29    resource_start: usize,
30}
31
32impl Arn {
33    /// Create a new ARN from the specified components.
34    ///
35    /// * `partition` - The partition the resource is in (required). This is usually `aws`, `aws-cn`, or `aws-us-gov`
36    ///     for actual AWS resources, but may be any string meeting the rules specified in [validate_partition] for
37    ///     non-AWS resources.
38    /// * `service` - The service the resource belongs to (required). This is a service name like `ec2` or `s3`.
39    ///     Non-AWS resources must conform to the naming rules specified in [validate_service].
40    /// * `region` - The region the resource is in (optional). If the resource is regional (and may other regions
41    ///     may have the resources with the same name), this is the region name. If the resource is global, this is
42    ///     empty. This is usually a region name like `us-east-1` or `us-west-2`, but may be any string meeting the
43    ///     rules specified in [validate_region].
44    /// * `account_id` - The account ID the resource belongs to (optional). This is the 12-digit account ID or the
45    ///     string `aws` for certain AWS-owned resources. Some resources (such as S3 buckets and objects) do not need
46    ///     the account ID (the bucket name is globally unique within a partition), so this may be empty.
47    /// * `resource` - The resource name (required). This is the name of the resource. The formatting is
48    ///     service-specific, but must be a valid UTF-8 string.
49    ///
50    /// # Errors
51    ///
52    /// * If the partition is invalid, [ArnError::InvalidPartition] is returned.
53    /// * If the service is invalid, [ArnError::InvalidService] is returned.
54    /// * If the region is invalid, [ArnError::InvalidRegion] is returned.
55    /// * If the account ID is invalid, [ArnError::InvalidAccountId] is returned.
56    pub fn new(
57        partition: &str,
58        service: &str,
59        region: &str,
60        account_id: &str,
61        resource: &str,
62    ) -> Result<Self, ArnError> {
63        validate_partition(partition)?;
64        validate_service(service)?;
65        if !region.is_empty() {
66            validate_region(region)?
67        }
68        if !account_id.is_empty() {
69            validate_account_id(account_id)?
70        }
71
72        // Safety: We have met the preconditions specified for new_unchecked above.
73        unsafe { Ok(Self::new_unchecked(partition, service, region, account_id, resource)) }
74    }
75
76    /// Create a new ARN from the specified components, bypassing any validation.
77    ///
78    /// # Safety
79    ///
80    /// The following constraints must be met:
81    ///
82    /// * `partition` - Must meet the rules specified in [validate_partition].
83    /// * `service` - Must meet the rules specified in [validate_service].
84    /// * `region` - Must be empty or meet the rules specified in [validate_region].
85    /// * `account_id` - Must be empty, a 12 ASCII digit account ID, or the string `aws`.
86    /// * `resource` - A valid UTF-8 string.
87    pub unsafe fn new_unchecked(
88        partition: &str,
89        service: &str,
90        region: &str,
91        account_id: &str,
92        resource: &str,
93    ) -> Self {
94        let arn = format!("arn:{partition}:{service}:{region}:{account_id}:{resource}");
95        let service_start = PARTITION_START + partition.len() + 1;
96        let region_start = service_start + service.len() + 1;
97        let account_id_start = region_start + region.len() + 1;
98        let resource_start = account_id_start + account_id.len() + 1;
99
100        Self {
101            arn,
102            service_start,
103            region_start,
104            account_id_start,
105            resource_start,
106        }
107    }
108
109    /// Retrieve the partition the resource is in.
110    #[inline]
111    pub fn partition(&self) -> &str {
112        &self.arn[PARTITION_START..self.service_start - 1]
113    }
114
115    /// Retrieve the service the resource belongs to.
116    #[inline]
117    pub fn service(&self) -> &str {
118        &self.arn[self.service_start..self.region_start - 1]
119    }
120
121    /// Retrieve the region the resource is in.
122    #[inline]
123    pub fn region(&self) -> &str {
124        &self.arn[self.region_start..self.account_id_start - 1]
125    }
126
127    /// Retrieve the account ID the resource belongs to.
128    #[inline]
129    pub fn account_id(&self) -> &str {
130        &self.arn[self.account_id_start..self.resource_start - 1]
131    }
132
133    /// Retrieve the resource name.
134    #[inline]
135    pub fn resource(&self) -> &str {
136        &self.arn[self.resource_start..]
137    }
138}
139
140impl Display for Arn {
141    /// Return the ARN.
142    fn fmt(&self, f: &mut Formatter) -> FmtResult {
143        f.write_str(&self.arn)
144    }
145}
146
147/// Parse a string into an [Arn].
148impl FromStr for Arn {
149    /// [ArnError] is returned if the string is not a valid ARN.
150    type Err = ArnError;
151
152    /// Parse an ARN from a string.
153    ///
154    /// # Errors
155    ///
156    /// * If the ARN is not composed of 6 colon-separated components, [ArnError::InvalidArn] is returned.
157    /// * If the ARN does not start with `arn:`, [ArnError::InvalidArn] is returned.
158    /// * If the partition is invalid, [ArnError::InvalidPartition] is returned.
159    /// * If the service is invalid, [ArnError::InvalidService] is returned.
160    /// * If the region is invalid, [ArnError::InvalidRegion] is returned.
161    /// * If the account ID is invalid, [ArnError::InvalidAccountId] is returned.
162    fn from_str(s: &str) -> Result<Self, ArnError> {
163        let parts: Vec<&str> = s.splitn(6, ':').collect();
164        if parts.len() != 6 {
165            return Err(ArnError::InvalidArn(s.to_string()));
166        }
167
168        if parts[0] != "arn" {
169            return Err(ArnError::InvalidScheme(parts[0].to_string()));
170        }
171
172        Self::new(parts[1], parts[2], parts[3], parts[4], parts[5])
173    }
174}
175
176/// Orders ARNs by partition, service, region, account ID, and resource.
177impl PartialOrd for Arn {
178    /// Returns the relative ordering between this and another ARN.
179    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
180        Some(self.cmp(other))
181    }
182}
183
184/// Orders ARNs by partition, service, region, account ID, and resource.
185impl Ord for Arn {
186    /// Returns the relative ordering between this and another ARN.
187    fn cmp(&self, other: &Self) -> Ordering {
188        match self.partition().cmp(other.partition()) {
189            Ordering::Equal => match self.service().cmp(other.service()) {
190                Ordering::Equal => match self.region().cmp(other.region()) {
191                    Ordering::Equal => match self.account_id().cmp(other.account_id()) {
192                        Ordering::Equal => self.resource().cmp(other.resource()),
193                        x => x,
194                    },
195                    x => x,
196                },
197                x => x,
198            },
199            x => x,
200        }
201    }
202}
203
204impl<'de> Deserialize<'de> for Arn {
205    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
206    where
207        D: serde::Deserializer<'de>,
208    {
209        let s = String::deserialize(deserializer)?;
210        Self::from_str(&s).map_err(de::Error::custom)
211    }
212}
213
214impl Serialize for Arn {
215    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
216    where
217        S: serde::Serializer,
218    {
219        serializer.serialize_str(&self.arn)
220    }
221}
222
223#[cfg(test)]
224mod test {
225    use {
226        super::Arn,
227        crate::{
228            utils::{validate_account_id, validate_region},
229            ArnError,
230        },
231        pretty_assertions::assert_eq,
232        std::{
233            collections::hash_map::DefaultHasher,
234            hash::{Hash, Hasher},
235            str::FromStr,
236        },
237    };
238
239    #[test]
240    fn check_arn_derived() {
241        let arn1a = Arn::from_str("arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0").unwrap();
242        let arn1b = Arn::from_str("arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0").unwrap();
243        let arn2 = Arn::from_str("arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef1").unwrap();
244        let arn3 = Arn::from_str("arn:aws:ec2:us-east-1:123456789013:instance/i-1234567890abcdef0").unwrap();
245        let arn4 = Arn::from_str("arn:aws:ec2:us-east-2:123456789012:instance/i-1234567890abcdef0").unwrap();
246        let arn5 = Arn::from_str("arn:aws:ec3:us-east-1:123456789012:instance/i-1234567890abcdef0").unwrap();
247        let arn6 = Arn::from_str("arn:awt:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0").unwrap();
248
249        assert_eq!(arn1a, arn1b);
250        assert!(arn1a < arn2);
251        assert!(arn2 < arn3);
252        assert!(arn3 < arn4);
253        assert!(arn4 < arn5);
254        assert!(arn5 < arn6);
255
256        assert_eq!(arn1a, arn1a.clone());
257
258        // Ensure ordering is logical.
259        assert!(arn1a <= arn1b);
260        assert!(arn1a < arn2);
261        assert!(arn2 > arn1a);
262        assert!(arn2 < arn3);
263        assert!(arn1a < arn3);
264        assert!(arn3 > arn2);
265        assert!(arn3 > arn1a);
266        assert!(arn3 < arn4);
267        assert!(arn4 > arn3);
268        assert!(arn4 < arn5);
269        assert!(arn5 > arn4);
270        assert!(arn5 < arn6);
271        assert!(arn6 > arn5);
272
273        assert!(arn3.clone().min(arn4.clone()) == arn3);
274        assert!(arn4.clone().max(arn3) == arn4);
275
276        // Ensure we can derive a hash for the arn.
277        let mut h1a = DefaultHasher::new();
278        let mut h1b = DefaultHasher::new();
279        arn1a.hash(&mut h1a);
280        arn1b.hash(&mut h1b);
281        assert_eq!(h1a.finish(), h1b.finish());
282
283        let mut h2 = DefaultHasher::new();
284        arn2.hash(&mut h2);
285
286        // Ensure we can debug print the arn.
287        _ = format!("{arn1a:?}");
288
289        assert_eq!(arn1a.to_string(), "arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0".to_string());
290    }
291
292    #[test]
293    fn check_arn_components() {
294        let arn = Arn::from_str("arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0").unwrap();
295        assert_eq!(arn.partition(), "aws");
296        assert_eq!(arn.service(), "ec2");
297        assert_eq!(arn.region(), "us-east-1");
298        assert_eq!(arn.account_id(), "123456789012");
299        assert_eq!(arn.resource(), "instance/i-1234567890abcdef0");
300    }
301
302    #[test]
303    fn check_arn_empty() {
304        let arn1 = Arn::from_str("arn:aws:s3:::bucket").unwrap();
305        let arn2 = Arn::from_str("arn:aws:s3:us-east-1::bucket").unwrap();
306        let arn3 = Arn::from_str("arn:aws:s3:us-east-1:123456789012:bucket").unwrap();
307
308        assert!(arn1 < arn2);
309        assert!(arn2 < arn3);
310        assert!(arn1 < arn3);
311    }
312
313    #[test]
314    fn check_unicode() {
315        let arn = Arn::from_str("arn:aws-中国:één:日本-東京-1:123456789012:instance/i-1234567890abcdef0").unwrap();
316        assert_eq!(arn.partition(), "aws-中国");
317        assert_eq!(arn.service(), "één");
318        assert_eq!(arn.region(), "日本-東京-1");
319
320        let arn = Arn::from_str(
321            "arn:việtnam:nœrøyfjorden:ap-southeast-7-hòa-hiệp-bắc-3:123456789012:instance/i-1234567890abcdef0",
322        )
323        .unwrap();
324        assert_eq!(arn.partition(), "việtnam");
325        assert_eq!(arn.service(), "nœrøyfjorden");
326        assert_eq!(arn.region(), "ap-southeast-7-hòa-hiệp-bắc-3");
327    }
328
329    #[test]
330    fn check_malformed_arns() {
331        let wrong_parts =
332            vec!["arn", "arn:aws", "arn:aws:ec2", "arn:aws:ec2:us-east-1", "arn:aws:ec2:us-east-1:123456789012"];
333        for wrong_part in wrong_parts {
334            assert_eq!(Arn::from_str(wrong_part).unwrap_err(), ArnError::InvalidArn(wrong_part.to_string()));
335        }
336    }
337
338    #[test]
339    fn check_invalid_scheme() {
340        let err = Arn::from_str("http:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0").unwrap_err();
341        assert_eq!(err.to_string(), r#"Invalid scheme: "http""#.to_string());
342    }
343
344    #[test]
345    fn check_invalid_partition() {
346        let err = Arn::from_str("arn:Aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0").unwrap_err();
347        assert_eq!(err.to_string(), r#"Invalid partition: "Aws""#.to_string());
348
349        let err = Arn::from_str("arn:local-:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0").unwrap_err();
350        assert_eq!(err.to_string(), r#"Invalid partition: "local-""#.to_string());
351
352        let err = Arn::from_str("arn::ec2:us-east-1:123456789012:instance/i-1234567890abcdef0").unwrap_err();
353        assert_eq!(err.to_string(), r#"Invalid partition: """#.to_string());
354
355        let err = Arn::from_str("arn:-local:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0").unwrap_err();
356        assert_eq!(err.to_string(), r#"Invalid partition: "-local""#.to_string());
357
358        let err = Arn::from_str("arn:aws--1:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0").unwrap_err();
359        assert_eq!(err.to_string(), r#"Invalid partition: "aws--1""#.to_string());
360
361        let err = Arn::from_str(
362            "arn:this-partition-has-too-many-chars:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0",
363        )
364        .unwrap_err();
365        assert_eq!(err.to_string(), r#"Invalid partition: "this-partition-has-too-many-chars""#.to_string());
366
367        let err = Arn::from_str("arn:🦀:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0").unwrap_err();
368        assert_eq!(err.to_string(), r#"Invalid partition: "🦀""#.to_string());
369    }
370
371    #[test]
372    fn check_invalid_services() {
373        let err = Arn::from_str("arn:aws:ec2-:us-east-1:123456789012:instance/i-1234567890abcdef0").unwrap_err();
374        assert_eq!(err.to_string(), r#"Invalid service name: "ec2-""#.to_string());
375
376        let err = Arn::from_str("arn:aws:-ec2:us-east-1:123456789012:instance/i-1234567890abcdef0").unwrap_err();
377        assert_eq!(err.to_string(), r#"Invalid service name: "-ec2""#.to_string());
378
379        let err = Arn::from_str("arn:aws:Ec2:us-east-1:123456789012:instance/i-1234567890abcdef0").unwrap_err();
380        assert_eq!(err.to_string(), r#"Invalid service name: "Ec2""#.to_string());
381
382        let err = Arn::from_str("arn:aws:ec--2:us-east-1:123456789012:instance/i-1234567890abcdef0").unwrap_err();
383        assert_eq!(err.to_string(), r#"Invalid service name: "ec--2""#.to_string());
384
385        let err = Arn::from_str("arn:aws:🦀:us-east-1:123456789012:instance/i-1234567890abcdef0").unwrap_err();
386        assert_eq!(err.to_string(), r#"Invalid service name: "🦀""#.to_string());
387
388        let err = Arn::from_str("arn:aws::us-east-1:123456789012:instance/i-1234567890abcdef0").unwrap_err();
389        assert_eq!(err.to_string(), r#"Invalid service name: """#.to_string());
390    }
391
392    #[test]
393    fn check_valid_regions() {
394        let arn = Arn::from_str("arn:aws:ec2:local:123456789012:instance/i-1234567890abcdef0").unwrap();
395        assert_eq!(arn.region(), "local");
396
397        let arn = Arn::from_str("arn:aws:ec2:us-east-1-bos-1:123456789012:instance/i-1234567890abcdef0").unwrap();
398        assert_eq!(arn.region(), "us-east-1-bos-1");
399    }
400
401    #[test]
402    fn check_invalid_region() {
403        let err = Arn::from_str("arn:aws:ec2:us-east-1-:123456789012:instance/i-1234567890abcdef0").unwrap_err();
404        assert_eq!(err.to_string(), r#"Invalid region: "us-east-1-""#.to_string());
405
406        let err = Arn::from_str("arn:aws:ec2:us-east-1a:123456789012:instance/i-1234567890abcdef0").unwrap_err();
407        assert_eq!(err.to_string(), r#"Invalid region: "us-east-1a""#.to_string());
408
409        let err = Arn::from_str("arn:aws:ec2:us-east1:123456789012:instance/i-1234567890abcdef0").unwrap_err();
410        assert_eq!(err.to_string(), r#"Invalid region: "us-east1""#.to_string());
411
412        let err = Arn::from_str("arn:aws:ec2:-us-east-1:123456789012:instance/i-1234567890abcdef0").unwrap_err();
413        assert_eq!(err.to_string(), r#"Invalid region: "-us-east-1""#.to_string());
414
415        let err = Arn::from_str("arn:aws:ec2:us-east:123456789012:instance/i-1234567890abcdef0").unwrap_err();
416        assert_eq!(err.to_string(), r#"Invalid region: "us-east""#.to_string());
417
418        let err = Arn::from_str("arn:aws:ec2:Us-East-1:123456789012:instance/i-1234567890abcdef0").unwrap_err();
419        assert_eq!(err.to_string(), r#"Invalid region: "Us-East-1""#.to_string());
420
421        let err = Arn::from_str("arn:aws:ec2:us-east--1:123456789012:instance/i-1234567890abcdef0").unwrap_err();
422        assert_eq!(err.to_string(), r#"Invalid region: "us-east--1""#.to_string());
423
424        let err =
425            Arn::from_str("arn:aws:ec2:us-east-1-bos-1-lax-1:123456789012:instance/i-1234567890abcdef0").unwrap_err();
426        assert_eq!(err.to_string(), r#"Invalid region: "us-east-1-bos-1-lax-1""#.to_string());
427
428        let err = Arn::from_str("arn:aws:ec2:us-east-🦀:123456789012:instance/i-1234567890abcdef0").unwrap_err();
429        assert_eq!(err.to_string(), r#"Invalid region: "us-east-🦀""#.to_string());
430
431        let err = validate_region("").unwrap_err();
432        assert_eq!(err, ArnError::InvalidRegion("".to_string()));
433    }
434
435    #[test]
436    fn check_valid_account_ids() {
437        let arn = Arn::from_str("arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0").unwrap();
438        assert_eq!(arn.account_id(), "123456789012");
439
440        let arn = Arn::from_str("arn:aws:ec2:us-east-1:aws:instance/i-1234567890abcdef0").unwrap();
441        assert_eq!(arn.account_id(), "aws");
442    }
443
444    #[test]
445    fn check_invalid_account_ids() {
446        let err = Arn::from_str("arn:aws:ec2:us-east-1:1234567890123:instance/i-1234567890abcdef0").unwrap_err();
447        assert_eq!(err.to_string(), r#"Invalid account id: "1234567890123""#.to_string());
448
449        let err = Arn::from_str("arn:aws:ec2:us-east-1:12345678901:instance/i-1234567890abcdef0").unwrap_err();
450        assert_eq!(err.to_string(), r#"Invalid account id: "12345678901""#.to_string());
451
452        let err = Arn::from_str("arn:aws:ec2:us-east-1:12345678901a:instance/i-1234567890abcdef0").unwrap_err();
453        assert_eq!(err.to_string(), r#"Invalid account id: "12345678901a""#.to_string());
454
455        let err = validate_account_id("").unwrap_err();
456        assert_eq!(err, ArnError::InvalidAccountId("".to_string()));
457    }
458
459    #[test]
460    fn check_serialization() {
461        let arn: Arn =
462            serde_json::from_str(r#""arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0""#).unwrap();
463        assert_eq!(arn.partition(), "aws");
464        assert_eq!(arn.service(), "ec2");
465        assert_eq!(arn.region(), "us-east-1");
466        assert_eq!(arn.account_id(), "123456789012");
467        assert_eq!(arn.resource(), "instance/i-1234567890abcdef0");
468
469        let arn_str = serde_json::to_string(&arn).unwrap();
470        assert_eq!(arn_str, r#""arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0""#);
471
472        let arn_err = serde_json::from_str::<Arn>(r#""arn:aws:ec2:us-east-1""#).unwrap_err();
473        assert_eq!(arn_err.to_string(), r#"Invalid ARN: "arn:aws:ec2:us-east-1""#);
474
475        let arn_err = serde_json::from_str::<Arn>(r#"{}"#);
476        assert_eq!(arn_err.unwrap_err().to_string(), "invalid type: map, expected a string at line 1 column 0");
477    }
478}