Skip to main content

datui_lib/
aws_profiles.rs

1//! AWS profiles, read from the files the AWS CLI and SDKs share.
2//!
3//! object_store reads only `AWS_*` environment variables, and Polars reads
4//! `~/.aws/credentials` with a pattern that takes the first key in the file, whichever
5//! profile it belongs to. So datui reads the files itself and hands both libraries
6//! explicit keys. Static keys come straight from the files, `credential_process` is
7//! run, and everything else a profile can be (SSO, assume-role, web identity) comes
8//! from `aws configure export-credentials`, so none of it is reimplemented here.
9
10use crate::cloud_browse::Environment;
11use crate::cloud_command::CommandError;
12use std::collections::HashMap;
13use std::path::PathBuf;
14use std::sync::{Mutex, OnceLock};
15use std::time::{Duration, SystemTime};
16
17/// One profile, merged from the config and credentials files.
18#[derive(Debug, Clone, Default, PartialEq, Eq)]
19pub struct Profile {
20    pub name: String,
21    pub region: Option<String>,
22    /// `endpoint_url` directly in the profile.
23    pub endpoint_url: Option<String>,
24    /// `endpoint_url` under `s3` in the profile's `services` section.
25    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    /// Credentials only the AWS CLI can produce here: SSO, assume-role, web identity.
32    pub needs_cli: bool,
33}
34
35/// Keys that make a profile something only the CLI can resolve.
36const 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    /// Whether the profile says how to get credentials at all. A profile holding only a
47    /// region is settings for some other profile's use, not a login.
48    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    /// The S3 endpoint for this profile, in the order the AWS SDKs apply:
55    /// `AWS_ENDPOINT_URL_S3`, `AWS_ENDPOINT_URL`, the `services` entry, then the
56    /// profile's own `endpoint_url`. `AWS_IGNORE_CONFIGURED_ENDPOINT_URLS` or the
57    /// profile's `ignore_configured_endpoint_urls` turns all of them off.
58    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
74/// The config file: `AWS_CONFIG_FILE`, else `~/.aws/config`.
75pub fn config_path(env: &Environment<'_>) -> Option<PathBuf> {
76    file_path(env, "AWS_CONFIG_FILE", "config")
77}
78
79/// The credentials file: `AWS_SHARED_CREDENTIALS_FILE`, else `~/.aws/credentials`.
80pub 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
91/// The profile the AWS tools would use with no `--profile`: `AWS_PROFILE`, then
92/// `AWS_DEFAULT_PROFILE`, then `default`.
93pub 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
102/// Every profile in the two files, `default` first, then by name.
103pub 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/// One `key = value`, with the indented lines under a key that has no value of its own
114/// (`s3 =` followed by `  endpoint_url = ...`).
115#[derive(Debug, Default)]
116struct Entry {
117    value: String,
118    nested: HashMap<String, String>,
119}
120
121type Sections = Vec<(String, HashMap<String, Entry>)>;
122
123/// The sections of an AWS-style INI file, keys lowercased, in file order.
124fn 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
168/// Profiles from the text of the config and credentials files.
169pub 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    // The credentials file wins for keys, as it does for the AWS tools.
214    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/// Keys that sign requests, and when they stop working.
243#[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
251/// Temporary credentials by profile, so a command runs once per expiry rather than once
252/// per open.
253fn cached() -> &'static Mutex<HashMap<String, Credentials>> {
254    static CACHE: OnceLock<Mutex<HashMap<String, Credentials>>> = OnceLock::new();
255    CACHE.get_or_init(Default::default)
256}
257
258/// A credential this close to expiring is fetched again rather than used.
259const REFRESH_BEFORE_EXPIRY: Duration = Duration::from_secs(5 * 60);
260
261/// The keys for `profile`: straight from the files, from its `credential_process`, or
262/// from the AWS CLI. Runs commands, so only ever call it from a worker.
263pub fn credentials(profile: &Profile, env: &Environment<'_>) -> Result<Credentials, String> {
264    if let (Some(key), Some(secret)) = (&profile.access_key_id, &profile.secret_access_key)
265        && profile.credential_process.is_none()
266        && !profile.needs_cli
267    {
268        return Ok(Credentials {
269            access_key_id: key.clone(),
270            secret_access_key: secret.clone(),
271            session_token: profile.session_token.clone(),
272            expires: None,
273        });
274    }
275
276    if let Some(fresh) = cached()
277        .lock()
278        .ok()
279        .and_then(|c| c.get(&profile.name).cloned())
280        .filter(|c| {
281            c.expires
282                .is_none_or(|at| at > SystemTime::now() + REFRESH_BEFORE_EXPIRY)
283        })
284    {
285        return Ok(fresh);
286    }
287
288    let output = if let Some(line) = &profile.credential_process {
289        let words = crate::cloud_command::split_command_line(line)
290            .ok_or_else(|| format!("credential_process for {} cannot be read", profile.name))?;
291        let args: Vec<&str> = words[1..].iter().map(String::as_str).collect();
292        (env.run)(&words[0], &args).map_err(|e| match e {
293            CommandError::Missing(program) => {
294                format!(
295                    "credential_process for {}: {program} not found",
296                    profile.name
297                )
298            }
299            other => format!("credential_process for {}: {other}", profile.name),
300        })?
301    } else if profile.needs_cli {
302        let args = [
303            "configure",
304            "export-credentials",
305            "--profile",
306            profile.name.as_str(),
307            "--format",
308            "process",
309        ];
310        (env.run)("aws", &args).map_err(|e| match e {
311            CommandError::Missing(_) => "needs the AWS CLI".to_string(),
312            other => other.to_string(),
313        })?
314    } else {
315        return Err(format!("profile {} has no credentials", profile.name));
316    };
317
318    let fresh = parse_process_output(&output)
319        .ok_or_else(|| format!("profile {}: credentials were not readable", profile.name))?;
320    if let Ok(mut cache) = cached().lock() {
321        cache.insert(profile.name.clone(), fresh.clone());
322    }
323    Ok(fresh)
324}
325
326/// The JSON `credential_process` prints, which `aws configure export-credentials
327/// --format process` prints too.
328pub fn parse_process_output(text: &str) -> Option<Credentials> {
329    let value: serde_json::Value = serde_json::from_str(text.trim()).ok()?;
330    let field = |name: &str| {
331        value
332            .get(name)
333            .and_then(|v| v.as_str())
334            .map(str::to_string)
335            .filter(|v| !v.is_empty())
336    };
337    Some(Credentials {
338        access_key_id: field("AccessKeyId")?,
339        secret_access_key: field("SecretAccessKey")?,
340        session_token: field("SessionToken"),
341        expires: field("Expiration")
342            .and_then(|at| chrono::DateTime::parse_from_rfc3339(&at).ok())
343            .map(SystemTime::from),
344    })
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use std::path::Path;
351
352    const CONFIG: &str = r#"
353[default]
354region = us-east-1
355
356[profile work]
357region = eu-west-1
358
359# An S3-compatible server reached through a profile
360[profile lab]
361services = lab-s3
362region = us-east-1
363
364[services lab-s3]
365s3 =
366  endpoint_url = http://localhost:9000
367  addressing_style = path
368
369[profile onprem]
370endpoint_url = https://minio.corp.example:9000
371
372[profile sso]
373sso_session = corp
374sso_account_id = 123456789012
375sso_role_name = ReadOnly
376
377[sso-session corp]
378sso_start_url = https://corp.awsapps.com/start
379
380[profile vault]
381credential_process = aws-vault export --format=json vault-inner
382
383[profile regional-only]
384region = ap-south-1
385"#;
386
387    const CREDENTIALS: &str = "
388[default]
389aws_access_key_id = AKIADEFAULT
390aws_secret_access_key = default-secret
391
392[work]
393aws_access_key_id = AKIAWORK
394aws_secret_access_key = work-secret
395aws_session_token = work-token
396
397[lab]
398aws_access_key_id = minioadmin
399aws_secret_access_key = minioadmin
400";
401
402    fn by_name<'a>(profiles: &'a [Profile], name: &str) -> &'a Profile {
403        profiles
404            .iter()
405            .find(|p| p.name == name)
406            .unwrap_or_else(|| panic!("{name} in {profiles:?}"))
407    }
408
409    fn no_vars(_: &str) -> Option<String> {
410        None
411    }
412
413    #[test]
414    fn profiles_merge_both_files() {
415        let profiles = parse(CONFIG, CREDENTIALS);
416        assert_eq!(profiles[0].name, "default");
417        let work = by_name(&profiles, "work");
418        assert_eq!(work.region.as_deref(), Some("eu-west-1"));
419        assert_eq!(work.access_key_id.as_deref(), Some("AKIAWORK"));
420        assert_eq!(work.session_token.as_deref(), Some("work-token"));
421        assert!(work.has_credentials() && !work.needs_cli);
422
423        assert!(by_name(&profiles, "sso").needs_cli);
424        assert_eq!(
425            by_name(&profiles, "vault").credential_process.as_deref(),
426            Some("aws-vault export --format=json vault-inner")
427        );
428        assert!(!by_name(&profiles, "regional-only").has_credentials());
429    }
430
431    #[test]
432    fn endpoints_follow_the_sdk_precedence() {
433        let profiles = parse(CONFIG, CREDENTIALS);
434        let lab = by_name(&profiles, "lab");
435        let onprem = by_name(&profiles, "onprem");
436        assert_eq!(
437            lab.s3_endpoint(&no_vars).as_deref(),
438            Some("http://localhost:9000")
439        );
440        assert_eq!(
441            onprem.s3_endpoint(&no_vars).as_deref(),
442            Some("https://minio.corp.example:9000")
443        );
444        assert_eq!(by_name(&profiles, "work").s3_endpoint(&no_vars), None);
445
446        let general = |key: &str| (key == "AWS_ENDPOINT_URL").then(|| "http://general".to_string());
447        assert_eq!(lab.s3_endpoint(&general).as_deref(), Some("http://general"));
448        let both = |key: &str| match key {
449            "AWS_ENDPOINT_URL_S3" => Some("http://s3-only".to_string()),
450            "AWS_ENDPOINT_URL" => Some("http://general".to_string()),
451            _ => None,
452        };
453        assert_eq!(lab.s3_endpoint(&both).as_deref(), Some("http://s3-only"));
454        let ignored =
455            |key: &str| (key == "AWS_IGNORE_CONFIGURED_ENDPOINT_URLS").then(|| "true".to_string());
456        assert_eq!(lab.s3_endpoint(&ignored), None);
457    }
458
459    fn environment<'a>(
460        var: &'a dyn Fn(&str) -> Option<String>,
461        run: &'a crate::cloud_command::Runner<'a>,
462    ) -> Environment<'a> {
463        Environment {
464            var,
465            exists: &|_: &Path| false,
466            read: &|_: &Path| None,
467            home: Some(PathBuf::from("/home/u")),
468            windows: false,
469            run,
470            all_vars: &|| Vec::new(),
471            list: &|_| Vec::new(),
472        }
473    }
474
475    #[test]
476    fn the_files_can_be_moved_and_the_active_profile_named() {
477        let var = |key: &str| match key {
478            "AWS_CONFIG_FILE" => Some("/etc/aws/config".to_string()),
479            "AWS_PROFILE" => Some("work".to_string()),
480            _ => None,
481        };
482        let run = |_: &str, _: &[&str]| Err(CommandError::Missing("aws".to_string()));
483        let env = environment(&var, &run);
484        assert_eq!(config_path(&env), Some(PathBuf::from("/etc/aws/config")));
485        assert_eq!(
486            credentials_path(&env),
487            Some(PathBuf::from("/home/u/.aws/credentials"))
488        );
489        assert_eq!(active_profile(&env), "work");
490    }
491
492    #[test]
493    fn keys_come_from_the_file_a_process_or_the_cli() {
494        let profiles = parse(CONFIG, CREDENTIALS);
495        let calls = std::sync::Mutex::new(Vec::<String>::new());
496        let run = |program: &str, args: &[&str]| {
497            calls
498                .lock()
499                .unwrap()
500                .push(format!("{program} {}", args.join(" ")));
501            Ok(
502                r#"{"Version": 1, "AccessKeyId": "ASIATEMP", "SecretAccessKey": "temp-secret",
503                   "SessionToken": "temp-token", "Expiration": "2999-01-01T00:00:00Z"}"#
504                    .to_string(),
505            )
506        };
507        let env = environment(&no_vars, &run);
508
509        let work = credentials(by_name(&profiles, "work"), &env).unwrap();
510        assert_eq!(work.access_key_id, "AKIAWORK");
511
512        let sso = credentials(by_name(&profiles, "sso"), &env).unwrap();
513        assert_eq!(sso.access_key_id, "ASIATEMP");
514        assert_eq!(sso.session_token.as_deref(), Some("temp-token"));
515        // Cached until close to expiry: a second open runs nothing.
516        credentials(by_name(&profiles, "sso"), &env).unwrap();
517
518        let vault = credentials(by_name(&profiles, "vault"), &env).unwrap();
519        assert_eq!(vault.secret_access_key, "temp-secret");
520
521        assert_eq!(
522            *calls.lock().unwrap(),
523            [
524                "aws configure export-credentials --profile sso --format process",
525                "aws-vault export --format=json vault-inner",
526            ]
527        );
528    }
529
530    #[test]
531    fn a_missing_cli_or_a_failed_login_says_so() {
532        let profiles = parse(CONFIG, CREDENTIALS);
533        let missing = |_: &str, _: &[&str]| Err(CommandError::Missing("aws".to_string()));
534        let env = environment(&no_vars, &missing);
535        let mut sso = by_name(&profiles, "sso").clone();
536        sso.name = "sso-missing-cli".to_string();
537        assert_eq!(
538            credentials(&sso, &env),
539            Err("needs the AWS CLI".to_string())
540        );
541
542        let expired = |_: &str, _: &[&str]| {
543            Err(CommandError::Failed(
544                "Error loading SSO Token: Token for corp does not exist".to_string(),
545            ))
546        };
547        let env = environment(&no_vars, &expired);
548        sso.name = "sso-expired".to_string();
549        assert!(
550            credentials(&sso, &env)
551                .unwrap_err()
552                .contains("Token for corp does not exist")
553        );
554        assert!(credentials(by_name(&profiles, "regional-only"), &env).is_err());
555    }
556
557    #[test]
558    fn process_output_needs_both_keys() {
559        assert!(parse_process_output(r#"{"Version": 1, "AccessKeyId": "A"}"#).is_none());
560        let parsed =
561            parse_process_output(r#"{"Version":1,"AccessKeyId":"A","SecretAccessKey":"S"}"#)
562                .unwrap();
563        assert_eq!(parsed.session_token, None);
564        assert_eq!(parsed.expires, None);
565    }
566}