purple_ssh/providers/aws_profile.rs
1//! AWS shared-config profiles: the `~/.aws/config` and `~/.aws/credentials`
2//! files, merged per profile name, plus the assume-role chain they describe.
3//!
4//! The AWS CLI reads both files and merges them per profile, with the
5//! credentials file winning key by key. Named profiles are `[profile <name>]`
6//! in the config file and `[<name>]` in the credentials file; `default` is
7//! bare in both.
8
9use std::collections::HashMap;
10use std::path::Path;
11
12/// How far a `source_profile` chain may nest before we call it a loop. AWS
13/// documents no limit; real chains are one or two hops, and STS itself refuses
14/// to chain a session beyond an hour, so anything deeper is a config mistake.
15const MAX_SOURCE_PROFILE_DEPTH: usize = 8;
16
17/// One profile's settings, already merged across the two files.
18#[derive(Clone, Default, PartialEq, Eq)]
19pub struct AwsProfile {
20 pub access_key_id: String,
21 pub secret_access_key: String,
22 pub session_token: String,
23 pub role_arn: String,
24 pub source_profile: String,
25 pub credential_source: String,
26 pub role_session_name: String,
27 pub external_id: String,
28 pub duration_seconds: String,
29 pub mfa_serial: String,
30 pub region: String,
31 pub credential_process: String,
32 /// `web_identity_token_file`: the OIDC token an EKS pod role or a CI job
33 /// signs in with. The AWS CLI hands a profile carrying it to its web
34 /// identity provider and keeps the assume-role provider away from it, so
35 /// it is not a `source_profile` chain even with a `role_arn` beside it.
36 pub web_identity_token_file: String,
37 /// Set when the profile configures IAM Identity Center, through either the
38 /// `sso_session` form or the legacy `sso_start_url` form.
39 pub sso: bool,
40 /// True when the key pair came from `~/.aws/credentials` rather than from
41 /// `~/.aws/config`. The AWS CLI reads that file ahead of
42 /// `credential_process` and the config file after it, so a profile
43 /// carrying both resolves differently depending on which file the keys
44 /// are in.
45 pub keys_from_credentials_file: bool,
46}
47
48/// Manual `Debug` so a key pair read out of `~/.aws/credentials` never reaches
49/// a log line, a panic message or test output through `{:?}`. Matches what
50/// `ProviderSection` does with the provider token, and what the deliberate
51/// absence of `Debug` on `AwsCredentials` protects further down the same
52/// pipeline. Everything holding an `AwsProfile` inherits this.
53impl std::fmt::Debug for AwsProfile {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 f.debug_struct("AwsProfile")
56 .field(
57 "access_key_id",
58 &super::config::redacted(&self.access_key_id),
59 )
60 .field(
61 "secret_access_key",
62 &super::config::redacted(&self.secret_access_key),
63 )
64 .field(
65 "session_token",
66 &super::config::redacted(&self.session_token),
67 )
68 .field("role_arn", &self.role_arn)
69 .field("source_profile", &self.source_profile)
70 .field("credential_source", &self.credential_source)
71 .field("role_session_name", &self.role_session_name)
72 .field("external_id", &self.external_id)
73 .field("duration_seconds", &self.duration_seconds)
74 .field("mfa_serial", &self.mfa_serial)
75 .field("region", &self.region)
76 .field("credential_process", &self.credential_process)
77 .field("web_identity_token_file", &self.web_identity_token_file)
78 .field("sso", &self.sso)
79 .field(
80 "keys_from_credentials_file",
81 &self.keys_from_credentials_file,
82 )
83 .finish()
84 }
85}
86
87impl AwsProfile {
88 /// Whether the profile carries a usable static key pair.
89 pub fn has_static_keys(&self) -> bool {
90 !self.access_key_id.is_empty() && !self.secret_access_key.is_empty()
91 }
92
93 /// Whether the profile offers a key pair at all, complete or not. This is
94 /// the test the AWS CLI applies when it decides that a profile is a
95 /// credential source rather than another link in a chain, so half a pair
96 /// stops the walk and is reported instead of being walked past.
97 fn offers_static_keys(&self) -> bool {
98 !self.access_key_id.is_empty() || !self.secret_access_key.is_empty()
99 }
100
101 /// Apply one `key = value` pair. Unknown keys are ignored, which is what
102 /// lets the same parser read files carrying settings purple has no use for.
103 fn set(&mut self, key: &str, value: &str) {
104 let value = value.to_string();
105 match key {
106 "aws_access_key_id" => self.access_key_id = value,
107 "aws_secret_access_key" => self.secret_access_key = value,
108 "aws_session_token" => self.session_token = value,
109 "role_arn" => self.role_arn = value,
110 "source_profile" => self.source_profile = value,
111 "credential_source" => self.credential_source = value,
112 "role_session_name" => self.role_session_name = value,
113 "external_id" => self.external_id = value,
114 "duration_seconds" => self.duration_seconds = value,
115 "mfa_serial" => self.mfa_serial = value,
116 "region" => self.region = value,
117 "credential_process" => self.credential_process = value,
118 "web_identity_token_file" => self.web_identity_token_file = value,
119 "sso_session" | "sso_start_url" => self.sso = true,
120 _ => {}
121 }
122 }
123}
124
125/// Every profile found in the two files, keyed by profile name.
126#[derive(Debug, Clone, Default)]
127pub struct AwsProfiles {
128 profiles: HashMap<String, AwsProfile>,
129 /// Files that are there but could not be opened, in load order. A file
130 /// purple cannot read looks exactly like an empty one, so this is what
131 /// keeps an absent profile from being reported as missing.
132 unreadable: Vec<String>,
133 /// The two files as they were asked for, in load order. `AWS_CONFIG_FILE`
134 /// and `AWS_SHARED_CREDENTIALS_FILE` move them, so a message that sends
135 /// the user to a file names the one purple actually read.
136 sources: Vec<String>,
137}
138
139impl AwsProfiles {
140 /// Read both files. A missing file is not an error: a user with only
141 /// `~/.aws/credentials` is the common case, and so is only `~/.aws/config`.
142 pub fn load(config_file: Option<&Path>, credentials_file: Option<&Path>) -> Self {
143 let mut profiles: HashMap<String, AwsProfile> = HashMap::new();
144 let mut unreadable: Vec<String> = Vec::new();
145 let mut sources: Vec<String> = Vec::new();
146 // The credentials file is read second, so its keys overwrite the
147 // config file's per the AWS precedence rule.
148 for (path, style) in [
149 (config_file, SectionStyle::Config),
150 (credentials_file, SectionStyle::Credentials),
151 ] {
152 let Some(path) = path else { continue };
153 sources.push(path.display().to_string());
154 match std::fs::read_to_string(path) {
155 Ok(text) => merge_into(&mut profiles, parse(&text, style), style),
156 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
157 Err(e) => {
158 log::warn!(
159 "[config] aws profiles: cannot read {}: {}",
160 path.display(),
161 e
162 );
163 unreadable.push(path.display().to_string());
164 }
165 }
166 }
167 Self {
168 profiles,
169 unreadable,
170 sources,
171 }
172 }
173
174 pub fn get(&self, name: &str) -> Option<&AwsProfile> {
175 self.profiles.get(name)
176 }
177
178 /// The files this set was read from, for a message that sends the user to
179 /// one of them. Falls back to the AWS defaults when no path was given.
180 pub fn sources(&self) -> String {
181 if self.sources.is_empty() {
182 return "~/.aws/config and ~/.aws/credentials".to_string();
183 }
184 self.sources.join(" and ")
185 }
186
187 /// Profile names, sorted, for error messages that list what is available.
188 pub fn names(&self) -> Vec<&str> {
189 let mut names: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
190 names.sort_unstable();
191 names
192 }
193
194 /// Walk a `source_profile` chain from `name` down to the profile holding
195 /// real keys. Returns the base profile first and each role to assume after
196 /// it, outermost last, so a caller assumes them in order.
197 ///
198 /// Errors name the profile at fault rather than the one the user asked for,
199 /// because a chain hides which link is broken.
200 pub fn resolve_chain(&self, name: &str) -> Result<ResolvedChain, ChainError> {
201 let mut visited: Vec<String> = Vec::new();
202 let mut roles: Vec<RoleStep> = Vec::new();
203 let mut current = name.to_string();
204 // Only the profile the user named follows its own `role_arn` come what
205 // may. The AWS CLI resolves a profile it reached through
206 // `source_profile` with its provider chain, which stops at that
207 // profile's keys and never looks at its role.
208 let mut requested = true;
209
210 loop {
211 if visited.iter().any(|seen| seen == ¤t) {
212 return Err(ChainError::Loop(current));
213 }
214 if visited.len() >= MAX_SOURCE_PROFILE_DEPTH {
215 return Err(ChainError::TooDeep(current));
216 }
217 visited.push(current.clone());
218
219 let Some(profile) = self.get(¤t) else {
220 return Err(self.name_the_file_if_unread(ChainError::Missing(current)));
221 };
222
223 // The AWS CLI keeps its assume-role provider away from a profile
224 // that signs in with an OIDC token, whatever else it carries, so
225 // this is not a chain to walk.
226 if !profile.web_identity_token_file.is_empty() {
227 return Err(ChainError::WebIdentity(current));
228 }
229
230 if !profile.role_arn.is_empty() && (requested || !profile.offers_static_keys()) {
231 if !profile.credential_source.is_empty() {
232 return Err(ChainError::CredentialSource(current));
233 }
234 if !profile.mfa_serial.is_empty() {
235 return Err(ChainError::MfaRequired(current));
236 }
237 if profile.source_profile.is_empty() {
238 return Err(ChainError::RoleWithoutSource(current));
239 }
240 // Push in walk order, reversed once the base is found, so the
241 // caller assumes the innermost role first.
242 roles.push(RoleStep {
243 profile: current.clone(),
244 role_arn: profile.role_arn.clone(),
245 role_session_name: profile.role_session_name.clone(),
246 external_id: profile.external_id.clone(),
247 duration_seconds: profile.duration_seconds.clone(),
248 });
249 if profile.source_profile != current {
250 current = profile.source_profile.clone();
251 requested = false;
252 continue;
253 }
254 // A profile may name itself as its own source, which is how
255 // AWS documents keeping static keys and a role in one block.
256 // With no key of its own it is a plain cycle, which is what
257 // the AWS CLI calls it too. Otherwise this same profile is the
258 // base, so fall through to the checks below.
259 if profile.access_key_id.is_empty() && profile.secret_access_key.is_empty() {
260 return Err(ChainError::Loop(current));
261 }
262 }
263
264 base_outcome(profile, ¤t).map_err(|e| self.name_the_file_if_unread(e))?;
265 let base = profile.clone();
266 roles.reverse();
267 return Ok(ResolvedChain {
268 base,
269 base_profile: current,
270 roles,
271 });
272 }
273 }
274
275 /// A file purple could not open parses as empty, so an outcome that turns
276 /// on a key not being there names that file instead of the profile. The
277 /// keys may well be sitting in it.
278 fn name_the_file_if_unread(&self, error: ChainError) -> ChainError {
279 match (&error, self.unreadable.first()) {
280 (
281 ChainError::Missing(_) | ChainError::NoKeys(_) | ChainError::PartialCredentials(..),
282 Some(path),
283 ) => ChainError::FileUnreadable(path.clone()),
284 _ => error,
285 }
286 }
287}
288
289/// Whether a profile can serve as the base of a chain, in the order the AWS
290/// CLI's own provider chain applies: Identity Center first, then the static
291/// key pair, then the providers purple cannot run.
292fn base_outcome(profile: &AwsProfile, name: &str) -> Result<(), ChainError> {
293 if profile.sso {
294 return Err(ChainError::SsoNotSupported(name.to_string()));
295 }
296 let has_id = !profile.access_key_id.is_empty();
297 let has_secret = !profile.secret_access_key.is_empty();
298 if has_id != has_secret {
299 let missing = if has_id {
300 "aws_secret_access_key"
301 } else {
302 "aws_access_key_id"
303 };
304 return Err(ChainError::PartialCredentials(name.to_string(), missing));
305 }
306 // The AWS CLI reads `~/.aws/credentials` before it runs
307 // `credential_process` and `~/.aws/config` after it, so which file the
308 // pair came from decides which one wins.
309 if !profile.credential_process.is_empty() && !profile.keys_from_credentials_file {
310 return Err(ChainError::CredentialProcess(name.to_string()));
311 }
312 if profile.has_static_keys() {
313 return Ok(());
314 }
315 if !profile.credential_process.is_empty() {
316 return Err(ChainError::CredentialProcess(name.to_string()));
317 }
318 if !profile.credential_source.is_empty() {
319 return Err(ChainError::CredentialSource(name.to_string()));
320 }
321 Err(ChainError::NoKeys(name.to_string()))
322}
323
324/// One `role_arn` to assume on the way to the requested profile.
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub struct RoleStep {
327 /// The profile this step came from, so an error can name it.
328 pub profile: String,
329 pub role_arn: String,
330 pub role_session_name: String,
331 pub external_id: String,
332 pub duration_seconds: String,
333}
334
335/// A profile chain resolved down to its credential-bearing base.
336#[derive(Debug, Clone, PartialEq, Eq)]
337pub struct ResolvedChain {
338 pub base: AwsProfile,
339 pub base_profile: String,
340 /// Roles to assume in order, innermost first. Empty for a plain profile.
341 pub roles: Vec<RoleStep>,
342}
343
344/// Why a profile chain could not be resolved. Each variant carries the profile
345/// name at fault.
346#[derive(Debug, Clone, PartialEq, Eq)]
347pub enum ChainError {
348 Missing(String),
349 NoKeys(String),
350 /// Half a key pair: the profile name, then the key it is missing.
351 PartialCredentials(String, &'static str),
352 RoleWithoutSource(String),
353 CredentialSource(String),
354 CredentialProcess(String),
355 /// `web_identity_token_file`: an OIDC sign-in purple does not perform.
356 WebIdentity(String),
357 SsoNotSupported(String),
358 MfaRequired(String),
359 Loop(String),
360 TooDeep(String),
361 /// A file that is there but could not be opened. Carries the path rather
362 /// than a profile name, because no profile could be read out of it.
363 FileUnreadable(String),
364}
365
366/// Which section-header spelling a file uses.
367#[derive(Clone, Copy, PartialEq, Eq)]
368enum SectionStyle {
369 /// `~/.aws/config`: `[default]` and `[profile <name>]`.
370 Config,
371 /// `~/.aws/credentials`: `[default]` and `[<name>]`.
372 Credentials,
373}
374
375/// The two keys that make a section own the credential set. A section
376/// carrying either one replaces all three, so a secret from `~/.aws/config`
377/// never pairs with an access key id from `~/.aws/credentials`. The AWS CLI
378/// reads the pair from one file too, and signing with a mixed pair fails with
379/// nothing but `SignatureDoesNotMatch` to go on.
380///
381/// `aws_session_token` is cleared with them but does not trigger the clear: a
382/// section holding only a token has no pair of its own to offer, so it must
383/// not blank the one the other file supplied.
384const CREDENTIAL_PAIR_KEYS: [&str; 2] = ["aws_access_key_id", "aws_secret_access_key"];
385
386/// Merge parsed sections into the running map, key by key, so a later file
387/// overrides only the settings it actually carries.
388fn merge_into(
389 target: &mut HashMap<String, AwsProfile>,
390 parsed: Vec<(String, Vec<(String, String)>)>,
391 style: SectionStyle,
392) {
393 for (name, pairs) in parsed {
394 let entry = target.entry(name).or_default();
395 if pairs
396 .iter()
397 .any(|(key, _)| CREDENTIAL_PAIR_KEYS.contains(&key.as_str()))
398 {
399 entry.access_key_id.clear();
400 entry.secret_access_key.clear();
401 entry.session_token.clear();
402 entry.keys_from_credentials_file = style == SectionStyle::Credentials;
403 }
404 for (key, value) in pairs {
405 entry.set(&key, &value);
406 }
407 }
408}
409
410/// Parse an AWS INI file into `(profile name, key-value pairs)`, one entry per
411/// section header.
412///
413/// Returns raw pairs rather than `AwsProfile` so the caller can merge two files
414/// key by key; building a struct per file would let an absent key in the second
415/// file blank one the first file set. Two headers naming the same profile stay
416/// two entries, so [`merge_into`] applies each one on its own and the
417/// credential set never splits across them.
418fn parse(text: &str, style: SectionStyle) -> Vec<(String, Vec<(String, String)>)> {
419 let mut out: Vec<(String, Vec<(String, String)>)> = Vec::new();
420 let mut current: Option<usize> = None;
421 // AWS nests a sub-setting under its parent by indenting it, and the parent
422 // is always a key with an empty value (`s3 =`). Tracking that is what
423 // separates a real sub-setting from a plainly indented setting, which
424 // hand-written files do use.
425 let mut inside_nested = false;
426
427 for raw in text.lines() {
428 let line = raw.trim();
429 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
430 continue;
431 }
432 if line.starts_with('[') {
433 inside_nested = false;
434 current = match section_name(line, style) {
435 Some(name) => {
436 out.push((name, Vec::new()));
437 Some(out.len() - 1)
438 }
439 None => None,
440 };
441 continue;
442 }
443 let indented = raw.starts_with(' ') || raw.starts_with('\t');
444 if indented && inside_nested {
445 continue;
446 }
447 let Some(idx) = current else { continue };
448 let Some((key, value)) = line.split_once('=') else {
449 continue;
450 };
451 let key = key.trim().to_ascii_lowercase();
452 if key.is_empty() {
453 continue;
454 }
455 let value = clean_value(value);
456 if !indented {
457 inside_nested = value.is_empty();
458 }
459 out[idx].1.push((key, value));
460 }
461 out
462}
463
464/// The profile name a section header names, or None when the header is not a
465/// profile (an `[sso-session x]` or `[services x]` block).
466fn section_name(line: &str, style: SectionStyle) -> Option<String> {
467 let close = line.find(']')?;
468 let inner = line[1..close].trim();
469 if inner.is_empty() {
470 return None;
471 }
472 // A fully quoted header is one name, spaces and all, so it is recognized
473 // before any whitespace split: `["my work"]` is the profile `my work`,
474 // not a keyword followed by a name.
475 if inner.starts_with('"') && inner.ends_with('"') && inner.len() >= 2 {
476 let name = unquote(inner);
477 return (!name.is_empty()).then(|| name.to_string());
478 }
479 // Otherwise the `profile` keyword is split off first, then the name is
480 // unquoted, so `[profile "my work"]` also yields `my work`. Quoted names
481 // are accepted because boto3 documents that form, even though the
482 // file-format reference says names hold no spaces.
483 match inner.split_once(char::is_whitespace) {
484 Some((kind, rest)) => {
485 if style == SectionStyle::Config && kind == "profile" {
486 let name = unquote(rest.trim());
487 (!name.is_empty()).then(|| name.to_string())
488 } else {
489 // `[sso-session x]`, `[services x]` or a `[profile x]` header
490 // in the credentials file, which AWS does not define.
491 None
492 }
493 }
494 None => {
495 let name = unquote(inner);
496 // The AWS CLI's own single-word sections in `~/.aws/config`. They
497 // carry settings rather than credentials, and a profile of the
498 // same name would be written `[profile plugins]`.
499 if style == SectionStyle::Config && CONFIG_ONLY_SECTIONS.contains(&name) {
500 return None;
501 }
502 Some(name.to_string())
503 }
504 }
505}
506
507/// Single-word `~/.aws/config` sections that are not profiles.
508const CONFIG_ONLY_SECTIONS: [&str; 2] = ["plugins", "preview"];
509
510/// Drop one surrounding pair of single or double quotes.
511fn unquote(value: &str) -> &str {
512 value
513 .strip_prefix('"')
514 .and_then(|rest| rest.strip_suffix('"'))
515 .or_else(|| {
516 value
517 .strip_prefix('\'')
518 .and_then(|rest| rest.strip_suffix('\''))
519 })
520 .unwrap_or(value)
521}
522
523/// Trim a value and drop a trailing comment.
524///
525/// A `#` or `;` only starts a comment when whitespace precedes it, so a value
526/// like `foo#1` stays intact. Surrounding quotes are dropped, matching what the
527/// Go and Java SDKs do.
528fn clean_value(value: &str) -> String {
529 let mut cut = value.len();
530 let bytes = value.as_bytes();
531 for (i, b) in bytes.iter().enumerate() {
532 if (*b == b'#' || *b == b';') && i > 0 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
533 cut = i;
534 break;
535 }
536 }
537 unquote(value[..cut].trim()).to_string()
538}
539
540#[cfg(test)]
541#[path = "aws_profile_tests.rs"]
542mod tests;