Skip to main content

tfparser_core/provider/
profile_map.rs

1//! `ProfileMap` — the AWS-profile → account mapping the resolver consults.
2//!
3//! Three concrete loaders are provided, mirroring [16-provider-resolver.md § 3]:
4//!
5//! - [`load_yaml_profile_map`]: a user-supplied YAML file (validated via the `validator` derive —
6//!   CLAUDE.md § Input Validation, "rejects, doesn't sanitize").
7//! - [`load_aws_config`]: parses `~/.aws/config` with `rust-ini`, following `source_profile` chains
8//!   up to a hop cap.
9//! - [`empty`]: returns an empty map (the `none` loader from spec § 3.3).
10//!
11//! All loaders return an [`Arc<ProfileMap>`] so the resolver hot path can
12//! `Arc::clone` cheaply, and downstream callers can wrap the value behind
13//! an [`SharedProfileMap`] (`ArcSwap<ProfileMap>`) for lock-free swap on
14//! re-runs (CLAUDE.md § Async & Concurrency).
15//!
16//! ## Size cap
17//!
18//! Per spec 16 § 9, both loaders enforce a hard 256 KiB byte cap on the
19//! source file. Anything larger is rejected with
20//! [`ProviderError::FileTooLarge`]. This is defence against
21//! decompression-style amplification when the loader is fed an
22//! adversarial file.
23
24use 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
37/// Spec 16 § 9: hard size cap for the source file (`~/.aws/config` or
38/// `profile-map.yaml`).
39pub const PROFILE_MAP_FILE_MAX_BYTES: u64 = 256 * 1024;
40
41/// Spec 16 § 3.1: maximum `source_profile` chain hop count.
42pub const AWS_CONFIG_MAX_CHAIN_HOPS: usize = 8;
43
44/// One entry in the [`ProfileMap`].
45///
46/// Per [16-provider-resolver.md § 2]. Account id and region are stored as
47/// validated newtypes; `role_arn` stays as `Arc<str>` because it is only
48/// ever passed back into [`extract_account_id`](super::resolver::extract_account_id)
49/// downstream.
50#[derive(Clone, Debug, PartialEq, Eq)]
51#[non_exhaustive]
52pub struct ProfileEntry {
53    /// AWS account id (12 digits).
54    pub account_id: AccountId,
55    /// Human-friendly label; may equal `account_id` when no other name is
56    /// available.
57    pub account_name: Arc<str>,
58    /// Region declared in the source, if any.
59    pub region: Option<Region>,
60    /// Raw `role_arn` if the profile assumed a role.
61    pub role_arn: Option<Arc<str>>,
62}
63
64/// Profile → entry index used by the provider resolver.
65///
66/// Construction goes through one of the loaders in this module so
67/// validation is enforced once and `ProfileMap` exposes only resolved data.
68#[derive(Clone, Debug, Default)]
69#[non_exhaustive]
70pub struct ProfileMap {
71    entries: HashMap<Arc<str>, ProfileEntry>,
72}
73
74impl ProfileMap {
75    /// Look up an entry by profile name. `O(1)`.
76    #[must_use]
77    pub fn lookup(&self, profile: &str) -> Option<&ProfileEntry> {
78        self.entries.get(profile)
79    }
80
81    /// Number of profiles in the map.
82    #[must_use]
83    pub fn len(&self) -> usize {
84        self.entries.len()
85    }
86
87    /// `true` if the map contains no entries.
88    #[must_use]
89    pub fn is_empty(&self) -> bool {
90        self.entries.is_empty()
91    }
92
93    /// Iterate `(profile, entry)` pairs. Iteration order is unspecified;
94    /// the consumer must sort if a deterministic walk is required.
95    pub fn iter(&self) -> impl Iterator<Item = (&Arc<str>, &ProfileEntry)> {
96        self.entries.iter()
97    }
98}
99
100/// Atomically-swappable handle for re-runs. Per CLAUDE.md § Async &
101/// Concurrency: `ArcSwap` for infrequently-updated shared data; lock-free
102/// reads on the hot resolver path.
103pub type SharedProfileMap = ArcSwap<ProfileMap>;
104
105/// Build a [`SharedProfileMap`] seeded with `initial`.
106#[must_use]
107pub fn shared(initial: Arc<ProfileMap>) -> SharedProfileMap {
108    ArcSwap::from(initial)
109}
110
111/// Return an empty [`ProfileMap`] (spec § 3.3 `none` loader).
112#[must_use]
113pub fn empty() -> Arc<ProfileMap> {
114    Arc::new(ProfileMap::default())
115}
116
117// ----------------------------------------------------------------------------
118// YAML loader (spec § 3.2)
119// ----------------------------------------------------------------------------
120
121/// Raw YAML body. `#[serde(deny_unknown_fields)]` per CLAUDE.md § Type
122/// Design — fails fast on typos.
123#[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    /// 12-digit AWS account id.
134    #[validate(regex(path = *ACCOUNT_ID_RE))]
135    account_id: String,
136    /// Human-friendly account name; bounded.
137    #[validate(length(min = 1, max = 64), regex(path = *NAME_RE))]
138    account_name: String,
139    /// Optional region (`a-z0-9-`).
140    #[serde(default)]
141    #[validate(custom(function = "validate_region_opt"))]
142    region: Option<String>,
143    /// Optional `role_arn` (loose check — `extract_account_id` does the
144    /// strict parse downstream).
145    #[serde(default)]
146    #[validate(length(max = 2048))]
147    role_arn: Option<String>,
148}
149
150// SAFETY (clippy::unwrap_used): the literal patterns are exercised by
151// `test_static_regexes_compile` and the validator-derived integration
152// tests; a regression in the literal would fail those tests at build
153// time. There is no observable runtime recovery path, so the unwrap is
154// the simplest sound choice.
155#[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
171/// Load a profile map from a YAML file at `path`.
172///
173/// # Errors
174///
175/// Returns [`ProviderError`] on I/O failure, size-cap breach, YAML parse,
176/// or validation rejection.
177pub 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
221// ----------------------------------------------------------------------------
222// AWS-config loader (spec § 3.1)
223// ----------------------------------------------------------------------------
224
225/// Load a profile map from `~/.aws/config` (or any user-supplied INI-shaped
226/// file).
227///
228/// Per spec § 3.1: for each `[profile <name>]` section we extract
229/// `sso_account_id`, `role_arn`, and `region`. When the section has none,
230/// we follow `source_profile = ...` up to [`AWS_CONFIG_MAX_CHAIN_HOPS`] hops.
231///
232/// # Errors
233///
234/// Returns [`ProviderError`] on I/O failure, size-cap breach, INI parse, or
235/// chain-cycle detection.
236pub fn load_aws_config(path: &Path) -> Result<Arc<ProfileMap>> {
237    let bytes = read_capped(path)?;
238    // CLAUDE.md § Input Validation — reject, don't sanitize. Refuse the
239    // file outright on invalid UTF-8 rather than silently swap in U+FFFD
240    // replacement characters.
241    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    // First pass: index every section by its profile name (the section
251    // label after `profile ` for `[profile xxx]`, plain `default` for
252    // `[default]`, ignored otherwise).
253    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    // Second pass: resolve each profile, following `source_profile` chains.
271    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
281/// Walk the `source_profile` chain for `name` and produce a [`ProfileEntry`]
282/// when an account id can be inferred.
283///
284/// Caps the chain at [`AWS_CONFIG_MAX_CHAIN_HOPS`] traversals: the starting
285/// profile is hop 0, the next is hop 1, and so on. If the chain has not
286/// resolved an account by hop `MAX` **and** the next profile is still a
287/// `source_profile` pointer, the chain is too long and we surface
288/// [`ProviderError::ChainTooLong`] rather than silently truncate (spec § 3.1).
289fn 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            // Chain points at an undefined profile — stop here.
311            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
368// ----------------------------------------------------------------------------
369// Shared helpers
370// ----------------------------------------------------------------------------
371
372fn 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    // -- YAML loader -----------------------------------------------------
411
412    #[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        // 257 KiB ASCII content under a "profiles:" header — content has to
466        // be ≥ cap to trigger the rejection. The body need not parse.
467        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    // -- aws_config loader ----------------------------------------------
515
516    #[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        // Region cascades too.
568        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        // A profile with no `sso_account_id` / `role_arn` / chain into one
606        // is silently dropped — we have nothing to map it to.
607        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    // -- ArcSwap wiring -------------------------------------------------
618
619    #[test]
620    fn test_shared_profile_map_swap_replaces_atomically() {
621        let initial = empty();
622        let shared = shared(Arc::clone(&initial));
623        // Replace with a non-empty map and verify the swap is visible.
624        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}