Skip to main content

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 == &current) {
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(&current) 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, &current).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;