use std::{collections::HashMap, fs, io::Read as _, path::Path, sync::Arc};
use arc_swap::ArcSwap;
use ini::Ini;
use regex::Regex;
use serde::Deserialize;
use validator::{Validate, ValidationError as VrError};
use crate::{
ir::{AccountId, Region},
provider::error::{ProviderError, Result},
};
pub const PROFILE_MAP_FILE_MAX_BYTES: u64 = 256 * 1024;
pub const AWS_CONFIG_MAX_CHAIN_HOPS: usize = 8;
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct ProfileEntry {
pub account_id: AccountId,
pub account_name: Arc<str>,
pub region: Option<Region>,
pub role_arn: Option<Arc<str>>,
}
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct ProfileMap {
entries: HashMap<Arc<str>, ProfileEntry>,
}
impl ProfileMap {
#[must_use]
pub fn lookup(&self, profile: &str) -> Option<&ProfileEntry> {
self.entries.get(profile)
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&Arc<str>, &ProfileEntry)> {
self.entries.iter()
}
}
pub type SharedProfileMap = ArcSwap<ProfileMap>;
#[must_use]
pub fn shared(initial: Arc<ProfileMap>) -> SharedProfileMap {
ArcSwap::from(initial)
}
#[must_use]
pub fn empty() -> Arc<ProfileMap> {
Arc::new(ProfileMap::default())
}
#[derive(Debug, Deserialize, Validate)]
#[serde(deny_unknown_fields)]
struct YamlBody {
#[validate(nested)]
profiles: HashMap<String, YamlEntry>,
}
#[derive(Debug, Deserialize, Validate)]
#[serde(deny_unknown_fields)]
struct YamlEntry {
#[validate(regex(path = *ACCOUNT_ID_RE))]
account_id: String,
#[validate(length(min = 1, max = 64), regex(path = *NAME_RE))]
account_name: String,
#[serde(default)]
#[validate(custom(function = "validate_region_opt"))]
region: Option<String>,
#[serde(default)]
#[validate(length(max = 2048))]
role_arn: Option<String>,
}
#[allow(clippy::unwrap_used)]
static ACCOUNT_ID_RE: std::sync::LazyLock<Regex> =
std::sync::LazyLock::new(|| Regex::new(r"^\d{12}$").unwrap());
#[allow(clippy::unwrap_used)]
static NAME_RE: std::sync::LazyLock<Regex> =
std::sync::LazyLock::new(|| Regex::new(r"^[A-Za-z0-9._\-/ ]{1,64}$").unwrap());
fn validate_region_opt(value: &str) -> std::result::Result<(), VrError> {
Region::new(value).map(|_| ()).map_err(|_| {
let mut e = VrError::new("region");
e.message = Some("region must match ^[a-z0-9-]{1,32}$".into());
e
})
}
pub fn load_yaml_profile_map(path: &Path) -> Result<Arc<ProfileMap>> {
let bytes = read_capped(path)?;
let body: YamlBody = serde_yaml::from_slice(&bytes).map_err(|source| ProviderError::Yaml {
path: path.to_path_buf(),
source,
})?;
body.validate()
.map_err(|source| ProviderError::Validation {
path: path.to_path_buf(),
source,
})?;
let mut entries = HashMap::with_capacity(body.profiles.len());
for (name, raw) in body.profiles {
let profile_arc: Arc<str> = Arc::from(name.as_str());
let account_id =
AccountId::new(&raw.account_id).map_err(|source| ProviderError::InvalidEntry {
profile: Arc::clone(&profile_arc),
source,
})?;
let region = match raw.region {
Some(r) => Some(
Region::new(&r).map_err(|source| ProviderError::InvalidEntry {
profile: Arc::clone(&profile_arc),
source,
})?,
),
None => None,
};
let role_arn = raw.role_arn.map(Arc::<str>::from);
let account_name = Arc::<str>::from(raw.account_name);
entries.insert(
profile_arc,
ProfileEntry {
account_id,
account_name,
region,
role_arn,
},
);
}
Ok(Arc::new(ProfileMap { entries }))
}
pub fn load_aws_config(path: &Path) -> Result<Arc<ProfileMap>> {
let bytes = read_capped(path)?;
let text = std::str::from_utf8(&bytes).map_err(|source| ProviderError::Io {
path: path.to_path_buf(),
source: std::io::Error::new(std::io::ErrorKind::InvalidData, source),
})?;
let ini = Ini::load_from_str(text).map_err(|source| ProviderError::Ini {
path: path.to_path_buf(),
source,
})?;
let mut raw_sections: HashMap<String, HashMap<String, String>> = HashMap::new();
for (section, props) in &ini {
let Some(s) = section else { continue };
let name = if s == "default" {
"default".to_owned()
} else if let Some(stripped) = s.strip_prefix("profile ") {
stripped.trim().to_owned()
} else {
continue;
};
let mut bucket: HashMap<String, String> = HashMap::new();
for (k, v) in props {
bucket.insert(k.to_owned(), v.to_owned());
}
raw_sections.insert(name, bucket);
}
let mut entries: HashMap<Arc<str>, ProfileEntry> = HashMap::new();
for name in raw_sections.keys().cloned().collect::<Vec<_>>() {
let resolved = resolve_aws_profile(&name, &raw_sections)?;
if let Some(entry) = resolved {
entries.insert(Arc::from(name.as_str()), entry);
}
}
Ok(Arc::new(ProfileMap { entries }))
}
fn resolve_aws_profile(
name: &str,
sections: &HashMap<String, HashMap<String, String>>,
) -> Result<Option<ProfileEntry>> {
let mut visited: Vec<String> = Vec::new();
let mut cur = name.to_string();
let mut last_region: Option<Region> = None;
let mut last_role_arn: Option<Arc<str>> = None;
let mut account: Option<AccountId> = None;
let mut hops: usize = 0;
loop {
if visited.iter().any(|v| v == &cur) {
return Err(ProviderError::ChainTooLong {
profile: Arc::from(cur.as_str()),
limit: AWS_CONFIG_MAX_CHAIN_HOPS,
});
}
visited.push(cur.clone());
let Some(props) = sections.get(&cur) else {
break;
};
if last_region.is_none()
&& let Some(r) = props.get("region")
&& let Ok(parsed) = Region::new(r)
{
last_region = Some(parsed);
}
if last_role_arn.is_none()
&& let Some(arn) = props.get("role_arn")
{
last_role_arn = Some(Arc::from(arn.as_str()));
}
if account.is_none()
&& let Some(sso) = props.get("sso_account_id")
&& let Ok(id) = AccountId::new(sso)
{
account = Some(id);
}
if account.is_none()
&& let Some(arn) = props.get("role_arn")
{
account = super::resolver::extract_account_id(arn);
}
if account.is_some() {
break;
}
match props.get("source_profile") {
Some(next) if !next.is_empty() => {
if hops >= AWS_CONFIG_MAX_CHAIN_HOPS {
return Err(ProviderError::ChainTooLong {
profile: Arc::from(name),
limit: AWS_CONFIG_MAX_CHAIN_HOPS,
});
}
cur = next.clone();
hops = hops.saturating_add(1);
}
_ => break,
}
}
let Some(account_id) = account else {
return Ok(None);
};
Ok(Some(ProfileEntry {
account_name: Arc::from(name),
account_id,
region: last_region,
role_arn: last_role_arn,
}))
}
fn read_capped(path: &Path) -> Result<Vec<u8>> {
let meta = fs::metadata(path).map_err(|source| ProviderError::Io {
path: path.to_path_buf(),
source,
})?;
if meta.len() > PROFILE_MAP_FILE_MAX_BYTES {
return Err(ProviderError::FileTooLarge {
path: path.to_path_buf(),
observed: meta.len(),
limit: PROFILE_MAP_FILE_MAX_BYTES,
});
}
let mut buf = Vec::with_capacity(meta.len().try_into().unwrap_or_default());
fs::File::open(path)
.and_then(|mut f| f.read_to_end(&mut buf))
.map_err(|source| ProviderError::Io {
path: path.to_path_buf(),
source,
})?;
Ok(buf)
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
fn write_tmp(text: &str) -> tempfile::NamedTempFile {
let f = tempfile::NamedTempFile::new().unwrap();
std::fs::write(f.path(), text).unwrap();
f
}
#[test]
fn test_should_load_minimal_yaml_profile_map() {
let f = write_tmp(
r#"
profiles:
main-developer:
account_id: "370025973162"
account_name: "primary"
region: "us-west-2"
"#,
);
let map = load_yaml_profile_map(f.path()).unwrap();
let entry = map.lookup("main-developer").unwrap();
assert_eq!(entry.account_id.as_str(), "370025973162");
assert_eq!(entry.account_name.as_ref(), "primary");
assert_eq!(entry.region.as_ref().map(Region::as_str), Some("us-west-2"));
}
#[test]
fn test_should_reject_yaml_bad_account_id() {
let f = write_tmp(
r#"
profiles:
bad:
account_id: "12"
account_name: "x"
"#,
);
let err = load_yaml_profile_map(f.path()).unwrap_err();
assert!(
matches!(err, ProviderError::Validation { .. }),
"got {err:?}"
);
}
#[test]
fn test_should_reject_yaml_unknown_field() {
let f = write_tmp(
r#"
profiles:
good:
account_id: "370025973162"
account_name: "primary"
rogue_field: "value"
"#,
);
let err = load_yaml_profile_map(f.path()).unwrap_err();
assert!(matches!(err, ProviderError::Yaml { .. }), "got {err:?}");
}
#[test]
fn test_should_reject_yaml_file_over_size_cap() {
let f = tempfile::NamedTempFile::new().unwrap();
let cap_usize: usize = usize::try_from(PROFILE_MAP_FILE_MAX_BYTES).unwrap();
let payload = "x".repeat(cap_usize + 64);
std::fs::write(f.path(), payload).unwrap();
let err = load_yaml_profile_map(f.path()).unwrap_err();
assert!(
matches!(err, ProviderError::FileTooLarge { .. }),
"got {err:?}"
);
}
#[test]
fn test_should_load_yaml_with_role_arn() {
let f = write_tmp(
r#"
profiles:
cross-account:
account_id: "999999999999"
account_name: "cross"
role_arn: "arn:aws:iam::999999999999:role/admin"
"#,
);
let map = load_yaml_profile_map(f.path()).unwrap();
let e = map.lookup("cross-account").unwrap();
assert_eq!(
e.role_arn.as_deref(),
Some("arn:aws:iam::999999999999:role/admin")
);
}
#[test]
fn test_should_reject_yaml_invalid_region_chars() {
let f = write_tmp(
r#"
profiles:
bad-region:
account_id: "111111111111"
account_name: "x"
region: "US-EAST-1"
"#,
);
let err = load_yaml_profile_map(f.path()).unwrap_err();
assert!(
matches!(err, ProviderError::Validation { .. }),
"got {err:?}"
);
}
#[test]
fn test_should_load_aws_config_with_sso_account_id() {
let f = write_tmp(
r"
[profile main]
sso_account_id = 370025973162
region = us-west-2
",
);
let map = load_aws_config(f.path()).unwrap();
let e = map.lookup("main").unwrap();
assert_eq!(e.account_id.as_str(), "370025973162");
assert_eq!(e.region.as_ref().map(Region::as_str), Some("us-west-2"));
}
#[test]
fn test_should_load_aws_config_role_arn_extracts_account_id() {
let f = write_tmp(
r"
[profile cross]
role_arn = arn:aws:iam::123456789012:role/admin
region = eu-west-1
",
);
let map = load_aws_config(f.path()).unwrap();
let e = map.lookup("cross").unwrap();
assert_eq!(e.account_id.as_str(), "123456789012");
assert_eq!(
e.role_arn.as_deref(),
Some("arn:aws:iam::123456789012:role/admin")
);
}
#[test]
fn test_should_follow_source_profile_chain() {
let f = write_tmp(
r"
[profile root]
sso_account_id = 111111111111
region = us-east-1
[profile mid]
source_profile = root
[profile leaf]
source_profile = mid
",
);
let map = load_aws_config(f.path()).unwrap();
let leaf = map.lookup("leaf").unwrap();
assert_eq!(leaf.account_id.as_str(), "111111111111");
assert_eq!(leaf.region.as_ref().map(Region::as_str), Some("us-east-1"));
}
#[test]
fn test_should_reject_aws_config_chain_cycle() {
let f = write_tmp(
r"
[profile a]
source_profile = b
[profile b]
source_profile = a
",
);
let err = load_aws_config(f.path()).unwrap_err();
assert!(
matches!(err, ProviderError::ChainTooLong { .. }),
"got {err:?}"
);
}
#[test]
fn test_should_load_aws_config_default_section() {
let f = write_tmp(
r"
[default]
sso_account_id = 222222222222
region = us-west-2
",
);
let map = load_aws_config(f.path()).unwrap();
let e = map.lookup("default").unwrap();
assert_eq!(e.account_id.as_str(), "222222222222");
}
#[test]
fn test_aws_config_skips_profiles_without_account_signals() {
let f = write_tmp(
r"
[profile bare]
region = us-east-1
",
);
let map = load_aws_config(f.path()).unwrap();
assert!(map.lookup("bare").is_none());
}
#[test]
fn test_shared_profile_map_swap_replaces_atomically() {
let initial = empty();
let shared = shared(Arc::clone(&initial));
let f = write_tmp(
r#"
profiles:
p:
account_id: "100000000001"
account_name: "x"
"#,
);
let next = load_yaml_profile_map(f.path()).unwrap();
shared.store(next);
let loaded = shared.load();
assert_eq!(loaded.len(), 1);
}
#[test]
fn test_static_regexes_compile() {
assert!(ACCOUNT_ID_RE.is_match("123456789012"));
assert!(!ACCOUNT_ID_RE.is_match("12345"));
assert!(NAME_RE.is_match("primary"));
assert!(!NAME_RE.is_match(""));
}
}