reqsign_aws_core/provide_credential/
profile.rs1use crate::Credential;
19#[cfg(not(target_arch = "wasm32"))]
20use crate::constants::*;
21#[cfg(not(target_arch = "wasm32"))]
22use ini::Ini;
23#[cfg(not(target_arch = "wasm32"))]
24use log::debug;
25#[cfg(not(target_arch = "wasm32"))]
26use reqsign_core::Error;
27use reqsign_core::{Context, ProvideCredential, Result};
28
29#[derive(Debug, Clone)]
40pub struct ProfileCredentialProvider {
41 profile: Option<String>,
42 config_file: Option<String>,
43 credentials_file: Option<String>,
44}
45
46impl Default for ProfileCredentialProvider {
47 fn default() -> Self {
48 Self::new()
49 }
50}
51
52impl ProfileCredentialProvider {
53 pub fn new() -> Self {
55 Self {
56 profile: None,
57 config_file: None,
58 credentials_file: None,
59 }
60 }
61
62 pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
64 self.profile = Some(profile.into());
65 self
66 }
67
68 pub fn with_config_file(mut self, path: impl Into<String>) -> Self {
70 self.config_file = Some(path.into());
71 self
72 }
73
74 pub fn with_credentials_file(mut self, path: impl Into<String>) -> Self {
76 self.credentials_file = Some(path.into());
77 self
78 }
79
80 #[cfg(not(target_arch = "wasm32"))]
81 async fn load_from_credentials_file(
82 &self,
83 ctx: &Context,
84 profile: &str,
85 ) -> Result<Option<Credential>> {
86 let path = if let Some(path) = &self.credentials_file {
87 path.clone()
88 } else if let Some(path) = ctx.env_var(AWS_SHARED_CREDENTIALS_FILE) {
89 path
90 } else {
91 "~/.aws/credentials".to_string()
92 };
93
94 let expanded_path = if path.starts_with("~/") {
95 match ctx.expand_home_dir(&path) {
96 Some(expanded) => expanded,
97 None => {
98 debug!("failed to expand homedir for path: {path}");
99 return Ok(None);
100 }
101 }
102 } else {
103 path.clone()
104 };
105
106 let content = match ctx.file_read(&expanded_path).await {
107 Ok(content) => content,
108 Err(err) => {
109 debug!("failed to read credentials file {expanded_path}: {err:?}");
110 return Ok(None);
111 }
112 };
113
114 let conf = Ini::load_from_str(&String::from_utf8_lossy(&content)).map_err(|e| {
115 Error::config_invalid("failed to parse credentials file").with_source(e)
116 })?;
117
118 let props = match conf.section(Some(profile)) {
119 Some(props) => props,
120 None => {
121 debug!("profile {profile} not found in credentials file");
122 return Ok(None);
123 }
124 };
125
126 let access_key_id = props.get("aws_access_key_id");
127 let secret_access_key = props.get("aws_secret_access_key");
128
129 match (access_key_id, secret_access_key) {
130 (Some(ak), Some(sk)) => Ok(Some(Credential {
131 access_key_id: ak.to_string(),
132 secret_access_key: sk.to_string(),
133 session_token: props.get("aws_session_token").map(|s| s.to_string()),
134 expires_in: None,
135 })),
136 _ => Ok(None),
137 }
138 }
139
140 #[cfg(not(target_arch = "wasm32"))]
141 async fn load_from_config_file(
142 &self,
143 ctx: &Context,
144 profile: &str,
145 ) -> Result<Option<Credential>> {
146 let path = if let Some(path) = &self.config_file {
147 path.clone()
148 } else if let Some(path) = ctx.env_var(AWS_CONFIG_FILE) {
149 path
150 } else {
151 "~/.aws/config".to_string()
152 };
153
154 let expanded_path = if path.starts_with("~/") {
155 match ctx.expand_home_dir(&path) {
156 Some(expanded) => expanded,
157 None => {
158 debug!("failed to expand homedir for path: {path}");
159 return Ok(None);
160 }
161 }
162 } else {
163 path.clone()
164 };
165
166 let content = match ctx.file_read(&expanded_path).await {
167 Ok(content) => content,
168 Err(err) => {
169 debug!("failed to read config file {expanded_path}: {err:?}");
170 return Ok(None);
171 }
172 };
173
174 let conf = Ini::load_from_str(&String::from_utf8_lossy(&content))
175 .map_err(|e| Error::config_invalid("failed to parse config file").with_source(e))?;
176
177 let section = match profile {
178 "default" => "default".to_string(),
179 x => format!("profile {x}"),
180 };
181
182 let props = match conf.section(Some(§ion)) {
183 Some(props) => props,
184 None => {
185 debug!("section {profile} not found in config file");
186 return Ok(None);
187 }
188 };
189
190 let access_key_id = props.get("aws_access_key_id");
191 let secret_access_key = props.get("aws_secret_access_key");
192
193 match (access_key_id, secret_access_key) {
194 (Some(ak), Some(sk)) => Ok(Some(Credential {
195 access_key_id: ak.to_string(),
196 secret_access_key: sk.to_string(),
197 session_token: props.get("aws_session_token").map(|s| s.to_string()),
198 expires_in: None,
199 })),
200 _ => Ok(None),
201 }
202 }
203}
204impl ProvideCredential for ProfileCredentialProvider {
205 type Credential = Credential;
206
207 async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
208 #[cfg(target_arch = "wasm32")]
209 {
210 let _ = ctx;
211 Ok(None)
212 }
213
214 #[cfg(not(target_arch = "wasm32"))]
215 {
216 let profile = self
217 .profile
218 .clone()
219 .or_else(|| ctx.env_var(AWS_PROFILE))
220 .unwrap_or_else(|| "default".to_string());
221
222 if let Some(cred) = self.load_from_credentials_file(ctx, &profile).await? {
224 return Ok(Some(cred));
225 }
226
227 self.load_from_config_file(ctx, &profile).await
229 }
230 }
231}
232
233#[cfg(test)]
234#[cfg(not(target_arch = "wasm32"))]
235mod tests {
236 use super::*;
237 use pretty_assertions::assert_eq;
238 use reqsign_core::StaticEnv;
239 use reqsign_file_read_tokio::TokioFileRead;
240 use reqsign_http_send_reqwest::ReqwestHttpSend;
241 use std::collections::HashMap;
242 use std::fs::File;
243 use std::io::Write;
244 use tempfile::tempdir;
245
246 #[tokio::test]
247 async fn test_profile_from_credentials_file() -> anyhow::Result<()> {
248 let _ = env_logger::builder().is_test(true).try_init();
249
250 let tmp_dir = tempdir()?;
251 let file_path = tmp_dir.path().join("credentials");
252 let mut tmp_file = File::create(&file_path)?;
253 writeln!(tmp_file, "[default]")?;
254 writeln!(tmp_file, "aws_access_key_id = DEFAULTACCESSKEYID")?;
255 writeln!(tmp_file, "aws_secret_access_key = DEFAULTSECRETACCESSKEY")?;
256 writeln!(tmp_file, "aws_session_token = DEFAULTSESSIONTOKEN")?;
257 writeln!(tmp_file)?;
258 writeln!(tmp_file, "[profile1]")?;
259 writeln!(tmp_file, "aws_access_key_id = PROFILE1ACCESSKEYID")?;
260 writeln!(tmp_file, "aws_secret_access_key = PROFILE1SECRETACCESSKEY")?;
261 writeln!(tmp_file, "aws_session_token = PROFILE1SESSIONTOKEN")?;
262
263 let context = Context::new()
264 .with_file_read(TokioFileRead)
265 .with_http_send(ReqwestHttpSend::default())
266 .with_env(StaticEnv {
267 home_dir: None,
268 envs: HashMap::new(),
269 });
270
271 let provider =
273 ProfileCredentialProvider::new().with_credentials_file(file_path.to_str().unwrap());
274 let cred = provider.provide_credential(&context).await?;
275 assert!(cred.is_some());
276 let cred = cred.unwrap();
277 assert_eq!(cred.access_key_id, "DEFAULTACCESSKEYID");
278 assert_eq!(cred.secret_access_key, "DEFAULTSECRETACCESSKEY");
279 assert_eq!(cred.session_token, Some("DEFAULTSESSIONTOKEN".to_string()));
280
281 let provider = ProfileCredentialProvider::new()
283 .with_profile("profile1")
284 .with_credentials_file(file_path.to_str().unwrap());
285 let cred = provider.provide_credential(&context).await?;
286 assert!(cred.is_some());
287 let cred = cred.unwrap();
288 assert_eq!(cred.access_key_id, "PROFILE1ACCESSKEYID");
289 assert_eq!(cred.secret_access_key, "PROFILE1SECRETACCESSKEY");
290 assert_eq!(cred.session_token, Some("PROFILE1SESSIONTOKEN".to_string()));
291
292 Ok(())
293 }
294
295 #[tokio::test]
296 async fn test_profile_from_config_file() -> anyhow::Result<()> {
297 let _ = env_logger::builder().is_test(true).try_init();
298
299 let tmp_dir = tempdir()?;
300 let file_path = tmp_dir.path().join("config");
301 let mut tmp_file = File::create(&file_path)?;
302 writeln!(tmp_file, "[default]")?;
303 writeln!(tmp_file, "aws_access_key_id = DEFAULTACCESSKEYID")?;
304 writeln!(tmp_file, "aws_secret_access_key = DEFAULTSECRETACCESSKEY")?;
305 writeln!(tmp_file)?;
306 writeln!(tmp_file, "[profile profile1]")?;
307 writeln!(tmp_file, "aws_access_key_id = PROFILE1ACCESSKEYID")?;
308 writeln!(tmp_file, "aws_secret_access_key = PROFILE1SECRETACCESSKEY")?;
309
310 let context = Context::new()
311 .with_file_read(TokioFileRead)
312 .with_http_send(ReqwestHttpSend::default())
313 .with_env(StaticEnv {
314 home_dir: None,
315 envs: HashMap::new(),
316 });
317
318 let provider =
320 ProfileCredentialProvider::new().with_config_file(file_path.to_str().unwrap());
321 let cred = provider.provide_credential(&context).await?;
322 assert!(cred.is_some());
323 let cred = cred.unwrap();
324 assert_eq!(cred.access_key_id, "DEFAULTACCESSKEYID");
325 assert_eq!(cred.secret_access_key, "DEFAULTSECRETACCESSKEY");
326 assert!(cred.session_token.is_none());
327
328 let provider = ProfileCredentialProvider::new()
330 .with_profile("profile1")
331 .with_config_file(file_path.to_str().unwrap());
332 let cred = provider.provide_credential(&context).await?;
333 assert!(cred.is_some());
334 let cred = cred.unwrap();
335 assert_eq!(cred.access_key_id, "PROFILE1ACCESSKEYID");
336 assert_eq!(cred.secret_access_key, "PROFILE1SECRETACCESSKEY");
337 assert!(cred.session_token.is_none());
338
339 Ok(())
340 }
341
342 #[tokio::test]
343 async fn test_profile_env_fallback() -> anyhow::Result<()> {
344 let _ = env_logger::builder().is_test(true).try_init();
345
346 let tmp_dir = tempdir()?;
347 let file_path = tmp_dir.path().join("credentials");
348 let mut tmp_file = File::create(&file_path)?;
349 writeln!(tmp_file, "[default]")?;
350 writeln!(tmp_file, "aws_access_key_id = DEFAULTACCESSKEYID")?;
351 writeln!(tmp_file, "aws_secret_access_key = DEFAULTSECRETACCESSKEY")?;
352 writeln!(tmp_file)?;
353 writeln!(tmp_file, "[profile1]")?;
354 writeln!(tmp_file, "aws_access_key_id = PROFILE1ACCESSKEYID")?;
355 writeln!(tmp_file, "aws_secret_access_key = PROFILE1SECRETACCESSKEY")?;
356
357 let context = Context::new()
358 .with_file_read(TokioFileRead)
359 .with_http_send(ReqwestHttpSend::default())
360 .with_env(StaticEnv {
361 home_dir: None,
362 envs: HashMap::from([(AWS_PROFILE.to_string(), "profile1".to_string())]),
363 });
364
365 let provider =
366 ProfileCredentialProvider::new().with_credentials_file(file_path.to_str().unwrap());
367 let cred = provider.provide_credential(&context).await?;
368 assert!(cred.is_some());
369 let cred = cred.unwrap();
370 assert_eq!(cred.access_key_id, "PROFILE1ACCESSKEYID");
371 assert_eq!(cred.secret_access_key, "PROFILE1SECRETACCESSKEY");
372
373 Ok(())
374 }
375
376 #[tokio::test]
377 async fn test_explicit_profile_overrides_env() -> anyhow::Result<()> {
378 let _ = env_logger::builder().is_test(true).try_init();
379
380 let tmp_dir = tempdir()?;
381 let file_path = tmp_dir.path().join("credentials");
382 let mut tmp_file = File::create(&file_path)?;
383 writeln!(tmp_file, "[default]")?;
384 writeln!(tmp_file, "aws_access_key_id = DEFAULTACCESSKEYID")?;
385 writeln!(tmp_file, "aws_secret_access_key = DEFAULTSECRETACCESSKEY")?;
386 writeln!(tmp_file)?;
387 writeln!(tmp_file, "[profile1]")?;
388 writeln!(tmp_file, "aws_access_key_id = PROFILE1ACCESSKEYID")?;
389 writeln!(tmp_file, "aws_secret_access_key = PROFILE1SECRETACCESSKEY")?;
390
391 let context = Context::new()
392 .with_file_read(TokioFileRead)
393 .with_http_send(ReqwestHttpSend::default())
394 .with_env(StaticEnv {
395 home_dir: None,
396 envs: HashMap::from([(AWS_PROFILE.to_string(), "profile1".to_string())]),
397 });
398
399 let provider = ProfileCredentialProvider::new()
400 .with_profile("default")
401 .with_credentials_file(file_path.to_str().unwrap());
402 let cred = provider.provide_credential(&context).await?;
403 assert!(cred.is_some());
404 let cred = cred.unwrap();
405 assert_eq!(cred.access_key_id, "DEFAULTACCESSKEYID");
406 assert_eq!(cred.secret_access_key, "DEFAULTSECRETACCESSKEY");
407
408 Ok(())
409 }
410
411 #[tokio::test]
412 async fn test_profile_missing_credentials() -> anyhow::Result<()> {
413 let context = Context::new()
414 .with_file_read(TokioFileRead)
415 .with_http_send(ReqwestHttpSend::default());
416
417 let provider = ProfileCredentialProvider::new()
418 .with_credentials_file("/non/existent/path")
419 .with_config_file("/non/existent/path");
420 let cred = provider.provide_credential(&context).await?;
421 assert!(cred.is_none());
422
423 Ok(())
424 }
425}