use std::collections::HashMap;
use std::path::Path;
const MAX_SOURCE_PROFILE_DEPTH: usize = 8;
#[derive(Clone, Default, PartialEq, Eq)]
pub struct AwsProfile {
pub access_key_id: String,
pub secret_access_key: String,
pub session_token: String,
pub role_arn: String,
pub source_profile: String,
pub credential_source: String,
pub role_session_name: String,
pub external_id: String,
pub duration_seconds: String,
pub mfa_serial: String,
pub region: String,
pub credential_process: String,
pub web_identity_token_file: String,
pub sso: bool,
pub keys_from_credentials_file: bool,
}
impl std::fmt::Debug for AwsProfile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AwsProfile")
.field(
"access_key_id",
&super::config::redacted(&self.access_key_id),
)
.field(
"secret_access_key",
&super::config::redacted(&self.secret_access_key),
)
.field(
"session_token",
&super::config::redacted(&self.session_token),
)
.field("role_arn", &self.role_arn)
.field("source_profile", &self.source_profile)
.field("credential_source", &self.credential_source)
.field("role_session_name", &self.role_session_name)
.field("external_id", &self.external_id)
.field("duration_seconds", &self.duration_seconds)
.field("mfa_serial", &self.mfa_serial)
.field("region", &self.region)
.field("credential_process", &self.credential_process)
.field("web_identity_token_file", &self.web_identity_token_file)
.field("sso", &self.sso)
.field(
"keys_from_credentials_file",
&self.keys_from_credentials_file,
)
.finish()
}
}
impl AwsProfile {
pub fn has_static_keys(&self) -> bool {
!self.access_key_id.is_empty() && !self.secret_access_key.is_empty()
}
fn offers_static_keys(&self) -> bool {
!self.access_key_id.is_empty() || !self.secret_access_key.is_empty()
}
fn set(&mut self, key: &str, value: &str) {
let value = value.to_string();
match key {
"aws_access_key_id" => self.access_key_id = value,
"aws_secret_access_key" => self.secret_access_key = value,
"aws_session_token" => self.session_token = value,
"role_arn" => self.role_arn = value,
"source_profile" => self.source_profile = value,
"credential_source" => self.credential_source = value,
"role_session_name" => self.role_session_name = value,
"external_id" => self.external_id = value,
"duration_seconds" => self.duration_seconds = value,
"mfa_serial" => self.mfa_serial = value,
"region" => self.region = value,
"credential_process" => self.credential_process = value,
"web_identity_token_file" => self.web_identity_token_file = value,
"sso_session" | "sso_start_url" => self.sso = true,
_ => {}
}
}
}
#[derive(Debug, Clone, Default)]
pub struct AwsProfiles {
profiles: HashMap<String, AwsProfile>,
unreadable: Vec<String>,
sources: Vec<String>,
}
impl AwsProfiles {
pub fn load(config_file: Option<&Path>, credentials_file: Option<&Path>) -> Self {
let mut profiles: HashMap<String, AwsProfile> = HashMap::new();
let mut unreadable: Vec<String> = Vec::new();
let mut sources: Vec<String> = Vec::new();
for (path, style) in [
(config_file, SectionStyle::Config),
(credentials_file, SectionStyle::Credentials),
] {
let Some(path) = path else { continue };
sources.push(path.display().to_string());
match std::fs::read_to_string(path) {
Ok(text) => merge_into(&mut profiles, parse(&text, style), style),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
log::warn!(
"[config] aws profiles: cannot read {}: {}",
path.display(),
e
);
unreadable.push(path.display().to_string());
}
}
}
Self {
profiles,
unreadable,
sources,
}
}
pub fn get(&self, name: &str) -> Option<&AwsProfile> {
self.profiles.get(name)
}
pub fn sources(&self) -> String {
if self.sources.is_empty() {
return "~/.aws/config and ~/.aws/credentials".to_string();
}
self.sources.join(" and ")
}
pub fn names(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
names.sort_unstable();
names
}
pub fn resolve_chain(&self, name: &str) -> Result<ResolvedChain, ChainError> {
let mut visited: Vec<String> = Vec::new();
let mut roles: Vec<RoleStep> = Vec::new();
let mut current = name.to_string();
let mut requested = true;
loop {
if visited.iter().any(|seen| seen == ¤t) {
return Err(ChainError::Loop(current));
}
if visited.len() >= MAX_SOURCE_PROFILE_DEPTH {
return Err(ChainError::TooDeep(current));
}
visited.push(current.clone());
let Some(profile) = self.get(¤t) else {
return Err(self.name_the_file_if_unread(ChainError::Missing(current)));
};
if !profile.web_identity_token_file.is_empty() {
return Err(ChainError::WebIdentity(current));
}
if !profile.role_arn.is_empty() && (requested || !profile.offers_static_keys()) {
if !profile.credential_source.is_empty() {
return Err(ChainError::CredentialSource(current));
}
if !profile.mfa_serial.is_empty() {
return Err(ChainError::MfaRequired(current));
}
if profile.source_profile.is_empty() {
return Err(ChainError::RoleWithoutSource(current));
}
roles.push(RoleStep {
profile: current.clone(),
role_arn: profile.role_arn.clone(),
role_session_name: profile.role_session_name.clone(),
external_id: profile.external_id.clone(),
duration_seconds: profile.duration_seconds.clone(),
});
if profile.source_profile != current {
current = profile.source_profile.clone();
requested = false;
continue;
}
if profile.access_key_id.is_empty() && profile.secret_access_key.is_empty() {
return Err(ChainError::Loop(current));
}
}
base_outcome(profile, ¤t).map_err(|e| self.name_the_file_if_unread(e))?;
let base = profile.clone();
roles.reverse();
return Ok(ResolvedChain {
base,
base_profile: current,
roles,
});
}
}
fn name_the_file_if_unread(&self, error: ChainError) -> ChainError {
match (&error, self.unreadable.first()) {
(
ChainError::Missing(_) | ChainError::NoKeys(_) | ChainError::PartialCredentials(..),
Some(path),
) => ChainError::FileUnreadable(path.clone()),
_ => error,
}
}
}
fn base_outcome(profile: &AwsProfile, name: &str) -> Result<(), ChainError> {
if profile.sso {
return Err(ChainError::SsoNotSupported(name.to_string()));
}
let has_id = !profile.access_key_id.is_empty();
let has_secret = !profile.secret_access_key.is_empty();
if has_id != has_secret {
let missing = if has_id {
"aws_secret_access_key"
} else {
"aws_access_key_id"
};
return Err(ChainError::PartialCredentials(name.to_string(), missing));
}
if !profile.credential_process.is_empty() && !profile.keys_from_credentials_file {
return Err(ChainError::CredentialProcess(name.to_string()));
}
if profile.has_static_keys() {
return Ok(());
}
if !profile.credential_process.is_empty() {
return Err(ChainError::CredentialProcess(name.to_string()));
}
if !profile.credential_source.is_empty() {
return Err(ChainError::CredentialSource(name.to_string()));
}
Err(ChainError::NoKeys(name.to_string()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoleStep {
pub profile: String,
pub role_arn: String,
pub role_session_name: String,
pub external_id: String,
pub duration_seconds: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedChain {
pub base: AwsProfile,
pub base_profile: String,
pub roles: Vec<RoleStep>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChainError {
Missing(String),
NoKeys(String),
PartialCredentials(String, &'static str),
RoleWithoutSource(String),
CredentialSource(String),
CredentialProcess(String),
WebIdentity(String),
SsoNotSupported(String),
MfaRequired(String),
Loop(String),
TooDeep(String),
FileUnreadable(String),
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum SectionStyle {
Config,
Credentials,
}
const CREDENTIAL_PAIR_KEYS: [&str; 2] = ["aws_access_key_id", "aws_secret_access_key"];
fn merge_into(
target: &mut HashMap<String, AwsProfile>,
parsed: Vec<(String, Vec<(String, String)>)>,
style: SectionStyle,
) {
for (name, pairs) in parsed {
let entry = target.entry(name).or_default();
if pairs
.iter()
.any(|(key, _)| CREDENTIAL_PAIR_KEYS.contains(&key.as_str()))
{
entry.access_key_id.clear();
entry.secret_access_key.clear();
entry.session_token.clear();
entry.keys_from_credentials_file = style == SectionStyle::Credentials;
}
for (key, value) in pairs {
entry.set(&key, &value);
}
}
}
fn parse(text: &str, style: SectionStyle) -> Vec<(String, Vec<(String, String)>)> {
let mut out: Vec<(String, Vec<(String, String)>)> = Vec::new();
let mut current: Option<usize> = None;
let mut inside_nested = false;
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
continue;
}
if line.starts_with('[') {
inside_nested = false;
current = match section_name(line, style) {
Some(name) => {
out.push((name, Vec::new()));
Some(out.len() - 1)
}
None => None,
};
continue;
}
let indented = raw.starts_with(' ') || raw.starts_with('\t');
if indented && inside_nested {
continue;
}
let Some(idx) = current else { continue };
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim().to_ascii_lowercase();
if key.is_empty() {
continue;
}
let value = clean_value(value);
if !indented {
inside_nested = value.is_empty();
}
out[idx].1.push((key, value));
}
out
}
fn section_name(line: &str, style: SectionStyle) -> Option<String> {
let close = line.find(']')?;
let inner = line[1..close].trim();
if inner.is_empty() {
return None;
}
if inner.starts_with('"') && inner.ends_with('"') && inner.len() >= 2 {
let name = unquote(inner);
return (!name.is_empty()).then(|| name.to_string());
}
match inner.split_once(char::is_whitespace) {
Some((kind, rest)) => {
if style == SectionStyle::Config && kind == "profile" {
let name = unquote(rest.trim());
(!name.is_empty()).then(|| name.to_string())
} else {
None
}
}
None => {
let name = unquote(inner);
if style == SectionStyle::Config && CONFIG_ONLY_SECTIONS.contains(&name) {
return None;
}
Some(name.to_string())
}
}
}
const CONFIG_ONLY_SECTIONS: [&str; 2] = ["plugins", "preview"];
fn unquote(value: &str) -> &str {
value
.strip_prefix('"')
.and_then(|rest| rest.strip_suffix('"'))
.or_else(|| {
value
.strip_prefix('\'')
.and_then(|rest| rest.strip_suffix('\''))
})
.unwrap_or(value)
}
fn clean_value(value: &str) -> String {
let mut cut = value.len();
let bytes = value.as_bytes();
for (i, b) in bytes.iter().enumerate() {
if (*b == b'#' || *b == b';') && i > 0 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
cut = i;
break;
}
}
unquote(value[..cut].trim()).to_string()
}
#[cfg(test)]
#[path = "aws_profile_tests.rs"]
mod tests;