Skip to main content

vantage_aws/
account.rs

1//! `AwsAccount` — account-wide credentials handle.
2//!
3//! Holds the access key, secret key, and region. Cheap to clone (everything
4//! lives behind an `Arc`). Used directly as the `TableSource` for JSON-1.1
5//! tables — see `crate::json1` for the protocol impl. The per-operation
6//! configuration (service, operation target, response array key) lives in
7//! the table name, formatted as `array_key:service/target`.
8
9use 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    /// Construct from explicit static credentials.
31    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    /// Read `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and
48    /// `AWS_REGION` from the environment. Picks up `AWS_SESSION_TOKEN`
49    /// for temporary credentials if present.
50    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    /// Read the `[default]` profile from `~/.aws/credentials`. Other
70    /// profiles, `AWS_PROFILE`, SSO, and assume-role aren't supported
71    /// in v0.
72    ///
73    /// Region resolution falls through `AWS_REGION` →
74    /// `AWS_DEFAULT_REGION` → `~/.aws/config` `[default]` `region`.
75    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    /// Try [`from_env`](Self::from_env), fall back to
129    /// [`from_credentials_file`](Self::from_credentials_file). Use
130    /// this when you don't care which one — typical CLI / dev setup.
131    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    /// Return a copy with the region overridden. Useful when credentials
139    /// come from `~/.aws/credentials` but the target region differs from
140    /// the profile default (e.g. a test fixture provisioned in a fixed
141    /// region regardless of the developer's local config).
142    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
194/// Pull the `[default]` section from an AWS-style INI file. Returns
195/// `None` if no `[default]` section was seen. `~/.aws/config` happens
196/// to use `[default]` (not `[profile default]`) for the default
197/// profile, so the same parser handles both files.
198fn 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}