use crate::Credential;
#[cfg(not(target_arch = "wasm32"))]
use crate::constants::*;
#[cfg(not(target_arch = "wasm32"))]
use ini::Ini;
#[cfg(not(target_arch = "wasm32"))]
use log::debug;
#[cfg(not(target_arch = "wasm32"))]
use reqsign_core::Error;
use reqsign_core::{Context, ProvideCredential, Result};
#[derive(Debug, Clone)]
pub struct ProfileCredentialProvider {
profile: Option<String>,
config_file: Option<String>,
credentials_file: Option<String>,
}
impl Default for ProfileCredentialProvider {
fn default() -> Self {
Self::new()
}
}
impl ProfileCredentialProvider {
pub fn new() -> Self {
Self {
profile: None,
config_file: None,
credentials_file: None,
}
}
pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
self.profile = Some(profile.into());
self
}
pub fn with_config_file(mut self, path: impl Into<String>) -> Self {
self.config_file = Some(path.into());
self
}
pub fn with_credentials_file(mut self, path: impl Into<String>) -> Self {
self.credentials_file = Some(path.into());
self
}
#[cfg(not(target_arch = "wasm32"))]
async fn load_from_credentials_file(
&self,
ctx: &Context,
profile: &str,
) -> Result<Option<Credential>> {
let path = if let Some(path) = &self.credentials_file {
path.clone()
} else if let Some(path) = ctx.env_var(AWS_SHARED_CREDENTIALS_FILE) {
path
} else {
"~/.aws/credentials".to_string()
};
let expanded_path = if path.starts_with("~/") {
match ctx.expand_home_dir(&path) {
Some(expanded) => expanded,
None => {
debug!("failed to expand homedir for path: {path}");
return Ok(None);
}
}
} else {
path.clone()
};
let content = match ctx.file_read(&expanded_path).await {
Ok(content) => content,
Err(err) => {
debug!("failed to read credentials file {expanded_path}: {err:?}");
return Ok(None);
}
};
let conf = Ini::load_from_str(&String::from_utf8_lossy(&content)).map_err(|e| {
Error::config_invalid("failed to parse credentials file").with_source(e)
})?;
let props = match conf.section(Some(profile)) {
Some(props) => props,
None => {
debug!("profile {profile} not found in credentials file");
return Ok(None);
}
};
let access_key_id = props.get("aws_access_key_id");
let secret_access_key = props.get("aws_secret_access_key");
match (access_key_id, secret_access_key) {
(Some(ak), Some(sk)) => Ok(Some(Credential {
access_key_id: ak.to_string(),
secret_access_key: sk.to_string(),
session_token: props.get("aws_session_token").map(|s| s.to_string()),
expires_in: None,
})),
_ => Ok(None),
}
}
#[cfg(not(target_arch = "wasm32"))]
async fn load_from_config_file(
&self,
ctx: &Context,
profile: &str,
) -> Result<Option<Credential>> {
let path = if let Some(path) = &self.config_file {
path.clone()
} else if let Some(path) = ctx.env_var(AWS_CONFIG_FILE) {
path
} else {
"~/.aws/config".to_string()
};
let expanded_path = if path.starts_with("~/") {
match ctx.expand_home_dir(&path) {
Some(expanded) => expanded,
None => {
debug!("failed to expand homedir for path: {path}");
return Ok(None);
}
}
} else {
path.clone()
};
let content = match ctx.file_read(&expanded_path).await {
Ok(content) => content,
Err(err) => {
debug!("failed to read config file {expanded_path}: {err:?}");
return Ok(None);
}
};
let conf = Ini::load_from_str(&String::from_utf8_lossy(&content))
.map_err(|e| Error::config_invalid("failed to parse config file").with_source(e))?;
let section = match profile {
"default" => "default".to_string(),
x => format!("profile {x}"),
};
let props = match conf.section(Some(§ion)) {
Some(props) => props,
None => {
debug!("section {profile} not found in config file");
return Ok(None);
}
};
let access_key_id = props.get("aws_access_key_id");
let secret_access_key = props.get("aws_secret_access_key");
match (access_key_id, secret_access_key) {
(Some(ak), Some(sk)) => Ok(Some(Credential {
access_key_id: ak.to_string(),
secret_access_key: sk.to_string(),
session_token: props.get("aws_session_token").map(|s| s.to_string()),
expires_in: None,
})),
_ => Ok(None),
}
}
}
impl ProvideCredential for ProfileCredentialProvider {
type Credential = Credential;
async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
#[cfg(target_arch = "wasm32")]
{
let _ = ctx;
Ok(None)
}
#[cfg(not(target_arch = "wasm32"))]
{
let profile = self
.profile
.clone()
.or_else(|| ctx.env_var(AWS_PROFILE))
.unwrap_or_else(|| "default".to_string());
if let Some(cred) = self.load_from_credentials_file(ctx, &profile).await? {
return Ok(Some(cred));
}
self.load_from_config_file(ctx, &profile).await
}
}
}
#[cfg(test)]
#[cfg(not(target_arch = "wasm32"))]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use reqsign_core::StaticEnv;
use reqsign_file_read_tokio::TokioFileRead;
use reqsign_http_send_reqwest::ReqwestHttpSend;
use std::collections::HashMap;
use std::fs::File;
use std::io::Write;
use tempfile::tempdir;
#[tokio::test]
async fn test_profile_from_credentials_file() -> anyhow::Result<()> {
let _ = env_logger::builder().is_test(true).try_init();
let tmp_dir = tempdir()?;
let file_path = tmp_dir.path().join("credentials");
let mut tmp_file = File::create(&file_path)?;
writeln!(tmp_file, "[default]")?;
writeln!(tmp_file, "aws_access_key_id = DEFAULTACCESSKEYID")?;
writeln!(tmp_file, "aws_secret_access_key = DEFAULTSECRETACCESSKEY")?;
writeln!(tmp_file, "aws_session_token = DEFAULTSESSIONTOKEN")?;
writeln!(tmp_file)?;
writeln!(tmp_file, "[profile1]")?;
writeln!(tmp_file, "aws_access_key_id = PROFILE1ACCESSKEYID")?;
writeln!(tmp_file, "aws_secret_access_key = PROFILE1SECRETACCESSKEY")?;
writeln!(tmp_file, "aws_session_token = PROFILE1SESSIONTOKEN")?;
let context = Context::new()
.with_file_read(TokioFileRead)
.with_http_send(ReqwestHttpSend::default())
.with_env(StaticEnv {
home_dir: None,
envs: HashMap::new(),
});
let provider =
ProfileCredentialProvider::new().with_credentials_file(file_path.to_str().unwrap());
let cred = provider.provide_credential(&context).await?;
assert!(cred.is_some());
let cred = cred.unwrap();
assert_eq!(cred.access_key_id, "DEFAULTACCESSKEYID");
assert_eq!(cred.secret_access_key, "DEFAULTSECRETACCESSKEY");
assert_eq!(cred.session_token, Some("DEFAULTSESSIONTOKEN".to_string()));
let provider = ProfileCredentialProvider::new()
.with_profile("profile1")
.with_credentials_file(file_path.to_str().unwrap());
let cred = provider.provide_credential(&context).await?;
assert!(cred.is_some());
let cred = cred.unwrap();
assert_eq!(cred.access_key_id, "PROFILE1ACCESSKEYID");
assert_eq!(cred.secret_access_key, "PROFILE1SECRETACCESSKEY");
assert_eq!(cred.session_token, Some("PROFILE1SESSIONTOKEN".to_string()));
Ok(())
}
#[tokio::test]
async fn test_profile_from_config_file() -> anyhow::Result<()> {
let _ = env_logger::builder().is_test(true).try_init();
let tmp_dir = tempdir()?;
let file_path = tmp_dir.path().join("config");
let mut tmp_file = File::create(&file_path)?;
writeln!(tmp_file, "[default]")?;
writeln!(tmp_file, "aws_access_key_id = DEFAULTACCESSKEYID")?;
writeln!(tmp_file, "aws_secret_access_key = DEFAULTSECRETACCESSKEY")?;
writeln!(tmp_file)?;
writeln!(tmp_file, "[profile profile1]")?;
writeln!(tmp_file, "aws_access_key_id = PROFILE1ACCESSKEYID")?;
writeln!(tmp_file, "aws_secret_access_key = PROFILE1SECRETACCESSKEY")?;
let context = Context::new()
.with_file_read(TokioFileRead)
.with_http_send(ReqwestHttpSend::default())
.with_env(StaticEnv {
home_dir: None,
envs: HashMap::new(),
});
let provider =
ProfileCredentialProvider::new().with_config_file(file_path.to_str().unwrap());
let cred = provider.provide_credential(&context).await?;
assert!(cred.is_some());
let cred = cred.unwrap();
assert_eq!(cred.access_key_id, "DEFAULTACCESSKEYID");
assert_eq!(cred.secret_access_key, "DEFAULTSECRETACCESSKEY");
assert!(cred.session_token.is_none());
let provider = ProfileCredentialProvider::new()
.with_profile("profile1")
.with_config_file(file_path.to_str().unwrap());
let cred = provider.provide_credential(&context).await?;
assert!(cred.is_some());
let cred = cred.unwrap();
assert_eq!(cred.access_key_id, "PROFILE1ACCESSKEYID");
assert_eq!(cred.secret_access_key, "PROFILE1SECRETACCESSKEY");
assert!(cred.session_token.is_none());
Ok(())
}
#[tokio::test]
async fn test_profile_env_fallback() -> anyhow::Result<()> {
let _ = env_logger::builder().is_test(true).try_init();
let tmp_dir = tempdir()?;
let file_path = tmp_dir.path().join("credentials");
let mut tmp_file = File::create(&file_path)?;
writeln!(tmp_file, "[default]")?;
writeln!(tmp_file, "aws_access_key_id = DEFAULTACCESSKEYID")?;
writeln!(tmp_file, "aws_secret_access_key = DEFAULTSECRETACCESSKEY")?;
writeln!(tmp_file)?;
writeln!(tmp_file, "[profile1]")?;
writeln!(tmp_file, "aws_access_key_id = PROFILE1ACCESSKEYID")?;
writeln!(tmp_file, "aws_secret_access_key = PROFILE1SECRETACCESSKEY")?;
let context = Context::new()
.with_file_read(TokioFileRead)
.with_http_send(ReqwestHttpSend::default())
.with_env(StaticEnv {
home_dir: None,
envs: HashMap::from([(AWS_PROFILE.to_string(), "profile1".to_string())]),
});
let provider =
ProfileCredentialProvider::new().with_credentials_file(file_path.to_str().unwrap());
let cred = provider.provide_credential(&context).await?;
assert!(cred.is_some());
let cred = cred.unwrap();
assert_eq!(cred.access_key_id, "PROFILE1ACCESSKEYID");
assert_eq!(cred.secret_access_key, "PROFILE1SECRETACCESSKEY");
Ok(())
}
#[tokio::test]
async fn test_explicit_profile_overrides_env() -> anyhow::Result<()> {
let _ = env_logger::builder().is_test(true).try_init();
let tmp_dir = tempdir()?;
let file_path = tmp_dir.path().join("credentials");
let mut tmp_file = File::create(&file_path)?;
writeln!(tmp_file, "[default]")?;
writeln!(tmp_file, "aws_access_key_id = DEFAULTACCESSKEYID")?;
writeln!(tmp_file, "aws_secret_access_key = DEFAULTSECRETACCESSKEY")?;
writeln!(tmp_file)?;
writeln!(tmp_file, "[profile1]")?;
writeln!(tmp_file, "aws_access_key_id = PROFILE1ACCESSKEYID")?;
writeln!(tmp_file, "aws_secret_access_key = PROFILE1SECRETACCESSKEY")?;
let context = Context::new()
.with_file_read(TokioFileRead)
.with_http_send(ReqwestHttpSend::default())
.with_env(StaticEnv {
home_dir: None,
envs: HashMap::from([(AWS_PROFILE.to_string(), "profile1".to_string())]),
});
let provider = ProfileCredentialProvider::new()
.with_profile("default")
.with_credentials_file(file_path.to_str().unwrap());
let cred = provider.provide_credential(&context).await?;
assert!(cred.is_some());
let cred = cred.unwrap();
assert_eq!(cred.access_key_id, "DEFAULTACCESSKEYID");
assert_eq!(cred.secret_access_key, "DEFAULTSECRETACCESSKEY");
Ok(())
}
#[tokio::test]
async fn test_profile_missing_credentials() -> anyhow::Result<()> {
let context = Context::new()
.with_file_read(TokioFileRead)
.with_http_send(ReqwestHttpSend::default());
let provider = ProfileCredentialProvider::new()
.with_credentials_file("/non/existent/path")
.with_config_file("/non/existent/path");
let cred = provider.provide_credential(&context).await?;
assert!(cred.is_none());
Ok(())
}
}