Skip to main content

brows3r_lib/profiles/
aws_config.rs

1//! AWS credentials and config file parser.
2//!
3//! Parses `~/.aws/credentials` and `~/.aws/config` into a flat list of
4//! [`AwsConfigEntry`] values without resolving or validating credentials.
5//!
6//! # Security contract
7//!
8//! `access_key_id`, `secret_access_key`, and `session_token` are:
9//! - Declared `pub(crate)` — not accessible outside this crate.
10//! - Annotated `#[serde(skip_serializing)]` — they can never be emitted
11//!   through Tauri's IPC boundary, even by accident.
12//!
13//! # OCP contract
14//!
15//! - [`AwsConfigSource`] is open for new variants (`Sso`, `WebIdentity`,
16//!   `EcsContainer`, …) without touching existing arms.
17//! - [`parse_aws_config_files`] takes explicit `&Path` arguments so every
18//!   parsing behavior is unit-testable against fixture files without hitting
19//!   the real filesystem.
20//! - [`discover_aws_profiles`] is the thin wrapper that resolves `~/.aws/*`
21//!   and delegates to the pure function.
22
23use std::collections::HashMap;
24use std::path::{Path, PathBuf};
25
26use ini::Ini;
27use serde::{Deserialize, Serialize};
28
29use crate::error::AppError;
30
31// ---------------------------------------------------------------------------
32// AwsConfigSource
33// ---------------------------------------------------------------------------
34
35/// Where a profile's configuration was read from.
36///
37/// Open for extension: new sources (`Sso`, `WebIdentity`, `EcsContainer`, …)
38/// can be added as new variants without modifying existing arms.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase")]
41pub enum AwsConfigSource {
42    /// Profile read from `~/.aws/credentials`.
43    Credentials,
44    /// Profile read from `~/.aws/config`.
45    Config,
46    /// Synthetic profile built from environment variables (AWS_ACCESS_KEY_ID, etc.).
47    Env,
48}
49
50// ---------------------------------------------------------------------------
51// ProfileChainRef
52// ---------------------------------------------------------------------------
53
54/// Role-chaining metadata surfaced when `role_arn` is present on a profile.
55///
56/// Used by the profile-validation layer (task 12+) to decide whether
57/// interactive MFA or SSO prompts are needed.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub struct ProfileChainRef {
61    /// The named profile whose credentials are used to assume the role.
62    pub source_profile: String,
63    /// ARN of the IAM role to assume.
64    pub role_arn: String,
65    /// ARN of the MFA device required before assuming the role, if any.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub mfa_serial: Option<String>,
68    /// Name of the SSO session block in `~/.aws/config`, if any.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub sso_session: Option<String>,
71}
72
73// ---------------------------------------------------------------------------
74// AwsConfigEntry
75// ---------------------------------------------------------------------------
76
77/// A single AWS profile as parsed from credentials / config files or env vars.
78///
79/// # IPC safety
80///
81/// The three secret fields (`access_key_id`, `secret_access_key`,
82/// `session_token`) are intentionally:
83/// - `pub(crate)` — not visible outside this crate.
84/// - `#[serde(skip_serializing)]` — never emitted by `serde_json::to_*`.
85///
86/// This means tests may inspect the fields directly, but no Tauri command can
87/// inadvertently leak credentials through the IPC boundary.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct AwsConfigEntry {
91    /// The profile name as it appears in the config files (e.g. `"default"`, `"dev"`).
92    pub profile_name: String,
93
94    /// Which file (or env) this entry originated from.
95    pub source: AwsConfigSource,
96
97    // ------------------------------------------------------------------
98    // Secret fields — never serialized through IPC
99    //
100    // These fields are populated by the parser so tests can verify secrets
101    // were *read correctly* from disk, but the production credential flow
102    // ignores them entirely (the AWS SDK credential-provider chain is the
103    // canonical path for real credentials, not these fields). Hence the
104    // `#[allow(dead_code)]` — the lack of read sites is intentional.
105    // ------------------------------------------------------------------
106    /// AWS access key ID. Parsed internally; never emitted over IPC.
107    #[serde(skip_serializing)]
108    #[allow(dead_code)]
109    pub(crate) access_key_id: Option<String>,
110
111    /// AWS secret access key. Parsed internally; never emitted over IPC.
112    #[serde(skip_serializing)]
113    #[allow(dead_code)]
114    pub(crate) secret_access_key: Option<String>,
115
116    /// AWS session token (for temporary credentials). Never emitted over IPC.
117    #[serde(skip_serializing)]
118    #[allow(dead_code)]
119    pub(crate) session_token: Option<String>,
120
121    // ------------------------------------------------------------------
122    // Non-secret metadata fields
123    // ------------------------------------------------------------------
124    /// AWS region (e.g. `"us-east-1"`).
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub region: Option<String>,
127
128    /// Named profile whose credentials are delegated to (role chaining).
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub source_profile: Option<String>,
131
132    /// IAM role ARN to assume.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub role_arn: Option<String>,
135
136    /// MFA device serial ARN required before assuming the role.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub mfa_serial: Option<String>,
139
140    /// SSO session block name in `~/.aws/config`.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub sso_session: Option<String>,
143
144    /// Role-chaining reference, populated when `role_arn` is present.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub chain_ref: Option<ProfileChainRef>,
147}
148
149// ---------------------------------------------------------------------------
150// INI parsing helpers
151// ---------------------------------------------------------------------------
152
153/// Strip the `profile ` prefix that `~/.aws/config` uses for non-default sections.
154///
155/// ```text
156/// [profile dev]  →  "dev"
157/// [default]      →  "default"
158/// ```
159fn strip_config_prefix(section: &str) -> &str {
160    section.strip_prefix("profile ").unwrap_or(section)
161}
162
163/// Parse an INI file at `path`, returning a `HashMap<section_name, key_values>`.
164/// Returns an empty map (not an error) when the file does not exist.
165fn 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            // The `rust-ini` crate surfaces top-level (headerless) keys under
180            // `None`. We skip them — AWS config files always use sections.
181            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
193// ---------------------------------------------------------------------------
194// parse_aws_config_files — pure, testable
195// ---------------------------------------------------------------------------
196
197/// Parse explicit credentials and config file paths into a list of AWS profiles.
198///
199/// This is the pure, testable surface. Pass fixture paths in tests; use
200/// [`discover_aws_profiles`] in production to resolve the real `~/.aws/*` paths.
201///
202/// Merge rules:
203/// - Each unique profile name yields one `AwsConfigEntry`.
204/// - Secret fields (`access_key_id`, `secret_access_key`, `session_token`)
205///   always come from the credentials file.
206/// - Non-secret fields (`region`, `role_arn`, `source_profile`, …) come from
207///   the config file when present, falling back to the credentials file.
208/// - `source` is `Credentials` when only the credentials file has the profile,
209///   `Config` when only the config file has it, and `Credentials` (primary)
210///   when both files contribute fields (secrets always come from credentials).
211///
212/// Environment variable injection is handled by [`discover_aws_profiles`] so
213/// this function remains pure and does not read `std::env`.
214pub 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    // Normalize config section names: strip the "profile " prefix.
222    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    // Collect all profile names from both files.
228    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    // Ensure "default" is first if present.
239    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        // Determine source: prefer Credentials when secrets are present.
251        let source = if creds.is_some() {
252            AwsConfigSource::Credentials
253        } else {
254            AwsConfigSource::Config
255        };
256
257        // Secret fields come exclusively from credentials file.
258        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        // Non-secret fields: config file takes precedence.
263        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        // Build chain_ref when role_arn and source_profile are both present.
284        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
312// ---------------------------------------------------------------------------
313// discover_aws_profiles — production entry point
314// ---------------------------------------------------------------------------
315
316/// Discover AWS profiles from the real `~/.aws/credentials` and `~/.aws/config`.
317///
318/// Adds a synthetic `env` profile when `AWS_ACCESS_KEY_ID` is set in the
319/// environment (e.g. CI, container environments).  The env profile has
320/// `source: AwsConfigSource::Env` and carries no persistent metadata.
321pub 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    // Inject a synthetic env profile when AWS_ACCESS_KEY_ID is set.
332    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
360/// Resolve the home directory.
361///
362/// Prefers `HOME` env var; falls back to `USERPROFILE` (Windows).
363fn 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// ---------------------------------------------------------------------------
370// Tests
371// ---------------------------------------------------------------------------
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use std::path::PathBuf;
377
378    fn fixture(name: &str) -> PathBuf {
379        // Cargo sets CARGO_MANIFEST_DIR to src-tauri/ during tests.
380        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    // ------------------------------------------------------------------
390    // Basic fixture: one default + one named profile
391    // ------------------------------------------------------------------
392
393    #[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        // Secrets parsed but not serializable.
408        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    // ------------------------------------------------------------------
426    // Chained fixture: role_arn + source_profile + mfa_serial
427    // ------------------------------------------------------------------
428
429    #[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    // ------------------------------------------------------------------
495    // Secrets-don't-leak: access_key_id not in JSON output
496    // ------------------------------------------------------------------
497
498    #[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        // access_key_id must be readable internally ...
510        assert!(
511            default.access_key_id.is_some(),
512            "access_key_id must be parsed"
513        );
514
515        // ... but must NOT appear in the JSON output.
516        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    // ------------------------------------------------------------------
532    // Empty credentials file — config-only profiles
533    // ------------------------------------------------------------------
534
535    #[test]
536    fn config_only_profile_source_is_config() {
537        // Use env_only.credentials (empty) + chained.config to verify
538        // that a profile only in the config file gets source = Config.
539        let entries =
540            parse_aws_config_files(&fixture("env_only.credentials"), &fixture("chained.config"))
541                .expect("must parse");
542
543        // All profiles come from config; none have secrets.
544        for entry in &entries {
545            assert_eq!(entry.source, AwsConfigSource::Config);
546            assert!(entry.access_key_id.is_none());
547        }
548    }
549
550    // ------------------------------------------------------------------
551    // Missing files are treated as empty (not an error)
552    // ------------------------------------------------------------------
553
554    #[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        // Profiles from config come through; none have secrets.
561        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}