1use crate::cloud::cloud_browse::Environment;
11use crate::cloud::cloud_command::CommandError;
12use std::collections::HashMap;
13use std::path::PathBuf;
14use std::sync::OnceLock;
15use std::time::SystemTime;
16
17#[derive(Debug, Clone, Default, PartialEq, Eq)]
19pub struct Profile {
20 pub name: String,
21 pub region: Option<String>,
22 pub endpoint_url: Option<String>,
24 pub s3_endpoint_url: Option<String>,
26 pub ignore_configured_endpoint_urls: bool,
27 pub access_key_id: Option<String>,
28 pub secret_access_key: Option<String>,
29 pub session_token: Option<String>,
30 pub credential_process: Option<String>,
31 pub needs_cli: bool,
33}
34
35const CLI_ONLY_KEYS: [&str; 6] = [
37 "sso_session",
38 "sso_start_url",
39 "role_arn",
40 "credential_source",
41 "web_identity_token_file",
42 "login_session",
43];
44
45impl Profile {
46 pub fn has_credentials(&self) -> bool {
49 (self.access_key_id.is_some() && self.secret_access_key.is_some())
50 || self.credential_process.is_some()
51 || self.needs_cli
52 }
53
54 pub fn s3_endpoint(&self, var: &dyn Fn(&str) -> Option<String>) -> Option<String> {
59 let ignored = var("AWS_IGNORE_CONFIGURED_ENDPOINT_URLS")
60 .is_some_and(|v| v.trim().eq_ignore_ascii_case("true"));
61 if ignored || self.ignore_configured_endpoint_urls {
62 return None;
63 }
64 ["AWS_ENDPOINT_URL_S3", "AWS_ENDPOINT_URL"]
65 .iter()
66 .filter_map(|key| var(key))
67 .chain(self.s3_endpoint_url.clone())
68 .chain(self.endpoint_url.clone())
69 .map(|v| v.trim().to_string())
70 .find(|v| !v.is_empty())
71 }
72}
73
74pub fn config_path(env: &Environment<'_>) -> Option<PathBuf> {
76 file_path(env, "AWS_CONFIG_FILE", "config")
77}
78
79pub fn credentials_path(env: &Environment<'_>) -> Option<PathBuf> {
81 file_path(env, "AWS_SHARED_CREDENTIALS_FILE", "credentials")
82}
83
84fn file_path(env: &Environment<'_>, variable: &str, name: &str) -> Option<PathBuf> {
85 match (env.var)(variable).filter(|v| !v.trim().is_empty()) {
86 Some(path) => Some(crate::config::expand_config_path(path.trim())),
87 None => env.home.as_ref().map(|home| home.join(".aws").join(name)),
88 }
89}
90
91pub fn active_profile(env: &Environment<'_>) -> String {
94 ["AWS_PROFILE", "AWS_DEFAULT_PROFILE"]
95 .iter()
96 .filter_map(|key| (env.var)(key))
97 .map(|v| v.trim().to_string())
98 .find(|v| !v.is_empty())
99 .unwrap_or_else(|| "default".to_string())
100}
101
102pub fn load(env: &Environment<'_>) -> Vec<Profile> {
104 let config = config_path(env)
105 .and_then(|p| (env.read)(&p))
106 .unwrap_or_default();
107 let credentials = credentials_path(env)
108 .and_then(|p| (env.read)(&p))
109 .unwrap_or_default();
110 parse(&config, &credentials)
111}
112
113#[derive(Debug, Default)]
116struct Entry {
117 value: String,
118 nested: HashMap<String, String>,
119}
120
121type Sections = Vec<(String, HashMap<String, Entry>)>;
122
123fn sections(text: &str) -> Sections {
125 let mut out: Sections = Vec::new();
126 let mut last_key: Option<String> = None;
127 for raw in text.lines() {
128 let line = raw.trim_end();
129 let trimmed = line.trim_start();
130 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
131 continue;
132 }
133 if let Some(header) = trimmed.strip_prefix('[').and_then(|h| h.strip_suffix(']')) {
134 out.push((header.trim().to_string(), HashMap::new()));
135 last_key = None;
136 continue;
137 }
138 let Some((key, value)) = trimmed.split_once('=') else {
139 continue;
140 };
141 let key = key.trim().to_ascii_lowercase();
142 let value = value.trim().to_string();
143 let Some((_, entries)) = out.last_mut() else {
144 continue;
145 };
146 let indented = line.len() != trimmed.len();
147 match &last_key {
148 Some(parent) if indented && entries.get(parent).is_some_and(|e| e.value.is_empty()) => {
149 if let Some(entry) = entries.get_mut(parent) {
150 entry.nested.insert(key, value);
151 }
152 }
153 _ => {
154 entries.insert(
155 key.clone(),
156 Entry {
157 value,
158 nested: HashMap::new(),
159 },
160 );
161 last_key = Some(key);
162 }
163 }
164 }
165 out
166}
167
168pub fn parse(config: &str, credentials: &str) -> Vec<Profile> {
170 let config = sections(config);
171 let credentials = sections(credentials);
172 let value = |entries: &HashMap<String, Entry>, key: &str| {
173 entries
174 .get(key)
175 .map(|e| e.value.clone())
176 .filter(|v| !v.is_empty())
177 };
178
179 let mut profiles: HashMap<String, Profile> = HashMap::new();
180 for (header, entries) in &config {
181 let name = if header == "default" {
182 "default"
183 } else if let Some(name) = header.strip_prefix("profile ") {
184 name.trim()
185 } else {
186 continue;
187 };
188 let profile = profiles.entry(name.to_string()).or_insert_with(|| Profile {
189 name: name.to_string(),
190 ..Default::default()
191 });
192 profile.region = value(entries, "region").or(profile.region.take());
193 profile.endpoint_url = value(entries, "endpoint_url");
194 profile.ignore_configured_endpoint_urls = value(entries, "ignore_configured_endpoint_urls")
195 .is_some_and(|v| v.eq_ignore_ascii_case("true"));
196 profile.access_key_id = value(entries, "aws_access_key_id");
197 profile.secret_access_key = value(entries, "aws_secret_access_key");
198 profile.session_token = value(entries, "aws_session_token");
199 profile.credential_process = value(entries, "credential_process");
200 profile.needs_cli = CLI_ONLY_KEYS.iter().any(|k| entries.contains_key(*k));
201 if let Some(services) = value(entries, "services") {
202 profile.s3_endpoint_url = config
203 .iter()
204 .find(|(h, _)| {
205 h.strip_prefix("services ").map(str::trim) == Some(services.as_str())
206 })
207 .and_then(|(_, e)| e.get("s3"))
208 .and_then(|s3| s3.nested.get("endpoint_url"))
209 .cloned()
210 .filter(|v| !v.is_empty());
211 }
212 }
213 for (name, entries) in &credentials {
215 let profile = profiles.entry(name.clone()).or_insert_with(|| Profile {
216 name: name.clone(),
217 ..Default::default()
218 });
219 if let Some(key) = value(entries, "aws_access_key_id") {
220 profile.access_key_id = Some(key);
221 }
222 if let Some(secret) = value(entries, "aws_secret_access_key") {
223 profile.secret_access_key = Some(secret);
224 }
225 if let Some(token) = value(entries, "aws_session_token") {
226 profile.session_token = Some(token);
227 }
228 if let Some(process) = value(entries, "credential_process") {
229 profile.credential_process = Some(process);
230 }
231 }
232
233 let mut out: Vec<Profile> = profiles.into_values().collect();
234 out.sort_by(|a, b| {
235 (a.name != "default")
236 .cmp(&(b.name != "default"))
237 .then_with(|| a.name.cmp(&b.name))
238 });
239 out
240}
241
242#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct Credentials {
245 pub access_key_id: String,
246 pub secret_access_key: String,
247 pub session_token: Option<String>,
248 pub expires: Option<SystemTime>,
249}
250
251fn cached() -> &'static crate::cloud::cloud_command::Expiring<Credentials> {
254 static CACHE: OnceLock<crate::cloud::cloud_command::Expiring<Credentials>> = OnceLock::new();
255 CACHE.get_or_init(Default::default)
256}
257
258pub fn credentials(profile: &Profile, env: &Environment<'_>) -> Result<Credentials, String> {
261 if let (Some(key), Some(secret)) = (&profile.access_key_id, &profile.secret_access_key)
262 && profile.credential_process.is_none()
263 && !profile.needs_cli
264 {
265 return Ok(Credentials {
266 access_key_id: key.clone(),
267 secret_access_key: secret.clone(),
268 session_token: profile.session_token.clone(),
269 expires: None,
270 });
271 }
272
273 if let Some(fresh) = cached().get(&profile.name) {
274 return Ok(fresh);
275 }
276
277 let output = if let Some(line) = &profile.credential_process {
278 let words = crate::cloud::cloud_command::split_command_line(line)
279 .ok_or_else(|| format!("credential_process for {} cannot be read", profile.name))?;
280 let args: Vec<&str> = words[1..].iter().map(String::as_str).collect();
281 (env.run)(&words[0], &args).map_err(|e| match e {
282 CommandError::Missing(program) => {
283 format!(
284 "credential_process for {}: {program} not found",
285 profile.name
286 )
287 }
288 other => format!("credential_process for {}: {other}", profile.name),
289 })?
290 } else if profile.needs_cli {
291 let args = [
292 "configure",
293 "export-credentials",
294 "--profile",
295 profile.name.as_str(),
296 "--format",
297 "process",
298 ];
299 (env.run)("aws", &args).map_err(|e| match e {
300 CommandError::Missing(_) => "needs the AWS CLI".to_string(),
301 other => other.to_string(),
302 })?
303 } else {
304 return Err(format!("profile {} has no credentials", profile.name));
305 };
306
307 let fresh = parse_process_output(&output)
308 .ok_or_else(|| format!("profile {}: credentials were not readable", profile.name))?;
309 cached().put(&profile.name, fresh.clone(), fresh.expires);
310 Ok(fresh)
311}
312
313pub fn parse_process_output(text: &str) -> Option<Credentials> {
316 let value: serde_json::Value = serde_json::from_str(text.trim()).ok()?;
317 let field = |name: &str| {
318 value
319 .get(name)
320 .and_then(|v| v.as_str())
321 .map(str::to_string)
322 .filter(|v| !v.is_empty())
323 };
324 Some(Credentials {
325 access_key_id: field("AccessKeyId")?,
326 secret_access_key: field("SecretAccessKey")?,
327 session_token: field("SessionToken"),
328 expires: field("Expiration")
329 .and_then(|at| chrono::DateTime::parse_from_rfc3339(&at).ok())
330 .map(SystemTime::from),
331 })
332}
333
334#[cfg(test)]
335mod tests;