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 http: reqwest::Client,
32}
33
34impl AwsAccount {
35 pub fn new(
37 access_key: impl Into<String>,
38 secret_key: impl Into<String>,
39 region: impl Into<String>,
40 ) -> Self {
41 Self {
42 inner: Arc::new(Inner {
43 access_key: access_key.into(),
44 secret_key: secret_key.into(),
45 session_token: None,
46 region: region.into(),
47 endpoint: env_endpoint(),
48 http: reqwest::Client::new(),
49 }),
50 }
51 }
52
53 pub fn from_env() -> Result<Self> {
57 let access_key =
58 std::env::var("AWS_ACCESS_KEY_ID").map_err(|_| error!("AWS_ACCESS_KEY_ID not set"))?;
59 let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY")
60 .map_err(|_| error!("AWS_SECRET_ACCESS_KEY not set"))?;
61 let region = std::env::var("AWS_REGION").map_err(|_| error!("AWS_REGION not set"))?;
62 let session_token = std::env::var("AWS_SESSION_TOKEN").ok();
63
64 Ok(Self {
65 inner: Arc::new(Inner {
66 access_key,
67 secret_key,
68 session_token,
69 region,
70 endpoint: env_endpoint(),
71 http: reqwest::Client::new(),
72 }),
73 })
74 }
75
76 pub fn from_credentials_file() -> Result<Self> {
86 let profile = std::env::var("AWS_PROFILE").unwrap_or_else(|_| "default".to_string());
87 Self::from_profile(&profile)
88 }
89
90 pub fn from_profile(profile: &str) -> Result<Self> {
94 let home_dir = home_dir().ok_or_else(|| error!("HOME not set"))?;
95 let region = resolve_region_for(&home_dir, profile)?;
96
97 let creds_path = home_dir.join(".aws/credentials");
99 if let Ok(creds_text) = std::fs::read_to_string(&creds_path)
100 && let Some(creds) =
101 parse_profile(&creds_text, profile, false)
102 && let (Some(ak), Some(sk)) = (
103 creds.get("aws_access_key_id"),
104 creds.get("aws_secret_access_key"),
105 )
106 {
107 return Ok(Self {
108 inner: Arc::new(Inner {
109 access_key: ak.clone(),
110 secret_key: sk.clone(),
111 session_token: creds.get("aws_session_token").cloned(),
112 region,
113 endpoint: env_endpoint(),
114 http: reqwest::Client::new(),
115 }),
116 });
117 }
118
119 let (ak, sk, token) = export_credentials_via_aws_cli(profile)?;
123 Ok(Self {
124 inner: Arc::new(Inner {
125 access_key: ak,
126 secret_key: sk,
127 session_token: token,
128 region,
129 endpoint: env_endpoint(),
130 http: reqwest::Client::new(),
131 }),
132 })
133 }
134
135 pub fn from_default() -> Result<Self> {
139 match Self::from_env() {
140 Ok(acc) => Ok(acc),
141 Err(_) => Self::from_credentials_file(),
142 }
143 }
144
145 pub fn with_region(self, region: impl Into<String>) -> Self {
150 let inner = &self.inner;
151 Self {
152 inner: std::sync::Arc::new(Inner {
153 access_key: inner.access_key.clone(),
154 secret_key: inner.secret_key.clone(),
155 session_token: inner.session_token.clone(),
156 region: region.into(),
157 endpoint: inner.endpoint.clone(),
158 http: inner.http.clone(),
159 }),
160 }
161 }
162
163 pub fn with_endpoint(self, endpoint: impl Into<String>) -> Self {
169 let inner = &self.inner;
170 Self {
171 inner: std::sync::Arc::new(Inner {
172 access_key: inner.access_key.clone(),
173 secret_key: inner.secret_key.clone(),
174 session_token: inner.session_token.clone(),
175 region: inner.region.clone(),
176 endpoint: Some(endpoint.into()),
177 http: inner.http.clone(),
178 }),
179 }
180 }
181
182 pub(crate) fn region(&self) -> &str {
183 &self.inner.region
184 }
185
186 pub(crate) fn access_key(&self) -> &str {
187 &self.inner.access_key
188 }
189
190 pub(crate) fn secret_key(&self) -> &str {
191 &self.inner.secret_key
192 }
193
194 pub(crate) fn session_token(&self) -> Option<&str> {
195 self.inner.session_token.as_deref()
196 }
197
198 pub(crate) fn http(&self) -> &reqwest::Client {
199 &self.inner.http
200 }
201
202 pub(crate) fn endpoint_for(&self, service: &str) -> (String, String) {
207 match self.inner.endpoint.as_deref() {
208 Some(ep) => {
209 let trimmed = ep.trim_end_matches('/');
210 let host = trimmed
211 .split_once("://")
212 .map(|(_, rest)| rest)
213 .unwrap_or(trimmed)
214 .to_string();
215 (format!("{trimmed}/"), host)
216 }
217 None => {
218 let host = format!("{service}.{}.amazonaws.com", self.inner.region);
219 (format!("https://{host}/"), host)
220 }
221 }
222 }
223}
224
225impl std::fmt::Debug for AwsAccount {
226 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227 f.debug_struct("AwsAccount")
228 .field("region", &self.inner.region)
229 .field("endpoint", &self.inner.endpoint)
230 .field("access_key", &"<redacted>")
231 .field("secret_key", &"<redacted>")
232 .field(
233 "session_token",
234 &self.inner.session_token.as_ref().map(|_| "<redacted>"),
235 )
236 .finish()
237 }
238}
239
240fn home_dir() -> Option<PathBuf> {
241 std::env::var_os("HOME").map(PathBuf::from)
242}
243
244fn env_endpoint() -> Option<String> {
248 std::env::var("AWS_ENDPOINT_URL")
249 .ok()
250 .filter(|s| !s.is_empty())
251}
252
253fn parse_profile(
261 content: &str,
262 profile: &str,
263 config_style: bool,
264) -> Option<HashMap<String, String>> {
265 let target_section = if config_style && profile != "default" {
266 format!("profile {}", profile)
267 } else {
268 profile.to_string()
269 };
270
271 let mut in_target = false;
272 let mut found = false;
273 let mut map = HashMap::new();
274
275 for raw in content.lines() {
276 let line = raw.trim();
277 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
278 continue;
279 }
280 if let Some(section) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
281 in_target = section.trim() == target_section;
282 if in_target {
283 found = true;
284 }
285 continue;
286 }
287 if in_target && let Some((k, v)) = line.split_once('=') {
288 map.insert(k.trim().to_string(), v.trim().to_string());
289 }
290 }
291
292 found.then_some(map)
293}
294
295fn resolve_region_for(home_dir: &std::path::Path, profile: &str) -> Result<String> {
299 if let Ok(r) = std::env::var("AWS_REGION") {
300 return Ok(r);
301 }
302 if let Ok(r) = std::env::var("AWS_DEFAULT_REGION") {
303 return Ok(r);
304 }
305 let config_path = home_dir.join(".aws/config");
306 if let Ok(text) = std::fs::read_to_string(&config_path)
307 && let Some(profile_map) = parse_profile(&text, profile, true)
308 && let Some(r) = profile_map.get("region")
309 {
310 return Ok(r.clone());
311 }
312 Err(error!(
313 "AWS region not found (set AWS_REGION, or add `region = ...` under the profile in ~/.aws/config)",
314 profile = profile
315 ))
316}
317
318fn export_credentials_via_aws_cli(profile: &str) -> Result<(String, String, Option<String>)> {
329 let output = match std::process::Command::new("aws")
330 .args([
331 "configure",
332 "export-credentials",
333 "--profile",
334 profile,
335 "--format",
336 "env",
337 ])
338 .output()
339 {
340 Ok(o) => o,
341 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
342 return Err(error!(
343 "AWS CLI not installed — needed to materialise SSO, assume-role, or credential_process credentials. Install via mise or your package manager.",
344 profile = profile
345 ));
346 }
347 Err(e) => return Err(error!(format!("failed to spawn `aws`: {e}"))),
348 };
349 if !output.status.success() {
350 let stderr = String::from_utf8_lossy(&output.stderr);
351 return Err(error!(
352 "`aws configure export-credentials` failed — for SSO profiles try `aws sso login --profile <name>` first",
353 profile = profile,
354 stderr = stderr.trim().to_string()
355 ));
356 }
357 let stdout = String::from_utf8_lossy(&output.stdout);
358 let mut access_key = None;
359 let mut secret_key = None;
360 let mut session_token = None;
361 for line in stdout.lines() {
362 let line = line.trim();
363 let body = line.strip_prefix("export ").unwrap_or(line);
365 if let Some((k, v)) = body.split_once('=') {
366 match k.trim() {
367 "AWS_ACCESS_KEY_ID" => access_key = Some(v.trim().to_string()),
368 "AWS_SECRET_ACCESS_KEY" => secret_key = Some(v.trim().to_string()),
369 "AWS_SESSION_TOKEN" => session_token = Some(v.trim().to_string()),
370 _ => {}
371 }
372 }
373 }
374 match (access_key, secret_key) {
375 (Some(ak), Some(sk)) => Ok((ak, sk, session_token)),
376 _ => Err(error!(
377 "`aws configure export-credentials` returned no usable credentials (missing AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY)",
378 profile = profile
379 )),
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use super::parse_profile;
386
387 #[test]
388 fn picks_default_section_only() {
389 let ini = "\
390[other]
391aws_access_key_id = NOPE
392aws_secret_access_key = NOPE
393
394[default]
395aws_access_key_id = AKIA_DEFAULT
396aws_secret_access_key = secret_default
397aws_session_token = token_default
398
399[another]
400aws_access_key_id = ALSO_NOPE
401";
402 let p = parse_profile(ini, "default", false).expect("default section");
403 assert_eq!(p.get("aws_access_key_id").unwrap(), "AKIA_DEFAULT");
404 assert_eq!(p.get("aws_secret_access_key").unwrap(), "secret_default");
405 assert_eq!(p.get("aws_session_token").unwrap(), "token_default");
406 }
407
408 #[test]
409 fn picks_named_credentials_profile() {
410 let ini = "\
411[default]
412aws_access_key_id = NOPE
413
414[work]
415aws_access_key_id = AKIA_WORK
416aws_secret_access_key = secret_work
417";
418 let p = parse_profile(ini, "work", false).expect("work section");
419 assert_eq!(p.get("aws_access_key_id").unwrap(), "AKIA_WORK");
420 }
421
422 #[test]
423 fn picks_named_config_profile_uses_profile_prefix() {
424 let ini = "\
427[default]
428region = eu-west-2
429
430[profile work]
431region = us-east-1
432";
433 let p = parse_profile(ini, "work", true).expect("work section");
434 assert_eq!(p.get("region").unwrap(), "us-east-1");
435 let d = parse_profile(ini, "default", true).expect("default section");
437 assert_eq!(d.get("region").unwrap(), "eu-west-2");
438 }
439
440 #[test]
441 fn missing_profile_returns_none() {
442 let ini = "[work]\naws_access_key_id = X\n";
443 assert!(parse_profile(ini, "default", false).is_none());
444 }
445
446 #[test]
447 fn ignores_comments_and_blank_lines() {
448 let ini = "\
449# top comment
450; also a comment
451
452[default]
453# inline comment line
454aws_access_key_id = AK
455 aws_secret_access_key = SK
456";
457 let p = parse_profile(ini, "default", false).unwrap();
458 assert_eq!(p.get("aws_access_key_id").unwrap(), "AK");
459 assert_eq!(p.get("aws_secret_access_key").unwrap(), "SK");
460 }
461}