1use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Source {
13 pub kind: Kind,
15 pub name: String,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Kind {
22 ApiKey,
24 EnvKeys,
26 WebIdentity,
28 Container,
30 Profile(ProfileKind),
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ProfileKind {
37 Login,
39 Sso,
41 Process,
43 AssumeRole,
45 StaticKeys,
47 WebIdentity,
49 InstanceRole,
51 ContainerRole,
53 Other,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum RegionFrom {
60 Env(&'static str),
62 Profile(String),
64}
65
66pub const API_KEY: &str = "AWS_BEARER_TOKEN_BEDROCK";
68
69impl Source {
70 #[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 #[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#[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#[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) = §ion.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#[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#[must_use]
199pub fn detect_here() -> Option<Source> {
200 detect(&var, &home())
201}
202
203#[must_use]
205pub fn region_here() -> Option<(String, RegionFrom)> {
206 region(&var, &home())
207}
208
209#[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#[derive(Default)]
231struct Section {
232 keys: Vec<String>,
233 region: Option<String>,
234 credential_source: Option<String>,
235}
236
237fn read(env: &dyn Fn(&str) -> Option<String>, home: &Path, name: &str) -> Option<Section> {
239 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 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 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 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 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 type Case<'a> = (&'a [(&'a str, &'a str)], &'a str, &'a str, Option<&'a str>);
351 let cases: &[Case] = &[
352 (
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 (
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 (&[("AWS_PROFILE", "gone")], login, "", None),
440 (&[], "", "", None),
441 (&[(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}