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(&section)) {
-            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