use alien_client_core::{ErrorData, Result};
use alien_error::{AlienError, Context, IntoAlienError};
use aws_credential_types::Credentials;
use serde::Deserialize;
use std::{collections::HashMap, time::Duration};
pub use alien_core::{
AwsClientConfig, AwsCredentials, AwsImpersonationConfig,
AwsServiceOverrides as ServiceOverrides, AwsWebIdentityConfig,
};
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
pub trait AwsClientConfigExt {
async fn from_env(environment_variables: &HashMap<String, String>) -> Result<AwsClientConfig>;
async fn from_std_env() -> Result<AwsClientConfig>;
async fn impersonate(&self, config: AwsImpersonationConfig) -> Result<AwsClientConfig>;
fn get_credentials(&self) -> Credentials;
async fn get_web_identity_credentials(&self) -> Result<AwsClientConfig>;
fn get_service_endpoint(&self, service_name: &str, default_endpoint: &str) -> String;
fn get_service_endpoint_option(&self, service_name: &str) -> Option<&str>;
#[cfg(any(test, feature = "test-utils"))]
fn with_service_overrides(self, overrides: ServiceOverrides) -> Self;
#[cfg(any(test, feature = "test-utils"))]
fn mock() -> Self;
}
pub mod acm;
pub mod apigatewayv2;
pub mod autoscaling;
pub mod aws_request_utils;
pub mod cloudformation;
pub mod codebuild;
pub mod credential_provider;
pub mod dynamodb;
pub mod ec2;
pub mod ecr;
pub mod eks;
pub mod elbv2;
pub mod eventbridge;
pub mod iam;
pub mod lambda;
pub mod s3;
pub mod secrets_manager;
pub mod sqs;
pub mod ssm;
pub mod sts;
const AWS_IMDS_ENDPOINT: &str = "http://169.254.169.254";
const AWS_IMDS_DISCOVERY_TIMEOUT: Duration = Duration::from_millis(500);
const AWS_IMDS_CREDENTIALS_TIMEOUT: Duration = Duration::from_secs(5);
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
impl AwsClientConfigExt for AwsClientConfig {
async fn from_env(environment_variables: &HashMap<String, String>) -> Result<Self> {
let region = resolve_region(environment_variables).await?;
let credentials = resolve_credentials(environment_variables).await?;
let service_overrides =
parse_service_overrides(environment_variables.get("AWS_SERVICE_OVERRIDES_ENDPOINTS"))?;
let account_id = infer_account_id(
environment_variables,
®ion,
&credentials,
service_overrides.as_ref(),
)
.await?;
let config = Self {
account_id,
region,
credentials,
service_overrides,
};
Ok(config)
}
async fn from_std_env() -> Result<Self> {
let env_vars: HashMap<String, String> = std::env::vars().collect();
Self::from_env(&env_vars).await
}
async fn impersonate(&self, config: AwsImpersonationConfig) -> Result<AwsClientConfig> {
use crate::aws::sts::{AssumeRoleRequest, StsApi, StsClient};
use reqwest::Client;
use uuid::Uuid;
let target_account_id = extract_account_id_from_role_arn(&config.role_arn)
.unwrap_or_else(|| self.account_id.clone());
let target_region = config.target_region.unwrap_or_else(|| self.region.clone());
let base_config = self.get_web_identity_credentials().await?;
let sts_client = StsClient::new(Client::new(), base_config);
let session_name = config
.session_name
.unwrap_or_else(|| format!("alien-impersonation-{}", Uuid::new_v4().simple()));
let assume_role_request = AssumeRoleRequest::builder()
.role_arn(config.role_arn)
.role_session_name(session_name)
.maybe_duration_seconds(config.duration_seconds)
.maybe_external_id(config.external_id)
.build();
let response = sts_client.assume_role(assume_role_request).await?;
let credentials = response.assume_role_result.credentials;
Ok(AwsClientConfig {
account_id: target_account_id,
region: target_region,
credentials: AwsCredentials::SessionCredentials {
access_key_id: credentials.access_key_id,
secret_access_key: credentials.secret_access_key,
session_token: credentials.session_token,
expires_at: credentials.expiration,
},
service_overrides: self.service_overrides.clone(),
})
}
fn get_credentials(&self) -> Credentials {
match &self.credentials {
AwsCredentials::AccessKeys {
access_key_id,
secret_access_key,
session_token,
} => Credentials::new(
access_key_id.clone(),
secret_access_key.clone(),
session_token.clone(),
None,
"ProvidedCredentials",
),
AwsCredentials::SessionCredentials {
access_key_id,
secret_access_key,
session_token,
..
} => Credentials::new(
access_key_id.clone(),
secret_access_key.clone(),
Some(session_token.clone()),
None,
"SessionCredentials",
),
AwsCredentials::Imds { .. }
| AwsCredentials::Profile { .. }
| AwsCredentials::WebIdentity { .. } => Credentials::new(
"PLACEHOLDER_ACCESS_KEY".to_string(),
"PLACEHOLDER_SECRET_KEY".to_string(),
None,
None,
"UnresolvedCredentialSource",
),
}
}
async fn get_web_identity_credentials(&self) -> Result<AwsClientConfig> {
match &self.credentials {
AwsCredentials::WebIdentity { config } => {
use crate::aws::sts::{AssumeRoleWithWebIdentityRequest, StsApi, StsClient};
use reqwest::Client;
use uuid::Uuid;
let token = std::fs::read_to_string(&config.web_identity_token_file)
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: format!(
"Failed to read web identity token file: {}",
config.web_identity_token_file
),
errors: None,
})?
.trim()
.to_string();
let temp_config = AwsClientConfig {
account_id: self.account_id.clone(),
region: self.region.clone(),
credentials: AwsCredentials::AccessKeys {
access_key_id: "TEMP".to_string(),
secret_access_key: "TEMP".to_string(),
session_token: None,
},
service_overrides: self.service_overrides.clone(),
};
let sts_client = StsClient::new(Client::new(), temp_config);
let session_name = config
.session_name
.clone()
.unwrap_or_else(|| format!("alien-web-identity-{}", Uuid::new_v4().simple()));
let assume_role_request = AssumeRoleWithWebIdentityRequest::builder()
.role_arn(config.role_arn.clone())
.role_session_name(session_name)
.web_identity_token(token)
.maybe_duration_seconds(config.duration_seconds)
.build();
let response = sts_client
.assume_role_with_web_identity(assume_role_request)
.await?;
let credentials = response.assume_role_with_web_identity_result.credentials;
Ok(AwsClientConfig {
account_id: self.account_id.clone(),
region: self.region.clone(),
credentials: AwsCredentials::SessionCredentials {
access_key_id: credentials.access_key_id,
secret_access_key: credentials.secret_access_key,
session_token: credentials.session_token,
expires_at: credentials.expiration,
},
service_overrides: self.service_overrides.clone(),
})
}
AwsCredentials::Imds { endpoint } => {
let credentials = load_imds_session_credentials(endpoint.as_deref()).await?;
Ok(AwsClientConfig {
account_id: self.account_id.clone(),
region: self.region.clone(),
credentials,
service_overrides: self.service_overrides.clone(),
})
}
AwsCredentials::Profile { name } => {
let credentials = load_profile_session_credentials(name)?;
Ok(AwsClientConfig {
account_id: self.account_id.clone(),
region: self.region.clone(),
credentials,
service_overrides: self.service_overrides.clone(),
})
}
AwsCredentials::AccessKeys { .. } | AwsCredentials::SessionCredentials { .. } => {
Ok(self.clone())
}
}
}
fn get_service_endpoint(&self, service_name: &str, default_endpoint: &str) -> String {
self.service_overrides
.as_ref()
.and_then(|overrides| overrides.endpoints.get(service_name))
.map(|s| s.clone())
.unwrap_or_else(|| default_endpoint.to_string())
}
fn get_service_endpoint_option(&self, service_name: &str) -> Option<&str> {
self.service_overrides
.as_ref()
.and_then(|overrides| overrides.endpoints.get(service_name))
.map(|s| s.as_str())
}
#[cfg(any(test, feature = "test-utils"))]
fn with_service_overrides(mut self, overrides: ServiceOverrides) -> Self {
self.service_overrides = Some(overrides);
self
}
#[cfg(any(test, feature = "test-utils"))]
fn mock() -> Self {
Self {
account_id: "123456789012".to_string(),
region: "us-east-1".to_string(),
credentials: AwsCredentials::AccessKeys {
access_key_id: "AKIAIOSFODNN7EXAMPLE".to_string(),
secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_string(),
session_token: None,
},
service_overrides: None,
}
}
}
async fn resolve_region(environment_variables: &HashMap<String, String>) -> Result<String> {
if let Some(region) = environment_variables.get("AWS_REGION") {
return Ok(region.clone());
}
if let Some(region) = environment_variables.get("AWS_DEFAULT_REGION") {
return Ok(region.clone());
}
let imds_error = if !metadata_disabled(environment_variables) {
match load_imds_region(environment_variables).await {
Ok(region) => return Ok(region),
Err(error) => Some(error),
}
} else {
None
};
let profile = profile_name(environment_variables);
match load_profile_region(&profile) {
Ok(Some(region)) => return Ok(region),
Ok(None) => {}
Err(profile_error) => {
if let Some(imds_error) = imds_error {
return Err(AlienError::new(ErrorData::InvalidClientConfig {
message: format!(
"Failed to resolve AWS region from IMDS and fallback profile '{}': {}; IMDS error: {}",
profile, profile_error, imds_error
),
errors: None,
}));
}
return Err(profile_error);
}
}
Err(AlienError::new(ErrorData::InvalidClientConfig {
message: "Missing AWS region. Set AWS_REGION, AWS_DEFAULT_REGION, or configure a default region in your AWS profile.".to_string(),
errors: None,
}))
}
async fn resolve_credentials(
environment_variables: &HashMap<String, String>,
) -> Result<AwsCredentials> {
if let (Some(role_arn), Some(token_file)) = (
environment_variables.get("AWS_ROLE_ARN"),
environment_variables.get("AWS_WEB_IDENTITY_TOKEN_FILE"),
) {
return Ok(AwsCredentials::WebIdentity {
config: AwsWebIdentityConfig {
role_arn: role_arn.clone(),
session_name: environment_variables.get("AWS_ROLE_SESSION_NAME").cloned(),
web_identity_token_file: token_file.clone(),
duration_seconds: environment_variables
.get("AWS_ROLE_DURATION_SECONDS")
.and_then(|s| s.parse().ok()),
},
});
}
if let (Some(access_key_id), Some(secret_access_key)) = (
environment_variables.get("AWS_ACCESS_KEY_ID"),
environment_variables.get("AWS_SECRET_ACCESS_KEY"),
) {
return Ok(AwsCredentials::AccessKeys {
access_key_id: access_key_id.clone(),
secret_access_key: secret_access_key.clone(),
session_token: environment_variables
.get("AWS_SESSION_TOKEN")
.filter(|token| !token.trim().is_empty())
.cloned(),
});
}
if profile_is_explicit(environment_variables) {
let profile = profile_name(environment_variables);
return Ok(AwsCredentials::Profile { name: profile });
}
let imds_error = if !metadata_disabled(environment_variables) {
match discover_imds_credentials(environment_variables).await {
Ok(()) => {
return Ok(AwsCredentials::Imds {
endpoint: environment_variables
.get("AWS_EC2_METADATA_SERVICE_ENDPOINT")
.cloned(),
})
}
Err(error) => Some(error),
}
} else {
None
};
let profile = profile_name(environment_variables);
match load_profile_session_credentials(&profile) {
Ok(_) => Ok(AwsCredentials::Profile { name: profile }),
Err(profile_error) => {
if let Some(imds_error) = imds_error {
return Err(AlienError::new(ErrorData::InvalidClientConfig {
message: format!(
"Failed to resolve AWS credentials from IMDS and fallback profile '{}': {}; IMDS error: {}",
profile, profile_error, imds_error
),
errors: None,
}));
}
Err(profile_error)
}
}
}
fn profile_is_explicit(environment_variables: &HashMap<String, String>) -> bool {
environment_variables.contains_key("AWS_PROFILE")
|| environment_variables.contains_key("AWS_DEFAULT_PROFILE")
}
fn metadata_disabled(environment_variables: &HashMap<String, String>) -> bool {
environment_variables
.get("AWS_EC2_METADATA_DISABLED")
.map(|value| value.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct AwsImdsCredentials {
access_key_id: String,
secret_access_key: String,
token: String,
expiration: String,
}
async fn discover_imds_credentials(environment_variables: &HashMap<String, String>) -> Result<()> {
let endpoint = environment_variables
.get("AWS_EC2_METADATA_SERVICE_ENDPOINT")
.map(String::as_str);
load_imds_session_credentials(endpoint).await.map(|_| ())
}
async fn load_imds_session_credentials(endpoint: Option<&str>) -> Result<AwsCredentials> {
let endpoint = endpoint.unwrap_or(AWS_IMDS_ENDPOINT).trim_end_matches('/');
let client = reqwest::Client::builder()
.build()
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: "Failed to create AWS IMDS HTTP client".to_string(),
errors: None,
})?;
let token_url = format!("{endpoint}/latest/api/token");
let token = client
.put(&token_url)
.timeout(AWS_IMDS_DISCOVERY_TIMEOUT)
.header("X-aws-ec2-metadata-token-ttl-seconds", "21600")
.send()
.await
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: "Failed to request AWS IMDSv2 token".to_string(),
errors: None,
})?
.error_for_status()
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: "AWS IMDSv2 token request failed".to_string(),
errors: None,
})?
.text()
.await
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: "Failed to read AWS IMDSv2 token".to_string(),
errors: None,
})?;
let role_url = format!("{endpoint}/latest/meta-data/iam/security-credentials/");
let role_name = client
.get(&role_url)
.timeout(AWS_IMDS_DISCOVERY_TIMEOUT)
.header("X-aws-ec2-metadata-token", &token)
.send()
.await
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: "Failed to request AWS IMDS role name".to_string(),
errors: None,
})?
.error_for_status()
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: "AWS IMDS role name request failed".to_string(),
errors: None,
})?
.text()
.await
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: "Failed to read AWS IMDS role name".to_string(),
errors: None,
})?;
let role_name = role_name
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.ok_or_else(|| {
AlienError::new(ErrorData::InvalidClientConfig {
message: "AWS IMDS did not return an IAM role name".to_string(),
errors: None,
})
})?;
let credentials_url = format!("{role_url}{role_name}");
let credentials: AwsImdsCredentials = client
.get(&credentials_url)
.timeout(AWS_IMDS_CREDENTIALS_TIMEOUT)
.header("X-aws-ec2-metadata-token", &token)
.send()
.await
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: "Failed to request AWS IMDS credentials".to_string(),
errors: None,
})?
.error_for_status()
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: "AWS IMDS credentials request failed".to_string(),
errors: None,
})?
.json()
.await
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: "Failed to parse AWS IMDS credentials".to_string(),
errors: None,
})?;
Ok(AwsCredentials::SessionCredentials {
access_key_id: credentials.access_key_id,
secret_access_key: credentials.secret_access_key,
session_token: credentials.token,
expires_at: credentials.expiration,
})
}
async fn load_imds_region(environment_variables: &HashMap<String, String>) -> Result<String> {
let endpoint = environment_variables
.get("AWS_EC2_METADATA_SERVICE_ENDPOINT")
.map(String::as_str)
.unwrap_or(AWS_IMDS_ENDPOINT)
.trim_end_matches('/');
let client = reqwest::Client::builder()
.build()
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: "Failed to create AWS IMDS HTTP client".to_string(),
errors: None,
})?;
let token_url = format!("{endpoint}/latest/api/token");
let token = client
.put(&token_url)
.timeout(AWS_IMDS_DISCOVERY_TIMEOUT)
.header("X-aws-ec2-metadata-token-ttl-seconds", "21600")
.send()
.await
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: "Failed to request AWS IMDSv2 token".to_string(),
errors: None,
})?
.error_for_status()
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: "AWS IMDSv2 token request failed".to_string(),
errors: None,
})?
.text()
.await
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: "Failed to read AWS IMDSv2 token".to_string(),
errors: None,
})?;
let region_url = format!("{endpoint}/latest/meta-data/placement/region");
let region = client
.get(®ion_url)
.timeout(AWS_IMDS_DISCOVERY_TIMEOUT)
.header("X-aws-ec2-metadata-token", token.trim())
.send()
.await
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: "Failed to request AWS IMDS region".to_string(),
errors: None,
})?
.error_for_status()
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: "AWS IMDS region request failed".to_string(),
errors: None,
})?
.text()
.await
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: "Failed to read AWS IMDS region".to_string(),
errors: None,
})?;
let region = region.trim();
if region.is_empty() {
return Err(AlienError::new(ErrorData::InvalidClientConfig {
message: "AWS IMDS did not return a region".to_string(),
errors: None,
}));
}
Ok(region.to_string())
}
fn parse_service_overrides(endpoints_json: Option<&String>) -> Result<Option<ServiceOverrides>> {
if let Some(endpoints_json) = endpoints_json {
let endpoints: HashMap<String, String> = serde_json::from_str(endpoints_json)
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: "Failed to parse AWS_SERVICE_OVERRIDES_ENDPOINTS".to_string(),
errors: None,
})?;
Ok(Some(ServiceOverrides { endpoints }))
} else {
Ok(None)
}
}
async fn infer_account_id(
environment_variables: &HashMap<String, String>,
region: &str,
credentials: &AwsCredentials,
service_overrides: Option<&ServiceOverrides>,
) -> Result<String> {
if let Some(account_id) = environment_variables.get("AWS_ACCOUNT_ID") {
return Ok(account_id.clone());
}
if let Some(role_arn) = environment_variables.get("AWS_ROLE_ARN") {
if let Some(account_id) = extract_account_id_from_role_arn(role_arn) {
return Ok(account_id);
}
}
if let AwsCredentials::WebIdentity { config } = credentials {
if let Some(account_id) = extract_account_id_from_role_arn(&config.role_arn) {
return Ok(account_id);
}
}
use crate::aws::sts::{StsApi, StsClient};
let mut probe_config = AwsClientConfig {
account_id: String::new(),
region: region.to_string(),
credentials: credentials.clone(),
service_overrides: service_overrides.cloned(),
};
if matches!(
probe_config.credentials,
AwsCredentials::WebIdentity { .. }
| AwsCredentials::Imds { .. }
| AwsCredentials::Profile { .. }
) {
probe_config = probe_config.get_web_identity_credentials().await?;
}
let caller_identity = StsClient::new(reqwest::Client::new(), probe_config)
.get_caller_identity()
.await
.context(ErrorData::InvalidClientConfig {
message: "Failed to infer AWS account ID from credentials".to_string(),
errors: None,
})?;
caller_identity
.get_caller_identity_result
.account
.ok_or_else(|| {
AlienError::new(ErrorData::InvalidClientConfig {
message: "Failed to infer AWS account ID from STS caller identity".to_string(),
errors: None,
})
})
}
fn profile_name(environment_variables: &HashMap<String, String>) -> String {
environment_variables
.get("AWS_PROFILE")
.or_else(|| environment_variables.get("AWS_DEFAULT_PROFILE"))
.cloned()
.unwrap_or_else(|| "default".to_string())
}
#[cfg(not(target_arch = "wasm32"))]
fn load_profile_session_credentials(profile: &str) -> Result<AwsCredentials> {
let output = std::process::Command::new("aws")
.args([
"configure",
"export-credentials",
"--profile",
profile,
"--format",
"process",
])
.output()
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: format!("Failed to invoke AWS CLI for profile '{}'", profile),
errors: None,
})?;
if !output.status.success() {
return Err(AlienError::new(ErrorData::InvalidClientConfig {
message: format!(
"Failed to export AWS credentials for profile '{}': {}",
profile,
String::from_utf8_lossy(&output.stderr).trim()
),
errors: None,
}));
}
let exported: AwsCliExportCredentials = serde_json::from_slice(&output.stdout)
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: format!(
"Failed to parse exported AWS credentials for profile '{}'",
profile
),
errors: None,
})?;
if let (Some(session_token), Some(expires_at)) = (exported.session_token, exported.expiration) {
Ok(AwsCredentials::SessionCredentials {
access_key_id: exported.access_key_id,
secret_access_key: exported.secret_access_key,
session_token,
expires_at,
})
} else {
Ok(AwsCredentials::AccessKeys {
access_key_id: exported.access_key_id,
secret_access_key: exported.secret_access_key,
session_token: None,
})
}
}
#[cfg(target_arch = "wasm32")]
fn load_profile_session_credentials(profile: &str) -> Result<AwsCredentials> {
Err(AlienError::new(ErrorData::InvalidClientConfig {
message: format!(
"AWS_PROFILE ('{}') is not supported in wasm builds; provide explicit credentials",
profile
),
errors: None,
}))
}
#[cfg(not(target_arch = "wasm32"))]
fn load_profile_region(profile: &str) -> Result<Option<String>> {
let output = std::process::Command::new("aws")
.args(["configure", "get", "region", "--profile", profile])
.output()
.into_alien_error()
.context(ErrorData::InvalidClientConfig {
message: format!("Failed to invoke AWS CLI for profile '{}'", profile),
errors: None,
})?;
if !output.status.success() {
return Ok(None);
}
let region = String::from_utf8_lossy(&output.stdout).trim().to_string();
if region.is_empty() {
Ok(None)
} else {
Ok(Some(region))
}
}
#[cfg(target_arch = "wasm32")]
fn load_profile_region(_profile: &str) -> Result<Option<String>> {
Ok(None)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct AwsCliExportCredentials {
access_key_id: String,
secret_access_key: String,
session_token: Option<String>,
expiration: Option<String>,
}
fn extract_account_id_from_role_arn(role_arn: &str) -> Option<String> {
let parts: Vec<&str> = role_arn.split(':').collect();
if parts.len() >= 5 && !parts[4].is_empty() {
Some(parts[4].to_string())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
#[test]
fn test_extract_account_id_from_role_arn() {
assert_eq!(
extract_account_id_from_role_arn("arn:aws:iam::123456789012:role/MyRole"),
Some("123456789012".to_string())
);
assert_eq!(
extract_account_id_from_role_arn("arn:aws:iam::987654321098:role/cross-account-role"),
Some("987654321098".to_string())
);
assert_eq!(extract_account_id_from_role_arn("invalid-arn"), None);
assert_eq!(
extract_account_id_from_role_arn("arn:aws:iam:::role/NoAccount"),
None
);
}
#[test]
fn test_profile_name_prefers_aws_profile() {
let mut env = HashMap::new();
env.insert("AWS_PROFILE".to_string(), "primary".to_string());
env.insert("AWS_DEFAULT_PROFILE".to_string(), "fallback".to_string());
assert_eq!(profile_name(&env), "primary".to_string());
}
#[test]
fn test_profile_name_falls_back_to_default() {
let env = HashMap::new();
assert_eq!(profile_name(&env), "default".to_string());
}
#[test]
fn test_profile_name_uses_aws_default_profile() {
let mut env = HashMap::new();
env.insert("AWS_DEFAULT_PROFILE".to_string(), "fallback".to_string());
assert_eq!(profile_name(&env), "fallback".to_string());
}
#[tokio::test]
async fn test_resolve_region_uses_default_region_fallback() {
let mut env = HashMap::new();
env.insert("AWS_DEFAULT_REGION".to_string(), "us-west-2".to_string());
assert_eq!(resolve_region(&env).await.unwrap(), "us-west-2");
}
#[test]
fn test_parse_service_overrides() {
let parsed =
parse_service_overrides(Some(&"{\"sts\":\"http://localhost:4566\"}".to_string()))
.unwrap()
.unwrap();
assert_eq!(
parsed.endpoints.get("sts"),
Some(&"http://localhost:4566".to_string())
);
}
#[tokio::test]
async fn test_resolve_credentials_prefers_explicit_keys() {
let mut env = HashMap::new();
env.insert("AWS_ACCESS_KEY_ID".to_string(), "AKIA123".to_string());
env.insert("AWS_SECRET_ACCESS_KEY".to_string(), "secret".to_string());
env.insert("AWS_SESSION_TOKEN".to_string(), "token".to_string());
env.insert("AWS_PROFILE".to_string(), "should-not-be-used".to_string());
let credentials = resolve_credentials(&env).await.unwrap();
assert_eq!(
credentials,
AwsCredentials::AccessKeys {
access_key_id: "AKIA123".to_string(),
secret_access_key: "secret".to_string(),
session_token: Some("token".to_string()),
}
);
}
#[tokio::test]
async fn test_resolve_credentials_ignores_empty_session_token() {
let mut env = HashMap::new();
env.insert("AWS_ACCESS_KEY_ID".to_string(), "AKIA123".to_string());
env.insert("AWS_SECRET_ACCESS_KEY".to_string(), "secret".to_string());
env.insert("AWS_SESSION_TOKEN".to_string(), "".to_string());
let credentials = resolve_credentials(&env).await.unwrap();
assert_eq!(
credentials,
AwsCredentials::AccessKeys {
access_key_id: "AKIA123".to_string(),
secret_access_key: "secret".to_string(),
session_token: None,
}
);
}
#[tokio::test]
async fn test_from_env_uses_imds_for_region_and_credentials() {
let endpoint = start_mock_imds().await;
let mut env = HashMap::new();
env.insert("AWS_ACCOUNT_ID".to_string(), "123456789012".to_string());
env.insert("AWS_EC2_METADATA_SERVICE_ENDPOINT".to_string(), endpoint);
let config = AwsClientConfig::from_env(&env).await.unwrap();
assert_eq!(config.region, "us-east-1");
assert_eq!(
config.credentials,
AwsCredentials::AccessKeys {
access_key_id: "AKIAIMDS".to_string(),
secret_access_key: "secret".to_string(),
session_token: Some("session".to_string()),
}
);
}
async fn start_mock_imds() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
break;
};
tokio::spawn(async move {
let mut buffer = [0u8; 2048];
let Ok(n) = stream.read(&mut buffer).await else {
return;
};
let request = String::from_utf8_lossy(&buffer[..n]);
let body = if request.starts_with("PUT /latest/api/token ") {
"token".to_string()
} else if request.starts_with("GET /latest/meta-data/placement/region ") {
"us-east-1".to_string()
} else if request
.starts_with("GET /latest/meta-data/iam/security-credentials/ ")
{
"test-role".to_string()
} else if request
.starts_with("GET /latest/meta-data/iam/security-credentials/test-role ")
{
r#"{"AccessKeyId":"AKIAIMDS","SecretAccessKey":"secret","Token":"session"}"#
.to_string()
} else {
let response =
"HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\n\r\n".to_string();
let _ = stream.write_all(response.as_bytes()).await;
return;
};
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(response.as_bytes()).await;
});
}
});
format!("http://{}", addr)
}
}