1use serde::{Deserialize, Serialize};
2use std::fmt;
3
4use crate::{Validate, ValidationContext, ValidationError};
5
6#[derive(Debug, Clone, PartialEq, Eq)]
15#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
16pub struct Arn {
17 pub partition: String,
19 pub service: String,
21 pub region: String,
23 pub account_id: String,
25 pub resource: String,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
32pub enum ArnError {
33 InvalidPrefix,
35 InvalidFormat,
37 InvalidPartition(String),
39 InvalidService(String),
41 InvalidAccountId(String),
43 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 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 let resource = parts[5..].join(":");
89
90 Ok(Arn {
91 partition,
92 service,
93 region,
94 account_id,
95 resource,
96 })
97 }
98
99 pub fn matches(&self, pattern: &str) -> Result<bool, ArnError> {
106 if pattern == "*" {
108 return Ok(true);
109 }
110
111 let pattern_arn = Arn::parse(pattern)?;
112
113 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 #[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; let mut p = 0; let mut star_t = 0; let mut star_p = 0; 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 t += 1;
151 p += 1;
152 } else if p < pattern_chars.len() && pattern_chars[p] == '*' {
153 has_star = true;
155 star_t = t;
156 star_p = p;
157 p += 1;
158 } else if has_star {
159 star_t += 1;
161 t = star_t;
162 p = star_p + 1;
163 } else {
164 return false;
165 }
166 }
167
168 while p < pattern_chars.len() && pattern_chars[p] == '*' {
170 p += 1;
171 }
172
173 p == pattern_chars.len()
174 }
175
176 #[must_use]
178 pub fn is_valid(&self) -> bool {
179 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 if !self
194 .partition
195 .chars()
196 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
197 {
198 return false;
199 }
200
201 if !self
203 .service
204 .chars()
205 .all(|c| c.is_alphanumeric() || c == '-')
206 {
207 return false;
208 }
209
210 if !Self::is_valid_account_id(&self.account_id) {
212 return false;
213 }
214
215 true
217 }
218
219 fn is_valid_account_id(account_id: &str) -> bool {
221 if account_id.is_empty() {
223 return true;
224 }
225
226 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 #[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 None
246 }
247 }
248
249 #[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 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 assert!(
379 arn.matches("arn:aws:s3:us-east-1:123456789012:bucket/my-bucket/file.txt")
380 .unwrap()
381 );
382
383 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 assert!(
395 arn.matches("arn:aws:s3:*:123456789012:bucket/my-bucket/file.txt")
396 .unwrap()
397 );
398
399 assert!(
401 arn.matches("arn:aws:s3:us-east-?:123456789012:bucket/my-bucket/file.txt")
402 .unwrap()
403 );
404
405 assert!(
407 !arn.matches("arn:aws:ec2:us-east-1:123456789012:bucket/my-bucket/file.txt")
408 .unwrap()
409 );
410
411 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 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 assert!(arn.matches("arn:aws:s3:::my-bucket/*/*/file.txt").unwrap());
479 assert!(arn.matches("arn:aws:s3:::*/folder/subfolder/*").unwrap());
480
481 assert!(
483 arn.matches("arn:aws:s3:::my-bucket/*/subfolder/file.?xt")
484 .unwrap()
485 );
486
487 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 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 let resource_arn =
519 Arn::parse("arn:aws:s3:::my-bucket/uploads/user123/document.pdf").unwrap();
520
521 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 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:*:*:*", "arn:aws:s3:::my-bucket/uploads/user12/document.pdf", ];
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", ];
608
609 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", "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", ];
623
624 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 let json_content = std::fs::read_to_string("tests/arns.json")
636 .expect("Failed to read tests/arns.json file");
637
638 let arns: Vec<String> =
640 serde_json::from_str(&json_content).expect("Failed to parse JSON content");
641
642 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 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 let reconstructed = arn.to_string();
659 assert_eq!(
660 reconstructed, arn_string,
661 "Reconstructed ARN does not match original: {arn_string}"
662 );
663
664 if arn.is_valid() {
666 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 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}