1use std::path::PathBuf;
5
6use indexmap::IndexMap;
7
8use super::AwsEnv;
9
10#[derive(Clone, Debug, Default, PartialEq, Eq)]
12pub struct Profiles {
13 pub profiles: IndexMap<String, IndexMap<String, String>>,
15 pub sso_sessions: IndexMap<String, IndexMap<String, String>>,
17}
18
19fn 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) = ¤t 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
60fn 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
73fn 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
84pub 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
91pub fn config_path(env: &AwsEnv) -> PathBuf {
93 expand(env.get("AWS_CONFIG_FILE").unwrap_or("~/.aws/config"), env)
94}
95
96pub 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
106pub 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 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}