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 http: reqwest::Client,
36}
37
38impl AwsAccount {
39 pub fn new(
41 access_key: impl Into<String>,
42 secret_key: impl Into<String>,
43 region: impl Into<String>,
44 ) -> Self {
45 Self {
46 inner: Arc::new(Inner {
47 access_key: access_key.into(),
48 secret_key: secret_key.into(),
49 session_token: None,
50 region: region.into(),
51 endpoint: env_endpoint(),
52 max_pages: None,
53 http: reqwest::Client::new(),
54 }),
55 }
56 }
57
58 pub fn from_env() -> Result<Self> {
62 let access_key =
63 std::env::var("AWS_ACCESS_KEY_ID").map_err(|_| error!("AWS_ACCESS_KEY_ID not set"))?;
64 let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY")
65 .map_err(|_| error!("AWS_SECRET_ACCESS_KEY not set"))?;
66 let region = std::env::var("AWS_REGION").map_err(|_| error!("AWS_REGION not set"))?;
67 let session_token = std::env::var("AWS_SESSION_TOKEN").ok();
68
69 Ok(Self {
70 inner: Arc::new(Inner {
71 access_key,
72 secret_key,
73 session_token,
74 region,
75 endpoint: env_endpoint(),
76 max_pages: None,
77 http: reqwest::Client::new(),
78 }),
79 })
80 }
81
82 pub fn from_credentials_file() -> Result<Self> {
92 let profile = std::env::var("AWS_PROFILE").unwrap_or_else(|_| "default".to_string());
93 Self::from_profile(&profile)
94 }
95
96 pub fn from_profile(profile: &str) -> Result<Self> {
100 let home_dir = home_dir().ok_or_else(|| error!("HOME not set"))?;
101 let region = resolve_region_for(&home_dir, profile)?;
102
103 let creds_path = home_dir.join(".aws/credentials");
105 if let Ok(creds_text) = std::fs::read_to_string(&creds_path)
106 && let Some(creds) =
107 parse_profile(&creds_text, profile, false)
108 && let (Some(ak), Some(sk)) = (
109 creds.get("aws_access_key_id"),
110 creds.get("aws_secret_access_key"),
111 )
112 {
113 return Ok(Self {
114 inner: Arc::new(Inner {
115 access_key: ak.clone(),
116 secret_key: sk.clone(),
117 session_token: creds.get("aws_session_token").cloned(),
118 region,
119 endpoint: env_endpoint(),
120 max_pages: None,
121 http: reqwest::Client::new(),
122 }),
123 });
124 }
125
126 let (ak, sk, token) = export_credentials_via_aws_cli(profile)?;
130 Ok(Self {
131 inner: Arc::new(Inner {
132 access_key: ak,
133 secret_key: sk,
134 session_token: token,
135 region,
136 endpoint: env_endpoint(),
137 max_pages: None,
138 http: reqwest::Client::new(),
139 }),
140 })
141 }
142
143 pub fn from_default() -> Result<Self> {
147 match Self::from_env() {
148 Ok(acc) => Ok(acc),
149 Err(_) => Self::from_credentials_file(),
150 }
151 }
152
153 pub fn with_region(self, region: impl Into<String>) -> Self {
158 let inner = &self.inner;
159 Self {
160 inner: std::sync::Arc::new(Inner {
161 access_key: inner.access_key.clone(),
162 secret_key: inner.secret_key.clone(),
163 session_token: inner.session_token.clone(),
164 region: region.into(),
165 endpoint: inner.endpoint.clone(),
166 max_pages: inner.max_pages,
167 http: inner.http.clone(),
168 }),
169 }
170 }
171
172 pub fn with_endpoint(self, endpoint: impl Into<String>) -> Self {
178 let inner = &self.inner;
179 Self {
180 inner: std::sync::Arc::new(Inner {
181 access_key: inner.access_key.clone(),
182 secret_key: inner.secret_key.clone(),
183 session_token: inner.session_token.clone(),
184 region: inner.region.clone(),
185 endpoint: Some(endpoint.into()),
186 max_pages: inner.max_pages,
187 http: inner.http.clone(),
188 }),
189 }
190 }
191
192 pub fn with_max_pages(self, n: usize) -> Self {
203 let inner = &self.inner;
204 Self {
205 inner: std::sync::Arc::new(Inner {
206 access_key: inner.access_key.clone(),
207 secret_key: inner.secret_key.clone(),
208 session_token: inner.session_token.clone(),
209 region: inner.region.clone(),
210 endpoint: inner.endpoint.clone(),
211 max_pages: Some(n),
212 http: inner.http.clone(),
213 }),
214 }
215 }
216
217 pub(crate) fn max_pages(&self) -> Option<usize> {
218 self.inner.max_pages
219 }
220
221 pub(crate) fn region(&self) -> &str {
222 &self.inner.region
223 }
224
225 pub(crate) fn access_key(&self) -> &str {
226 &self.inner.access_key
227 }
228
229 pub(crate) fn secret_key(&self) -> &str {
230 &self.inner.secret_key
231 }
232
233 pub(crate) fn session_token(&self) -> Option<&str> {
234 self.inner.session_token.as_deref()
235 }
236
237 pub(crate) fn http(&self) -> &reqwest::Client {
238 &self.inner.http
239 }
240
241 pub(crate) fn endpoint_for(&self, service: &str) -> (String, String) {
246 match self.inner.endpoint.as_deref() {
247 Some(ep) => {
248 let trimmed = ep.trim_end_matches('/');
249 let host = trimmed
250 .split_once("://")
251 .map(|(_, rest)| rest)
252 .unwrap_or(trimmed)
253 .to_string();
254 (format!("{trimmed}/"), host)
255 }
256 None => {
257 let host = format!("{service}.{}.amazonaws.com", self.inner.region);
258 (format!("https://{host}/"), host)
259 }
260 }
261 }
262}
263
264impl std::fmt::Debug for AwsAccount {
265 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266 f.debug_struct("AwsAccount")
267 .field("region", &self.inner.region)
268 .field("endpoint", &self.inner.endpoint)
269 .field("access_key", &"<redacted>")
270 .field("secret_key", &"<redacted>")
271 .field(
272 "session_token",
273 &self.inner.session_token.as_ref().map(|_| "<redacted>"),
274 )
275 .finish()
276 }
277}
278
279fn home_dir() -> Option<PathBuf> {
280 std::env::var_os("HOME").map(PathBuf::from)
281}
282
283fn env_endpoint() -> Option<String> {
287 std::env::var("AWS_ENDPOINT_URL")
288 .ok()
289 .filter(|s| !s.is_empty())
290}
291
292fn parse_profile(
300 content: &str,
301 profile: &str,
302 config_style: bool,
303) -> Option<HashMap<String, String>> {
304 let target_section = if config_style && profile != "default" {
305 format!("profile {}", profile)
306 } else {
307 profile.to_string()
308 };
309
310 let mut in_target = false;
311 let mut found = false;
312 let mut map = HashMap::new();
313
314 for raw in content.lines() {
315 let line = raw.trim();
316 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
317 continue;
318 }
319 if let Some(section) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
320 in_target = section.trim() == target_section;
321 if in_target {
322 found = true;
323 }
324 continue;
325 }
326 if in_target && let Some((k, v)) = line.split_once('=') {
327 map.insert(k.trim().to_string(), v.trim().to_string());
328 }
329 }
330
331 found.then_some(map)
332}
333
334fn resolve_region_for(home_dir: &std::path::Path, profile: &str) -> Result<String> {
338 if let Ok(r) = std::env::var("AWS_REGION") {
339 return Ok(r);
340 }
341 if let Ok(r) = std::env::var("AWS_DEFAULT_REGION") {
342 return Ok(r);
343 }
344 let config_path = home_dir.join(".aws/config");
345 if let Ok(text) = std::fs::read_to_string(&config_path)
346 && let Some(profile_map) = parse_profile(&text, profile, true)
347 && let Some(r) = profile_map.get("region")
348 {
349 return Ok(r.clone());
350 }
351 Err(error!(
352 "AWS region not found (set AWS_REGION, or add `region = ...` under the profile in ~/.aws/config)",
353 profile = profile
354 ))
355}
356
357fn export_credentials_via_aws_cli(profile: &str) -> Result<(String, String, Option<String>)> {
368 let output = match std::process::Command::new("aws")
369 .args([
370 "configure",
371 "export-credentials",
372 "--profile",
373 profile,
374 "--format",
375 "env",
376 ])
377 .output()
378 {
379 Ok(o) => o,
380 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
381 return Err(error!(
382 "AWS CLI not installed — needed to materialise SSO, assume-role, or credential_process credentials. Install via mise or your package manager.",
383 profile = profile
384 ));
385 }
386 Err(e) => return Err(error!(format!("failed to spawn `aws`: {e}"))),
387 };
388 if !output.status.success() {
389 let stderr = String::from_utf8_lossy(&output.stderr);
390 return Err(error!(
391 "`aws configure export-credentials` failed — for SSO profiles try `aws sso login --profile <name>` first",
392 profile = profile,
393 stderr = stderr.trim().to_string()
394 ));
395 }
396 let stdout = String::from_utf8_lossy(&output.stdout);
397 let mut access_key = None;
398 let mut secret_key = None;
399 let mut session_token = None;
400 for line in stdout.lines() {
401 let line = line.trim();
402 let body = line.strip_prefix("export ").unwrap_or(line);
404 if let Some((k, v)) = body.split_once('=') {
405 match k.trim() {
406 "AWS_ACCESS_KEY_ID" => access_key = Some(v.trim().to_string()),
407 "AWS_SECRET_ACCESS_KEY" => secret_key = Some(v.trim().to_string()),
408 "AWS_SESSION_TOKEN" => session_token = Some(v.trim().to_string()),
409 _ => {}
410 }
411 }
412 }
413 match (access_key, secret_key) {
414 (Some(ak), Some(sk)) => Ok((ak, sk, session_token)),
415 _ => Err(error!(
416 "`aws configure export-credentials` returned no usable credentials (missing AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY)",
417 profile = profile
418 )),
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use super::parse_profile;
425
426 #[test]
427 fn picks_default_section_only() {
428 let ini = "\
429[other]
430aws_access_key_id = NOPE
431aws_secret_access_key = NOPE
432
433[default]
434aws_access_key_id = AKIA_DEFAULT
435aws_secret_access_key = secret_default
436aws_session_token = token_default
437
438[another]
439aws_access_key_id = ALSO_NOPE
440";
441 let p = parse_profile(ini, "default", false).expect("default section");
442 assert_eq!(p.get("aws_access_key_id").unwrap(), "AKIA_DEFAULT");
443 assert_eq!(p.get("aws_secret_access_key").unwrap(), "secret_default");
444 assert_eq!(p.get("aws_session_token").unwrap(), "token_default");
445 }
446
447 #[test]
448 fn picks_named_credentials_profile() {
449 let ini = "\
450[default]
451aws_access_key_id = NOPE
452
453[work]
454aws_access_key_id = AKIA_WORK
455aws_secret_access_key = secret_work
456";
457 let p = parse_profile(ini, "work", false).expect("work section");
458 assert_eq!(p.get("aws_access_key_id").unwrap(), "AKIA_WORK");
459 }
460
461 #[test]
462 fn picks_named_config_profile_uses_profile_prefix() {
463 let ini = "\
466[default]
467region = eu-west-2
468
469[profile work]
470region = us-east-1
471";
472 let p = parse_profile(ini, "work", true).expect("work section");
473 assert_eq!(p.get("region").unwrap(), "us-east-1");
474 let d = parse_profile(ini, "default", true).expect("default section");
476 assert_eq!(d.get("region").unwrap(), "eu-west-2");
477 }
478
479 #[test]
480 fn missing_profile_returns_none() {
481 let ini = "[work]\naws_access_key_id = X\n";
482 assert!(parse_profile(ini, "default", false).is_none());
483 }
484
485 #[test]
486 fn ignores_comments_and_blank_lines() {
487 let ini = "\
488# top comment
489; also a comment
490
491[default]
492# inline comment line
493aws_access_key_id = AK
494 aws_secret_access_key = SK
495";
496 let p = parse_profile(ini, "default", false).unwrap();
497 assert_eq!(p.get("aws_access_key_id").unwrap(), "AK");
498 assert_eq!(p.get("aws_secret_access_key").unwrap(), "SK");
499 }
500}