Skip to main content

yapi_ai/aws/
ini.rs

1//! The shared AWS `config` and `credentials` files, read as
2//! `@smithy/shared-ini-file-loader` reads them.
3
4use std::path::PathBuf;
5
6use indexmap::IndexMap;
7
8use super::AwsEnv;
9
10/// Settings by profile name, and `[sso-session]` sections by name.
11#[derive(Clone, Debug, Default, PartialEq, Eq)]
12pub struct Profiles {
13    /// Profiles, with the credentials file's values over the config file's.
14    pub profiles: IndexMap<String, IndexMap<String, String>>,
15    /// `[sso-session name]` sections of the config file.
16    pub sso_sessions: IndexMap<String, IndexMap<String, String>>,
17}
18
19/// Sections of an INI file, by their header text.
20fn parse(text: &str) -> IndexMap<String, IndexMap<String, String>> {
21    let mut sections: IndexMap<String, IndexMap<String, String>> = IndexMap::new();
22    let mut current: Option<String> = None;
23    let mut parent: Option<String> = None;
24    for line in text.lines() {
25        let trimmed = without_comment(line).trim();
26        if trimmed.is_empty() {
27            continue;
28        }
29        if let Some(header) = trimmed
30            .strip_prefix('[')
31            .and_then(|rest| rest.strip_suffix(']'))
32        {
33            let name = header.trim().to_owned();
34            sections.entry(name.clone()).or_default();
35            current = Some(name);
36            parent = None;
37            continue;
38        }
39        let Some(section) = &current else {
40            continue;
41        };
42        let Some((key, value)) = trimmed.split_once('=') else {
43            continue;
44        };
45        let (key, value) = (key.trim(), value.trim());
46        let indented = line.starts_with([' ', '\t']);
47        let entries = sections.entry(section.clone()).or_default();
48        if indented && let Some(parent) = &parent {
49            entries.insert(format!("{parent}.{key}"), value.to_owned());
50        } else if value.is_empty() {
51            parent = Some(key.to_owned());
52        } else {
53            parent = None;
54            entries.insert(key.to_owned(), value.to_owned());
55        }
56    }
57    sections
58}
59
60/// The text before a comment, which starts with `#` or `;` at the start of
61/// the line or after whitespace.
62fn without_comment(line: &str) -> &str {
63    let mut after_space = true;
64    for (index, c) in line.char_indices() {
65        if after_space && matches!(c, '#' | ';') {
66            return &line[..index];
67        }
68        after_space = c.is_whitespace();
69    }
70    line
71}
72
73/// A path setting, with a leading `~` as the home directory.
74fn expand(path: &str, env: &AwsEnv) -> PathBuf {
75    match path
76        .strip_prefix("~/")
77        .or_else(|| (path == "~").then_some(""))
78    {
79        Some(rest) => home(env).join(rest),
80        None => PathBuf::from(path),
81    }
82}
83
84/// The home directory: `HOME`, else `USERPROFILE`.
85pub fn home(env: &AwsEnv) -> PathBuf {
86    env.get("HOME")
87        .or_else(|| env.get("USERPROFILE"))
88        .map_or_else(PathBuf::new, PathBuf::from)
89}
90
91/// The config file: `AWS_CONFIG_FILE`, else `~/.aws/config`.
92pub fn config_path(env: &AwsEnv) -> PathBuf {
93    expand(env.get("AWS_CONFIG_FILE").unwrap_or("~/.aws/config"), env)
94}
95
96/// The credentials file: `AWS_SHARED_CREDENTIALS_FILE`, else
97/// `~/.aws/credentials`.
98pub fn credentials_path(env: &AwsEnv) -> PathBuf {
99    expand(
100        env.get("AWS_SHARED_CREDENTIALS_FILE")
101            .unwrap_or("~/.aws/credentials"),
102        env,
103    )
104}
105
106/// Reads both files. A missing file counts as empty.
107pub async fn load(env: &AwsEnv) -> Profiles {
108    let read = |path: PathBuf| async move {
109        tokio::fs::read_to_string(path)
110            .await
111            .map(|text| parse(&text))
112            .unwrap_or_default()
113    };
114    let config = read(config_path(env)).await;
115    let credentials = read(credentials_path(env)).await;
116    merge(config, credentials)
117}
118
119fn merge(
120    config: IndexMap<String, IndexMap<String, String>>,
121    credentials: IndexMap<String, IndexMap<String, String>>,
122) -> Profiles {
123    let mut profiles = Profiles::default();
124    for (header, values) in config {
125        let header = header.split_whitespace().collect::<Vec<_>>();
126        match header.as_slice() {
127            ["default"] => {
128                profiles
129                    .profiles
130                    .entry("default".into())
131                    .or_default()
132                    .extend(values);
133            }
134            ["profile", name] => {
135                profiles
136                    .profiles
137                    .entry((*name).to_owned())
138                    .or_default()
139                    .extend(values);
140            }
141            ["sso-session", name] => {
142                profiles.sso_sessions.insert((*name).to_owned(), values);
143            }
144            _ => {}
145        }
146    }
147    for (name, values) in credentials {
148        profiles
149            .profiles
150            .entry(name.trim().to_owned())
151            .or_default()
152            .extend(values);
153    }
154    profiles
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn merges_config_and_credentials() {
163        let config = parse(
164            "[default]\nregion = us-west-2\n\n[profile dev]\nrole_arn = arn:aws:iam::1:role/r\nsource_profile = default\ns3 =\n  max_concurrent_requests = 10\n[sso-session corp]\nsso_region = eu-west-1\n[ignored]\nx = 1\n",
165        );
166        let credentials = parse(
167            "# keys\n[default]\naws_access_key_id = AKID\naws_secret_access_key = secret\nregion = eu-central-1\n[prod] # production account\nregion = eu-west-1 # Ireland\nsecret = a#b;c\n  ; indented\n",
168        );
169        let profiles = merge(config, credentials);
170        let default = &profiles.profiles["default"];
171        assert_eq!(default["region"], "eu-central-1");
172        assert_eq!(default["aws_access_key_id"], "AKID");
173        assert_eq!(profiles.profiles["dev"]["source_profile"], "default");
174        assert_eq!(profiles.profiles["dev"]["s3.max_concurrent_requests"], "10");
175        assert_eq!(profiles.sso_sessions["corp"]["sso_region"], "eu-west-1");
176        // A comment starts at `#` or `;` after whitespace, as in smithy.
177        assert_eq!(profiles.profiles["prod"]["region"], "eu-west-1");
178        assert_eq!(profiles.profiles["prod"]["secret"], "a#b;c");
179        assert!(!profiles.profiles.contains_key("ignored"));
180    }
181}