From 6c654f7707272a20a60e302e6d6d51deefdc8f0c Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Sat, 3 Oct 2026 01:30:53 +0800 Subject: [PATCH 1/3] feat(aws): expose shared configuration and region discovery --- services/aws-core/Cargo.toml | 2 + services/aws-core/README.md | 65 +++ services/aws-core/src/config.rs | 248 +++++++++ services/aws-core/src/imds.rs | 139 +++++ services/aws-core/src/lib.rs | 6 + .../aws-core/src/provide_credential/imds.rs | 97 +--- .../src/provide_credential/process.rs | 40 +- .../src/provide_credential/profile.rs | 96 +--- .../aws-core/src/provide_credential/sso.rs | 40 +- services/aws-core/src/region.rs | 109 ++++ services/aws-core/tests/shared_config.rs | 482 ++++++++++++++++++ services/aws-v4/src/lib.rs | 1 + services/aws-v4a/src/lib.rs | 1 + 13 files changed, 1095 insertions(+), 231 deletions(-) create mode 100644 services/aws-core/src/config.rs create mode 100644 services/aws-core/src/imds.rs create mode 100644 services/aws-core/src/region.rs create mode 100644 services/aws-core/tests/shared_config.rs diff --git a/services/aws-core/Cargo.toml b/services/aws-core/Cargo.toml index 4ce24227..15a081db 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 04ab37dc..017bfe2c 100644 --- a/services/aws-core/README.md +++ b/services/aws-core/README.md @@ -7,3 +7,68 @@ 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(()) +# } +``` + +Profile and file selection use explicit options first, then `AWS_PROFILE`, +`AWS_CONFIG_FILE`, and `AWS_SHARED_CREDENTIALS_FILE`, then `default`, +`~/.aws/config`, and `~/.aws/credentials`. The Context supplies the home directory, +file reader, and environment. Config files use `[default]` and `[profile name]`; +credentials files use `[default]` and `[name]`. Named profiles do not inherit +`default`. During shared configuration loading, credentials-file properties +replace config-file properties with the same name, preserving other properties. +The static credential provider retains its existing complete-credentials-file +precedence and config-file fallback. + +Region precedence is `with_region`, `AWS_REGION`, `AWS_DEFAULT_REGION`, then the +merged profile's `region`. Global endpoint precedence is `with_endpoint_url`, +`AWS_ENDPOINT_URL`, then the merged profile's `endpoint_url`. `Profile::get` +returns the raw merged file property, before these overrides. Service-specific +endpoint variables, `[services ...]` sections, endpoint rules, and +`AWS_IGNORE_CONFIGURED_ENDPOINT_URLS` are not interpreted. The endpoint is a +setting for the caller to use, not an automatically selected signing endpoint. + +Unreadable files and unavailable home directories are treated as absent. +Malformed readable files return an error without including their contents. +Missing values remain `None`; applications choose their own fallback regions. +Calling `load` never queries metadata, even after `with_imds`. `resolve_region` +skips file reads when an explicit or environment region is available, and only +queries metadata when no configured region is found and discovery is enabled. + +`IMDSv2RegionProvider` performs a token request and an authenticated +`/latest/meta-data/placement/region` request, without requiring an IAM role. It +honors `AWS_EC2_METADATA_DISABLED`, `AWS_EC2_METADATA_SERVICE_ENDPOINT`, and +`AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE=IPv6`. An explicit endpoint takes +precedence. The whole lookup has a configurable two-second deadline and no +retries or IMDSv1 fallback. Disabled discovery returns `None`; transport, +protocol, and timeout failures return errors. The Context HTTP adapter must +cooperate with future cancellation. The timer works independently of the +application's async runtime (using browser timers on wasm32). + +See the AWS documentation for [shared configuration file format](https://docs.aws.amazon.com/sdkref/latest/guide/file-format.html) +and [EC2 metadata categories](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-data-categories.html). diff --git a/services/aws-core/src/config.rs b/services/aws-core/src/config.rs new file mode 100644 index 00000000..2edcd3f9 --- /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, + config_file: Option, + credentials_file: Option, + region: Option, + endpoint_url: Option, + imds: Option, +} + +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) -> 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) -> 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) -> Self { + self.credentials_file = Some(path.into()); + self + } + + /// Override region environment variables and profile settings. + pub fn with_region(mut self, region: impl Into) -> 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) -> 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 { + 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 { + 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 { + 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 and endpoint rules are not resolved. + pub async fn load(&self, ctx: &Context) -> Result { + 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> { + 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, + region: Option, + endpoint_url: Option, +} + +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 { + 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"))?; + // AWS values, especially credential_process commands, contain literal quotes and backslashes. + // Keep nested service settings distinct from top-level profile properties. + 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 00000000..13f4039f --- /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, + token: Arc>, +} + +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) -> 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 { + 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 498744e8..ec6db1bf 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 31fd03d9..989b991b 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, - token: Arc>, -} - -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) -> 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 { - { - 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> { - // 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 2fe482f7..29506357 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; use log::debug; use reqsign_core::{Context, Error, ProvideCredential, Result}; use serde::Deserialize; @@ -84,42 +83,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 { - // 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/provide_credential/profile.rs b/services/aws-core/src/provide_credential/profile.rs index 5221a43a..4943182c 100644 --- a/services/aws-core/src/provide_credential/profile.rs +++ b/services/aws-core/src/provide_credential/profile.rs @@ -16,14 +16,10 @@ // under the License. use crate::Credential; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(test)] 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. @@ -77,43 +73,28 @@ impl ProfileCredentialProvider { self } + #[cfg(not(target_arch = "wasm32"))] + fn shared_config(&self) -> crate::SharedConfig { + let mut config = crate::SharedConfig::new(); + if let Some(v) = &self.profile { + config = config.with_profile(v); + } + 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"))] async fn load_from_credentials_file( &self, ctx: &Context, profile: &str, ) -> Result> { - 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 conf = self.shared_config().load_credentials_file(ctx).await?; let props = match conf.section(Some(profile)) { Some(props) => props, @@ -143,41 +124,8 @@ impl ProfileCredentialProvider { ctx: &Context, profile: &str, ) -> Result> { - 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 conf = self.shared_config().load_config_file(ctx).await?; + let section = crate::config::config_section(profile); let props = match conf.section(Some(§ion)) { Some(props) => props, @@ -213,11 +161,7 @@ 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()); + let profile = self.shared_config().profile_name(ctx); // Try credentials file first if let Some(cred) = self.load_from_credentials_file(ctx, &profile).await? { diff --git a/services/aws-core/src/provide_credential/sso.rs b/services/aws-core/src/provide_credential/sso.rs index 9fb6f287..da9fceb0 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 { - // 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 00000000..f2fea13e --- /dev/null +++ b/services/aws-core/src/region.rs @@ -0,0 +1,109 @@ +// 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 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) -> 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> { + 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 { + 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 00000000..fefd090e --- /dev/null +++ b/services/aws-core/tests/shared_config.rs @@ -0,0 +1,482 @@ +// 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>); +impl FileRead for Files { + async fn file_read(&self, path: &str) -> Result> { + 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) -> Result> { + panic!("unexpected network discovery") + } +} +impl FileRead for NoIo { + async fn file_read(&self, _: &str) -> Result> { + panic!("unexpected file read") + } +} +impl reqsign_core::CommandExecute for NoIo { + async fn command_execute(&self, _: &str, _: &[&str]) -> Result { + 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>>, + 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) -> Result> { + 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()) + ); + 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 credential_metadata_still_uses_shared_token_protocol() -> Result<()> { + use reqsign_core::ProvideCredential; + let http = Metadata::default(); + let ctx = context(&[], &[]).with_http_send(http.clone()); + let provider = reqsign_aws_core::IMDSv2CredentialProvider::new(); + let credential = provider.provide_credential(&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!("{provider:?}").contains("secret-imds-token")); + Ok(()) +} + +#[tokio::test] +async fn metadata_tokens_are_reused_only_for_the_same_endpoint() -> Result<()> { + let http = Metadata::default(); + let ctx = context(&[], &[]).with_http_send(http.clone()); + 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 1defd448..fb3cb5ce 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 4475abf0..0523cf8f 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; From cb961d9eb73aa2438de9b77f441ed5afdcb52f12 Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Sat, 3 Oct 2026 01:35:47 +0800 Subject: [PATCH 2/3] fix(aws): preserve process config interpretation during loader reuse --- services/aws-core/src/config.rs | 43 +++++++++++++------ .../src/provide_credential/process.rs | 6 ++- services/aws-core/tests/shared_config.rs | 33 ++++++++++++++ 3 files changed, 68 insertions(+), 14 deletions(-) diff --git a/services/aws-core/src/config.rs b/services/aws-core/src/config.rs index 2edcd3f9..c65bda53 100644 --- a/services/aws-core/src/config.rs +++ b/services/aws-core/src/config.rs @@ -100,11 +100,21 @@ impl SharedConfig { } pub(crate) async fn load_config_file(&self, ctx: &Context) -> Result { + self.load_config_file_with_options(ctx, aws_parse_options()) + .await + } + + pub(crate) async fn load_config_file_with_options( + &self, + ctx: &Context, + options: ParseOption, + ) -> Result { read_ini( ctx, self.config_file.as_deref(), "AWS_CONFIG_FILE", "~/.aws/config", + options, ) .await } @@ -115,6 +125,7 @@ impl SharedConfig { self.credentials_file.as_deref(), "AWS_SHARED_CREDENTIALS_FILE", "~/.aws/credentials", + aws_parse_options(), ) .await } @@ -220,7 +231,23 @@ pub(crate) fn config_section(profile: &str) -> String { } } -async fn read_ini(ctx: &Context, explicit: Option<&str>, env: &str, default: &str) -> Result { +fn aws_parse_options() -> ParseOption { + // Preserve literal commands and keep nested settings distinct from profile properties. + ParseOption { + enabled_quote: false, + enabled_escape: false, + enabled_indented_mutiline_value: true, + ..Default::default() + } +} + +async fn read_ini( + ctx: &Context, + explicit: Option<&str>, + env: &str, + default: &str, + options: ParseOption, +) -> Result { let path = explicit .map(str::to_owned) .or_else(|| ctx.env_var(env)) @@ -233,16 +260,6 @@ async fn read_ini(ctx: &Context, explicit: Option<&str>, env: &str, default: &st }; let content = std::str::from_utf8(&content) .map_err(|_| Error::config_invalid("AWS shared configuration is not UTF-8"))?; - // AWS values, especially credential_process commands, contain literal quotes and backslashes. - // Keep nested service settings distinct from top-level profile properties. - 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")) + Ini::load_from_str_opt(content, options) + .map_err(|_| Error::config_invalid("failed to parse AWS shared configuration")) } diff --git a/services/aws-core/src/provide_credential/process.rs b/services/aws-core/src/provide_credential/process.rs index 29506357..e70012ad 100644 --- a/services/aws-core/src/provide_credential/process.rs +++ b/services/aws-core/src/provide_credential/process.rs @@ -92,7 +92,11 @@ impl ProcessCredentialProvider { } async fn load_command_from_config(&self, ctx: &Context, profile: &str) -> Result { - let conf = crate::SharedConfig::new().load_config_file(ctx).await?; + // The current command tokenizer relies on INI quote handling. Preserve that + // interpretation when sharing file loading with other profile consumers. + let conf = crate::SharedConfig::new() + .load_config_file_with_options(ctx, ini::ParseOption::default()) + .await?; let profile_section = crate::config::config_section(profile); let section = conf.section(Some(profile_section)).ok_or_else(|| { diff --git a/services/aws-core/tests/shared_config.rs b/services/aws-core/tests/shared_config.rs index fefd090e..ecd5b179 100644 --- a/services/aws-core/tests/shared_config.rs +++ b/services/aws-core/tests/shared_config.rs @@ -480,3 +480,36 @@ async fn metadata_tokens_are_reused_only_for_the_same_endpoint() -> Result<()> { assert_eq!(requests[3], "http://other.invalid/latest/api/token"); Ok(()) } + +#[cfg(not(target_arch = "wasm32"))] +#[tokio::test] +async fn sharing_config_loading_preserves_existing_process_invocation() -> Result<()> { + use reqsign_core::{CommandExecute, CommandOutput, ProvideCredential}; + #[derive(Debug)] + struct Command; + impl CommandExecute for Command { + async fn command_execute(&self, program: &str, args: &[&str]) -> Result { + assert_eq!(program, "helper"); + assert_eq!(args, ["--profile", "selected"]); + Ok(CommandOutput { + status: 0, + stdout: br#"{"Version":1,"AccessKeyId":"key","SecretAccessKey":"secret"}"#.to_vec(), + stderr: vec![], + }) + } + } + let ctx = context( + &[( + "/config", + "[profile selected]\ncredential_process = \"helper\" --profile selected\n", + )], + &[("AWS_PROFILE", "selected"), ("AWS_CONFIG_FILE", "/config")], + ) + .with_command_execute(Command); + let credential = reqsign_aws_core::ProcessCredentialProvider::new() + .provide_credential(&ctx) + .await? + .unwrap(); + assert_eq!(credential.access_key_id, "key"); + Ok(()) +} From c7d9d365a8f57c8d5860463236256914c4f0e24b Mon Sep 17 00:00:00 2001 From: Xuanwo Date: Mon, 5 Oct 2026 13:20:59 +0800 Subject: [PATCH 3/3] refactor(aws): simplify shared configuration integration --- services/aws-core/README.md | 41 +------- services/aws-core/src/config.rs | 3 +- .../src/provide_credential/profile.rs | 97 ++++++------------- services/aws-core/src/region.rs | 1 + services/aws-core/tests/shared_config.rs | 62 ++++-------- 5 files changed, 53 insertions(+), 151 deletions(-) diff --git a/services/aws-core/README.md b/services/aws-core/README.md index 017bfe2c..793fdee6 100644 --- a/services/aws-core/README.md +++ b/services/aws-core/README.md @@ -35,40 +35,9 @@ let region = config # } ``` -Profile and file selection use explicit options first, then `AWS_PROFILE`, -`AWS_CONFIG_FILE`, and `AWS_SHARED_CREDENTIALS_FILE`, then `default`, -`~/.aws/config`, and `~/.aws/credentials`. The Context supplies the home directory, -file reader, and environment. Config files use `[default]` and `[profile name]`; -credentials files use `[default]` and `[name]`. Named profiles do not inherit -`default`. During shared configuration loading, credentials-file properties -replace config-file properties with the same name, preserving other properties. -The static credential provider retains its existing complete-credentials-file -precedence and config-file fallback. +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. -Region precedence is `with_region`, `AWS_REGION`, `AWS_DEFAULT_REGION`, then the -merged profile's `region`. Global endpoint precedence is `with_endpoint_url`, -`AWS_ENDPOINT_URL`, then the merged profile's `endpoint_url`. `Profile::get` -returns the raw merged file property, before these overrides. Service-specific -endpoint variables, `[services ...]` sections, endpoint rules, and -`AWS_IGNORE_CONFIGURED_ENDPOINT_URLS` are not interpreted. The endpoint is a -setting for the caller to use, not an automatically selected signing endpoint. - -Unreadable files and unavailable home directories are treated as absent. -Malformed readable files return an error without including their contents. -Missing values remain `None`; applications choose their own fallback regions. -Calling `load` never queries metadata, even after `with_imds`. `resolve_region` -skips file reads when an explicit or environment region is available, and only -queries metadata when no configured region is found and discovery is enabled. - -`IMDSv2RegionProvider` performs a token request and an authenticated -`/latest/meta-data/placement/region` request, without requiring an IAM role. It -honors `AWS_EC2_METADATA_DISABLED`, `AWS_EC2_METADATA_SERVICE_ENDPOINT`, and -`AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE=IPv6`. An explicit endpoint takes -precedence. The whole lookup has a configurable two-second deadline and no -retries or IMDSv1 fallback. Disabled discovery returns `None`; transport, -protocol, and timeout failures return errors. The Context HTTP adapter must -cooperate with future cancellation. The timer works independently of the -application's async runtime (using browser timers on wasm32). - -See the AWS documentation for [shared configuration file format](https://docs.aws.amazon.com/sdkref/latest/guide/file-format.html) -and [EC2 metadata categories](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-data-categories.html). +[`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 index 13310885..289f76f4 100644 --- a/services/aws-core/src/config.rs +++ b/services/aws-core/src/config.rs @@ -124,7 +124,8 @@ impl SharedConfig { /// 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 and endpoint rules are not resolved. + /// 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 { let name = self.profile_name(ctx); let config = self.load_config_file(ctx).await?; diff --git a/services/aws-core/src/provide_credential/profile.rs b/services/aws-core/src/provide_credential/profile.rs index 4943182c..53fac75b 100644 --- a/services/aws-core/src/provide_credential/profile.rs +++ b/services/aws-core/src/provide_credential/profile.rs @@ -16,10 +16,8 @@ // under the License. use crate::Credential; -#[cfg(test)] +#[cfg(all(test, not(target_arch = "wasm32")))] use crate::constants::*; -#[cfg(not(target_arch = "wasm32"))] -use log::debug; use reqsign_core::{Context, ProvideCredential, Result}; /// ProfileCredentialProvider loads AWS credentials from configuration files. @@ -28,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 @@ -87,68 +89,18 @@ impl ProfileCredentialProvider { } config } +} - #[cfg(not(target_arch = "wasm32"))] - async fn load_from_credentials_file( - &self, - ctx: &Context, - profile: &str, - ) -> Result> { - let conf = self.shared_config().load_credentials_file(ctx).await?; - - let props = match conf.section(Some(profile)) { - Some(props) => props, - None => { - debug!("profile {profile} not found in credentials file"); - return Ok(None); - } - }; - - let access_key_id = props.get("aws_access_key_id"); - let secret_access_key = props.get("aws_secret_access_key"); - - match (access_key_id, secret_access_key) { - (Some(ak), Some(sk)) => Ok(Some(Credential { - access_key_id: ak.to_string(), - secret_access_key: sk.to_string(), - session_token: props.get("aws_session_token").map(|s| s.to_string()), - expires_in: None, - })), - _ => Ok(None), - } - } - - #[cfg(not(target_arch = "wasm32"))] - async fn load_from_config_file( - &self, - ctx: &Context, - profile: &str, - ) -> Result> { - let conf = self.shared_config().load_config_file(ctx).await?; - let section = crate::config::config_section(profile); - - 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), - } - } +#[cfg(not(target_arch = "wasm32"))] +fn credential_from_properties(props: &ini::Properties) -> Option { + 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; @@ -161,15 +113,20 @@ impl ProvideCredential for ProfileCredentialProvider { #[cfg(not(target_arch = "wasm32"))] { - let profile = self.shared_config().profile_name(ctx); - - // 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/region.rs b/services/aws-core/src/region.rs index f2fea13e..692f0238 100644 --- a/services/aws-core/src/region.rs +++ b/services/aws-core/src/region.rs @@ -28,6 +28,7 @@ use std::time::Duration; /// 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. diff --git a/services/aws-core/tests/shared_config.rs b/services/aws-core/tests/shared_config.rs index ecd5b179..74e87b7c 100644 --- a/services/aws-core/tests/shared_config.rs +++ b/services/aws-core/tests/shared_config.rs @@ -434,6 +434,17 @@ async fn credential_provider_uses_the_same_profile_and_files() -> Result<()> { 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(()) } @@ -448,23 +459,19 @@ async fn metadata_transport_failure_is_an_error() { } #[tokio::test] -async fn credential_metadata_still_uses_shared_token_protocol() -> Result<()> { - use reqsign_core::ProvideCredential; +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 provider = reqsign_aws_core::IMDSv2CredentialProvider::new(); - let credential = provider.provide_credential(&ctx).await?.unwrap(); + 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!("{provider:?}").contains("secret-imds-token")); - Ok(()) -} + assert!(!format!("{credentials:?}").contains("secret-imds-token")); + http.requests.lock().unwrap().clear(); -#[tokio::test] -async fn metadata_tokens_are_reused_only_for_the_same_endpoint() -> Result<()> { - let http = Metadata::default(); - let ctx = context(&[], &[]).with_http_send(http.clone()); let provider = IMDSv2RegionProvider::new(); provider.resolve_region(&ctx).await?; provider.resolve_region(&ctx).await?; @@ -480,36 +487,3 @@ async fn metadata_tokens_are_reused_only_for_the_same_endpoint() -> Result<()> { assert_eq!(requests[3], "http://other.invalid/latest/api/token"); Ok(()) } - -#[cfg(not(target_arch = "wasm32"))] -#[tokio::test] -async fn sharing_config_loading_preserves_existing_process_invocation() -> Result<()> { - use reqsign_core::{CommandExecute, CommandOutput, ProvideCredential}; - #[derive(Debug)] - struct Command; - impl CommandExecute for Command { - async fn command_execute(&self, program: &str, args: &[&str]) -> Result { - assert_eq!(program, "helper"); - assert_eq!(args, ["--profile", "selected"]); - Ok(CommandOutput { - status: 0, - stdout: br#"{"Version":1,"AccessKeyId":"key","SecretAccessKey":"secret"}"#.to_vec(), - stderr: vec![], - }) - } - } - let ctx = context( - &[( - "/config", - "[profile selected]\ncredential_process = \"helper\" --profile selected\n", - )], - &[("AWS_PROFILE", "selected"), ("AWS_CONFIG_FILE", "/config")], - ) - .with_command_execute(Command); - let credential = reqsign_aws_core::ProcessCredentialProvider::new() - .provide_credential(&ctx) - .await? - .unwrap(); - assert_eq!(credential.access_key_id, "key"); - Ok(()) -}