1use super::types::{CloudMapping, Resource, Service, TenantPath, WamiArn};
4use crate::error::{AmiError, Result};
5use std::str::FromStr;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum ArnParseError {
10 InvalidFormat(String),
12 MissingComponent(String),
14 InvalidComponent(String),
16}
17
18impl std::fmt::Display for ArnParseError {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 match self {
21 ArnParseError::InvalidFormat(msg) => write!(f, "Invalid ARN format: {}", msg),
22 ArnParseError::MissingComponent(msg) => write!(f, "Missing ARN component: {}", msg),
23 ArnParseError::InvalidComponent(msg) => write!(f, "Invalid ARN component: {}", msg),
24 }
25 }
26}
27
28impl std::error::Error for ArnParseError {}
29
30impl From<ArnParseError> for AmiError {
31 fn from(err: ArnParseError) -> Self {
32 AmiError::InvalidParameter {
33 message: err.to_string(),
34 }
35 }
36}
37
38impl FromStr for WamiArn {
39 type Err = ArnParseError;
40
41 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
70 let parts: Vec<&str> = s.split(':').collect();
72
73 if parts.len() < 7 {
75 return Err(ArnParseError::InvalidFormat(format!(
76 "Expected at least 7 parts, got {}",
77 parts.len()
78 )));
79 }
80
81 if parts[0] != "arn" {
83 return Err(ArnParseError::InvalidFormat(format!(
84 "Expected 'arn' prefix, got '{}'",
85 parts[0]
86 )));
87 }
88
89 if parts[1] != "wami" {
90 return Err(ArnParseError::InvalidFormat(format!(
91 "Expected 'wami' namespace, got '{}'",
92 parts[1]
93 )));
94 }
95
96 let service = Service::from(parts[2]);
98
99 let tenant_segments: std::result::Result<Vec<u64>, ArnParseError> = parts[3]
101 .split('/')
102 .map(|s| {
103 s.parse::<u64>().map_err(|_| {
104 ArnParseError::InvalidComponent(format!(
105 "Invalid tenant path segment: '{}' (must be a u64)",
106 s
107 ))
108 })
109 })
110 .collect();
111
112 let tenant_segments = tenant_segments?;
113
114 if tenant_segments.is_empty() {
115 return Err(ArnParseError::InvalidComponent(
116 "Tenant path cannot be empty".to_string(),
117 ));
118 }
119
120 let tenant_path = TenantPath::new(tenant_segments);
121
122 if parts[4] != "wami" {
124 return Err(ArnParseError::InvalidFormat(format!(
125 "Expected 'wami' marker at position 4, got '{}'",
126 parts[4]
127 )));
128 }
129
130 let wami_instance_id = parts[5].to_string();
132 if wami_instance_id.is_empty() {
133 return Err(ArnParseError::InvalidComponent(
134 "WAMI instance ID cannot be empty".to_string(),
135 ));
136 }
137
138 let (cloud_mapping, resource_part) = if parts.len() >= 10 {
143 if !parts[6].contains('/') && !parts[7].contains('/') && !parts[8].contains('/') {
146 let provider = parts[6].to_string();
148 let account_id = parts[7].to_string();
149 let region = parts[8].to_string();
150
151 if provider.is_empty() || account_id.is_empty() || region.is_empty() {
152 return Err(ArnParseError::InvalidComponent(
153 "Provider, account ID, and region cannot be empty".to_string(),
154 ));
155 }
156
157 let cloud_mapping = if region == "global" {
158 Some(CloudMapping::new(provider, account_id))
159 } else {
160 Some(CloudMapping::with_region(provider, account_id, region))
161 };
162
163 let resource_part = parts[9..].join(":");
165
166 (cloud_mapping, resource_part)
167 } else {
168 let resource_part = parts[6..].join(":");
170 (None, resource_part)
171 }
172 } else if parts.len() >= 9 {
173 if !parts[6].contains('/') && !parts[7].contains('/') {
176 let provider = parts[6].to_string();
178 let account_id = parts[7].to_string();
179
180 if provider.is_empty() || account_id.is_empty() {
181 return Err(ArnParseError::InvalidComponent(
182 "Provider and account ID cannot be empty".to_string(),
183 ));
184 }
185
186 let cloud_mapping = Some(CloudMapping::new(provider, account_id));
187
188 let resource_part = parts[8..].join(":");
190
191 (cloud_mapping, resource_part)
192 } else {
193 let resource_part = parts[6..].join(":");
195 (None, resource_part)
196 }
197 } else {
198 let resource_part = parts[6..].join(":");
200 (None, resource_part)
201 };
202
203 let resource_parts: Vec<&str> = resource_part.split('/').collect();
205 if resource_parts.len() < 2 {
206 return Err(ArnParseError::InvalidFormat(format!(
207 "Resource must be in format 'type/id', got '{}'",
208 resource_part
209 )));
210 }
211
212 let resource_type = resource_parts[0].to_string();
213 let resource_id = resource_parts[1..].join("/"); if resource_type.is_empty() || resource_id.is_empty() {
216 return Err(ArnParseError::InvalidComponent(
217 "Resource type and ID cannot be empty".to_string(),
218 ));
219 }
220
221 let resource = Resource::new(resource_type, resource_id);
222
223 Ok(WamiArn {
224 service,
225 tenant_path,
226 wami_instance_id,
227 cloud_mapping,
228 resource,
229 })
230 }
231}
232
233#[allow(clippy::result_large_err)]
246pub fn parse_arn(s: &str) -> Result<WamiArn> {
247 WamiArn::from_str(s).map_err(|e| e.into())
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 #[test]
255 fn test_parse_wami_native() {
256 let arn_str = "arn:wami:iam:12345678/87654321/99999999:wami:999888777:user/77557755";
257 let arn = WamiArn::from_str(arn_str).unwrap();
258
259 assert_eq!(arn.service, Service::Iam);
260 assert_eq!(arn.tenant_path.segments, vec![12345678, 87654321, 99999999]);
261 assert_eq!(arn.wami_instance_id, "999888777");
262 assert_eq!(arn.cloud_mapping, None);
263 assert_eq!(arn.resource.resource_type, "user");
264 assert_eq!(arn.resource.resource_id, "77557755");
265 assert!(!arn.is_cloud_synced());
266 }
267
268 #[test]
269 fn test_parse_cloud_synced_aws() {
270 let arn_str = "arn:wami:iam:12345678/87654321/99999999:wami:999888777:aws:223344556677:global:user/77557755";
271 let arn = WamiArn::from_str(arn_str).unwrap();
272
273 assert_eq!(arn.service, Service::Iam);
274 assert_eq!(arn.tenant_path.segments, vec![12345678, 87654321, 99999999]);
275 assert_eq!(arn.wami_instance_id, "999888777");
276 assert!(arn.is_cloud_synced());
277 assert_eq!(arn.cloud_mapping.as_ref().unwrap().provider, "aws");
278 assert_eq!(
279 arn.cloud_mapping.as_ref().unwrap().account_id,
280 "223344556677"
281 );
282 assert_eq!(arn.cloud_mapping.as_ref().unwrap().region, None);
283 assert_eq!(arn.resource.resource_type, "user");
284 assert_eq!(arn.resource.resource_id, "77557755");
285 }
286
287 #[test]
288 fn test_parse_cloud_synced_with_region() {
289 let arn_str =
290 "arn:wami:iam:12345678/87654321/99999999:wami:999888777:aws:223344556677:us-east-1:user/77557755";
291 let arn = WamiArn::from_str(arn_str).unwrap();
292
293 assert_eq!(arn.service, Service::Iam);
294 assert!(arn.is_cloud_synced());
295 assert_eq!(
296 arn.cloud_mapping.as_ref().unwrap().region,
297 Some("us-east-1".to_string())
298 );
299 assert!(arn.cloud_mapping.as_ref().unwrap().is_regional());
300 }
301
302 #[test]
303 fn test_parse_legacy_cloud_synced_without_region() {
304 let arn_str = "arn:wami:iam:12345678:wami:999888777:aws:223344556677:user/77557755";
306 let arn = WamiArn::from_str(arn_str).unwrap();
307
308 assert_eq!(arn.service, Service::Iam);
309 assert!(arn.is_cloud_synced());
310 assert_eq!(arn.cloud_mapping.as_ref().unwrap().region, None);
311 }
312
313 #[test]
314 fn test_parse_cloud_synced_gcp() {
315 let arn_str =
316 "arn:wami:iam:12345678/87654321/99999999:wami:999888777:gcp:554433221:us-central1:user/77557755";
317 let arn = WamiArn::from_str(arn_str).unwrap();
318
319 assert_eq!(arn.cloud_mapping.as_ref().unwrap().provider, "gcp");
320 assert_eq!(arn.cloud_mapping.as_ref().unwrap().account_id, "554433221");
321 assert_eq!(
322 arn.cloud_mapping.as_ref().unwrap().region,
323 Some("us-central1".to_string())
324 );
325 }
326
327 #[test]
328 fn test_parse_cloud_synced_scaleway() {
329 let arn_str =
330 "arn:wami:iam:12345678/87654321/99999999:wami:999888777:scaleway:112233445:fr-par:user/77557755";
331 let arn = WamiArn::from_str(arn_str).unwrap();
332
333 assert_eq!(arn.cloud_mapping.as_ref().unwrap().provider, "scaleway");
334 assert_eq!(arn.cloud_mapping.as_ref().unwrap().account_id, "112233445");
335 assert_eq!(
336 arn.cloud_mapping.as_ref().unwrap().region,
337 Some("fr-par".to_string())
338 );
339 }
340
341 #[test]
342 fn test_parse_single_tenant() {
343 let arn_str = "arn:wami:sts:12345678:wami:111222333:session/sess123";
344 let arn = WamiArn::from_str(arn_str).unwrap();
345
346 assert_eq!(arn.service, Service::Sts);
347 assert_eq!(arn.tenant_path.segments, vec![12345678]);
348 assert_eq!(arn.wami_instance_id, "111222333");
349 assert_eq!(arn.resource.resource_type, "session");
350 assert_eq!(arn.resource.resource_id, "sess123");
351 }
352
353 #[test]
354 fn test_parse_custom_service() {
355 let arn_str = "arn:wami:custom-service:12345678:wami:999888777:resource/res123";
356 let arn = WamiArn::from_str(arn_str).unwrap();
357
358 assert_eq!(arn.service, Service::Custom("custom-service".to_string()));
359 }
360
361 #[test]
362 fn test_parse_sso_admin() {
363 let arn_str = "arn:wami:sso-admin:12345678:wami:999888777:permission-set/ps123";
364 let arn = WamiArn::from_str(arn_str).unwrap();
365
366 assert_eq!(arn.service, Service::SsoAdmin);
367 assert_eq!(arn.resource.resource_type, "permission-set");
368 assert_eq!(arn.resource.resource_id, "ps123");
369 }
370
371 #[test]
372 fn test_parse_resource_with_slash() {
373 let arn_str = "arn:wami:iam:12345678:wami:999888777:policy/path/to/policy";
374 let arn = WamiArn::from_str(arn_str).unwrap();
375
376 assert_eq!(arn.resource.resource_type, "policy");
377 assert_eq!(arn.resource.resource_id, "path/to/policy");
378 }
379
380 #[test]
381 fn test_parse_roundtrip_native() {
382 let original = "arn:wami:iam:12345678/87654321/99999999:wami:999888777:user/77557755";
383 let arn = WamiArn::from_str(original).unwrap();
384 let serialized = arn.to_string();
385 assert_eq!(original, serialized);
386 }
387
388 #[test]
389 fn test_parse_roundtrip_cloud_synced_global() {
390 let original = "arn:wami:iam:12345678/87654321/99999999:wami:999888777:aws:223344556677:global:user/77557755";
391 let arn = WamiArn::from_str(original).unwrap();
392 let serialized = arn.to_string();
393 assert_eq!(original, serialized);
394 }
395
396 #[test]
397 fn test_parse_roundtrip_cloud_synced_regional() {
398 let original =
399 "arn:wami:iam:12345678/87654321/99999999:wami:999888777:aws:223344556677:us-east-1:user/77557755";
400 let arn = WamiArn::from_str(original).unwrap();
401 let serialized = arn.to_string();
402 assert_eq!(original, serialized);
403 }
404
405 #[test]
406 fn test_parse_invalid_prefix() {
407 let result = WamiArn::from_str("invalid:wami:iam:t1:wami:999888777:user/77557755");
408 assert!(result.is_err());
409 assert!(result
410 .unwrap_err()
411 .to_string()
412 .contains("Expected 'arn' prefix"));
413 }
414
415 #[test]
416 fn test_parse_invalid_namespace() {
417 let result = WamiArn::from_str("arn:invalid:iam:t1:wami:999888777:user/77557755");
418 assert!(result.is_err());
419 assert!(result
420 .unwrap_err()
421 .to_string()
422 .contains("Expected 'wami' namespace"));
423 }
424
425 #[test]
426 fn test_parse_missing_wami_marker() {
427 let result = WamiArn::from_str("arn:wami:iam:12345678:invalid:999888777:user/77557755");
428 assert!(result.is_err());
429 assert!(result
430 .unwrap_err()
431 .to_string()
432 .contains("Expected 'wami' marker"));
433 }
434
435 #[test]
436 fn test_parse_empty_tenant() {
437 let result = WamiArn::from_str("arn:wami:iam::wami:999888777:user/77557755");
438 assert!(result.is_err());
439 let error_msg = result.unwrap_err().to_string();
441 assert!(
442 error_msg.contains("Invalid tenant path segment")
443 || error_msg.contains("Tenant path cannot be empty"),
444 "Error message: {}",
445 error_msg
446 );
447 }
448
449 #[test]
450 fn test_parse_empty_instance_id() {
451 let result = WamiArn::from_str("arn:wami:iam:12345678:wami::user/77557755");
452 assert!(result.is_err());
453 assert!(result
454 .unwrap_err()
455 .to_string()
456 .contains("WAMI instance ID cannot be empty"));
457 }
458
459 #[test]
460 fn test_parse_invalid_resource_format() {
461 let result = WamiArn::from_str("arn:wami:iam:12345678:wami:999888777:invalid");
462 assert!(result.is_err());
463 assert!(result
464 .unwrap_err()
465 .to_string()
466 .contains("Resource must be in format 'type/id'"));
467 }
468
469 #[test]
470 fn test_parse_too_few_parts() {
471 let result = WamiArn::from_str("arn:wami:iam:12345678");
472 assert!(result.is_err());
473 assert!(result
474 .unwrap_err()
475 .to_string()
476 .contains("Expected at least 7 parts"));
477 }
478
479 #[test]
480 fn test_parse_arn_function() {
481 let result = parse_arn("arn:wami:iam:12345678:wami:999888777:user/77557755");
482 assert!(result.is_ok());
483
484 let result = parse_arn("invalid");
485 assert!(result.is_err());
486 }
487}