1use std::collections::HashMap;
24use std::path::{Path, PathBuf};
25
26use ini::Ini;
27use serde::{Deserialize, Serialize};
28
29use crate::error::AppError;
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase")]
41pub enum AwsConfigSource {
42 Credentials,
44 Config,
46 Env,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub struct ProfileChainRef {
61 pub source_profile: String,
63 pub role_arn: String,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub mfa_serial: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub sso_session: Option<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct AwsConfigEntry {
91 pub profile_name: String,
93
94 pub source: AwsConfigSource,
96
97 #[serde(skip_serializing)]
108 #[allow(dead_code)]
109 pub(crate) access_key_id: Option<String>,
110
111 #[serde(skip_serializing)]
113 #[allow(dead_code)]
114 pub(crate) secret_access_key: Option<String>,
115
116 #[serde(skip_serializing)]
118 #[allow(dead_code)]
119 pub(crate) session_token: Option<String>,
120
121 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub region: Option<String>,
127
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub source_profile: Option<String>,
131
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub role_arn: Option<String>,
135
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub mfa_serial: Option<String>,
139
140 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub sso_session: Option<String>,
143
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub chain_ref: Option<ProfileChainRef>,
147}
148
149fn strip_config_prefix(section: &str) -> &str {
160 section.strip_prefix("profile ").unwrap_or(section)
161}
162
163fn parse_ini_file(path: &Path) -> Result<HashMap<String, HashMap<String, String>>, AppError> {
166 if !path.exists() {
167 return Ok(HashMap::new());
168 }
169
170 let ini = Ini::load_from_file(path).map_err(|e| AppError::Internal {
171 trace_id: format!("parse_ini_file: {}: {}", path.display(), e),
172 })?;
173
174 let mut result: HashMap<String, HashMap<String, String>> = HashMap::new();
175
176 for (section, props) in &ini {
177 let name = match section {
178 Some(s) => s.to_string(),
179 None => continue,
182 };
183
184 let entry = result.entry(name).or_default();
185 for (k, v) in props.iter() {
186 entry.insert(k.to_string(), v.to_string());
187 }
188 }
189
190 Ok(result)
191}
192
193pub fn parse_aws_config_files(
215 creds_path: &Path,
216 config_path: &Path,
217) -> Result<Vec<AwsConfigEntry>, AppError> {
218 let creds_sections = parse_ini_file(creds_path)?;
219 let config_sections_raw = parse_ini_file(config_path)?;
220
221 let config_sections: HashMap<String, HashMap<String, String>> = config_sections_raw
223 .into_iter()
224 .map(|(k, v)| (strip_config_prefix(&k).to_string(), v))
225 .collect();
226
227 let mut all_names: Vec<String> = {
229 let mut names: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
230 for name in creds_sections.keys() {
231 names.insert(name.clone());
232 }
233 for name in config_sections.keys() {
234 names.insert(name.clone());
235 }
236 names.into_iter().collect()
237 };
238 if let Some(pos) = all_names.iter().position(|n| n == "default") {
240 all_names.remove(pos);
241 all_names.insert(0, "default".to_string());
242 }
243
244 let mut entries: Vec<AwsConfigEntry> = Vec::with_capacity(all_names.len());
245
246 for name in all_names {
247 let creds = creds_sections.get(&name);
248 let config = config_sections.get(&name);
249
250 let source = if creds.is_some() {
252 AwsConfigSource::Credentials
253 } else {
254 AwsConfigSource::Config
255 };
256
257 let access_key_id = creds.and_then(|m| m.get("aws_access_key_id").cloned());
259 let secret_access_key = creds.and_then(|m| m.get("aws_secret_access_key").cloned());
260 let session_token = creds.and_then(|m| m.get("aws_session_token").cloned());
261
262 let region = config
264 .and_then(|m| m.get("region").cloned())
265 .or_else(|| creds.and_then(|m| m.get("region").cloned()));
266
267 let source_profile = config
268 .and_then(|m| m.get("source_profile").cloned())
269 .or_else(|| creds.and_then(|m| m.get("source_profile").cloned()));
270
271 let role_arn = config
272 .and_then(|m| m.get("role_arn").cloned())
273 .or_else(|| creds.and_then(|m| m.get("role_arn").cloned()));
274
275 let mfa_serial = config
276 .and_then(|m| m.get("mfa_serial").cloned())
277 .or_else(|| creds.and_then(|m| m.get("mfa_serial").cloned()));
278
279 let sso_session = config
280 .and_then(|m| m.get("sso_session").cloned())
281 .or_else(|| creds.and_then(|m| m.get("sso_session").cloned()));
282
283 let chain_ref = match (&role_arn, &source_profile) {
285 (Some(arn), Some(src)) => Some(ProfileChainRef {
286 source_profile: src.clone(),
287 role_arn: arn.clone(),
288 mfa_serial: mfa_serial.clone(),
289 sso_session: sso_session.clone(),
290 }),
291 _ => None,
292 };
293
294 entries.push(AwsConfigEntry {
295 profile_name: name,
296 source,
297 access_key_id,
298 secret_access_key,
299 session_token,
300 region,
301 source_profile,
302 role_arn,
303 mfa_serial,
304 sso_session,
305 chain_ref,
306 });
307 }
308
309 Ok(entries)
310}
311
312pub async fn discover_aws_profiles() -> Result<Vec<AwsConfigEntry>, AppError> {
322 let home = home_dir().ok_or_else(|| AppError::Internal {
323 trace_id: "discover_aws_profiles: cannot resolve home directory".to_string(),
324 })?;
325
326 let creds_path = home.join(".aws").join("credentials");
327 let config_path = home.join(".aws").join("config");
328
329 let mut entries = parse_aws_config_files(&creds_path, &config_path)?;
330
331 if let Ok(key_id) = std::env::var("AWS_ACCESS_KEY_ID") {
333 let secret = std::env::var("AWS_SECRET_ACCESS_KEY").ok();
334 let token = std::env::var("AWS_SESSION_TOKEN").ok();
335 let region = std::env::var("AWS_DEFAULT_REGION")
336 .ok()
337 .or_else(|| std::env::var("AWS_REGION").ok());
338
339 entries.insert(
340 0,
341 AwsConfigEntry {
342 profile_name: "env".to_string(),
343 source: AwsConfigSource::Env,
344 access_key_id: Some(key_id),
345 secret_access_key: secret,
346 session_token: token,
347 region,
348 source_profile: None,
349 role_arn: None,
350 mfa_serial: None,
351 sso_session: None,
352 chain_ref: None,
353 },
354 );
355 }
356
357 Ok(entries)
358}
359
360fn home_dir() -> Option<PathBuf> {
364 std::env::var_os("HOME")
365 .or_else(|| std::env::var_os("USERPROFILE"))
366 .map(PathBuf::from)
367}
368
369#[cfg(test)]
374mod tests {
375 use super::*;
376 use std::path::PathBuf;
377
378 fn fixture(name: &str) -> PathBuf {
379 let manifest = std::env::var("CARGO_MANIFEST_DIR")
381 .expect("CARGO_MANIFEST_DIR must be set during cargo test");
382 PathBuf::from(manifest)
383 .join("tests")
384 .join("fixtures")
385 .join("aws_config")
386 .join(name)
387 }
388
389 #[test]
394 fn basic_parses_two_profiles() {
395 let entries =
396 parse_aws_config_files(&fixture("basic.credentials"), &fixture("basic.config"))
397 .expect("basic fixtures must parse");
398
399 assert_eq!(entries.len(), 2, "expected default + dev");
400
401 let default = entries
402 .iter()
403 .find(|e| e.profile_name == "default")
404 .unwrap();
405 assert_eq!(default.source, AwsConfigSource::Credentials);
406 assert_eq!(default.region.as_deref(), Some("us-east-1"));
407 assert_eq!(default.access_key_id.as_deref(), Some("AKIADEFAULTEXAMPLE"));
409
410 let dev = entries.iter().find(|e| e.profile_name == "dev").unwrap();
411 assert_eq!(dev.source, AwsConfigSource::Credentials);
412 assert_eq!(dev.region.as_deref(), Some("eu-west-1"));
413 assert_eq!(dev.access_key_id.as_deref(), Some("AKIADEVEXAMPLE00000"));
414 }
415
416 #[test]
417 fn basic_default_is_first() {
418 let entries =
419 parse_aws_config_files(&fixture("basic.credentials"), &fixture("basic.config"))
420 .expect("basic fixtures must parse");
421
422 assert_eq!(entries[0].profile_name, "default");
423 }
424
425 #[test]
430 fn chained_parses_assume_role_profile() {
431 let entries =
432 parse_aws_config_files(&fixture("chained.credentials"), &fixture("chained.config"))
433 .expect("chained fixtures must parse");
434
435 let role = entries
436 .iter()
437 .find(|e| e.profile_name == "assume-role")
438 .expect("assume-role profile must be present");
439
440 assert_eq!(
441 role.source_profile.as_deref(),
442 Some("base-user"),
443 "source_profile must be base-user"
444 );
445 assert_eq!(
446 role.role_arn.as_deref(),
447 Some("arn:aws:iam::123456789012:role/MyRole"),
448 "role_arn must be populated"
449 );
450 assert_eq!(
451 role.mfa_serial.as_deref(),
452 Some("arn:aws:iam::123456789012:mfa/my-user"),
453 "mfa_serial must be populated"
454 );
455 assert_eq!(role.region.as_deref(), Some("us-west-2"));
456 }
457
458 #[test]
459 fn chained_chain_ref_populated() {
460 let entries =
461 parse_aws_config_files(&fixture("chained.credentials"), &fixture("chained.config"))
462 .expect("chained fixtures must parse");
463
464 let role = entries
465 .iter()
466 .find(|e| e.profile_name == "assume-role")
467 .unwrap();
468
469 let chain = role.chain_ref.as_ref().expect("chain_ref must be present");
470 assert_eq!(chain.source_profile, "base-user");
471 assert_eq!(chain.role_arn, "arn:aws:iam::123456789012:role/MyRole");
472 assert_eq!(
473 chain.mfa_serial.as_deref(),
474 Some("arn:aws:iam::123456789012:mfa/my-user")
475 );
476 }
477
478 #[test]
479 fn profile_without_role_arn_has_no_chain_ref() {
480 let entries =
481 parse_aws_config_files(&fixture("chained.credentials"), &fixture("chained.config"))
482 .expect("chained fixtures must parse");
483
484 let base = entries
485 .iter()
486 .find(|e| e.profile_name == "base-user")
487 .unwrap();
488 assert!(
489 base.chain_ref.is_none(),
490 "base-user has no role_arn so chain_ref must be None"
491 );
492 }
493
494 #[test]
499 fn secret_fields_are_not_serialized() {
500 let entries =
501 parse_aws_config_files(&fixture("basic.credentials"), &fixture("basic.config"))
502 .expect("basic fixtures must parse");
503
504 let default = entries
505 .iter()
506 .find(|e| e.profile_name == "default")
507 .unwrap();
508
509 assert!(
511 default.access_key_id.is_some(),
512 "access_key_id must be parsed"
513 );
514
515 let json = serde_json::to_value(default).expect("AwsConfigEntry must serialize");
517 assert!(
518 json.get("accessKeyId").is_none(),
519 "accessKeyId must NOT appear in IPC JSON"
520 );
521 assert!(
522 json.get("secretAccessKey").is_none(),
523 "secretAccessKey must NOT appear in IPC JSON"
524 );
525 assert!(
526 json.get("sessionToken").is_none(),
527 "sessionToken must NOT appear in IPC JSON"
528 );
529 }
530
531 #[test]
536 fn config_only_profile_source_is_config() {
537 let entries =
540 parse_aws_config_files(&fixture("env_only.credentials"), &fixture("chained.config"))
541 .expect("must parse");
542
543 for entry in &entries {
545 assert_eq!(entry.source, AwsConfigSource::Config);
546 assert!(entry.access_key_id.is_none());
547 }
548 }
549
550 #[test]
555 fn missing_credentials_file_is_ok() {
556 let missing = PathBuf::from("/nonexistent/path/.aws/credentials");
557 let result = parse_aws_config_files(&missing, &fixture("basic.config"));
558 assert!(result.is_ok(), "missing credentials file must not error");
559 let entries = result.unwrap();
560 assert!(!entries.is_empty());
562 for e in &entries {
563 assert!(e.access_key_id.is_none());
564 }
565 }
566
567 #[test]
568 fn both_files_missing_returns_empty_vec() {
569 let result = parse_aws_config_files(
570 &PathBuf::from("/no/such/creds"),
571 &PathBuf::from("/no/such/config"),
572 );
573 assert!(result.is_ok());
574 assert!(result.unwrap().is_empty());
575 }
576}