iam_rs/core/
arn.rs

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