1use std::{collections::HashMap, fs, io::Read as _, path::Path, sync::Arc};
25
26use arc_swap::ArcSwap;
27use ini::Ini;
28use regex::Regex;
29use serde::Deserialize;
30use validator::{Validate, ValidationError as VrError};
31
32use crate::{
33 ir::{AccountId, Region},
34 provider::error::{ProviderError, Result},
35};
36
37pub const PROFILE_MAP_FILE_MAX_BYTES: u64 = 256 * 1024;
40
41pub const AWS_CONFIG_MAX_CHAIN_HOPS: usize = 8;
43
44#[derive(Clone, Debug, PartialEq, Eq)]
51#[non_exhaustive]
52pub struct ProfileEntry {
53 pub account_id: AccountId,
55 pub account_name: Arc<str>,
58 pub region: Option<Region>,
60 pub role_arn: Option<Arc<str>>,
62}
63
64#[derive(Clone, Debug, Default)]
69#[non_exhaustive]
70pub struct ProfileMap {
71 entries: HashMap<Arc<str>, ProfileEntry>,
72}
73
74impl ProfileMap {
75 #[must_use]
77 pub fn lookup(&self, profile: &str) -> Option<&ProfileEntry> {
78 self.entries.get(profile)
79 }
80
81 #[must_use]
83 pub fn len(&self) -> usize {
84 self.entries.len()
85 }
86
87 #[must_use]
89 pub fn is_empty(&self) -> bool {
90 self.entries.is_empty()
91 }
92
93 pub fn iter(&self) -> impl Iterator<Item = (&Arc<str>, &ProfileEntry)> {
96 self.entries.iter()
97 }
98}
99
100pub type SharedProfileMap = ArcSwap<ProfileMap>;
104
105#[must_use]
107pub fn shared(initial: Arc<ProfileMap>) -> SharedProfileMap {
108 ArcSwap::from(initial)
109}
110
111#[must_use]
113pub fn empty() -> Arc<ProfileMap> {
114 Arc::new(ProfileMap::default())
115}
116
117#[derive(Debug, Deserialize, Validate)]
124#[serde(deny_unknown_fields)]
125struct YamlBody {
126 #[validate(nested)]
127 profiles: HashMap<String, YamlEntry>,
128}
129
130#[derive(Debug, Deserialize, Validate)]
131#[serde(deny_unknown_fields)]
132struct YamlEntry {
133 #[validate(regex(path = *ACCOUNT_ID_RE))]
135 account_id: String,
136 #[validate(length(min = 1, max = 64), regex(path = *NAME_RE))]
138 account_name: String,
139 #[serde(default)]
141 #[validate(custom(function = "validate_region_opt"))]
142 region: Option<String>,
143 #[serde(default)]
146 #[validate(length(max = 2048))]
147 role_arn: Option<String>,
148}
149
150#[allow(clippy::unwrap_used)]
156static ACCOUNT_ID_RE: std::sync::LazyLock<Regex> =
157 std::sync::LazyLock::new(|| Regex::new(r"^\d{12}$").unwrap());
158
159#[allow(clippy::unwrap_used)]
160static NAME_RE: std::sync::LazyLock<Regex> =
161 std::sync::LazyLock::new(|| Regex::new(r"^[A-Za-z0-9._\-/ ]{1,64}$").unwrap());
162
163fn validate_region_opt(value: &str) -> std::result::Result<(), VrError> {
164 Region::new(value).map(|_| ()).map_err(|_| {
165 let mut e = VrError::new("region");
166 e.message = Some("region must match ^[a-z0-9-]{1,32}$".into());
167 e
168 })
169}
170
171pub fn load_yaml_profile_map(path: &Path) -> Result<Arc<ProfileMap>> {
178 let bytes = read_capped(path)?;
179 let body: YamlBody = serde_yaml::from_slice(&bytes).map_err(|source| ProviderError::Yaml {
180 path: path.to_path_buf(),
181 source,
182 })?;
183 body.validate()
184 .map_err(|source| ProviderError::Validation {
185 path: path.to_path_buf(),
186 source,
187 })?;
188
189 let mut entries = HashMap::with_capacity(body.profiles.len());
190 for (name, raw) in body.profiles {
191 let profile_arc: Arc<str> = Arc::from(name.as_str());
192 let account_id =
193 AccountId::new(&raw.account_id).map_err(|source| ProviderError::InvalidEntry {
194 profile: Arc::clone(&profile_arc),
195 source,
196 })?;
197 let region = match raw.region {
198 Some(r) => Some(
199 Region::new(&r).map_err(|source| ProviderError::InvalidEntry {
200 profile: Arc::clone(&profile_arc),
201 source,
202 })?,
203 ),
204 None => None,
205 };
206 let role_arn = raw.role_arn.map(Arc::<str>::from);
207 let account_name = Arc::<str>::from(raw.account_name);
208 entries.insert(
209 profile_arc,
210 ProfileEntry {
211 account_id,
212 account_name,
213 region,
214 role_arn,
215 },
216 );
217 }
218 Ok(Arc::new(ProfileMap { entries }))
219}
220
221pub fn load_aws_config(path: &Path) -> Result<Arc<ProfileMap>> {
237 let bytes = read_capped(path)?;
238 let text = std::str::from_utf8(&bytes).map_err(|source| ProviderError::Io {
242 path: path.to_path_buf(),
243 source: std::io::Error::new(std::io::ErrorKind::InvalidData, source),
244 })?;
245 let ini = Ini::load_from_str(text).map_err(|source| ProviderError::Ini {
246 path: path.to_path_buf(),
247 source,
248 })?;
249
250 let mut raw_sections: HashMap<String, HashMap<String, String>> = HashMap::new();
254 for (section, props) in &ini {
255 let Some(s) = section else { continue };
256 let name = if s == "default" {
257 "default".to_owned()
258 } else if let Some(stripped) = s.strip_prefix("profile ") {
259 stripped.trim().to_owned()
260 } else {
261 continue;
262 };
263 let mut bucket: HashMap<String, String> = HashMap::new();
264 for (k, v) in props {
265 bucket.insert(k.to_owned(), v.to_owned());
266 }
267 raw_sections.insert(name, bucket);
268 }
269
270 let mut entries: HashMap<Arc<str>, ProfileEntry> = HashMap::new();
272 for name in raw_sections.keys().cloned().collect::<Vec<_>>() {
273 let resolved = resolve_aws_profile(&name, &raw_sections)?;
274 if let Some(entry) = resolved {
275 entries.insert(Arc::from(name.as_str()), entry);
276 }
277 }
278 Ok(Arc::new(ProfileMap { entries }))
279}
280
281fn resolve_aws_profile(
290 name: &str,
291 sections: &HashMap<String, HashMap<String, String>>,
292) -> Result<Option<ProfileEntry>> {
293 let mut visited: Vec<String> = Vec::new();
294 let mut cur = name.to_string();
295 let mut last_region: Option<Region> = None;
296 let mut last_role_arn: Option<Arc<str>> = None;
297 let mut account: Option<AccountId> = None;
298 let mut hops: usize = 0;
299
300 loop {
301 if visited.iter().any(|v| v == &cur) {
302 return Err(ProviderError::ChainTooLong {
303 profile: Arc::from(cur.as_str()),
304 limit: AWS_CONFIG_MAX_CHAIN_HOPS,
305 });
306 }
307 visited.push(cur.clone());
308
309 let Some(props) = sections.get(&cur) else {
310 break;
312 };
313
314 if last_region.is_none()
315 && let Some(r) = props.get("region")
316 && let Ok(parsed) = Region::new(r)
317 {
318 last_region = Some(parsed);
319 }
320 if last_role_arn.is_none()
321 && let Some(arn) = props.get("role_arn")
322 {
323 last_role_arn = Some(Arc::from(arn.as_str()));
324 }
325
326 if account.is_none()
327 && let Some(sso) = props.get("sso_account_id")
328 && let Ok(id) = AccountId::new(sso)
329 {
330 account = Some(id);
331 }
332 if account.is_none()
333 && let Some(arn) = props.get("role_arn")
334 {
335 account = super::resolver::extract_account_id(arn);
336 }
337
338 if account.is_some() {
339 break;
340 }
341
342 match props.get("source_profile") {
343 Some(next) if !next.is_empty() => {
344 if hops >= AWS_CONFIG_MAX_CHAIN_HOPS {
345 return Err(ProviderError::ChainTooLong {
346 profile: Arc::from(name),
347 limit: AWS_CONFIG_MAX_CHAIN_HOPS,
348 });
349 }
350 cur = next.clone();
351 hops = hops.saturating_add(1);
352 }
353 _ => break,
354 }
355 }
356
357 let Some(account_id) = account else {
358 return Ok(None);
359 };
360 Ok(Some(ProfileEntry {
361 account_name: Arc::from(name),
362 account_id,
363 region: last_region,
364 role_arn: last_role_arn,
365 }))
366}
367
368fn read_capped(path: &Path) -> Result<Vec<u8>> {
373 let meta = fs::metadata(path).map_err(|source| ProviderError::Io {
374 path: path.to_path_buf(),
375 source,
376 })?;
377 if meta.len() > PROFILE_MAP_FILE_MAX_BYTES {
378 return Err(ProviderError::FileTooLarge {
379 path: path.to_path_buf(),
380 observed: meta.len(),
381 limit: PROFILE_MAP_FILE_MAX_BYTES,
382 });
383 }
384 let mut buf = Vec::with_capacity(meta.len().try_into().unwrap_or_default());
385 fs::File::open(path)
386 .and_then(|mut f| f.read_to_end(&mut buf))
387 .map_err(|source| ProviderError::Io {
388 path: path.to_path_buf(),
389 source,
390 })?;
391 Ok(buf)
392}
393
394#[cfg(test)]
395#[allow(
396 clippy::unwrap_used,
397 clippy::expect_used,
398 clippy::panic,
399 clippy::indexing_slicing
400)]
401mod tests {
402 use super::*;
403
404 fn write_tmp(text: &str) -> tempfile::NamedTempFile {
405 let f = tempfile::NamedTempFile::new().unwrap();
406 std::fs::write(f.path(), text).unwrap();
407 f
408 }
409
410 #[test]
413 fn test_should_load_minimal_yaml_profile_map() {
414 let f = write_tmp(
415 r#"
416profiles:
417 main-developer:
418 account_id: "370025973162"
419 account_name: "primary"
420 region: "us-west-2"
421"#,
422 );
423 let map = load_yaml_profile_map(f.path()).unwrap();
424 let entry = map.lookup("main-developer").unwrap();
425 assert_eq!(entry.account_id.as_str(), "370025973162");
426 assert_eq!(entry.account_name.as_ref(), "primary");
427 assert_eq!(entry.region.as_ref().map(Region::as_str), Some("us-west-2"));
428 }
429
430 #[test]
431 fn test_should_reject_yaml_bad_account_id() {
432 let f = write_tmp(
433 r#"
434profiles:
435 bad:
436 account_id: "12"
437 account_name: "x"
438"#,
439 );
440 let err = load_yaml_profile_map(f.path()).unwrap_err();
441 assert!(
442 matches!(err, ProviderError::Validation { .. }),
443 "got {err:?}"
444 );
445 }
446
447 #[test]
448 fn test_should_reject_yaml_unknown_field() {
449 let f = write_tmp(
450 r#"
451profiles:
452 good:
453 account_id: "370025973162"
454 account_name: "primary"
455 rogue_field: "value"
456"#,
457 );
458 let err = load_yaml_profile_map(f.path()).unwrap_err();
459 assert!(matches!(err, ProviderError::Yaml { .. }), "got {err:?}");
460 }
461
462 #[test]
463 fn test_should_reject_yaml_file_over_size_cap() {
464 let f = tempfile::NamedTempFile::new().unwrap();
465 let cap_usize: usize = usize::try_from(PROFILE_MAP_FILE_MAX_BYTES).unwrap();
468 let payload = "x".repeat(cap_usize + 64);
469 std::fs::write(f.path(), payload).unwrap();
470 let err = load_yaml_profile_map(f.path()).unwrap_err();
471 assert!(
472 matches!(err, ProviderError::FileTooLarge { .. }),
473 "got {err:?}"
474 );
475 }
476
477 #[test]
478 fn test_should_load_yaml_with_role_arn() {
479 let f = write_tmp(
480 r#"
481profiles:
482 cross-account:
483 account_id: "999999999999"
484 account_name: "cross"
485 role_arn: "arn:aws:iam::999999999999:role/admin"
486"#,
487 );
488 let map = load_yaml_profile_map(f.path()).unwrap();
489 let e = map.lookup("cross-account").unwrap();
490 assert_eq!(
491 e.role_arn.as_deref(),
492 Some("arn:aws:iam::999999999999:role/admin")
493 );
494 }
495
496 #[test]
497 fn test_should_reject_yaml_invalid_region_chars() {
498 let f = write_tmp(
499 r#"
500profiles:
501 bad-region:
502 account_id: "111111111111"
503 account_name: "x"
504 region: "US-EAST-1"
505"#,
506 );
507 let err = load_yaml_profile_map(f.path()).unwrap_err();
508 assert!(
509 matches!(err, ProviderError::Validation { .. }),
510 "got {err:?}"
511 );
512 }
513
514 #[test]
517 fn test_should_load_aws_config_with_sso_account_id() {
518 let f = write_tmp(
519 r"
520[profile main]
521sso_account_id = 370025973162
522region = us-west-2
523",
524 );
525 let map = load_aws_config(f.path()).unwrap();
526 let e = map.lookup("main").unwrap();
527 assert_eq!(e.account_id.as_str(), "370025973162");
528 assert_eq!(e.region.as_ref().map(Region::as_str), Some("us-west-2"));
529 }
530
531 #[test]
532 fn test_should_load_aws_config_role_arn_extracts_account_id() {
533 let f = write_tmp(
534 r"
535[profile cross]
536role_arn = arn:aws:iam::123456789012:role/admin
537region = eu-west-1
538",
539 );
540 let map = load_aws_config(f.path()).unwrap();
541 let e = map.lookup("cross").unwrap();
542 assert_eq!(e.account_id.as_str(), "123456789012");
543 assert_eq!(
544 e.role_arn.as_deref(),
545 Some("arn:aws:iam::123456789012:role/admin")
546 );
547 }
548
549 #[test]
550 fn test_should_follow_source_profile_chain() {
551 let f = write_tmp(
552 r"
553[profile root]
554sso_account_id = 111111111111
555region = us-east-1
556
557[profile mid]
558source_profile = root
559
560[profile leaf]
561source_profile = mid
562",
563 );
564 let map = load_aws_config(f.path()).unwrap();
565 let leaf = map.lookup("leaf").unwrap();
566 assert_eq!(leaf.account_id.as_str(), "111111111111");
567 assert_eq!(leaf.region.as_ref().map(Region::as_str), Some("us-east-1"));
569 }
570
571 #[test]
572 fn test_should_reject_aws_config_chain_cycle() {
573 let f = write_tmp(
574 r"
575[profile a]
576source_profile = b
577
578[profile b]
579source_profile = a
580",
581 );
582 let err = load_aws_config(f.path()).unwrap_err();
583 assert!(
584 matches!(err, ProviderError::ChainTooLong { .. }),
585 "got {err:?}"
586 );
587 }
588
589 #[test]
590 fn test_should_load_aws_config_default_section() {
591 let f = write_tmp(
592 r"
593[default]
594sso_account_id = 222222222222
595region = us-west-2
596",
597 );
598 let map = load_aws_config(f.path()).unwrap();
599 let e = map.lookup("default").unwrap();
600 assert_eq!(e.account_id.as_str(), "222222222222");
601 }
602
603 #[test]
604 fn test_aws_config_skips_profiles_without_account_signals() {
605 let f = write_tmp(
608 r"
609[profile bare]
610region = us-east-1
611",
612 );
613 let map = load_aws_config(f.path()).unwrap();
614 assert!(map.lookup("bare").is_none());
615 }
616
617 #[test]
620 fn test_shared_profile_map_swap_replaces_atomically() {
621 let initial = empty();
622 let shared = shared(Arc::clone(&initial));
623 let f = write_tmp(
625 r#"
626profiles:
627 p:
628 account_id: "100000000001"
629 account_name: "x"
630"#,
631 );
632 let next = load_yaml_profile_map(f.path()).unwrap();
633 shared.store(next);
634 let loaded = shared.load();
635 assert_eq!(loaded.len(), 1);
636 }
637
638 #[test]
639 fn test_static_regexes_compile() {
640 assert!(ACCOUNT_ID_RE.is_match("123456789012"));
641 assert!(!ACCOUNT_ID_RE.is_match("12345"));
642 assert!(NAME_RE.is_match("primary"));
643 assert!(!NAME_RE.is_match(""));
644 }
645}