Skip to main content

speccoder_runtime/
aws.rs

1//! Where Bedrock's credentials will come from, named offline: the AWS
2//! credential chain's first source, by kind and name, from environment
3//! variable presence and the section headers and key names of the AWS
4//! config and credentials files. No value is ever read into a message —
5//! only a profile's `region` — and nothing here calls AWS.
6
7use std::path::{Path, PathBuf};
8
9/// A credential source: its kind and the variable or profile that names
10/// it, never its value.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Source {
13    /// What kind of source.
14    pub kind: Kind,
15    /// The environment variable or the profile.
16    pub name: String,
17}
18
19/// The kinds of source, in the order the chain tries them.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Kind {
22    /// A Bedrock API key in `AWS_BEARER_TOKEN_BEDROCK` (wins over the rest).
23    ApiKey,
24    /// `AWS_ACCESS_KEY_ID` and its secret in the environment.
25    EnvKeys,
26    /// `AWS_WEB_IDENTITY_TOKEN_FILE` (e.g. Kubernetes service accounts).
27    WebIdentity,
28    /// A container role (`AWS_CONTAINER_CREDENTIALS_*_URI`).
29    Container,
30    /// A profile in the AWS config or credentials file.
31    Profile(ProfileKind),
32}
33
34/// What a profile's keys say it is.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ProfileKind {
37    /// An `aws login` console session (`login_session`).
38    Login,
39    /// IAM Identity Center (`sso_session` or `sso_start_url`).
40    Sso,
41    /// `credential_process`.
42    Process,
43    /// `role_arn`.
44    AssumeRole,
45    /// `aws_access_key_id`.
46    StaticKeys,
47    /// `web_identity_token_file`.
48    WebIdentity,
49    /// `credential_source = Ec2InstanceMetadata`.
50    InstanceRole,
51    /// `credential_source = EcsContainer` (or `Environment`).
52    ContainerRole,
53    /// None of these keys.
54    Other,
55}
56
57/// Where the region was found.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum RegionFrom {
60    /// `AWS_REGION` or `AWS_DEFAULT_REGION`.
61    Env(&'static str),
62    /// The profile's `region`.
63    Profile(String),
64}
65
66/// The variable a Bedrock API key is read from.
67pub const API_KEY: &str = "AWS_BEARER_TOKEN_BEDROCK";
68
69impl Source {
70    /// The words `doctor`, `init` and the app show for this source.
71    #[must_use]
72    pub fn describe(&self) -> String {
73        let p = &self.name;
74        match self.kind {
75            Kind::ApiKey => format!("Bedrock API key ({API_KEY})"),
76            Kind::EnvKeys => "access keys in the environment".to_owned(),
77            Kind::WebIdentity => "web identity".to_owned(),
78            Kind::Container => "container role".to_owned(),
79            Kind::Profile(ProfileKind::Login) => format!("aws login session (profile {p})"),
80            Kind::Profile(ProfileKind::Sso) => format!("SSO (profile {p})"),
81            Kind::Profile(ProfileKind::Other) => format!("profile {p}"),
82            Kind::Profile(kind) => {
83                let words = match kind {
84                    ProfileKind::Process => "credential process",
85                    ProfileKind::AssumeRole => "assume role",
86                    ProfileKind::StaticKeys => "static keys",
87                    ProfileKind::WebIdentity => "web identity",
88                    ProfileKind::InstanceRole => "instance role",
89                    _ => "container role",
90                };
91                format!("profile {p} ({words})")
92            }
93        }
94    }
95
96    /// The fix for a refused or expired source: the command that renews it.
97    #[must_use]
98    pub fn renew(&self) -> String {
99        let p = &self.name;
100        match self.kind {
101            Kind::ApiKey => format!("→ create a new Bedrock API key and export {API_KEY}"),
102            Kind::EnvKeys => "→ export fresh AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY".to_owned(),
103            Kind::WebIdentity | Kind::Container => {
104                "→ check the role's trust and session".to_owned()
105            }
106            Kind::Profile(ProfileKind::Login) => format!("aws login --profile {p}"),
107            Kind::Profile(ProfileKind::Sso) => format!("aws sso login --profile {p}"),
108            Kind::Profile(_) => format!("→ renew the credentials of profile {p}"),
109        }
110    }
111}
112
113/// The first source the chain will use, or `None`: an API key, keys in
114/// the environment, web identity, a container role, then the profile
115/// (`AWS_PROFILE`, else `default`). `env` answers a variable's value when
116/// it is set and not empty; `home` holds `.aws/`.
117#[must_use]
118pub fn detect(env: &dyn Fn(&str) -> Option<String>, home: &Path) -> Option<Source> {
119    let set = |name: &str| env(name).is_some_and(|v| !v.is_empty());
120    let named = |kind, name: &str| {
121        Some(Source {
122            kind,
123            name: name.to_owned(),
124        })
125    };
126    if set(API_KEY) {
127        return named(Kind::ApiKey, API_KEY);
128    }
129    if set("AWS_ACCESS_KEY_ID") {
130        return named(Kind::EnvKeys, "AWS_ACCESS_KEY_ID");
131    }
132    if set("AWS_WEB_IDENTITY_TOKEN_FILE") {
133        return named(Kind::WebIdentity, "AWS_WEB_IDENTITY_TOKEN_FILE");
134    }
135    for v in [
136        "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
137        "AWS_CONTAINER_CREDENTIALS_FULL_URI",
138    ] {
139        if set(v) {
140            return named(Kind::Container, v);
141        }
142    }
143    profile(env, home)
144}
145
146/// The profile source alone (`AWS_PROFILE`, else `default`), when the
147/// config or credentials file has that profile.
148#[must_use]
149pub fn profile(env: &dyn Fn(&str) -> Option<String>, home: &Path) -> Option<Source> {
150    let name = profile_name(env);
151    let section = read(env, home, &name)?;
152    let has = |key: &str| section.keys.iter().any(|k| k == key);
153    let kind = if has("login_session") {
154        ProfileKind::Login
155    } else if has("sso_session") || has("sso_start_url") {
156        ProfileKind::Sso
157    } else if has("credential_process") {
158        ProfileKind::Process
159    } else if has("role_arn") {
160        ProfileKind::AssumeRole
161    } else if has("web_identity_token_file") {
162        ProfileKind::WebIdentity
163    } else if let Some(source) = &section.credential_source {
164        if source.eq_ignore_ascii_case("Ec2InstanceMetadata") {
165            ProfileKind::InstanceRole
166        } else {
167            ProfileKind::ContainerRole
168        }
169    } else if has("aws_access_key_id") {
170        ProfileKind::StaticKeys
171    } else {
172        ProfileKind::Other
173    };
174    Some(Source {
175        kind: Kind::Profile(kind),
176        name,
177    })
178}
179
180/// The region the AWS tools would use: `AWS_REGION`, `AWS_DEFAULT_REGION`,
181/// then the profile's `region`.
182#[must_use]
183pub fn region(env: &dyn Fn(&str) -> Option<String>, home: &Path) -> Option<(String, RegionFrom)> {
184    for v in ["AWS_REGION", "AWS_DEFAULT_REGION"] {
185        if let Some(r) = env(v)
186            .map(|r| r.trim().to_owned())
187            .filter(|r| !r.is_empty())
188        {
189            return Some((r, RegionFrom::Env(v)));
190        }
191    }
192    let name = profile_name(env);
193    let r = read(env, home, &name)?.region?;
194    Some((r, RegionFrom::Profile(name)))
195}
196
197/// [`detect`] in this process's environment and home.
198#[must_use]
199pub fn detect_here() -> Option<Source> {
200    detect(&var, &home())
201}
202
203/// [`region`] in this process's environment and home.
204#[must_use]
205pub fn region_here() -> Option<(String, RegionFrom)> {
206    region(&var, &home())
207}
208
209/// A variable of this process, when set and not empty.
210#[must_use]
211pub fn var(name: &str) -> Option<String> {
212    std::env::var(name).ok().filter(|v| !v.is_empty())
213}
214
215fn home() -> PathBuf {
216    std::env::var_os("HOME")
217        .map(PathBuf::from)
218        .unwrap_or_default()
219}
220
221fn profile_name(env: &dyn Fn(&str) -> Option<String>) -> String {
222    env("AWS_PROFILE")
223        .map(|p| p.trim().to_owned())
224        .filter(|p| !p.is_empty())
225        .unwrap_or_else(|| "default".to_owned())
226}
227
228/// What a profile's sections hold: key names, and the two values detection
229/// needs (`region`, `credential_source`) — never a credential.
230#[derive(Default)]
231struct Section {
232    keys: Vec<String>,
233    region: Option<String>,
234    credential_source: Option<String>,
235}
236
237/// The profile `name` from both files, or `None` when neither has it.
238fn read(env: &dyn Fn(&str) -> Option<String>, home: &Path, name: &str) -> Option<Section> {
239    // Without a home, only a file a variable names.
240    let file = |var: &str, default: &str| {
241        let home = Some(home).filter(|h| !h.as_os_str().is_empty());
242        match env(var) {
243            Some(p) => match (p.strip_prefix("~/"), home) {
244                (Some(rest), Some(h)) => Some(h.join(rest)),
245                (Some(_), None) => None,
246                (None, _) => Some(PathBuf::from(p)),
247            },
248            None => home.map(|h| h.join(".aws").join(default)),
249        }
250    };
251    let config = file("AWS_CONFIG_FILE", "config");
252    let credentials = file("AWS_SHARED_CREDENTIALS_FILE", "credentials");
253    let mut found = false;
254    let mut section = Section::default();
255    // The config file names `[profile P]` (or `[default]`); the
256    // credentials file names `[P]`.
257    let in_config = |h: &str| {
258        let words: Vec<&str> = h.split_whitespace().collect();
259        words == ["profile", name] || (name == "default" && words == ["default"])
260    };
261    let in_credentials = |h: &str| h.trim() == name;
262    for (path, is_ours) in [
263        (&config, &in_config as &dyn Fn(&str) -> bool),
264        (&credentials, &in_credentials),
265    ] {
266        let Some(Ok(text)) = path.as_ref().map(std::fs::read_to_string) else {
267            continue;
268        };
269        let mut ours = false;
270        for line in text.lines() {
271            // Indented lines are a nested value's sub-keys.
272            if line.starts_with([' ', '\t']) {
273                continue;
274            }
275            let line = line.trim();
276            if line.is_empty() || line.starts_with(['#', ';']) {
277                continue;
278            }
279            // A header may end in a comment: `[profile dev] # work`.
280            if let Some(header) = line
281                .strip_prefix('[')
282                .and_then(|l| l.split_once(']'))
283                .filter(|(_, rest)| rest.trim().is_empty() || rest.trim().starts_with(['#', ';']))
284                .map(|(header, _)| header)
285            {
286                ours = is_ours(header);
287                found |= ours;
288                continue;
289            }
290            let Some((key, value)) = line.split_once('=').filter(|_| ours) else {
291                continue;
292            };
293            let key = key.trim().to_ascii_lowercase();
294            // The only values read: neither is a credential.
295            match key.as_str() {
296                "region" if section.region.is_none() => {
297                    section.region = Some(value.trim().to_owned()).filter(|r| !r.is_empty());
298                }
299                "credential_source" => {
300                    section.credential_source = Some(value.trim().to_owned());
301                }
302                _ => {}
303            }
304            section.keys.push(key);
305        }
306    }
307    found.then_some(section)
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use std::collections::HashMap;
314
315    struct Home {
316        dir: tempfile::TempDir,
317    }
318
319    impl Home {
320        fn new(config: &str, credentials: &str) -> Self {
321            let dir = tempfile::tempdir().unwrap();
322            std::fs::create_dir_all(dir.path().join(".aws")).unwrap();
323            if !config.is_empty() {
324                std::fs::write(dir.path().join(".aws/config"), config).unwrap();
325            }
326            if !credentials.is_empty() {
327                std::fs::write(dir.path().join(".aws/credentials"), credentials).unwrap();
328            }
329            Self { dir }
330        }
331    }
332
333    fn env(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
334        let map: HashMap<String, String> = vars
335            .iter()
336            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
337            .collect();
338        move |k: &str| map.get(k).cloned().filter(|v| !v.is_empty())
339    }
340
341    fn described(vars: &[(&str, &str)], config: &str, credentials: &str) -> Option<String> {
342        let home = Home::new(config, credentials);
343        detect(&env(vars), home.dir.path()).map(|s| s.describe())
344    }
345
346    #[test]
347    fn sources_are_named_in_the_chains_order() {
348        let login = "[profile dev]\nlogin_session = arn:aws:iam::123456789012:user/me\nregion = eu-central-1\n";
349        // (environment, config file, credentials file, the words)
350        type Case<'a> = (&'a [(&'a str, &'a str)], &'a str, &'a str, Option<&'a str>);
351        let cases: &[Case] = &[
352            // The API key wins over a profile.
353            (
354                &[(API_KEY, "k"), ("AWS_PROFILE", "dev")],
355                login,
356                "",
357                Some("Bedrock API key (AWS_BEARER_TOKEN_BEDROCK)"),
358            ),
359            (
360                &[("AWS_ACCESS_KEY_ID", "AKIA"), ("AWS_PROFILE", "dev")],
361                login,
362                "",
363                Some("access keys in the environment"),
364            ),
365            (
366                &[("AWS_WEB_IDENTITY_TOKEN_FILE", "/t")],
367                "",
368                "",
369                Some("web identity"),
370            ),
371            (
372                &[("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "/v2")],
373                "",
374                "",
375                Some("container role"),
376            ),
377            (
378                &[("AWS_PROFILE", "dev")],
379                login,
380                "",
381                Some("aws login session (profile dev)"),
382            ),
383            (
384                &[],
385                "[default]\nsso_session = corp\n[sso-session corp]\nsso_start_url = https://x\n",
386                "",
387                Some("SSO (profile default)"),
388            ),
389            (
390                &[("AWS_PROFILE", "dev")],
391                "[profile  dev]\nsso_start_url = https://x\n",
392                "",
393                Some("SSO (profile dev)"),
394            ),
395            // A header with a comment after it.
396            (
397                &[("AWS_PROFILE", "dev")],
398                "[profile dev] # work\nsso_start_url = https://x\n",
399                "",
400                Some("SSO (profile dev)"),
401            ),
402            (
403                &[("AWS_PROFILE", "ci")],
404                "",
405                "[ci] ; build\naws_access_key_id = AKIA\naws_secret_access_key = SECRETVALUE\n",
406                Some("profile ci (static keys)"),
407            ),
408            (
409                &[],
410                "[default]\ncredential_process = /bin/creds\n",
411                "",
412                Some("profile default (credential process)"),
413            ),
414            (
415                &[],
416                "[default]\nrole_arn = arn:aws:iam::1:role/r\nsource_profile = base\n",
417                "",
418                Some("profile default (assume role)"),
419            ),
420            (
421                &[],
422                "[default]\ncredential_source = Ec2InstanceMetadata\n",
423                "",
424                Some("profile default (instance role)"),
425            ),
426            (
427                &[("AWS_PROFILE", "ci")],
428                "",
429                "[ci]\naws_access_key_id = AKIA\naws_secret_access_key = SECRETVALUE\n",
430                Some("profile ci (static keys)"),
431            ),
432            (
433                &[],
434                "[default]\nregion = us-east-1\n",
435                "",
436                Some("profile default"),
437            ),
438            // A profile neither file has is no source.
439            (&[("AWS_PROFILE", "gone")], login, "", None),
440            (&[], "", "", None),
441            // An empty variable is not set.
442            (&[(API_KEY, "")], "", "", None),
443        ];
444        for (vars, config, credentials, want) in cases {
445            assert_eq!(
446                described(vars, config, credentials).as_deref(),
447                *want,
448                "{vars:?} {config:?} {credentials:?}"
449            );
450        }
451    }
452
453    #[test]
454    fn overridden_file_paths_are_read() {
455        let home = Home::new("", "");
456        let elsewhere = tempfile::tempdir().unwrap();
457        let config = elsewhere.path().join("cfg");
458        std::fs::write(
459            &config,
460            "[profile dev]\nlogin_session = x\nregion = ap-southeast-2\n",
461        )
462        .unwrap();
463        let creds = elsewhere.path().join("creds");
464        std::fs::write(&creds, "[ops]\naws_access_key_id = AKIA\n").unwrap();
465        let (c, k) = (config.to_string_lossy(), creds.to_string_lossy());
466        let vars = env(&[
467            ("AWS_PROFILE", "dev"),
468            ("AWS_CONFIG_FILE", &c),
469            ("AWS_SHARED_CREDENTIALS_FILE", &k),
470        ]);
471        let s = detect(&vars, home.dir.path()).unwrap();
472        assert_eq!(s.kind, Kind::Profile(ProfileKind::Login));
473        assert_eq!(s.renew(), "aws login --profile dev");
474        assert_eq!(
475            region(&vars, home.dir.path()),
476            Some((
477                "ap-southeast-2".to_owned(),
478                RegionFrom::Profile("dev".to_owned())
479            ))
480        );
481        let ops = env(&[("AWS_PROFILE", "ops"), ("AWS_SHARED_CREDENTIALS_FILE", &k)]);
482        assert_eq!(
483            detect(&ops, home.dir.path()).map(|s| s.kind),
484            Some(Kind::Profile(ProfileKind::StaticKeys))
485        );
486    }
487
488    #[test]
489    fn the_region_comes_from_the_environment_then_the_profile() {
490        let home = Home::new(
491            "[default]\nregion = eu-west-1\n[profile dev]\nregion = us-west-2\n",
492            "",
493        );
494        let at = |vars: &[(&str, &str)]| region(&env(vars), home.dir.path());
495        assert_eq!(
496            at(&[
497                ("AWS_REGION", "eu-central-1"),
498                ("AWS_DEFAULT_REGION", "us-east-1")
499            ]),
500            Some(("eu-central-1".to_owned(), RegionFrom::Env("AWS_REGION")))
501        );
502        assert_eq!(
503            at(&[("AWS_DEFAULT_REGION", "us-east-1")]),
504            Some((
505                "us-east-1".to_owned(),
506                RegionFrom::Env("AWS_DEFAULT_REGION")
507            ))
508        );
509        assert_eq!(
510            at(&[("AWS_PROFILE", "dev")]),
511            Some((
512                "us-west-2".to_owned(),
513                RegionFrom::Profile("dev".to_owned())
514            ))
515        );
516        assert_eq!(
517            at(&[]),
518            Some((
519                "eu-west-1".to_owned(),
520                RegionFrom::Profile("default".to_owned())
521            ))
522        );
523        assert_eq!(region(&env(&[]), Home::new("", "").dir.path()), None);
524    }
525
526    #[test]
527    fn no_written_value_reaches_the_words() {
528        let secrets = "[default]\naws_access_key_id = AKIAEXAMPLEID\naws_secret_access_key = SECRETVALUE\naws_session_token = TOKENVALUE\n";
529        let home = Home::new(
530            &format!("{secrets}login_session = arn:aws:iam::1:user/SESSIONVALUE\n"),
531            secrets,
532        );
533        let s = detect(&env(&[]), home.dir.path()).unwrap();
534        for words in [s.describe(), s.renew(), format!("{s:?}")] {
535            for value in ["AKIAEXAMPLEID", "SECRETVALUE", "TOKENVALUE", "SESSIONVALUE"] {
536                assert!(!words.contains(value), "{words}");
537            }
538        }
539        let key = detect(&env(&[(API_KEY, "sk-test-secret-123")]), home.dir.path()).unwrap();
540        assert!(
541            !format!("{} {} {key:?}", key.describe(), key.renew()).contains("sk-test-secret-123")
542        );
543    }
544
545    #[test]
546    fn renewals_name_the_command_for_the_source() {
547        let s = |kind, name: &str| Source {
548            kind,
549            name: name.to_owned(),
550        };
551        assert_eq!(
552            s(Kind::ApiKey, API_KEY).renew(),
553            "→ create a new Bedrock API key and export AWS_BEARER_TOKEN_BEDROCK"
554        );
555        assert_eq!(
556            s(Kind::Profile(ProfileKind::Sso), "dev").renew(),
557            "aws sso login --profile dev"
558        );
559        assert_eq!(
560            s(Kind::Profile(ProfileKind::Process), "dev").renew(),
561            "→ renew the credentials of profile dev"
562        );
563        assert_eq!(
564            s(Kind::EnvKeys, "AWS_ACCESS_KEY_ID").renew(),
565            "→ export fresh AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY"
566        );
567        assert_eq!(
568            s(Kind::Container, "x").renew(),
569            "→ check the role's trust and session"
570        );
571    }
572}