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 http: reqwest::Client,
27}
28
29impl AwsAccount {
30 pub fn new(
32 access_key: impl Into<String>,
33 secret_key: impl Into<String>,
34 region: impl Into<String>,
35 ) -> Self {
36 Self {
37 inner: Arc::new(Inner {
38 access_key: access_key.into(),
39 secret_key: secret_key.into(),
40 session_token: None,
41 region: region.into(),
42 http: reqwest::Client::new(),
43 }),
44 }
45 }
46
47 pub fn from_env() -> Result<Self> {
51 let access_key =
52 std::env::var("AWS_ACCESS_KEY_ID").map_err(|_| error!("AWS_ACCESS_KEY_ID not set"))?;
53 let secret_key = std::env::var("AWS_SECRET_ACCESS_KEY")
54 .map_err(|_| error!("AWS_SECRET_ACCESS_KEY not set"))?;
55 let region = std::env::var("AWS_REGION").map_err(|_| error!("AWS_REGION not set"))?;
56 let session_token = std::env::var("AWS_SESSION_TOKEN").ok();
57
58 Ok(Self {
59 inner: Arc::new(Inner {
60 access_key,
61 secret_key,
62 session_token,
63 region,
64 http: reqwest::Client::new(),
65 }),
66 })
67 }
68
69 pub fn from_credentials_file() -> Result<Self> {
76 let home_dir = home_dir().ok_or_else(|| error!("HOME not set"))?;
77 let creds_path = home_dir.join(".aws/credentials");
78 let creds_text = std::fs::read_to_string(&creds_path)
79 .map_err(|e| error!(format!("failed to read {}: {}", creds_path.display(), e)))?;
80 let creds = parse_default_profile(&creds_text)
81 .ok_or_else(|| error!(format!("no [default] profile in {}", creds_path.display())))?;
82
83 let access_key = creds
84 .get("aws_access_key_id")
85 .ok_or_else(|| {
86 error!(format!(
87 "aws_access_key_id missing in {} [default]",
88 creds_path.display()
89 ))
90 })?
91 .clone();
92 let secret_key = creds
93 .get("aws_secret_access_key")
94 .ok_or_else(|| {
95 error!(format!(
96 "aws_secret_access_key missing in {} [default]",
97 creds_path.display()
98 ))
99 })?
100 .clone();
101 let session_token = creds.get("aws_session_token").cloned();
102
103 let region = std::env::var("AWS_REGION")
104 .ok()
105 .or_else(|| std::env::var("AWS_DEFAULT_REGION").ok())
106 .or_else(|| {
107 let config_path = home_dir.join(".aws/config");
108 let text = std::fs::read_to_string(&config_path).ok()?;
109 parse_default_profile(&text)?.get("region").cloned()
110 })
111 .ok_or_else(|| {
112 error!(
113 "AWS region not found (set AWS_REGION or add region to ~/.aws/config [default])"
114 )
115 })?;
116
117 Ok(Self {
118 inner: Arc::new(Inner {
119 access_key,
120 secret_key,
121 session_token,
122 region,
123 http: reqwest::Client::new(),
124 }),
125 })
126 }
127
128 pub fn from_default() -> Result<Self> {
132 match Self::from_env() {
133 Ok(acc) => Ok(acc),
134 Err(_) => Self::from_credentials_file(),
135 }
136 }
137
138 pub fn with_region(self, region: impl Into<String>) -> Self {
143 let inner = &self.inner;
144 Self {
145 inner: std::sync::Arc::new(Inner {
146 access_key: inner.access_key.clone(),
147 secret_key: inner.secret_key.clone(),
148 session_token: inner.session_token.clone(),
149 region: region.into(),
150 http: inner.http.clone(),
151 }),
152 }
153 }
154
155 pub(crate) fn region(&self) -> &str {
156 &self.inner.region
157 }
158
159 pub(crate) fn access_key(&self) -> &str {
160 &self.inner.access_key
161 }
162
163 pub(crate) fn secret_key(&self) -> &str {
164 &self.inner.secret_key
165 }
166
167 pub(crate) fn session_token(&self) -> Option<&str> {
168 self.inner.session_token.as_deref()
169 }
170
171 pub(crate) fn http(&self) -> &reqwest::Client {
172 &self.inner.http
173 }
174}
175
176impl std::fmt::Debug for AwsAccount {
177 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178 f.debug_struct("AwsAccount")
179 .field("region", &self.inner.region)
180 .field("access_key", &"<redacted>")
181 .field("secret_key", &"<redacted>")
182 .field(
183 "session_token",
184 &self.inner.session_token.as_ref().map(|_| "<redacted>"),
185 )
186 .finish()
187 }
188}
189
190fn home_dir() -> Option<PathBuf> {
191 std::env::var_os("HOME").map(PathBuf::from)
192}
193
194fn parse_default_profile(content: &str) -> Option<HashMap<String, String>> {
199 let mut in_default = false;
200 let mut found_default = false;
201 let mut map = HashMap::new();
202
203 for raw in content.lines() {
204 let line = raw.trim();
205 if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
206 continue;
207 }
208 if let Some(section) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
209 in_default = section.trim() == "default";
210 if in_default {
211 found_default = true;
212 }
213 continue;
214 }
215 if in_default && let Some((k, v)) = line.split_once('=') {
216 map.insert(k.trim().to_string(), v.trim().to_string());
217 }
218 }
219
220 found_default.then_some(map)
221}
222
223#[cfg(test)]
224mod tests {
225 use super::parse_default_profile;
226
227 #[test]
228 fn picks_default_section_only() {
229 let ini = "\
230[other]
231aws_access_key_id = NOPE
232aws_secret_access_key = NOPE
233
234[default]
235aws_access_key_id = AKIA_DEFAULT
236aws_secret_access_key = secret_default
237aws_session_token = token_default
238
239[another]
240aws_access_key_id = ALSO_NOPE
241";
242 let p = parse_default_profile(ini).expect("default section");
243 assert_eq!(p.get("aws_access_key_id").unwrap(), "AKIA_DEFAULT");
244 assert_eq!(p.get("aws_secret_access_key").unwrap(), "secret_default");
245 assert_eq!(p.get("aws_session_token").unwrap(), "token_default");
246 }
247
248 #[test]
249 fn no_default_returns_none() {
250 let ini = "[work]\naws_access_key_id = X\n";
251 assert!(parse_default_profile(ini).is_none());
252 }
253
254 #[test]
255 fn ignores_comments_and_blank_lines() {
256 let ini = "\
257# top comment
258; also a comment
259
260[default]
261# inline comment line
262aws_access_key_id = AK
263 aws_secret_access_key = SK
264";
265 let p = parse_default_profile(ini).unwrap();
266 assert_eq!(p.get("aws_access_key_id").unwrap(), "AK");
267 assert_eq!(p.get("aws_secret_access_key").unwrap(), "SK");
268 }
269}