Skip to main content

iam_rs/core/
arn.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4use crate::{Validate, ValidationContext, ValidationError};
5
6/// Represents an Amazon Resource Name (ARN)
7///
8/// ARNs uniquely identify AWS resources. The general format is:
9/// `arn:partition:service:region:account-id:resource-type/resource-id`
10///
11/// Some services use slightly different formats:
12/// - `arn:partition:service:region:account-id:resource-type:resource-id`
13/// - `arn:partition:service:region:account-id:resource-type/resource-id/sub-resource`
14#[derive(Debug, Clone, PartialEq, Eq)]
15#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
16pub struct Arn {
17    /// The partition (e.g., "aws", "aws-cn", "aws-us-gov")
18    pub partition: String,
19    /// The service namespace (e.g., "s3", "ec2", "iam")
20    pub service: String,
21    /// The region (e.g., "us-east-1", can be empty for global services)
22    pub region: String,
23    /// The account ID (12-digit number, can be empty for some services)
24    pub account_id: String,
25    /// The resource specification (format varies by service)
26    pub resource: String,
27}
28
29/// Error types for ARN parsing and validation
30#[derive(Debug, Clone, PartialEq, Eq)]
31#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
32pub enum ArnError {
33    /// ARN doesn't start with "arn:"
34    InvalidPrefix,
35    /// ARN has incorrect number of components
36    InvalidFormat,
37    /// Partition is empty or invalid
38    InvalidPartition(String),
39    /// Service is empty or invalid
40    InvalidService(String),
41    /// Account ID format is invalid (should be 12 digits or empty)
42    InvalidAccountId(String),
43    /// Resource format is invalid
44    InvalidResource(String),
45}
46
47impl fmt::Display for ArnError {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            ArnError::InvalidPrefix => write!(f, "ARN must start with 'arn:'"),
51            ArnError::InvalidFormat => write!(f, "ARN must have exactly 6 parts separated by ':'"),
52            ArnError::InvalidPartition(p) => write!(f, "Invalid partition: '{p}'"),
53            ArnError::InvalidService(s) => write!(f, "Invalid service: '{s}'"),
54            ArnError::InvalidAccountId(id) => write!(f, "Invalid account ID: '{id}'"),
55            ArnError::InvalidResource(r) => write!(f, "Invalid resource: '{r}'"),
56        }
57    }
58}
59
60impl std::error::Error for ArnError {}
61
62impl Arn {
63    /// Parse an ARN string into an Arn struct
64    /// This method is extremely lenient and only validates bare format requirements.
65    /// Use `is_valid()` to perform comprehensive validation.
66    ///
67    /// # Errors
68    ///
69    /// Returns `ArnError::InvalidPrefix` if the string doesn't start with "arn:"
70    /// Returns `ArnError::InvalidFormat` if the ARN has incorrect number of components
71    pub fn parse(arn_str: &str) -> Result<Self, ArnError> {
72        let parts: Vec<&str> = arn_str.split(':').collect();
73
74        if parts.len() < 6 {
75            return Err(ArnError::InvalidFormat);
76        }
77
78        if parts[0] != "arn" {
79            return Err(ArnError::InvalidPrefix);
80        }
81
82        let partition = parts[1].to_string();
83        let service = parts[2].to_string();
84        let region = parts[3].to_string();
85        let account_id = parts[4].to_string();
86
87        // Join remaining parts as resource (handles cases with multiple colons in resource)
88        let resource = parts[5..].join(":");
89
90        Ok(Arn {
91            partition,
92            service,
93            region,
94            account_id,
95            resource,
96        })
97    }
98
99    /// Check if this ARN matches another ARN or pattern
100    /// Supports wildcards (* and ?) in any component except service
101    ///
102    /// # Errors
103    ///
104    /// Returns `ArnError` if the pattern is not a valid ARN format
105    pub fn matches(&self, pattern: &str) -> Result<bool, ArnError> {
106        // "*" matches any ARN.
107        if pattern == "*" {
108            return Ok(true);
109        }
110
111        let pattern_arn = Arn::parse(pattern)?;
112
113        // Service cannot contain wildcards
114        if pattern_arn.service.contains('*') || pattern_arn.service.contains('?') {
115            return Ok(false);
116        }
117
118        Ok(
119            Self::wildcard_match(&self.partition, &pattern_arn.partition)
120                && self.service == pattern_arn.service
121                && Self::wildcard_match(&self.region, &pattern_arn.region)
122                && Self::wildcard_match(&self.account_id, &pattern_arn.account_id)
123                && Self::wildcard_match(&self.resource, &pattern_arn.resource),
124        )
125    }
126
127    /// Check if a string matches a pattern with wildcards
128    /// * matches any sequence of characters
129    ///   ? matches any single character
130    ///
131    /// Uses an iterative greedy matcher with backtracking on the last `*`, so
132    /// it never recurses (no stack overflow) and avoids the exponential worst
133    /// case of a naive matcher; O(n·m) worst case, near-linear in practice.
134    #[must_use]
135    pub fn wildcard_match(text: &str, pattern: &str) -> bool {
136        let text_chars: Vec<char> = text.chars().collect();
137        let pattern_chars: Vec<char> = pattern.chars().collect();
138
139        let mut t = 0; // current position in text
140        let mut p = 0; // current position in pattern
141        let mut star_t = 0; // text position after the last `*` match
142        let mut star_p = 0; // pattern position of the last `*`
143        let mut has_star = false;
144
145        while t < text_chars.len() {
146            if p < pattern_chars.len()
147                && (pattern_chars[p] == '?' || pattern_chars[p] == text_chars[t])
148            {
149                // '?' or an exact character matches.
150                t += 1;
151                p += 1;
152            } else if p < pattern_chars.len() && pattern_chars[p] == '*' {
153                // Remember where to resume after this `*`.
154                has_star = true;
155                star_t = t;
156                star_p = p;
157                p += 1;
158            } else if has_star {
159                // Backtrack: let the last `*` consume one more text character.
160                star_t += 1;
161                t = star_t;
162                p = star_p + 1;
163            } else {
164                return false;
165            }
166        }
167
168        // Any remaining `*`s in the pattern match the empty string.
169        while p < pattern_chars.len() && pattern_chars[p] == '*' {
170            p += 1;
171        }
172
173        p == pattern_chars.len()
174    }
175
176    /// Check if this ARN is valid according to AWS ARN rules
177    #[must_use]
178    pub fn is_valid(&self) -> bool {
179        // Basic format validation rules
180        if self.partition.is_empty() {
181            return false;
182        }
183
184        if self.service.is_empty() {
185            return false;
186        }
187
188        if self.resource.is_empty() {
189            return false;
190        }
191
192        // Validate partition (alphanumeric, dash, and underscore, but no other special characters)
193        if !self
194            .partition
195            .chars()
196            .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
197        {
198            return false;
199        }
200
201        // Validate service (alphanumeric and dash, no other special characters)
202        if !self
203            .service
204            .chars()
205            .all(|c| c.is_alphanumeric() || c == '-')
206        {
207            return false;
208        }
209
210        // Validate account ID if present
211        if !Self::is_valid_account_id(&self.account_id) {
212            return false;
213        }
214
215        // Service-specific validation could be added here
216        true
217    }
218
219    /// Validate if a string is a valid account ID (12 digits) or a wildcard pattern
220    fn is_valid_account_id(account_id: &str) -> bool {
221        // Allow empty
222        if account_id.is_empty() {
223            return true;
224        }
225
226        // If wildcards are present, we're more lenient
227        if account_id.contains('*') || account_id.contains('?') {
228            return true;
229        }
230
231        account_id.len() == 12 && account_id.chars().all(|c| c.is_ascii_digit())
232    }
233
234    /// Get the resource type from the resource string
235    /// For resources like "bucket/object", returns "bucket"
236    /// For resources like "user/username", returns "user"
237    #[must_use]
238    pub fn resource_type(&self) -> Option<&str> {
239        if let Some(slash_pos) = self.resource.find('/') {
240            Some(&self.resource[..slash_pos])
241        } else if let Some(colon_pos) = self.resource.find(':') {
242            Some(&self.resource[..colon_pos])
243        } else {
244            // Some services just have a resource ID without type
245            None
246        }
247    }
248
249    /// Get the resource ID from the resource string
250    /// For resources like "bucket/object", returns "object"
251    /// For resources like "user/username", returns "username"
252    #[must_use]
253    pub fn resource_id(&self) -> Option<&str> {
254        if let Some(slash_pos) = self.resource.find('/') {
255            Some(&self.resource[slash_pos + 1..])
256        } else if let Some(colon_pos) = self.resource.find(':') {
257            Some(&self.resource[colon_pos + 1..])
258        } else {
259            // The entire resource string is the ID
260            Some(&self.resource)
261        }
262    }
263}
264
265impl Validate for Arn {
266    fn validate(&self, _context: &mut ValidationContext) -> crate::ValidationResult {
267        if self.is_valid() {
268            Ok(())
269        } else {
270            Err(ValidationError::InvalidArn {
271                arn: self.to_string(),
272                reason: "ARN format is valid but is not a conformant ARN".to_string(),
273            })
274        }
275    }
276}
277
278impl fmt::Display for Arn {
279    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280        write!(
281            f,
282            "arn:{}:{}:{}:{}:{}",
283            self.partition, self.service, self.region, self.account_id, self.resource
284        )
285    }
286}
287
288impl std::str::FromStr for Arn {
289    type Err = ArnError;
290
291    fn from_str(s: &str) -> Result<Self, Self::Err> {
292        Arn::parse(s)
293    }
294}
295
296impl Serialize for Arn {
297    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
298    where
299        S: serde::Serializer,
300    {
301        serializer.serialize_str(&self.to_string())
302    }
303}
304
305impl<'de> Deserialize<'de> for Arn {
306    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
307    where
308        D: serde::Deserializer<'de>,
309    {
310        let arn_str = String::deserialize(deserializer)?;
311        Arn::parse(&arn_str).map_err(serde::de::Error::custom)
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn test_valid_arn_parsing() {
321        let arn_str = "arn:aws:s3:us-east-1:123456789012:bucket/my-bucket";
322        let arn = Arn::parse(arn_str).unwrap();
323
324        assert_eq!(arn.partition, "aws");
325        assert_eq!(arn.service, "s3");
326        assert_eq!(arn.region, "us-east-1");
327        assert_eq!(arn.account_id, "123456789012");
328        assert_eq!(arn.resource, "bucket/my-bucket");
329        assert_eq!(arn.to_string(), arn_str);
330    }
331
332    #[test]
333    fn test_arn_without_region() {
334        let arn_str = "arn:aws:iam::123456789012:user/username";
335        let arn = Arn::parse(arn_str).unwrap();
336
337        assert_eq!(arn.partition, "aws");
338        assert_eq!(arn.service, "iam");
339        assert_eq!(arn.region, "");
340        assert_eq!(arn.account_id, "123456789012");
341        assert_eq!(arn.resource, "user/username");
342    }
343
344    #[test]
345    fn test_arn_with_colons_in_resource() {
346        let arn_str = "arn:aws:ssm:us-east-1:123456789012:parameter/app/db/url";
347        let arn = Arn::parse(arn_str).unwrap();
348
349        assert_eq!(arn.resource, "parameter/app/db/url");
350    }
351
352    #[test]
353    fn test_invalid_arn_prefix() {
354        let result = Arn::parse("invalid:aws:s3:::bucket");
355        assert_eq!(result, Err(ArnError::InvalidPrefix));
356    }
357
358    #[test]
359    fn test_invalid_arn_format() {
360        let result = Arn::parse("arn:aws:s3");
361        assert_eq!(result, Err(ArnError::InvalidFormat));
362    }
363
364    #[test]
365    fn test_invalid_account_id() {
366        let result = Arn::parse("arn:aws:s3:us-east-1:invalid:bucket/my-bucket")
367            .unwrap()
368            .is_valid();
369        assert!(!result);
370    }
371
372    #[test]
373    fn test_wildcard_matching() {
374        let arn =
375            Arn::parse("arn:aws:s3:us-east-1:123456789012:bucket/my-bucket/file.txt").unwrap();
376
377        // Exact match
378        assert!(
379            arn.matches("arn:aws:s3:us-east-1:123456789012:bucket/my-bucket/file.txt")
380                .unwrap()
381        );
382
383        // Wildcard in resource
384        assert!(
385            arn.matches("arn:aws:s3:us-east-1:123456789012:bucket/my-bucket/*")
386                .unwrap()
387        );
388        assert!(
389            arn.matches("arn:aws:s3:us-east-1:123456789012:bucket/*/file.txt")
390                .unwrap()
391        );
392
393        // Wildcard in region
394        assert!(
395            arn.matches("arn:aws:s3:*:123456789012:bucket/my-bucket/file.txt")
396                .unwrap()
397        );
398
399        // Single character wildcard
400        assert!(
401            arn.matches("arn:aws:s3:us-east-?:123456789012:bucket/my-bucket/file.txt")
402                .unwrap()
403        );
404
405        // Should not match different service
406        assert!(
407            !arn.matches("arn:aws:ec2:us-east-1:123456789012:bucket/my-bucket/file.txt")
408                .unwrap()
409        );
410
411        // Should not allow wildcards in service
412        assert!(
413            !arn.matches("arn:aws:*:us-east-1:123456789012:bucket/my-bucket/file.txt")
414                .unwrap()
415        );
416    }
417
418    #[test]
419    fn test_matches_wildcard_all() {
420        let arn = Arn::parse("arn:aws:s3:us-east-1:123456789012:bucket/my-bucket").unwrap();
421
422        // "*" matches any ARN (consistent with ArnMatcher).
423        assert!(arn.matches("*").unwrap());
424    }
425
426    #[test]
427    fn test_resource_parsing() {
428        let arn = Arn::parse("arn:aws:s3:::bucket/folder/file.txt").unwrap();
429        assert_eq!(arn.resource_type(), Some("bucket"));
430        assert_eq!(arn.resource_id(), Some("folder/file.txt"));
431
432        let arn2 = Arn::parse("arn:aws:iam::123456789012:role/MyRole").unwrap();
433        assert_eq!(arn2.resource_type(), Some("role"));
434        assert_eq!(arn2.resource_id(), Some("MyRole"));
435
436        let arn3 = Arn::parse("arn:aws:sns:us-east-1:123456789012:my-topic").unwrap();
437        assert_eq!(arn3.resource_type(), None);
438        assert_eq!(arn3.resource_id(), Some("my-topic"));
439    }
440
441    #[test]
442    fn test_arn_validation() {
443        let valid_arn = Arn::parse("arn:aws:s3:us-east-1:123456789012:bucket/my-bucket").unwrap();
444        assert!(valid_arn.is_valid());
445
446        let valid_arn = Arn {
447            partition: "aws-cn".to_string(),
448            service: "s3".to_string(),
449            region: "us-east-1".to_string(),
450            account_id: "123456789012".to_string(),
451            resource: "bucket/my-bucket".to_string(),
452        };
453        assert!(valid_arn.is_valid());
454
455        let valid_arn = Arn::parse("arn:aws:s3:abc::*").unwrap();
456        assert!(valid_arn.is_valid());
457        let invalid_partition = Arn::parse("arn:@:s3:abc::*").unwrap();
458        assert!(!invalid_partition.is_valid());
459        let invalid_service = Arn::parse("arn:aws:@:abc::*").unwrap();
460        assert!(!invalid_service.is_valid());
461        let invalid_account_id = Arn::parse("arn:aws:s3:abc:12345:*").unwrap();
462        assert!(!invalid_account_id.is_valid());
463    }
464
465    #[test]
466    fn test_wildcard_parsing() {
467        let arn = Arn::parse("arn:aws:s3:*:*:bucket/*").unwrap();
468        assert_eq!(arn.region, "*");
469        assert_eq!(arn.account_id, "*");
470        assert_eq!(arn.resource, "bucket/*");
471    }
472
473    #[test]
474    fn test_complex_wildcard_patterns() {
475        let arn = Arn::parse("arn:aws:s3:::my-bucket/folder/subfolder/file.txt").unwrap();
476
477        // Multiple wildcards
478        assert!(arn.matches("arn:aws:s3:::my-bucket/*/*/file.txt").unwrap());
479        assert!(arn.matches("arn:aws:s3:::*/folder/subfolder/*").unwrap());
480
481        // Mixed wildcards
482        assert!(
483            arn.matches("arn:aws:s3:::my-bucket/*/subfolder/file.?xt")
484                .unwrap()
485        );
486
487        // Should not match
488        assert!(
489            !arn.matches("arn:aws:s3:::other-bucket/folder/subfolder/file.txt")
490                .unwrap()
491        );
492        assert!(
493            !arn.matches("arn:aws:s3:::my-bucket/folder/other/file.txt")
494                .unwrap()
495        );
496    }
497
498    #[test]
499    fn test_arn_validation_in_policies() {
500        // Test valid ARNs in policy resources
501        let valid_arns = vec![
502            "arn:aws:s3:::my-bucket/*",
503            "arn:aws:s3:::my-bucket/folder/*",
504            "arn:aws:iam::123456789012:user/username",
505            "arn:aws:ec2:us-east-1:123456789012:instance/*",
506            "arn:aws:lambda:us-east-1:123456789012:function:MyFunction",
507        ];
508
509        for arn_str in valid_arns {
510            let arn = Arn::parse(arn_str).unwrap();
511            assert!(arn.is_valid(), "ARN should be valid: {arn_str}");
512        }
513    }
514
515    #[test]
516    fn test_arn_wildcard_matching_in_policies() {
517        // Test ARN pattern matching for resource access
518        let resource_arn =
519            Arn::parse("arn:aws:s3:::my-bucket/uploads/user123/document.pdf").unwrap();
520
521        // These patterns should match
522        let matching_patterns = vec![
523            "arn:aws:s3:::my-bucket/*",
524            "arn:aws:s3:::my-bucket/uploads/*",
525            "arn:aws:s3:::my-bucket/uploads/user123/*",
526            "arn:aws:s3:::*/uploads/user123/document.pdf",
527            "arn:aws:s3:::my-bucket/uploads/*/document.pdf",
528            "arn:aws:s3:::my-bucket/*/user123/document.pdf",
529            "arn:aws:s3:::my-bucket/uploads/user???/document.pdf",
530        ];
531
532        for pattern in matching_patterns {
533            assert!(
534                resource_arn.matches(pattern).unwrap(),
535                "Pattern '{pattern}' should match ARN '{resource_arn}'"
536            );
537        }
538
539        // These patterns should NOT match
540        let non_matching_patterns = vec![
541            "arn:aws:s3:::other-bucket/*",
542            "arn:aws:s3:::my-bucket/downloads/*",
543            "arn:aws:s3:::my-bucket/uploads/user456/*",
544            "arn:aws:ec2:*:*:*", // Different service
545            "arn:aws:s3:::my-bucket/uploads/user12/document.pdf", // user12 != user123
546        ];
547
548        for pattern in non_matching_patterns {
549            assert!(
550                !resource_arn.matches(pattern).unwrap(),
551                "Pattern '{pattern}' should NOT match ARN '{resource_arn}'"
552            );
553        }
554    }
555
556    #[test]
557    fn test_arn_resource_parsing() {
558        let test_cases = vec![
559            ("arn:aws:s3:::bucket/object", Some("bucket"), Some("object")),
560            (
561                "arn:aws:iam::123456789012:user/username",
562                Some("user"),
563                Some("username"),
564            ),
565            (
566                "arn:aws:iam::123456789012:role/MyRole",
567                Some("role"),
568                Some("MyRole"),
569            ),
570            (
571                "arn:aws:sns:us-east-1:123456789012:my-topic",
572                None,
573                Some("my-topic"),
574            ),
575            (
576                "arn:aws:dynamodb:us-east-1:123456789012:table/MyTable",
577                Some("table"),
578                Some("MyTable"),
579            ),
580            (
581                "arn:aws:s3:::bucket/folder/subfolder/file.txt",
582                Some("bucket"),
583                Some("folder/subfolder/file.txt"),
584            ),
585        ];
586
587        for (arn_str, expected_type, expected_id) in test_cases {
588            let arn = Arn::parse(arn_str).unwrap();
589            assert_eq!(
590                arn.resource_type(),
591                expected_type,
592                "Resource type mismatch for {arn_str}"
593            );
594            assert_eq!(
595                arn.resource_id(),
596                expected_id,
597                "Resource ID mismatch for {arn_str}"
598            );
599        }
600    }
601
602    #[test]
603    fn test_invalid_arns() {
604        let invalid_arns = vec![
605            "not-an-arn",
606            "arn:aws:s3", // Too few parts
607        ];
608
609        // These should fail parsing entirely (basic format issues)
610        for invalid_arn in invalid_arns {
611            let result = Arn::parse(invalid_arn);
612            assert!(result.is_err(), "ARN should fail parsing: {invalid_arn}");
613        }
614
615        let validation_invalid_arns = vec![
616            "arn::s3:us-east-1:123456789012:bucket/my-bucket", // Empty partition
617            "arn:aws::us-east-1:123456789012:bucket/my-bucket", // Empty service
618            "arn:aws:s3:us-east-1:123456789012:",              // Empty resource
619            "arn:aws:s3:us-east-1:invalid-account:bucket/my-bucket", // Invalid account ID
620            "arn:aws:s3:us-east-1:12345678901:bucket/my-bucket", // Account ID too short
621            "arn:aws:s3:us-east-1:1234567890123:bucket/my-bucket", // Account ID too long
622        ];
623
624        // These should parse but fail validation
625        for invalid_arn in validation_invalid_arns {
626            let arn =
627                Arn::parse(invalid_arn).unwrap_or_else(|_| panic!("Should parse: {invalid_arn}"));
628            assert!(!arn.is_valid(), "ARN should be invalid: {invalid_arn}");
629        }
630    }
631
632    #[test]
633    fn test_amazon_arns_from_json() {
634        // Read the JSON file containing Amazon ARN examples
635        let json_content = std::fs::read_to_string("tests/arns.json")
636            .expect("Failed to read tests/arns.json file");
637
638        // Parse the JSON array of ARN strings
639        let arns: Vec<String> =
640            serde_json::from_str(&json_content).expect("Failed to parse JSON content");
641
642        // Check if we have any ARNs to test
643        assert!(!arns.is_empty(), "No ARNs found in tests/arns.json");
644
645        println!("Testing {} ARNs from tests/arns.json", arns.len());
646        for (index, arn_string) in arns.iter().enumerate() {
647            // Trim any whitespace (some ARNs in the JSON might have trailing spaces)
648            let arn_string = arn_string.trim();
649
650            if arn_string.is_empty() {
651                continue;
652            }
653
654            println!("Testing ARN {}: {} ", index + 1, arn_string);
655            let arn = Arn::parse(arn_string).unwrap();
656
657            // Verify the ARN can be serialized back to string
658            let reconstructed = arn.to_string();
659            assert_eq!(
660                reconstructed, arn_string,
661                "Reconstructed ARN does not match original: {arn_string}"
662            );
663
664            // Check if the ARN passes validation
665            if arn.is_valid() {
666                // Additional checks for well-formed ARNs
667                assert!(
668                    !arn.partition.is_empty(),
669                    "Partition should not be empty for ARN: {arn_string}"
670                );
671                assert!(
672                    !arn.service.is_empty(),
673                    "Service should not be empty for ARN: {arn_string}"
674                );
675                assert!(
676                    !arn.resource.is_empty(),
677                    "Resource should not be empty for ARN: {arn_string}"
678                );
679
680                // Test that the ARN can be round-tripped
681                let reparsed = Arn::parse(&reconstructed).unwrap_or_else(|_| {
682                    panic!("Failed to reparse reconstructed ARN: {reconstructed}")
683                });
684                assert_eq!(
685                    arn, reparsed,
686                    "Round-trip parsing failed for ARN: {arn_string}"
687                );
688            } else {
689                panic!("ARN parsed but failed validation: {arn_string}");
690            }
691        }
692    }
693}