You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@reqsign.apache.org by xu...@apache.org on 2026/10/05 05:26:22 UTC
(reqsign) branch main updated: feat(aws): expose shared configuration and region discovery (#902)
This is an automated email from the ASF dual-hosted git repository.
Xuanwo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/reqsign.git
The following commit(s) were added to refs/heads/main by this push:
new a550ed9 feat(aws): expose shared configuration and region discovery (#902)
a550ed9 is described below
commit a550ed95ea59cbf3d214d4a81f0bf6f4ab7cea0f
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Mon Oct 5 13:26:18 2026 +0800
feat(aws): expose shared configuration and region discovery (#902)
Consumers need to load an AWS profile's region and global endpoint
without duplicating INI parsing or acquiring credentials. `SharedConfig`
exposes the selected profile, merged properties, and
explicit/environment/file precedence. Profile, process, and SSO
credential providers reuse its file selection and parser; static
credentials retain complete-file precedence without mixing key pairs
across files.
`resolve_region` uses explicit, environment, and profile values before
an opt-in `IMDSv2RegionProvider` fallback. Discovery is independent of
IAM credentials and bounds the complete token-plus-region exchange with
a configurable deadline. The timer is runtime-independent and supports
browser timers on wasm32. Missing values remain absent; malformed-file
and metadata errors remain visible to callers.
Configuration loading has no network or credential execution side
effects. Endpoint settings are exposed for the caller to interpret;
service endpoint rules and role/SSO execution remain outside this API.
Fixes #894.
---
services/aws-core/Cargo.toml | 2 +
services/aws-core/README.md | 34 ++
services/aws-core/src/config.rs | 248 +++++++++++
services/aws-core/src/imds.rs | 139 ++++++
services/aws-core/src/lib.rs | 6 +
services/aws-core/src/provide_credential/imds.rs | 97 +---
.../aws-core/src/provide_credential/process.rs | 48 +-
.../aws-core/src/provide_credential/profile.rs | 175 ++------
services/aws-core/src/provide_credential/sso.rs | 40 +-
services/aws-core/src/region.rs | 110 +++++
services/aws-core/tests/shared_config.rs | 489 +++++++++++++++++++++
services/aws-v4/src/lib.rs | 1 +
services/aws-v4a/src/lib.rs | 1 +
13 files changed, 1090 insertions(+), 300 deletions(-)
diff --git a/services/aws-core/Cargo.toml b/services/aws-core/Cargo.toml
index 4ce2422..15a081d 100644
--- a/services/aws-core/Cargo.toml
+++ b/services/aws-core/Cargo.toml
@@ -28,6 +28,8 @@ rust-version.workspace = true
[dependencies]
bytes = { workspace = true }
+futures = { workspace = true }
+futures-timer = { version = "3", features = ["wasm-bindgen"] }
form_urlencoded = { workspace = true }
hex = { workspace = true }
http = { workspace = true }
diff --git a/services/aws-core/README.md b/services/aws-core/README.md
index 04ab37d..793fdee 100644
--- a/services/aws-core/README.md
+++ b/services/aws-core/README.md
@@ -7,3 +7,37 @@ This crate contains the AWS credential type, algorithm-independent credential
providers, and canonical request primitives shared by SigV4 and SigV4a. Most
users should depend on `reqsign-aws-v4` or `reqsign-aws-v4a`, which re-export
the relevant public types.
+
+## Shared configuration and region discovery
+
+`SharedConfig` reads the selected AWS profile without acquiring credentials,
+executing `credential_process`, or contacting metadata or SSO services. It is
+also re-exported by both signing crates and `reqsign::aws`.
+
+```no_run
+use reqsign_aws_core::{IMDSv2RegionProvider, SharedConfig};
+use reqsign_core::{Context, OsEnv};
+
+# async fn example(ctx: Context) -> reqsign_core::Result<()> {
+// Supply FileRead and HttpSend adapters through Context as needed.
+let ctx = ctx.with_env(OsEnv);
+let config = SharedConfig::new().with_profile("example");
+let profile = config.load(&ctx).await?;
+let endpoint = profile.endpoint_url();
+let configured_region = profile.region();
+
+// Metadata fallback is opt-in and independent of authentication.
+let region = config
+ .with_imds(IMDSv2RegionProvider::new())
+ .resolve_region(&ctx)
+ .await?;
+# Ok(())
+# }
+```
+
+See [`SharedConfig`] for profile/file selection, merge rules, and region/endpoint
+precedence. Missing values remain `None`; applications choose their own defaults.
+[`IMDSv2RegionProvider`] documents the opt-in metadata fallback and its deadline.
+
+[`SharedConfig`]: https://docs.rs/reqsign-aws-core/latest/reqsign_aws_core/struct.SharedConfig.html
+[`IMDSv2RegionProvider`]: https://docs.rs/reqsign-aws-core/latest/reqsign_aws_core/struct.IMDSv2RegionProvider.html
diff --git a/services/aws-core/src/config.rs b/services/aws-core/src/config.rs
new file mode 100644
index 0000000..289f76f
--- /dev/null
+++ b/services/aws-core/src/config.rs
@@ -0,0 +1,248 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::IMDSv2RegionProvider;
+use ini::{Ini, ParseOption};
+use reqsign_core::{Context, Error, Result};
+use std::collections::HashMap;
+use std::fmt;
+
+/// Loads AWS shared configuration without acquiring credentials or making HTTP requests.
+///
+/// Selection precedence is explicit profile/path, the corresponding `AWS_PROFILE`,
+/// `AWS_CONFIG_FILE`, or `AWS_SHARED_CREDENTIALS_FILE` environment variable, then
+/// `default`, `~/.aws/config`, or `~/.aws/credentials`. Named config sections use
+/// `[profile name]`; credentials sections use `[name]`. A named profile does not
+/// inherit `[default]`. Credentials-file properties override config-file properties
+/// individually, while properties present in only one file are retained.
+///
+/// Unreadable files (including missing files) and unavailable home directories are
+/// treated as absent, as in the credential providers. Malformed readable files are
+/// errors. No credential processes, SSO sessions, or role chains are executed.
+#[derive(Clone, Debug, Default)]
+pub struct SharedConfig {
+ profile: Option<String>,
+ config_file: Option<String>,
+ credentials_file: Option<String>,
+ region: Option<String>,
+ endpoint_url: Option<String>,
+ imds: Option<IMDSv2RegionProvider>,
+}
+
+impl SharedConfig {
+ /// Create a loader. Metadata discovery is disabled by default.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Select a profile, overriding `AWS_PROFILE`.
+ pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
+ self.profile = Some(profile.into());
+ self
+ }
+
+ /// Override `AWS_CONFIG_FILE` and the default config path.
+ pub fn with_config_file(mut self, path: impl Into<String>) -> Self {
+ self.config_file = Some(path.into());
+ self
+ }
+
+ /// Override `AWS_SHARED_CREDENTIALS_FILE` and the default credentials path.
+ pub fn with_credentials_file(mut self, path: impl Into<String>) -> Self {
+ self.credentials_file = Some(path.into());
+ self
+ }
+
+ /// Override region environment variables and profile settings.
+ pub fn with_region(mut self, region: impl Into<String>) -> Self {
+ self.region = Some(region.into());
+ self
+ }
+
+ /// Override `AWS_ENDPOINT_URL` and the profile's global `endpoint_url`.
+ pub fn with_endpoint_url(mut self, endpoint: impl Into<String>) -> Self {
+ self.endpoint_url = Some(endpoint.into());
+ self
+ }
+
+ /// Enable metadata fallback for [`Self::resolve_region`] only.
+ pub fn with_imds(mut self, provider: IMDSv2RegionProvider) -> Self {
+ self.imds = Some(provider);
+ self
+ }
+
+ pub(crate) fn profile_name(&self, ctx: &Context) -> String {
+ self.profile
+ .clone()
+ .or_else(|| ctx.env_var("AWS_PROFILE"))
+ .unwrap_or_else(|| "default".into())
+ }
+
+ fn configured_region(&self, ctx: &Context) -> Option<String> {
+ self.region
+ .clone()
+ .or_else(|| ctx.env_var("AWS_REGION"))
+ .or_else(|| ctx.env_var("AWS_DEFAULT_REGION"))
+ }
+
+ pub(crate) async fn load_config_file(&self, ctx: &Context) -> Result<Ini> {
+ read_ini(
+ ctx,
+ self.config_file.as_deref(),
+ "AWS_CONFIG_FILE",
+ "~/.aws/config",
+ )
+ .await
+ }
+
+ pub(crate) async fn load_credentials_file(&self, ctx: &Context) -> Result<Ini> {
+ read_ini(
+ ctx,
+ self.credentials_file.as_deref(),
+ "AWS_SHARED_CREDENTIALS_FILE",
+ "~/.aws/credentials",
+ )
+ .await
+ }
+
+ /// Read and merge the selected profile. This never contacts IMDS, even when enabled.
+ ///
+ /// Region precedence: explicit override, `AWS_REGION`, `AWS_DEFAULT_REGION`,
+ /// merged profile. Endpoint precedence: explicit override, `AWS_ENDPOINT_URL`,
+ /// merged profile. Missing values remain `None`; no application defaults are added.
+ /// Service-specific endpoint settings, `AWS_IGNORE_CONFIGURED_ENDPOINT_URLS`,
+ /// and endpoint rules are not interpreted. Callers decide how to use the setting.
+ pub async fn load(&self, ctx: &Context) -> Result<Profile> {
+ let name = self.profile_name(ctx);
+ let config = self.load_config_file(ctx).await?;
+ let credentials = self.load_credentials_file(ctx).await?;
+ let mut properties = HashMap::new();
+ for section in [
+ config.section(Some(config_section(&name))),
+ credentials.section(Some(&name)),
+ ]
+ .into_iter()
+ .flatten()
+ {
+ properties.extend(section.iter().map(|(k, v)| (k.to_owned(), v.to_owned())));
+ }
+ let region = self
+ .configured_region(ctx)
+ .or_else(|| properties.get("region").cloned());
+ let endpoint_url = self
+ .endpoint_url
+ .clone()
+ .or_else(|| ctx.env_var("AWS_ENDPOINT_URL"))
+ .or_else(|| properties.get("endpoint_url").cloned());
+ Ok(Profile {
+ name,
+ properties,
+ region,
+ endpoint_url,
+ })
+ }
+
+ /// Resolve region independently of credentials, optionally falling back to IMDSv2.
+ ///
+ /// Explicit/environment regions skip file reads. A profile region skips metadata.
+ /// Disabled discovery or an absent region returns `None`; configuration, metadata,
+ /// and timeout errors are returned to the caller without an application fallback.
+ pub async fn resolve_region(&self, ctx: &Context) -> Result<Option<String>> {
+ if let Some(region) = self.configured_region(ctx) {
+ return Ok(Some(region));
+ }
+ if let Some(region) = self.load(ctx).await?.region {
+ return Ok(Some(region));
+ }
+ match &self.imds {
+ Some(imds) => imds.resolve_region(ctx).await,
+ None => Ok(None),
+ }
+ }
+}
+
+/// A selected AWS profile with resolved region and global endpoint settings.
+///
+/// Raw properties may contain secrets. Debug output omits all property values.
+#[derive(Clone)]
+pub struct Profile {
+ name: String,
+ properties: HashMap<String, String>,
+ region: Option<String>,
+ endpoint_url: Option<String>,
+}
+
+impl fmt::Debug for Profile {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("Profile")
+ .field("name", &self.name)
+ .finish_non_exhaustive()
+ }
+}
+
+impl Profile {
+ /// Selected profile name.
+ pub fn name(&self) -> &str {
+ &self.name
+ }
+ /// Merged file property, before explicit or environment overrides.
+ pub fn get(&self, key: &str) -> Option<&str> {
+ self.properties.get(key).map(String::as_str)
+ }
+ /// Region after explicit and environment overrides, without metadata discovery.
+ pub fn region(&self) -> Option<&str> {
+ self.region.as_deref()
+ }
+ /// Global endpoint after explicit and environment overrides. No service rules are applied.
+ pub fn endpoint_url(&self) -> Option<&str> {
+ self.endpoint_url.as_deref()
+ }
+}
+
+pub(crate) fn config_section(profile: &str) -> String {
+ if profile == "default" {
+ "default".into()
+ } else {
+ format!("profile {profile}")
+ }
+}
+
+async fn read_ini(ctx: &Context, explicit: Option<&str>, env: &str, default: &str) -> Result<Ini> {
+ let path = explicit
+ .map(str::to_owned)
+ .or_else(|| ctx.env_var(env))
+ .unwrap_or_else(|| default.into());
+ let Some(path) = ctx.expand_home_dir(&path) else {
+ return Ok(Ini::new());
+ };
+ let Ok(content) = ctx.file_read(&path).await else {
+ return Ok(Ini::new());
+ };
+ let content = std::str::from_utf8(&content)
+ .map_err(|_| Error::config_invalid("AWS shared configuration is not UTF-8"))?;
+ // Commands contain literal quotes/backslashes; nested settings belong to their parent value.
+ Ini::load_from_str_opt(
+ content,
+ ParseOption {
+ enabled_quote: false,
+ enabled_escape: false,
+ enabled_indented_mutiline_value: true,
+ ..Default::default()
+ },
+ )
+ .map_err(|_| Error::config_invalid("failed to parse AWS shared configuration"))
+}
diff --git a/services/aws-core/src/imds.rs b/services/aws-core/src/imds.rs
new file mode 100644
index 0000000..13f4039
--- /dev/null
+++ b/services/aws-core/src/imds.rs
@@ -0,0 +1,139 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::provide_credential::utils::parse_imds_error;
+use bytes::Bytes;
+use http::Method;
+use http::header::CONTENT_LENGTH;
+use reqsign_core::time::Timestamp;
+use reqsign_core::{Context, Error, Result};
+use std::sync::{Arc, Mutex};
+use std::time::Duration;
+
+#[derive(Clone)]
+pub(crate) struct ImdsClient {
+ endpoint: Option<String>,
+ token: Arc<Mutex<(String, String, Timestamp)>>,
+}
+
+impl Default for ImdsClient {
+ fn default() -> Self {
+ Self {
+ endpoint: None,
+ token: Arc::new(Mutex::new((
+ String::new(),
+ String::new(),
+ Timestamp::default(),
+ ))),
+ }
+ }
+}
+
+impl ImdsClient {
+ /// Create a new `ImdsClient` instance.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Set the endpoint for the metadata service.
+ pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
+ self.endpoint = Some(endpoint.into());
+ self
+ }
+}
+
+impl ImdsClient {
+ pub(crate) fn get_endpoint(&self, ctx: &Context) -> String {
+ // First check configured endpoint, then environment, then default
+ self.endpoint.clone().unwrap_or_else(|| {
+ ctx.env_var("AWS_EC2_METADATA_SERVICE_ENDPOINT")
+ .unwrap_or_else(|| {
+ if ctx
+ .env_var("AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE")
+ .is_some_and(|v| v.eq_ignore_ascii_case("IPv6"))
+ {
+ "http://[fd00:ec2::254]".into()
+ } else {
+ "http://169.254.169.254".into()
+ }
+ })
+ })
+ }
+
+ pub(crate) async fn load_ec2_metadata_token(&self, ctx: &Context) -> Result<String> {
+ let endpoint = self.get_endpoint(ctx);
+ {
+ let (cached_endpoint, token, expires_in) =
+ self.token.lock().expect("lock poisoned").clone();
+ if cached_endpoint == endpoint && expires_in > Timestamp::now() {
+ return Ok(token);
+ }
+ }
+
+ let url = format!("{}/latest/api/token", endpoint.trim_end_matches('/'));
+ let req = http::Request::builder()
+ .uri(&url)
+ .method(Method::PUT)
+ .header(CONTENT_LENGTH, "0")
+ // 21600s (6h) is recommended by AWS.
+ .header("x-aws-ec2-metadata-token-ttl-seconds", "21600")
+ .body(Bytes::new())
+ .map_err(|e| {
+ Error::request_invalid("failed to build IMDS token request")
+ .with_source(e)
+ .with_context(format!("url: {url}"))
+ })?;
+
+ let resp = ctx.http_send_as_string(req).await.map_err(|e| {
+ Error::unexpected("failed to connect to IMDS")
+ .with_source(e)
+ .with_context(format!("endpoint: {endpoint}"))
+ .with_context("hint: check if running on EC2 instance")
+ .set_retryable(true)
+ })?;
+
+ if resp.status() != http::StatusCode::OK {
+ return Err(parse_imds_error(
+ "fetch_imds_token",
+ resp.status(),
+ resp.body(),
+ ));
+ }
+ let ec2_token = resp.into_body();
+ // Refresh ten minutes before the token expires.
+ let expires_in = Timestamp::now() + Duration::from_secs(21600) - Duration::from_secs(600);
+
+ {
+ *self.token.lock().expect("lock poisoned") = (endpoint, ec2_token.clone(), expires_in);
+ }
+
+ Ok(ec2_token)
+ }
+}
+
+impl std::fmt::Debug for ImdsClient {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("ImdsClient")
+ .field("endpoint", &self.endpoint)
+ .finish_non_exhaustive()
+ }
+}
+
+pub(crate) fn disabled(ctx: &Context) -> bool {
+ ctx.env_var("AWS_EC2_METADATA_DISABLED")
+ .is_some_and(|v| v.eq_ignore_ascii_case("true"))
+}
diff --git a/services/aws-core/src/lib.rs b/services/aws-core/src/lib.rs
index 498744e..ec6db1b 100644
--- a/services/aws-core/src/lib.rs
+++ b/services/aws-core/src/lib.rs
@@ -19,6 +19,12 @@
pub mod constants;
+mod config;
+pub use config::{Profile, SharedConfig};
+mod imds;
+mod region;
+pub use region::IMDSv2RegionProvider;
+
#[doc(hidden)]
pub mod assume_role;
pub use assume_role::AssumeRoleGrant;
diff --git a/services/aws-core/src/provide_credential/imds.rs b/services/aws-core/src/provide_credential/imds.rs
index 31fd03d..989b991 100644
--- a/services/aws-core/src/provide_credential/imds.rs
+++ b/services/aws-core/src/provide_credential/imds.rs
@@ -16,29 +16,16 @@
// under the License.
use crate::Credential;
+use crate::imds::ImdsClient;
use crate::provide_credential::utils::parse_imds_error;
use bytes::Bytes;
use http::Method;
-use http::header::CONTENT_LENGTH;
-use reqsign_core::time::Timestamp;
use reqsign_core::{Context, Error, ProvideCredential, Result};
use serde::Deserialize;
-use std::sync::{Arc, Mutex};
-use std::time::Duration;
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, Default)]
pub struct IMDSv2CredentialProvider {
- endpoint: Option<String>,
- token: Arc<Mutex<(String, Timestamp)>>,
-}
-
-impl Default for IMDSv2CredentialProvider {
- fn default() -> Self {
- Self {
- endpoint: None,
- token: Arc::new(Mutex::new((String::new(), Timestamp::default()))),
- }
- }
+ client: ImdsClient,
}
impl IMDSv2CredentialProvider {
@@ -46,93 +33,25 @@ impl IMDSv2CredentialProvider {
pub fn new() -> Self {
Self::default()
}
-
/// Set the endpoint for the metadata service.
pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
- self.endpoint = Some(endpoint.into());
+ self.client = self.client.with_endpoint(endpoint);
self
}
}
-impl IMDSv2CredentialProvider {
- fn get_endpoint(&self, ctx: &Context) -> String {
- // First check configured endpoint, then environment, then default
- self.endpoint.clone().unwrap_or_else(|| {
- ctx.env_vars()
- .get("AWS_EC2_METADATA_SERVICE_ENDPOINT")
- .cloned()
- .unwrap_or_else(|| "http://169.254.169.254".into())
- })
- }
-
- async fn load_ec2_metadata_token(&self, ctx: &Context) -> Result<String> {
- {
- let (token, expires_in) = self.token.lock().expect("lock poisoned").clone();
- if expires_in > Timestamp::now() {
- return Ok(token);
- }
- }
-
- let endpoint = self.get_endpoint(ctx);
- let url = format!("{endpoint}/latest/api/token");
- let req = http::Request::builder()
- .uri(&url)
- .method(Method::PUT)
- .header(CONTENT_LENGTH, "0")
- // 21600s (6h) is recommended by AWS.
- .header("x-aws-ec2-metadata-token-ttl-seconds", "21600")
- .body(Bytes::new())
- .map_err(|e| {
- Error::request_invalid("failed to build IMDS token request")
- .with_source(e)
- .with_context(format!("url: {url}"))
- })?;
-
- let resp = ctx.http_send_as_string(req).await.map_err(|e| {
- Error::unexpected("failed to connect to IMDS")
- .with_source(e)
- .with_context("endpoint: {endpoint}")
- .with_context("hint: check if running on EC2 instance")
- .set_retryable(true)
- })?;
-
- if resp.status() != http::StatusCode::OK {
- return Err(parse_imds_error(
- "fetch_imds_token",
- resp.status(),
- resp.body(),
- ));
- }
- let ec2_token = resp.into_body();
- // Set expires_in to 10 minutes to enforce re-read.
- let expires_in = Timestamp::now() + Duration::from_secs(21600) - Duration::from_secs(600);
-
- {
- *self.token.lock().expect("lock poisoned") = (ec2_token.clone(), expires_in);
- }
-
- Ok(ec2_token)
- }
-}
impl ProvideCredential for IMDSv2CredentialProvider {
type Credential = Credential;
async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
- // Check if disabled via environment
- let disabled_env = ctx
- .env_vars()
- .get("AWS_EC2_METADATA_DISABLED")
- .map(|v| v == "true")
- .unwrap_or(false);
-
- if disabled_env {
+ if crate::imds::disabled(ctx) {
return Ok(None);
}
- let token = self.load_ec2_metadata_token(ctx).await?;
+ let token = self.client.load_ec2_metadata_token(ctx).await?;
// List all credentials that node has.
- let endpoint = self.get_endpoint(ctx);
+ let endpoint = self.client.get_endpoint(ctx);
let url = format!("{endpoint}/latest/meta-data/iam/security-credentials/");
let req = http::Request::builder()
.uri(&url)
@@ -171,7 +90,7 @@ impl ProvideCredential for IMDSv2CredentialProvider {
}
// Get the credentials via role_name.
- let endpoint = self.get_endpoint(ctx);
+ let endpoint = self.client.get_endpoint(ctx);
let url = format!("{endpoint}/latest/meta-data/iam/security-credentials/{profile_name}");
let req = http::Request::builder()
.uri(url)
diff --git a/services/aws-core/src/provide_credential/process.rs b/services/aws-core/src/provide_credential/process.rs
index 17d410d..6ea2f86 100644
--- a/services/aws-core/src/provide_credential/process.rs
+++ b/services/aws-core/src/provide_credential/process.rs
@@ -16,7 +16,6 @@
// under the License.
use crate::Credential;
-use ini::{Ini, ParseOption};
use log::debug;
use reqsign_core::{Context, Error, ProvideCredential, Result};
use serde::Deserialize;
@@ -89,50 +88,17 @@ impl ProcessCredentialProvider {
}
// Otherwise, load from config file
- // Priority: 1. self.profile, 2. AWS_PROFILE env var, 3. "default"
- let profile_name = self
- .profile
- .clone()
- .or_else(|| ctx.env_var("AWS_PROFILE"))
- .unwrap_or_else(|| "default".to_string());
+ let mut shared = crate::SharedConfig::new();
+ if let Some(profile) = &self.profile {
+ shared = shared.with_profile(profile);
+ }
+ let profile_name = shared.profile_name(ctx);
self.load_command_from_config(ctx, &profile_name).await
}
async fn load_command_from_config(&self, ctx: &Context, profile: &str) -> Result<String> {
- // Load AWS config file
- let config_path = ctx
- .env_var("AWS_CONFIG_FILE")
- .unwrap_or_else(|| "~/.aws/config".to_string());
-
- let expanded_path = if config_path.starts_with("~/") {
- match ctx.expand_home_dir(&config_path) {
- Some(expanded) => expanded,
- None => return Err(Error::config_invalid("failed to expand home directory")),
- }
- } else {
- config_path
- };
-
- let content = ctx.file_read(&expanded_path).await.map_err(|_| {
- Error::config_invalid(format!("failed to read config file: {expanded_path}"))
- })?;
-
- // The command parser owns quoting; INI parsing must preserve Windows paths.
- let conf = Ini::load_from_str_opt(
- &String::from_utf8_lossy(&content),
- ParseOption {
- enabled_quote: false,
- enabled_escape: false,
- ..Default::default()
- },
- )
- .map_err(|e| Error::config_invalid(format!("failed to parse config file: {e}")))?;
-
- let profile_section = if profile == "default" {
- profile.to_string()
- } else {
- format!("profile {profile}")
- };
+ let conf = crate::SharedConfig::new().load_config_file(ctx).await?;
+ let profile_section = crate::config::config_section(profile);
let section = conf.section(Some(profile_section)).ok_or_else(|| {
Error::config_invalid(format!("profile '{profile}' not found in config"))
diff --git a/services/aws-core/src/provide_credential/profile.rs b/services/aws-core/src/provide_credential/profile.rs
index 5221a43..53fac75 100644
--- a/services/aws-core/src/provide_credential/profile.rs
+++ b/services/aws-core/src/provide_credential/profile.rs
@@ -16,14 +16,8 @@
// under the License.
use crate::Credential;
-#[cfg(not(target_arch = "wasm32"))]
+#[cfg(all(test, 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};
/// ProfileCredentialProvider loads AWS credentials from configuration files.
@@ -32,6 +26,10 @@ use reqsign_core::{Context, ProvideCredential, Result};
/// - `~/.aws/credentials` (or the path specified by `AWS_SHARED_CREDENTIALS_FILE`)
/// - `~/.aws/config` (or the path specified by `AWS_CONFIG_FILE`)
///
+/// A complete access key and secret key pair in the credentials file takes
+/// precedence. Otherwise, the provider tries the config file without combining
+/// credentials across files.
+///
/// The profile to use is determined by:
/// 1. The profile specified via `with_profile()`
/// 2. The `AWS_PROFILE` environment variable
@@ -78,129 +76,31 @@ impl ProfileCredentialProvider {
}
#[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),
+ fn shared_config(&self) -> crate::SharedConfig {
+ let mut config = crate::SharedConfig::new();
+ if let Some(v) = &self.profile {
+ config = config.with_profile(v);
}
- }
-
- #[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),
+ if let Some(v) = &self.config_file {
+ config = config.with_config_file(v);
+ }
+ if let Some(v) = &self.credentials_file {
+ config = config.with_credentials_file(v);
}
+ config
}
}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn credential_from_properties(props: &ini::Properties) -> Option<Credential> {
+ Some(Credential {
+ access_key_id: props.get("aws_access_key_id")?.to_owned(),
+ secret_access_key: props.get("aws_secret_access_key")?.to_owned(),
+ session_token: props.get("aws_session_token").map(str::to_owned),
+ expires_in: None,
+ })
+}
+
impl ProvideCredential for ProfileCredentialProvider {
type Credential = Credential;
@@ -213,19 +113,20 @@ impl ProvideCredential for ProfileCredentialProvider {
#[cfg(not(target_arch = "wasm32"))]
{
- let profile = self
- .profile
- .clone()
- .or_else(|| ctx.env_var(AWS_PROFILE))
- .unwrap_or_else(|| "default".to_string());
-
- // Try credentials file first
- if let Some(cred) = self.load_from_credentials_file(ctx, &profile).await? {
- return Ok(Some(cred));
+ let shared = self.shared_config();
+ let profile = shared.profile_name(ctx);
+ let credentials = shared.load_credentials_file(ctx).await?;
+ if let Some(credential) = credentials
+ .section(Some(&profile))
+ .and_then(credential_from_properties)
+ {
+ return Ok(Some(credential));
}
- // Then try config file
- self.load_from_config_file(ctx, &profile).await
+ let config = shared.load_config_file(ctx).await?;
+ Ok(config
+ .section(Some(crate::config::config_section(&profile)))
+ .and_then(credential_from_properties))
}
}
}
diff --git a/services/aws-core/src/provide_credential/sso.rs b/services/aws-core/src/provide_credential/sso.rs
index 9fb6f28..da9fceb 100644
--- a/services/aws-core/src/provide_credential/sso.rs
+++ b/services/aws-core/src/provide_credential/sso.rs
@@ -17,7 +17,6 @@
use crate::Credential;
use http::{Method, Request, StatusCode};
-use ini::Ini;
use log::{debug, warn};
use reqsign_core::time::Timestamp;
use reqsign_core::{Context, Error, ProvideCredential, Result};
@@ -126,42 +125,17 @@ impl SSOCredentialProvider {
}
// Otherwise, load from config file
- // Priority: 1. self.profile, 2. AWS_PROFILE env var, 3. "default"
- let profile_name = self
- .profile
- .clone()
- .or_else(|| ctx.env_var("AWS_PROFILE"))
- .unwrap_or_else(|| "default".to_string());
+ let mut shared = crate::SharedConfig::new();
+ if let Some(profile) = &self.profile {
+ shared = shared.with_profile(profile);
+ }
+ let profile_name = shared.profile_name(ctx);
self.load_from_config_file(ctx, &profile_name).await
}
async fn load_from_config_file(&self, ctx: &Context, profile: &str) -> Result<SSOConfig> {
- // Load AWS config file
- let config_path = ctx
- .env_var("AWS_CONFIG_FILE")
- .unwrap_or_else(|| "~/.aws/config".to_string());
-
- let expanded_path = if config_path.starts_with("~/") {
- match ctx.expand_home_dir(&config_path) {
- Some(expanded) => expanded,
- None => return Err(Error::config_invalid("failed to expand home directory")),
- }
- } else {
- config_path
- };
-
- let content = ctx.file_read(&expanded_path).await.map_err(|_| {
- Error::config_invalid(format!("failed to read config file: {expanded_path}"))
- })?;
-
- let conf = Ini::load_from_str(&String::from_utf8_lossy(&content))
- .map_err(|e| Error::config_invalid(format!("failed to parse config file: {e}")))?;
-
- let profile_section = if profile == "default" {
- profile.to_string()
- } else {
- format!("profile {profile}")
- };
+ let conf = crate::SharedConfig::new().load_config_file(ctx).await?;
+ let profile_section = crate::config::config_section(profile);
let section = conf.section(Some(profile_section)).ok_or_else(|| {
Error::config_invalid(format!("profile '{profile}' not found in config"))
diff --git a/services/aws-core/src/region.rs b/services/aws-core/src/region.rs
new file mode 100644
index 0000000..692f023
--- /dev/null
+++ b/services/aws-core/src/region.rs
@@ -0,0 +1,110 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::imds::ImdsClient;
+use bytes::Bytes;
+use futures::future::{Either, select};
+use reqsign_core::{Context, Error, Result};
+use std::time::Duration;
+
+/// Discovers an EC2 region without obtaining IAM credentials.
+///
+/// Uses IMDSv2 only: one token request followed by `meta-data/placement/region`.
+/// `AWS_EC2_METADATA_DISABLED=true` disables discovery. Endpoint precedence is
+/// explicit endpoint, `AWS_EC2_METADATA_SERVICE_ENDPOINT`, then the IPv4 metadata
+/// endpoint (or IPv6 when `AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE=IPv6`).
+///
+/// The runtime-independent timer also supports browser timers on wasm32.
+/// The entire discovery has a default two-second deadline with no retries. HTTP
+/// requests use the supplied Context. Timeout cancels the in-flight HTTP future;
+/// the HTTP adapter must cooperate with cancellation and must not block polling.
+#[derive(Debug, Clone)]
+pub struct IMDSv2RegionProvider {
+ client: ImdsClient,
+ timeout: Duration,
+}
+
+impl Default for IMDSv2RegionProvider {
+ fn default() -> Self {
+ Self {
+ client: ImdsClient::new(),
+ timeout: Duration::from_secs(2),
+ }
+ }
+}
+
+impl IMDSv2RegionProvider {
+ /// Create a region provider with a two-second deadline.
+ pub fn new() -> Self {
+ Self::default()
+ }
+ /// Override the metadata service endpoint.
+ pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
+ self.client = self.client.with_endpoint(endpoint);
+ self
+ }
+ /// Set the deadline for the whole discovery, including the token request.
+ pub fn with_timeout(mut self, timeout: Duration) -> Self {
+ self.timeout = timeout;
+ self
+ }
+ /// Return the EC2 region, or `None` when metadata is disabled.
+ ///
+ /// Transport errors, non-success responses, empty/invalid region responses, and
+ /// deadline expiry return errors. No credentials or instance role are required.
+ pub async fn resolve_region(&self, ctx: &Context) -> Result<Option<String>> {
+ if crate::imds::disabled(ctx) {
+ return Ok(None);
+ }
+ let discovery = Box::pin(self.discover(ctx));
+ match select(discovery, futures_timer::Delay::new(self.timeout)).await {
+ Either::Left((result, _)) => result.map(Some),
+ Either::Right(_) => {
+ Err(Error::unexpected("IMDS region discovery timed out").set_retryable(true))
+ }
+ }
+ }
+
+ async fn discover(&self, ctx: &Context) -> Result<String> {
+ let token = self.client.load_ec2_metadata_token(ctx).await?;
+ let endpoint = self.client.get_endpoint(ctx);
+ let request = http::Request::builder()
+ .uri(format!(
+ "{}/latest/meta-data/placement/region",
+ endpoint.trim_end_matches('/')
+ ))
+ .header("x-aws-ec2-metadata-token", token)
+ .body(Bytes::new())
+ .map_err(|_| Error::request_invalid("failed to build IMDS region request"))?;
+ let response = ctx.http_send(request).await?;
+ if response.status() != http::StatusCode::OK {
+ return Err(Error::unexpected("failed to fetch IMDS region")
+ .with_context(format!("status: {}", response.status())));
+ }
+ let region = std::str::from_utf8(response.body())
+ .map_err(|_| Error::unexpected("invalid IMDS region response"))?
+ .trim();
+ if region.is_empty()
+ || !region
+ .bytes()
+ .all(|b| b.is_ascii_alphanumeric() || b == b'-')
+ {
+ return Err(Error::unexpected("invalid IMDS region response"));
+ }
+ Ok(region.to_owned())
+ }
+}
diff --git a/services/aws-core/tests/shared_config.rs b/services/aws-core/tests/shared_config.rs
new file mode 100644
index 0000000..74e87b7
--- /dev/null
+++ b/services/aws-core/tests/shared_config.rs
@@ -0,0 +1,489 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use bytes::Bytes;
+use reqsign_aws_core::{IMDSv2RegionProvider, SharedConfig};
+use reqsign_core::{Context, Error, ErrorKind, FileRead, HttpSend, Result, StaticEnv};
+use std::collections::HashMap;
+use std::sync::{Arc, Mutex};
+use std::time::Duration;
+
+#[derive(Debug, Clone, Default)]
+struct Files(HashMap<String, Vec<u8>>);
+impl FileRead for Files {
+ async fn file_read(&self, path: &str) -> Result<Vec<u8>> {
+ self.0
+ .get(path)
+ .cloned()
+ .ok_or_else(|| Error::unexpected("file absent"))
+ }
+}
+
+#[derive(Debug)]
+struct NoIo;
+impl HttpSend for NoIo {
+ async fn http_send(&self, _: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
+ panic!("unexpected network discovery")
+ }
+}
+impl FileRead for NoIo {
+ async fn file_read(&self, _: &str) -> Result<Vec<u8>> {
+ panic!("unexpected file read")
+ }
+}
+impl reqsign_core::CommandExecute for NoIo {
+ async fn command_execute(&self, _: &str, _: &[&str]) -> Result<reqsign_core::CommandOutput> {
+ panic!("unexpected credential process")
+ }
+}
+
+fn context(files: &[(&str, &str)], env: &[(&str, &str)]) -> Context {
+ Context::new()
+ .with_file_read(Files(
+ files
+ .iter()
+ .map(|(k, v)| (k.to_string(), v.as_bytes().to_vec()))
+ .collect(),
+ ))
+ .with_env(StaticEnv {
+ home_dir: Some("/test-home".into()),
+ envs: env
+ .iter()
+ .map(|(k, v)| (k.to_string(), v.to_string()))
+ .collect(),
+ })
+ .with_http_send(NoIo)
+ .with_command_execute(NoIo)
+}
+
+#[tokio::test]
+async fn selection_merge_and_no_side_effects() -> Result<()> {
+ let ctx = context(
+ &[
+ (
+ "/test-home/.aws/config",
+ "[default]\nregion = default-region\n[profile example]\nregion = config-region\nendpoint_url = https://s3.example.invalid\ncredential_process = forbidden\nsso_session = forbidden\nrole_arn = forbidden\naws_secret_access_key = secret-value\n",
+ ),
+ (
+ "/test-home/.aws/credentials",
+ "[example]\nregion = credentials-region\naws_access_key_id = key\n",
+ ),
+ ],
+ &[("AWS_PROFILE", "example")],
+ );
+ let config = SharedConfig::new().with_imds(IMDSv2RegionProvider::new());
+ let profile = config.load(&ctx).await?;
+ assert_eq!(profile.name(), "example");
+ assert_eq!(profile.region(), Some("credentials-region"));
+ assert_eq!(profile.endpoint_url(), Some("https://s3.example.invalid"));
+ assert_eq!(profile.get("aws_access_key_id"), Some("key"));
+ assert_eq!(profile.get("aws_secret_access_key"), Some("secret-value"));
+ assert!(!format!("{profile:?}").contains("secret-value"));
+ assert_eq!(
+ config.resolve_region(&ctx).await?.as_deref(),
+ Some("credentials-region")
+ );
+ let explicit = SharedConfig::new()
+ .with_profile("default")
+ .load(&ctx)
+ .await?;
+ assert_eq!(explicit.region(), Some("default-region"));
+ let missing = SharedConfig::new()
+ .with_profile("missing")
+ .load(&ctx)
+ .await?;
+ assert_eq!(missing.region(), None);
+ assert_eq!(missing.endpoint_url(), None);
+ Ok(())
+}
+
+#[tokio::test]
+async fn default_profile_and_alternate_paths() -> Result<()> {
+ let ctx = context(
+ &[
+ (
+ "/config",
+ "[default]\nregion = config-region\nendpoint_url = https://config.invalid\n",
+ ),
+ ("/credentials", "[default]\nregion = credentials-region\n"),
+ ("/explicit-config", "[default]\nregion = explicit-region\n"),
+ (
+ "/explicit-credentials",
+ "[default]\nendpoint_url = https://explicit.invalid\n",
+ ),
+ ],
+ &[
+ ("AWS_CONFIG_FILE", "/config"),
+ ("AWS_SHARED_CREDENTIALS_FILE", "/credentials"),
+ ],
+ );
+ let p = SharedConfig::new().load(&ctx).await?;
+ assert_eq!(p.name(), "default");
+ assert_eq!(p.region(), Some("credentials-region"));
+ assert_eq!(p.endpoint_url(), Some("https://config.invalid"));
+ let p = SharedConfig::new()
+ .with_config_file("/explicit-config")
+ .with_credentials_file("/explicit-credentials")
+ .load(&ctx)
+ .await?;
+ assert_eq!(p.region(), Some("explicit-region"));
+ assert_eq!(p.endpoint_url(), Some("https://explicit.invalid"));
+ Ok(())
+}
+
+#[tokio::test]
+async fn override_and_environment_precedence() -> Result<()> {
+ let ctx = context(
+ &[(
+ "/test-home/.aws/config",
+ "[default]\nregion = profile-region\nendpoint_url = https://profile.invalid\n",
+ )],
+ &[
+ ("AWS_REGION", "env-region"),
+ ("AWS_DEFAULT_REGION", "env-default-region"),
+ ("AWS_ENDPOINT_URL", "https://env.invalid"),
+ ],
+ );
+ let p = SharedConfig::new().load(&ctx).await?;
+ assert_eq!(p.region(), Some("env-region"));
+ assert_eq!(p.get("region"), Some("profile-region"));
+ assert_eq!(p.endpoint_url(), Some("https://env.invalid"));
+ let p = SharedConfig::new()
+ .with_region("explicit-region")
+ .with_endpoint_url("https://explicit.invalid")
+ .load(&ctx)
+ .await?;
+ assert_eq!(p.region(), Some("explicit-region"));
+ assert_eq!(p.endpoint_url(), Some("https://explicit.invalid"));
+ let ctx = ctx.with_file_read(NoIo);
+ assert_eq!(
+ SharedConfig::new().resolve_region(&ctx).await?.as_deref(),
+ Some("env-region")
+ );
+ assert_eq!(
+ SharedConfig::new()
+ .with_region("explicit")
+ .resolve_region(&ctx)
+ .await?
+ .as_deref(),
+ Some("explicit")
+ );
+ let ctx = context(&[], &[("AWS_DEFAULT_REGION", "fallback")]).with_file_read(NoIo);
+ assert_eq!(
+ SharedConfig::new().resolve_region(&ctx).await?.as_deref(),
+ Some("fallback")
+ );
+ Ok(())
+}
+
+#[tokio::test]
+async fn missing_malformed_and_literal_values() -> Result<()> {
+ let ctx = context(&[], &[]);
+ assert!(SharedConfig::new().load(&ctx).await?.region().is_none());
+ assert!(SharedConfig::new().resolve_region(&ctx).await?.is_none());
+ let ctx = ctx
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::new(),
+ })
+ .with_file_read(NoIo);
+ assert!(SharedConfig::new().load(&ctx).await?.region().is_none());
+ for path in ["/test-home/.aws/config", "/test-home/.aws/credentials"] {
+ let ctx = context(&[(path, "[unterminated-secret-value")], &[]);
+ let err = SharedConfig::new().load(&ctx).await.unwrap_err();
+ assert_eq!(err.kind(), ErrorKind::ConfigInvalid);
+ assert!(!format!("{err:?}").contains("secret-value"));
+ }
+ let ctx = context(
+ &[(
+ "/test-home/.aws/config",
+ r#"[default]
+credential_process = "C:\Program Files\helper.exe" "argument with spaces"
+s3 =
+ endpoint_url = https://nested.invalid
+region = us-east-1
+"#,
+ )],
+ &[],
+ );
+ let p = SharedConfig::new().load(&ctx).await?;
+ assert_eq!(
+ p.get("credential_process"),
+ Some(r#""C:\Program Files\helper.exe" "argument with spaces""#)
+ );
+ assert_eq!(p.region(), Some("us-east-1"));
+ assert_eq!(p.endpoint_url(), None);
+ Ok(())
+}
+
+#[derive(Debug, Clone)]
+struct Metadata {
+ requests: Arc<Mutex<Vec<String>>>,
+ fail_at: Option<&'static str>,
+ pending_at: Option<&'static str>,
+ region: &'static str,
+}
+impl Default for Metadata {
+ fn default() -> Self {
+ Self {
+ requests: Default::default(),
+ fail_at: None,
+ pending_at: None,
+ region: "us-east-2\n",
+ }
+ }
+}
+impl HttpSend for Metadata {
+ async fn http_send(&self, req: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
+ let path = req.uri().path();
+ self.requests.lock().unwrap().push(req.uri().to_string());
+ if self.pending_at == Some(path) {
+ return std::future::pending().await;
+ }
+ if self.fail_at == Some(path) {
+ return Ok(http::Response::builder()
+ .status(503)
+ .body(Bytes::from_static(b"unavailable"))
+ .unwrap());
+ }
+ let body = match path {
+ "/latest/api/token" => {
+ assert_eq!(req.method(), http::Method::PUT);
+ assert_eq!(
+ req.headers()["x-aws-ec2-metadata-token-ttl-seconds"],
+ "21600"
+ );
+ "secret-imds-token"
+ }
+ "/latest/meta-data/placement/region" => {
+ assert_eq!(req.method(), http::Method::GET);
+ assert_eq!(
+ req.headers()["x-aws-ec2-metadata-token"],
+ "secret-imds-token"
+ );
+ self.region
+ }
+ "/latest/meta-data/iam/security-credentials/" => "test-role",
+ "/latest/meta-data/iam/security-credentials/test-role" => {
+ assert_eq!(
+ req.headers()["x-aws-ec2-metadata-token"],
+ "secret-imds-token"
+ );
+ r#"{"Code":"Success","AccessKeyId":"key","SecretAccessKey":"secret","Token":"session","Expiration":"2099-01-01T00:00:00Z"}"#
+ }
+ _ => panic!("unexpected metadata lookup: {path}"),
+ };
+ Ok(http::Response::new(Bytes::from(body)))
+ }
+}
+
+#[tokio::test]
+async fn metadata_discovery_and_endpoint_selection() -> Result<()> {
+ for (explicit, env, host) in [
+ (None, vec![], "http://169.254.169.254"),
+ (
+ None,
+ vec![("AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE", "IPv6")],
+ "http://[fd00:ec2::254]",
+ ),
+ (
+ None,
+ vec![("AWS_EC2_METADATA_SERVICE_ENDPOINT", "http://env.invalid")],
+ "http://env.invalid",
+ ),
+ (
+ Some("http://explicit.invalid/"),
+ vec![("AWS_EC2_METADATA_SERVICE_ENDPOINT", "http://env.invalid")],
+ "http://explicit.invalid",
+ ),
+ ] {
+ let http = Metadata::default();
+ let ctx = context(&[], &env).with_http_send(http.clone());
+ let mut provider = IMDSv2RegionProvider::new();
+ if let Some(endpoint) = explicit {
+ provider = provider.with_endpoint(endpoint);
+ }
+ let config = SharedConfig::new().with_imds(provider.clone());
+ assert!(config.load(&ctx).await?.region().is_none());
+ assert!(http.requests.lock().unwrap().is_empty());
+ assert_eq!(
+ config.resolve_region(&ctx).await?.as_deref(),
+ Some("us-east-2")
+ );
+ assert_eq!(
+ *http.requests.lock().unwrap(),
+ vec![
+ format!("{host}/latest/api/token"),
+ format!("{host}/latest/meta-data/placement/region")
+ ]
+ );
+ assert!(!format!("{provider:?}").contains("secret-imds-token"));
+ }
+ Ok(())
+}
+
+#[tokio::test]
+async fn disabled_metadata_and_missing_region() -> Result<()> {
+ let ctx = context(&[], &[("AWS_EC2_METADATA_DISABLED", "TRUE")]);
+ let config = SharedConfig::new().with_imds(IMDSv2RegionProvider::new());
+ assert!(config.resolve_region(&ctx).await?.is_none());
+ Ok(())
+}
+
+#[tokio::test]
+async fn metadata_failures_and_deadline_cover_both_requests() {
+ for path in ["/latest/api/token", "/latest/meta-data/placement/region"] {
+ let http = Metadata {
+ fail_at: Some(path),
+ ..Default::default()
+ };
+ let ctx = context(&[], &[]).with_http_send(http.clone());
+ assert!(
+ IMDSv2RegionProvider::new()
+ .resolve_region(&ctx)
+ .await
+ .is_err()
+ );
+ assert!(
+ http.requests
+ .lock()
+ .unwrap()
+ .last()
+ .unwrap()
+ .ends_with(path)
+ );
+ let http = Metadata {
+ pending_at: Some(path),
+ ..Default::default()
+ };
+ let ctx = context(&[], &[]).with_http_send(http.clone());
+ let err = IMDSv2RegionProvider::new()
+ .with_timeout(Duration::from_millis(10))
+ .resolve_region(&ctx)
+ .await
+ .unwrap_err();
+ assert!(err.to_string().contains("timed out"));
+ assert!(
+ http.requests
+ .lock()
+ .unwrap()
+ .last()
+ .unwrap()
+ .ends_with(path)
+ );
+ }
+ for region in ["", "\n", "invalid region"] {
+ let ctx = context(&[], &[]).with_http_send(Metadata {
+ region,
+ ..Default::default()
+ });
+ let err = IMDSv2RegionProvider::new()
+ .resolve_region(&ctx)
+ .await
+ .unwrap_err();
+ assert!(err.to_string().contains("invalid IMDS region"));
+ }
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+#[tokio::test]
+async fn credential_provider_uses_the_same_profile_and_files() -> Result<()> {
+ use reqsign_core::ProvideCredential;
+ let ctx = context(
+ &[
+ (
+ "/config",
+ "[profile selected]\nregion = eu-west-1\naws_access_key_id = config-key\naws_secret_access_key = config-secret\n",
+ ),
+ (
+ "/credentials",
+ "[selected]\naws_access_key_id = credentials-key\naws_secret_access_key = credentials-secret\n",
+ ),
+ ],
+ &[
+ ("AWS_PROFILE", "selected"),
+ ("AWS_CONFIG_FILE", "/config"),
+ ("AWS_SHARED_CREDENTIALS_FILE", "/credentials"),
+ ],
+ );
+ let profile = SharedConfig::new().load(&ctx).await?;
+ let credential = reqsign_aws_core::ProfileCredentialProvider::new()
+ .provide_credential(&ctx)
+ .await?
+ .unwrap();
+ assert_eq!(profile.region(), Some("eu-west-1"));
+ assert_eq!(
+ profile.get("aws_access_key_id"),
+ Some(credential.access_key_id.as_str())
+ );
+ assert_eq!(
+ profile.get("aws_secret_access_key"),
+ Some(credential.secret_access_key.as_str())
+ );
+ let ctx = ctx.with_file_read(Files(HashMap::from([
+ ("/config".into(), b"[profile selected]\naws_access_key_id = config-key\naws_secret_access_key = config-secret\n".to_vec()),
+ ("/credentials".into(), b"[selected]\naws_access_key_id = incomplete-key\naws_session_token = unrelated-token\n".to_vec()),
+ ])));
+ let credential = reqsign_aws_core::ProfileCredentialProvider::new()
+ .provide_credential(&ctx)
+ .await?
+ .unwrap();
+ assert_eq!(credential.access_key_id, "config-key");
+ assert_eq!(credential.secret_access_key, "config-secret");
+ assert_eq!(credential.session_token, None);
+ Ok(())
+}
+
+#[tokio::test]
+async fn metadata_transport_failure_is_an_error() {
+ let ctx = context(&[], &[]).with_http_send(reqsign_core::NoopHttpSend);
+ let err = IMDSv2RegionProvider::new()
+ .resolve_region(&ctx)
+ .await
+ .unwrap_err();
+ assert!(err.to_string().contains("failed to connect to IMDS"));
+}
+
+#[tokio::test]
+async fn metadata_credentials_and_region_share_protocol_with_scoped_tokens() -> Result<()> {
+ let http = Metadata::default();
+ let ctx = context(&[], &[]).with_http_send(http.clone());
+ let credentials = reqsign_aws_core::IMDSv2CredentialProvider::new();
+ let credential = reqsign_core::ProvideCredential::provide_credential(&credentials, &ctx)
+ .await?
+ .unwrap();
+ assert_eq!(credential.access_key_id, "key");
+ assert_eq!(credential.session_token.as_deref(), Some("session"));
+ assert_eq!(http.requests.lock().unwrap().len(), 3);
+ assert!(!format!("{credentials:?}").contains("secret-imds-token"));
+ http.requests.lock().unwrap().clear();
+
+ let provider = IMDSv2RegionProvider::new();
+ provider.resolve_region(&ctx).await?;
+ provider.resolve_region(&ctx).await?;
+ assert_eq!(http.requests.lock().unwrap().len(), 3);
+ let ctx = context(
+ &[],
+ &[("AWS_EC2_METADATA_SERVICE_ENDPOINT", "http://other.invalid")],
+ )
+ .with_http_send(http.clone());
+ provider.resolve_region(&ctx).await?;
+ let requests = http.requests.lock().unwrap();
+ assert_eq!(requests.len(), 5);
+ assert_eq!(requests[3], "http://other.invalid/latest/api/token");
+ Ok(())
+}
diff --git a/services/aws-v4/src/lib.rs b/services/aws-v4/src/lib.rs
index 1defd44..fb3cb5c 100644
--- a/services/aws-v4/src/lib.rs
+++ b/services/aws-v4/src/lib.rs
@@ -173,6 +173,7 @@ pub use reqsign_aws_core::{
DefaultCredentialProviderBuilder, ECSCredentialProvider, EnvCredentialProvider,
IMDSv2CredentialProvider, ProfileCredentialProvider, StaticCredentialProvider,
};
+pub use reqsign_aws_core::{IMDSv2RegionProvider, Profile, SharedConfig};
#[cfg(not(target_arch = "wasm32"))]
pub use reqsign_aws_core::{ProcessCredentialProvider, SSOCredentialProvider};
diff --git a/services/aws-v4a/src/lib.rs b/services/aws-v4a/src/lib.rs
index 4475abf..0523cf8 100644
--- a/services/aws-v4a/src/lib.rs
+++ b/services/aws-v4a/src/lib.rs
@@ -29,6 +29,7 @@ pub use reqsign_aws_core::{
EnvCredentialProvider, IMDSv2CredentialProvider, ProfileCredentialProvider,
StaticCredentialProvider,
};
+pub use reqsign_aws_core::{IMDSv2RegionProvider, Profile, SharedConfig};
#[cfg(not(target_arch = "wasm32"))]
pub use reqsign_aws_core::{ProcessCredentialProvider, SSOCredentialProvider};
pub use sign_request::RequestSigner;
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@reqsign.apache.org
For additional commands, e-mail: commits-help@reqsign.apache.org