1use std::collections::HashMap;
10use std::path::PathBuf;
11use std::sync::Arc;
12
13use vantage_core::Result;
14use vantage_core::error;
15
16#[derive(Clone)]
17pub struct AwsAccount {
18 inner: Arc<Inner>,
19}
20
21struct Inner {
22 access_key: String,
23 secret_key: String,
24 session_token: Option<String>,
25 region: String,
26 endpoint: Option<String>,
31 max_pages: Option<usize>,
35 anonymous: bool,
38 http: reqwest::Client,
39}
40
41impl AwsAccount {
42 pub fn new(
44 access_key: impl Into<String>,
45 secret_key: impl Into<String>,
46 region: impl Into<String>,
47 ) -> Self {
48 Self {
49 inner: Arc::new(Inner {
50 access_key: access_key.into(),
51 secret_key: secret_key.into(),
52 session_token: None,
53 region: region.into(),
54 endpoint: env_endpoint(),
55 max_pages: None,
56 anonymous: false,
57 http: reqwest::Client::new(),
58 }),
59 }
60 }
61
62 pub fn public(region: impl Into<String>) -> Self {
67 Self {
68 inner: Arc::new(Inner {
69 access_key: String::new(),
70 secret_key: String::new(),
71 session_token: None,
72 region: region.into(),
73 endpoint: env_endpoint(),
74 max_pages: None,
75 anonymous: true,
76 http: reqwest::Client::new(),
77 }),
78 }
79 }
80
81 pub fn from_env() -> Result<Self> {
85 let access_key =
86 std::env::var("AWS_ACCESS_KEY_ID").map_err(|_| error!("AWS_ACCESS_KEY_ID not set"))?;
87 let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY")
88 .map_err(|_| error!("AWS_SECRET_ACCESS_KEY not set"))?;
89 let region = std::env::var("AWS_REGION").map_err(|_| error!("AWS_REGION not set"))?;
90 let session_token = std::env::var("AWS_SESSION_TOKEN").ok();
91
92 Ok(Self {
93 inner: Arc::new(Inner {
94 access_key,
95 secret_key,
96 session_token,
97 region,
98 endpoint: env_endpoint(),
99 max_pages: None,
100 anonymous: false,
101 http: reqwest::Client::new(),
102 }),
103 })
104 }
105
106 pub fn from_credentials_file() -> Result<Self> {
116 let profile = std::env::var("AWS_PROFILE").unwrap_or_else(|_| "default".to_string());
117 Self::from_profile(&profile)
118 }
119
120 pub fn from_profile(profile: &str) -> Result<Self> {
124 let home_dir = home_dir().ok_or_else(|| error!("HOME not set"))?;
125 let region = resolve_region_for(&home_dir, profile)?;
126
127 let creds_path = home_dir.join(".aws/credentials");
129 if let Ok(creds_text) = std::fs::read_to_string(&creds_path)
130 && let Some(creds) =
131 parse_profile(&creds_text, profile, false)
132 && let (Some(ak), Some(sk)) = (
133 creds.get("aws_access_key_id"),
134 creds.get("aws_secret_access_key"),
135 )
136 {
137 return Ok(Self {
138 inner: Arc::new(Inner {
139 access_key: ak.clone(),
140 secret_key: sk.clone(),
141 session_token: creds.get("aws_session_token").cloned(),
142 region,
143 endpoint: env_endpoint(),
144 max_pages: None,
145 anonymous: false,
146 http: reqwest::Client::new(),
147 }),
148 });
149 }
150
151 let (ak, sk, token) = export_credentials_via_aws_cli(profile)?;
155 Ok(Self {
156 inner: Arc::new(Inner {
157 access_key: ak,
158 secret_key: sk,
159 session_token: token,
160 region,
161 endpoint: env_endpoint(),
162 max_pages: None,
163 anonymous: false,
164 http: reqwest::Client::new(),
165 }),
166 })
167 }
168
169 pub fn from_default() -> Result<Self> {
173 match Self::from_env() {
174 Ok(acc) => Ok(acc),
175 Err(_) => Self::from_credentials_file(),
176 }
177 }
178
179 pub fn with_region(self, region: impl Into<String>) -> Self {
184 let inner = &self.inner;
185 Self {
186 inner: std::sync::Arc::new(Inner {
187 access_key: inner.access_key.clone(),
188 secret_key: inner.secret_key.clone(),
189 session_token: inner.session_token.clone(),
190 region: region.into(),
191 endpoint: inner.endpoint.clone(),
192 max_pages: inner.max_pages,
193 anonymous: inner.anonymous,
194 http: inner.http.clone(),
195 }),
196 }
197 }
198
199 pub fn with_endpoint(self, endpoint: impl Into<String>) -> Self {
205 let inner = &self.inner;
206 Self {
207 inner: std::sync::Arc::new(Inner {
208 access_key: inner.access_key.clone(),
209 secret_key: inner.secret_key.clone(),
210 session_token: inner.session_token.clone(),
211 region: inner.region.clone(),
212 endpoint: Some(endpoint.into()),
213 max_pages: inner.max_pages,
214 anonymous: inner.anonymous,
215 http: inner.http.clone(),
216 }),
217 }
218 }
219
220 pub fn with_max_pages(self, n: usize) -> Self {
231 let inner = &self.inner;
232 Self {
233 inner: std::sync::Arc::new(Inner {
234 access_key: inner.access_key.clone(),
235 secret_key: inner.secret_key.clone(),
236 session_token: inner.session_token.clone(),
237 region: inner.region.clone(),
238 endpoint: inner.endpoint.clone(),
239 max_pages: Some(n),
240 anonymous: inner.anonymous,
241 http: inner.http.clone(),
242 }),
243 }
244 }
245
246 pub(crate) fn max_pages(&self) -> Option<usize> {
247 self.inner.max_pages
248 }
249
250 pub(crate) fn is_anonymous(&self) -> bool {
251 self.inner.anonymous
252 }
253
254 pub(crate) fn region(&self) -> &str {
255 &self.inner.region
256 }
257
258 pub(crate) fn access_key(&self) -> &str {
259 &self.inner.access_key
260 }
261
262 pub(crate) fn secret_key(&self) -> &str {
263 &self.inner.secret_key
264 }
265
266 pub(crate) fn session_token(&self) -> Option<&str> {
267 self.inner.session_token.as_deref()
268 }
269
270 pub(crate) fn http(&self) -> &reqwest::Client {
271 &self.inner.http
272 }
273
274 pub(crate) fn endpoint_for(&self, service: &str) -> (String, String) {
279 match self.inner.endpoint.as_deref() {
280 Some(ep) => {
281 let trimmed = ep.trim_end_matches('/');
282 let host = trimmed
283 .split_once("://")
284 .map(|(_, rest)| rest)
285 .unwrap_or(trimmed)
286 .to_string();
287 (format!("{trimmed}/"), host)
288 }
289 None => {
290 let host = format!("{service}.{}.amazonaws.com", self.inner.region);
291 (format!("https://{host}/"), host)
292 }
293 }
294 }
295}
296
297impl std::fmt::Debug for AwsAccount {
298 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299 f.debug_struct("AwsAccount")
300 .field("region", &self.inner.region)
301 .field("endpoint", &self.inner.endpoint)
302 .field("access_key", &"<redacted>")
303 .field("secret_key", &"<redacted>")
304 .field(
305 "session_token",
306 &self.inner.session_token.as_ref().map(|_| "<redacted>"),
307 )
308 .finish()
309 }
310}
311
312fn home_dir() -> Option<PathBuf> {
313 std::env::var_os("HOME").map(PathBuf::from)
314}
315
316fn env_endpoint() -> Option<String> {
320 std::env::var("AWS_ENDPOINT_URL")
321 .ok()
322 .filter(|s| !s.is_empty())
323}
324
325fn parse_profile(
333 content: &str,
334 profile: &str,
335 config_style: bool,
336) -> Option<HashMap<String, String>> {
337 let target_section = if config_style && profile != "default" {
338 format!("profile {}", profile)
339 } else {
340 profile.to_string()
341 };
342
343 let mut in_target = false;
344 let mut found = false;
345 let mut map = HashMap::new();
346
347 for raw in content.lines() {
348 let line = raw.trim();
349 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
350 continue;
351 }
352 if let Some(section) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
353 in_target = section.trim() == target_section;
354 if in_target {
355 found = true;
356 }
357 continue;
358 }
359 if in_target && let Some((k, v)) = line.split_once('=') {
360 map.insert(k.trim().to_string(), v.trim().to_string());
361 }
362 }
363
364 found.then_some(map)
365}
366
367fn resolve_region_for(home_dir: &std::path::Path, profile: &str) -> Result<String> {
371 if let Ok(r) = std::env::var("AWS_REGION") {
372 return Ok(r);
373 }
374 if let Ok(r) = std::env::var("AWS_DEFAULT_REGION") {
375 return Ok(r);
376 }
377 let config_path = home_dir.join(".aws/config");
378 if let Ok(text) = std::fs::read_to_string(&config_path)
379 && let Some(profile_map) = parse_profile(&text, profile, true)
380 && let Some(r) = profile_map.get("region")
381 {
382 return Ok(r.clone());
383 }
384 Err(error!(
385 "AWS region not found (set AWS_REGION, or add `region = ...` under the profile in ~/.aws/config)",
386 profile = profile
387 ))
388}
389
390fn export_credentials_via_aws_cli(profile: &str) -> Result<(String, String, Option<String>)> {
401 let output = match std::process::Command::new("aws")
402 .args([
403 "configure",
404 "export-credentials",
405 "--profile",
406 profile,
407 "--format",
408 "env",
409 ])
410 .output()
411 {
412 Ok(o) => o,
413 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
414 return Err(error!(
415 "AWS CLI not installed — needed to materialise SSO, assume-role, or credential_process credentials. Install via mise or your package manager.",
416 profile = profile
417 ));
418 }
419 Err(e) => return Err(error!(format!("failed to spawn `aws`: {e}"))),
420 };
421 if !output.status.success() {
422 let stderr = String::from_utf8_lossy(&output.stderr);
423 return Err(error!(
424 "`aws configure export-credentials` failed — for SSO profiles try `aws sso login --profile <name>` first",
425 profile = profile,
426 stderr = stderr.trim().to_string()
427 ));
428 }
429 let stdout = String::from_utf8_lossy(&output.stdout);
430 let mut access_key = None;
431 let mut secret_key = None;
432 let mut session_token = None;
433 for line in stdout.lines() {
434 let line = line.trim();
435 let body = line.strip_prefix("export ").unwrap_or(line);
437 if let Some((k, v)) = body.split_once('=') {
438 match k.trim() {
439 "AWS_ACCESS_KEY_ID" => access_key = Some(v.trim().to_string()),
440 "AWS_SECRET_ACCESS_KEY" => secret_key = Some(v.trim().to_string()),
441 "AWS_SESSION_TOKEN" => session_token = Some(v.trim().to_string()),
442 _ => {}
443 }
444 }
445 }
446 match (access_key, secret_key) {
447 (Some(ak), Some(sk)) => Ok((ak, sk, session_token)),
448 _ => Err(error!(
449 "`aws configure export-credentials` returned no usable credentials (missing AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY)",
450 profile = profile
451 )),
452 }
453}
454
455#[cfg(test)]
456mod tests {
457 use super::parse_profile;
458
459 #[test]
460 fn picks_default_section_only() {
461 let ini = "\
462[other]
463aws_access_key_id = NOPE
464aws_secret_access_key = NOPE
465
466[default]
467aws_access_key_id = AKIA_DEFAULT
468aws_secret_access_key = secret_default
469aws_session_token = token_default
470
471[another]
472aws_access_key_id = ALSO_NOPE
473";
474 let p = parse_profile(ini, "default", false).expect("default section");
475 assert_eq!(p.get("aws_access_key_id").unwrap(), "AKIA_DEFAULT");
476 assert_eq!(p.get("aws_secret_access_key").unwrap(), "secret_default");
477 assert_eq!(p.get("aws_session_token").unwrap(), "token_default");
478 }
479
480 #[test]
481 fn picks_named_credentials_profile() {
482 let ini = "\
483[default]
484aws_access_key_id = NOPE
485
486[work]
487aws_access_key_id = AKIA_WORK
488aws_secret_access_key = secret_work
489";
490 let p = parse_profile(ini, "work", false).expect("work section");
491 assert_eq!(p.get("aws_access_key_id").unwrap(), "AKIA_WORK");
492 }
493
494 #[test]
495 fn picks_named_config_profile_uses_profile_prefix() {
496 let ini = "\
499[default]
500region = eu-west-2
501
502[profile work]
503region = us-east-1
504";
505 let p = parse_profile(ini, "work", true).expect("work section");
506 assert_eq!(p.get("region").unwrap(), "us-east-1");
507 let d = parse_profile(ini, "default", true).expect("default section");
509 assert_eq!(d.get("region").unwrap(), "eu-west-2");
510 }
511
512 #[test]
513 fn missing_profile_returns_none() {
514 let ini = "[work]\naws_access_key_id = X\n";
515 assert!(parse_profile(ini, "default", false).is_none());
516 }
517
518 #[test]
519 fn ignores_comments_and_blank_lines() {
520 let ini = "\
521# top comment
522; also a comment
523
524[default]
525# inline comment line
526aws_access_key_id = AK
527 aws_secret_access_key = SK
528";
529 let p = parse_profile(ini, "default", false).unwrap();
530 assert_eq!(p.get("aws_access_key_id").unwrap(), "AK");
531 assert_eq!(p.get("aws_secret_access_key").unwrap(), "SK");
532 }
533}