Skip to main content

wami_core/arn/
parser.rs

1//! ARN parsing and serialization.
2
3use super::types::{CloudMapping, Resource, Service, TenantPath, WamiArn};
4use crate::error::{AmiError, Result};
5use std::str::FromStr;
6
7/// Error type for ARN parsing.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum ArnParseError {
10    /// Invalid ARN format
11    InvalidFormat(String),
12    /// Missing required component
13    MissingComponent(String),
14    /// Invalid component value
15    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    /// Parses a WAMI ARN from a string.
42    ///
43    /// # Format
44    ///
45    /// ## WAMI Native:
46    /// ```text
47    /// arn:wami:{service}:{tenant_path}:wami:{wami_instance_id}:{resource_type}/{resource_id}
48    /// ```
49    ///
50    /// ## Cloud-Synced:
51    /// ```text
52    /// arn:wami:{service}:{tenant_path}:wami:{wami_instance_id}:{provider}:{account_id}:{resource_type}/{resource_id}
53    /// ```
54    ///
55    /// # Examples
56    ///
57    /// ```
58    /// use wami_core::arn::WamiArn;
59    /// use std::str::FromStr;
60    ///
61    /// let arn = WamiArn::from_str("arn:wami:iam:12345678/87654321/99999999:wami:999888777:user/77557755").unwrap();
62    /// assert_eq!(arn.resource_type(), "user");
63    /// assert_eq!(arn.resource_id(), "77557755");
64    ///
65    /// let arn = WamiArn::from_str("arn:wami:iam:12345678:wami:999888777:aws:223344556677:user/77557755").unwrap();
66    /// assert!(arn.is_cloud_synced());
67    /// assert_eq!(arn.provider(), Some("aws"));
68    /// ```
69    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
70        // Split by ':' first
71        let parts: Vec<&str> = s.split(':').collect();
72
73        // Minimum parts: arn:wami:service:tenant:wami:instance:resource
74        if parts.len() < 7 {
75            return Err(ArnParseError::InvalidFormat(format!(
76                "Expected at least 7 parts, got {}",
77                parts.len()
78            )));
79        }
80
81        // Validate prefix
82        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        // Parse service
97        let service = Service::from(parts[2]);
98
99        // Parse tenant path (numeric segments separated by '/')
100        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        // Validate "wami" marker
123        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        // Parse WAMI instance ID
131        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        // Now we need to determine if this is a cloud-synced ARN
139        // Cloud-synced: arn:wami:service:tenant:wami:instance:provider:account:region:resource
140        // Native:       arn:wami:service:tenant:wami:instance:resource
141
142        let (cloud_mapping, resource_part) = if parts.len() >= 10 {
143            // Potentially cloud-synced with region (provider:account:region:resource)
144            // Check if parts[6], [7], [8] look like provider/account/region (not containing '/')
145            if !parts[6].contains('/') && !parts[7].contains('/') && !parts[8].contains('/') {
146                // Cloud-synced format with region
147                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                // Resource is everything after region, joined back with ':'
164                let resource_part = parts[9..].join(":");
165
166                (cloud_mapping, resource_part)
167            } else {
168                // Native format (resource contains ':')
169                let resource_part = parts[6..].join(":");
170                (None, resource_part)
171            }
172        } else if parts.len() >= 9 {
173            // Legacy cloud-synced without region (provider:account:resource)
174            // Check if parts[6] and [7] look like provider/account (not containing '/')
175            if !parts[6].contains('/') && !parts[7].contains('/') {
176                // Legacy cloud-synced format without region
177                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                // Resource is everything after account_id, joined back with ':'
189                let resource_part = parts[8..].join(":");
190
191                (cloud_mapping, resource_part)
192            } else {
193                // Native format (resource contains ':')
194                let resource_part = parts[6..].join(":");
195                (None, resource_part)
196            }
197        } else {
198            // Native format
199            let resource_part = parts[6..].join(":");
200            (None, resource_part)
201        };
202
203        // Parse resource (type/id)
204        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("/"); // Handle resource IDs with '/'
214
215        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/// Parses a WAMI ARN from a string, returning a Result with AmiError.
234///
235/// This is a convenience function that wraps FromStr and converts the error.
236///
237/// # Examples
238///
239/// ```
240/// use wami_core::arn::parse_arn;
241///
242/// let arn = parse_arn("arn:wami:iam:12345678:wami:999888777:user/77557755").unwrap();
243/// assert_eq!(arn.resource_type(), "user");
244/// ```
245#[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        // Support legacy format without region for backward compatibility
305        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        // Empty tenant path will fail when parsing empty string as u64
440        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}