1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct Arn {
14 pub partition: String,
16 pub service: String,
18 pub region: String,
20 pub account_id: String,
22 pub resource: String,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum ArnError {
29 InvalidPrefix,
31 InvalidFormat,
33 InvalidPartition(String),
35 InvalidService(String),
37 InvalidAccountId(String),
39 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 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 let resource = parts[5..].join(":");
80
81 Ok(Arn {
82 partition,
83 service,
84 region,
85 account_id,
86 resource,
87 })
88 }
89
90 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 pub fn matches(&self, pattern: &str) -> Result<bool, ArnError> {
101 let pattern_arn = Arn::parse(pattern)?;
102
103 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 pub fn wildcard_match(text: &str, pattern: &str) -> bool {
121 Self::wildcard_match_recursive(text, pattern, 0, 0)
122 }
123
124 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 pattern_idx >= pattern_chars.len() && text_idx >= text_chars.len() {
136 return true;
137 }
138
139 if pattern_idx >= pattern_chars.len() {
142 return false;
143 }
144
145 match pattern_chars[pattern_idx] {
146 '*' => {
147 if Self::wildcard_match_recursive(text, pattern, text_idx, pattern_idx + 1) {
149 return true;
150 }
151
152 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 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 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 pub fn is_valid(&self) -> bool {
181 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 if !self
196 .partition
197 .chars()
198 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
199 {
200 return false;
201 }
202
203 if !self
205 .service
206 .chars()
207 .all(|c| c.is_alphanumeric() || c == '-')
208 {
209 return false;
210 }
211
212 if !Self::is_valid_account_id(&self.account_id) {
214 return false;
215 }
216
217 true
219 }
220
221 fn is_valid_account_id(account_id: &str) -> bool {
223 if account_id.is_empty() {
225 return true;
226 }
227
228 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 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 None
247 }
248 }
249
250 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 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 assert!(
343 arn.matches("arn:aws:s3:us-east-1:123456789012:bucket/my-bucket/file.txt")
344 .unwrap()
345 );
346
347 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 assert!(
359 arn.matches("arn:aws:s3:*:123456789012:bucket/my-bucket/file.txt")
360 .unwrap()
361 );
362
363 assert!(
365 arn.matches("arn:aws:s3:us-east-?:123456789012:bucket/my-bucket/file.txt")
366 .unwrap()
367 );
368
369 assert!(
371 !arn.matches("arn:aws:ec2:us-east-1:123456789012:bucket/my-bucket/file.txt")
372 .unwrap()
373 );
374
375 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 assert!(arn.matches("arn:aws:s3:::my-bucket/*/*/file.txt").unwrap());
435 assert!(arn.matches("arn:aws:s3:::*/folder/subfolder/*").unwrap());
436
437 assert!(
439 arn.matches("arn:aws:s3:::my-bucket/*/subfolder/file.?xt")
440 .unwrap()
441 );
442
443 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 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 let resource_arn =
475 Arn::parse("arn:aws:s3:::my-bucket/uploads/user123/document.pdf").unwrap();
476
477 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 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:*:*:*", "arn:aws:s3:::my-bucket/uploads/user12/document.pdf", ];
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", ];
570
571 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", "arn:aws::us-east-1:123456789012:bucket/my-bucket", "arn:aws:s3:us-east-1:123456789012:", "arn:aws:s3:us-east-1:invalid-account:bucket/my-bucket", "arn:aws:s3:us-east-1:12345678901:bucket/my-bucket", "arn:aws:s3:us-east-1:1234567890123:bucket/my-bucket", ];
585
586 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 let json_content = std::fs::read_to_string("tests/arns.json")
597 .expect("Failed to read tests/arns.json file");
598
599 let arns: Vec<String> =
601 serde_json::from_str(&json_content).expect("Failed to parse JSON content");
602
603 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 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 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 if arn.is_valid() {
628 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 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}