You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by xu...@apache.org on 2026/03/18 17:48:30 UTC
(opendal-reqsign) branch main updated: feat(aliyun-oss): add shared credentials and config file providers (#722)
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/opendal-reqsign.git
The following commit(s) were added to refs/heads/main by this push:
new 8139992 feat(aliyun-oss): add shared credentials and config file providers (#722)
8139992 is described below
commit 81399926577bbc7913c9d10192bc1a56f3f35265
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Thu Mar 19 01:48:25 2026 +0800
feat(aliyun-oss): add shared credentials and config file providers (#722)
This stacked PR builds on #720 by adding the two Alibaba shared file
sources that are distinct from the OSS profile file: shared
`credentials.ini` and `config.json` providers.
It keeps the slot boundaries explicit (`credentials_file` and
`config_file`), limits this PR to direct static modes, and adds
precedence tests showing these file-based providers slot cleanly in
front of OIDC. Related to #684.
---
services/aliyun-oss/README.md | 53 +++-
services/aliyun-oss/src/constants.rs | 3 +
services/aliyun-oss/src/lib.rs | 18 +-
.../src/provide_credential/config_file.rs | 339 +++++++++++++++++++++
.../src/provide_credential/credentials_file.rs | 335 ++++++++++++++++++++
.../aliyun-oss/src/provide_credential/default.rs | 256 +++++++++++++++-
services/aliyun-oss/src/provide_credential/mod.rs | 6 +
7 files changed, 1001 insertions(+), 9 deletions(-)
diff --git a/services/aliyun-oss/README.md b/services/aliyun-oss/README.md
index fd7da7c..908c9b8 100644
--- a/services/aliyun-oss/README.md
+++ b/services/aliyun-oss/README.md
@@ -10,7 +10,8 @@ This crate provides signing support for Alibaba Cloud Object Storage Service (OS
```rust
use reqsign_aliyun_oss::{
- AssumeRoleWithOidcCredentialProvider, DefaultCredentialProvider, EnvCredentialProvider,
+ AssumeRoleWithOidcCredentialProvider, ConfigFileCredentialProvider,
+ CredentialsFileCredentialProvider, DefaultCredentialProvider, EnvCredentialProvider,
OssProfileCredentialProvider, RequestSigner, StaticCredentialProvider,
};
use reqsign_core::{Context, Result, Signer};
@@ -26,6 +27,8 @@ async fn main() -> Result<()> {
let loader = DefaultCredentialProvider::builder()
.env(EnvCredentialProvider::new())
.oss_profile(OssProfileCredentialProvider::new())
+ .credentials_file(CredentialsFileCredentialProvider::new())
+ .config_file(ConfigFileCredentialProvider::new())
.oidc(AssumeRoleWithOidcCredentialProvider::new())
.build();
@@ -57,7 +60,7 @@ async fn main() -> Result<()> {
## Features
- **HMAC-SHA1 Signing**: Complete implementation of Aliyun's signing algorithm
-- **Multiple Credential Sources**: Environment variables, OSS profile files, and OIDC-based STS exchange
+- **Multiple Credential Sources**: Environment variables, OSS profile files, Alibaba shared credential/config files, and OIDC-based STS exchange
- **STS Support**: Temporary credentials via Security Token Service
- **All OSS Operations**: Object, bucket, and multipart operations
@@ -106,6 +109,50 @@ access_key_secret = prod-access-key-secret
Override the file path with `OSS_CREDENTIAL_PROFILES_FILE` and the selected profile with `OSS_PROFILE`.
+### Alibaba Shared Credentials File
+
+Reads from `~/.alibabacloud/credentials.ini` first and falls back to `~/.aliyun/credentials.ini`:
+
+```ini
+[default]
+enable = true
+type = access_key
+access_key_id = your-access-key-id
+access_key_secret = your-access-key-secret
+
+[prod]
+enable = true
+type = sts_token
+access_key_id = prod-access-key-id
+access_key_secret = prod-access-key-secret
+sts_token = optional-session-token
+```
+
+Override the file path with `ALIBABA_CLOUD_CREDENTIALS_FILE` and the selected profile with `ALIBABA_CLOUD_PROFILE`.
+
+Only direct static modes are loaded in this crate today: `access_key` and `sts_token`.
+
+### Alibaba CLI Config File
+
+Reads from `~/.aliyun/config.json` by default:
+
+```json
+{
+ "current": "default",
+ "profiles": [
+ {
+ "name": "default",
+ "mode": "AK",
+ "access_key_id": "your-access-key-id",
+ "access_key_secret": "your-access-key-secret"
+ }
+ ]
+}
+```
+
+Override the file path with `ALIBABA_CLOUD_CONFIG_FILE` and the selected profile with `ALIBABA_CLOUD_PROFILE`.
+
+Only direct static modes are loaded in this crate today: `AK` and `StsToken`.
### STS AssumeRole with OIDC
For Kubernetes/ACK environments:
@@ -118,6 +165,8 @@ use reqsign_aliyun_oss::{AssumeRoleWithOidcCredentialProvider, DefaultCredential
let loader = DefaultCredentialProvider::builder()
.no_env()
.no_oss_profile()
+ .no_credentials_file()
+ .no_config_file()
.oidc(
AssumeRoleWithOidcCredentialProvider::new().with_role_session_name("my-session"),
)
diff --git a/services/aliyun-oss/src/constants.rs b/services/aliyun-oss/src/constants.rs
index ab6b857..3657fa2 100644
--- a/services/aliyun-oss/src/constants.rs
+++ b/services/aliyun-oss/src/constants.rs
@@ -19,6 +19,9 @@
pub const ALIBABA_CLOUD_ACCESS_KEY_ID: &str = "ALIBABA_CLOUD_ACCESS_KEY_ID";
pub const ALIBABA_CLOUD_ACCESS_KEY_SECRET: &str = "ALIBABA_CLOUD_ACCESS_KEY_SECRET";
pub const ALIBABA_CLOUD_SECURITY_TOKEN: &str = "ALIBABA_CLOUD_SECURITY_TOKEN";
+pub const ALIBABA_CLOUD_PROFILE: &str = "ALIBABA_CLOUD_PROFILE";
+pub const ALIBABA_CLOUD_CREDENTIALS_FILE: &str = "ALIBABA_CLOUD_CREDENTIALS_FILE";
+pub const ALIBABA_CLOUD_CONFIG_FILE: &str = "ALIBABA_CLOUD_CONFIG_FILE";
pub const OSS_ACCESS_KEY_ID: &str = "OSS_ACCESS_KEY_ID";
pub const OSS_ACCESS_KEY_SECRET: &str = "OSS_ACCESS_KEY_SECRET";
pub const OSS_SESSION_TOKEN: &str = "OSS_SESSION_TOKEN";
diff --git a/services/aliyun-oss/src/lib.rs b/services/aliyun-oss/src/lib.rs
index e242321..8bda35c 100644
--- a/services/aliyun-oss/src/lib.rs
+++ b/services/aliyun-oss/src/lib.rs
@@ -33,7 +33,8 @@
//!
//! ```no_run
//! use reqsign_aliyun_oss::{
-//! AssumeRoleWithOidcCredentialProvider, DefaultCredentialProvider, EnvCredentialProvider,
+//! AssumeRoleWithOidcCredentialProvider, ConfigFileCredentialProvider,
+//! CredentialsFileCredentialProvider, DefaultCredentialProvider, EnvCredentialProvider,
//! OssProfileCredentialProvider, RequestSigner, StaticCredentialProvider,
//! };
//! use reqsign_core::{Context, Signer, Result};
@@ -47,10 +48,13 @@
//! .with_file_read(TokioFileRead::default())
//! .with_http_send(ReqwestHttpSend::default());
//!
-//! // Create credential loader with the default env -> oidc chain.
+//! // Create credential loader with the default env -> OSS profile ->
+//! // shared credentials file -> config file -> oidc chain.
//! let loader = DefaultCredentialProvider::builder()
//! .env(EnvCredentialProvider::new())
//! .oss_profile(OssProfileCredentialProvider::new())
+//! .credentials_file(CredentialsFileCredentialProvider::new())
+//! .config_file(ConfigFileCredentialProvider::new())
//! .oidc(AssumeRoleWithOidcCredentialProvider::new())
//! .build();
//!
@@ -98,10 +102,11 @@
//! The crate can load credentials from the OSS profile file
//! (typically `~/.oss/credentials`).
//!
-//! ### ECS RAM Role
+//! ### Alibaba Shared Credential and Config Files
//!
-//! When running on Alibaba Cloud ECS instances with RAM roles attached,
-//! credentials are automatically obtained from the metadata service.
+//! The crate can also load static credentials from Alibaba shared SDK files
+//! (`~/.alibabacloud/credentials.ini`, `~/.aliyun/credentials.ini`) and the
+//! Alibaba CLI config file (`~/.aliyun/config.json`).
//!
//! ## OSS Operations
//!
@@ -153,6 +158,9 @@
//! // Optionally set ALIBABA_CLOUD_ROLE_SESSION_NAME
//! let loader = DefaultCredentialProvider::builder()
//! .no_env()
+//! .no_oss_profile()
+//! .no_credentials_file()
+//! .no_config_file()
//! .oidc(
//! AssumeRoleWithOidcCredentialProvider::new().with_role_session_name("my-session"),
//! )
diff --git a/services/aliyun-oss/src/provide_credential/config_file.rs b/services/aliyun-oss/src/provide_credential/config_file.rs
new file mode 100644
index 0000000..bbd5335
--- /dev/null
+++ b/services/aliyun-oss/src/provide_credential/config_file.rs
@@ -0,0 +1,339 @@
+// 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::Credential;
+#[cfg(not(target_arch = "wasm32"))]
+use crate::constants::{ALIBABA_CLOUD_CONFIG_FILE, ALIBABA_CLOUD_PROFILE};
+#[cfg(not(target_arch = "wasm32"))]
+use log::debug;
+#[cfg(not(target_arch = "wasm32"))]
+use reqsign_core::Error;
+use reqsign_core::{Context, ProvideCredential, Result};
+#[cfg(not(target_arch = "wasm32"))]
+use serde::Deserialize;
+
+/// ConfigFileCredentialProvider loads credentials from Alibaba Cloud CLI config files.
+///
+/// This provider reads credentials from `~/.aliyun/config.json` by default. The
+/// file path can be overridden by `ALIBABA_CLOUD_CONFIG_FILE`, and the selected
+/// profile can be overridden by `ALIBABA_CLOUD_PROFILE`.
+#[derive(Debug, Clone)]
+pub struct ConfigFileCredentialProvider {
+ profile: Option<String>,
+ config_file: Option<String>,
+}
+
+impl Default for ConfigFileCredentialProvider {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl ConfigFileCredentialProvider {
+ /// Create a new config file provider.
+ pub fn new() -> Self {
+ Self {
+ profile: None,
+ config_file: None,
+ }
+ }
+
+ /// Set the profile name to use when `ALIBABA_CLOUD_PROFILE` is not set.
+ pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
+ self.profile = Some(profile.into());
+ self
+ }
+
+ /// Set the path to the config file.
+ pub fn with_config_file(mut self, path: impl Into<String>) -> Self {
+ self.config_file = Some(path.into());
+ self
+ }
+
+ #[cfg(not(target_arch = "wasm32"))]
+ async fn load_config(&self, ctx: &Context, path: &str) -> Result<Option<ConfigFile>> {
+ 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.to_string()
+ };
+
+ let content = match ctx.file_read(&expanded_path).await {
+ Ok(content) => content,
+ Err(err) => {
+ debug!("failed to read Alibaba config file {expanded_path}: {err:?}");
+ return Ok(None);
+ }
+ };
+
+ let config: ConfigFile = serde_json::from_slice(&content).map_err(|e| {
+ Error::config_invalid("failed to parse Alibaba config file").with_source(e)
+ })?;
+
+ Ok(Some(config))
+ }
+}
+
+impl ProvideCredential for ConfigFileCredentialProvider {
+ type Credential = Credential;
+
+ async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
+ #[cfg(target_arch = "wasm32")]
+ {
+ let _ = ctx;
+ Ok(None)
+ }
+
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ let path = self
+ .config_file
+ .clone()
+ .or_else(|| ctx.env_var(ALIBABA_CLOUD_CONFIG_FILE))
+ .unwrap_or_else(|| "~/.aliyun/config.json".to_string());
+
+ let Some(config) = self.load_config(ctx, &path).await? else {
+ return Ok(None);
+ };
+
+ let profile = ctx
+ .env_var(ALIBABA_CLOUD_PROFILE)
+ .or_else(|| self.profile.clone())
+ .or(config.current.clone())
+ .unwrap_or_else(|| "default".to_string());
+
+ let profile = match config.profiles.iter().find(|entry| entry.name == profile) {
+ Some(profile) => profile,
+ None => {
+ debug!("profile not found in Alibaba config file");
+ return Ok(None);
+ }
+ };
+
+ if !profile.supports_static_credential() {
+ debug!(
+ "Alibaba config profile {} uses unsupported mode {:?}",
+ profile.name, profile.mode
+ );
+ return Ok(None);
+ }
+
+ match (&profile.access_key_id, &profile.access_key_secret) {
+ (Some(ak), Some(sk)) => Ok(Some(Credential {
+ access_key_id: ak.clone(),
+ access_key_secret: sk.clone(),
+ security_token: profile
+ .security_token
+ .clone()
+ .or_else(|| profile.sts_token.clone()),
+ expires_in: None,
+ })),
+ _ => Ok(None),
+ }
+ }
+ }
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+#[derive(Debug, Deserialize)]
+struct ConfigFile {
+ current: Option<String>,
+ #[serde(default)]
+ profiles: Vec<ConfigProfile>,
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+#[derive(Debug, Deserialize)]
+struct ConfigProfile {
+ name: String,
+ mode: Option<String>,
+ access_key_id: Option<String>,
+ access_key_secret: Option<String>,
+ security_token: Option<String>,
+ sts_token: Option<String>,
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+impl ConfigProfile {
+ fn supports_static_credential(&self) -> bool {
+ match self.mode.as_deref() {
+ None => true,
+ Some(mode) => matches!(
+ mode.trim().to_ascii_lowercase().as_str(),
+ "ak" | "ststoken" | "sts_token" | "sts"
+ ),
+ }
+ }
+}
+
+#[cfg(test)]
+#[cfg(not(target_arch = "wasm32"))]
+mod tests {
+ use super::*;
+ use reqsign_core::{FileRead, StaticEnv};
+ use std::collections::HashMap;
+
+ #[derive(Debug)]
+ struct TestFileRead {
+ files: HashMap<String, Vec<u8>>,
+ }
+
+ impl TestFileRead {
+ fn new(files: HashMap<String, Vec<u8>>) -> Self {
+ Self { files }
+ }
+ }
+
+ impl FileRead for TestFileRead {
+ async fn file_read(&self, path: &str) -> Result<Vec<u8>> {
+ self.files.get(path).cloned().ok_or_else(|| {
+ reqsign_core::Error::config_invalid(format!("unexpected file path: {path}"))
+ })
+ }
+ }
+
+ #[tokio::test]
+ async fn test_config_file_provider_uses_current_profile() -> anyhow::Result<()> {
+ let ctx = Context::new()
+ .with_file_read(TestFileRead::new(HashMap::from([(
+ "/mock/config.json".to_string(),
+ br#"{
+ "current": "prod",
+ "profiles": [
+ {
+ "name": "default",
+ "mode": "AK",
+ "access_key_id": "default_access_key",
+ "access_key_secret": "default_secret_key"
+ },
+ {
+ "name": "prod",
+ "mode": "StsToken",
+ "access_key_id": "prod_access_key",
+ "access_key_secret": "prod_secret_key",
+ "sts_token": "prod_token"
+ }
+ ]
+}"#
+ .to_vec(),
+ )])))
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from([(
+ ALIBABA_CLOUD_CONFIG_FILE.to_string(),
+ "/mock/config.json".to_string(),
+ )]),
+ });
+
+ let cred = ConfigFileCredentialProvider::new()
+ .provide_credential(&ctx)
+ .await?
+ .unwrap();
+
+ assert_eq!(cred.access_key_id, "prod_access_key");
+ assert_eq!(cred.access_key_secret, "prod_secret_key");
+ assert_eq!(cred.security_token, Some("prod_token".to_string()));
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_config_file_provider_env_profile_overrides_current() -> anyhow::Result<()> {
+ let ctx = Context::new()
+ .with_file_read(TestFileRead::new(HashMap::from([(
+ "/mock/config.json".to_string(),
+ br#"{
+ "current": "prod",
+ "profiles": [
+ {
+ "name": "default",
+ "mode": "AK",
+ "access_key_id": "default_access_key",
+ "access_key_secret": "default_secret_key"
+ },
+ {
+ "name": "prod",
+ "mode": "AK",
+ "access_key_id": "prod_access_key",
+ "access_key_secret": "prod_secret_key"
+ }
+ ]
+}"#
+ .to_vec(),
+ )])))
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from([
+ (
+ ALIBABA_CLOUD_CONFIG_FILE.to_string(),
+ "/mock/config.json".to_string(),
+ ),
+ (ALIBABA_CLOUD_PROFILE.to_string(), "default".to_string()),
+ ]),
+ });
+
+ let cred = ConfigFileCredentialProvider::new()
+ .provide_credential(&ctx)
+ .await?
+ .unwrap();
+
+ assert_eq!(cred.access_key_id, "default_access_key");
+ assert_eq!(cred.access_key_secret, "default_secret_key");
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_config_file_provider_ignores_unsupported_mode() -> anyhow::Result<()> {
+ let ctx = Context::new()
+ .with_file_read(TestFileRead::new(HashMap::from([(
+ "/mock/config.json".to_string(),
+ br#"{
+ "profiles": [
+ {
+ "name": "default",
+ "mode": "RamRoleArn",
+ "access_key_id": "shared_access_key",
+ "access_key_secret": "shared_secret_key"
+ }
+ ]
+}"#
+ .to_vec(),
+ )])))
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from([(
+ ALIBABA_CLOUD_CONFIG_FILE.to_string(),
+ "/mock/config.json".to_string(),
+ )]),
+ });
+
+ let cred = ConfigFileCredentialProvider::new()
+ .provide_credential(&ctx)
+ .await?;
+
+ assert!(cred.is_none());
+
+ Ok(())
+ }
+}
diff --git a/services/aliyun-oss/src/provide_credential/credentials_file.rs b/services/aliyun-oss/src/provide_credential/credentials_file.rs
new file mode 100644
index 0000000..c15e972
--- /dev/null
+++ b/services/aliyun-oss/src/provide_credential/credentials_file.rs
@@ -0,0 +1,335 @@
+// 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::Credential;
+#[cfg(not(target_arch = "wasm32"))]
+use crate::constants::{ALIBABA_CLOUD_CREDENTIALS_FILE, ALIBABA_CLOUD_PROFILE};
+#[cfg(not(target_arch = "wasm32"))]
+use ini::Ini;
+#[cfg(not(target_arch = "wasm32"))]
+use log::debug;
+use reqsign_core::{Context, ProvideCredential, Result};
+#[cfg(not(target_arch = "wasm32"))]
+use reqsign_core::{Error, ErrorKind};
+
+/// CredentialsFileCredentialProvider loads credentials from Alibaba Cloud shared credential files.
+///
+/// This provider reads credentials from `~/.alibabacloud/credentials.ini` first
+/// and falls back to `~/.aliyun/credentials.ini`. The file path can be overridden
+/// by `ALIBABA_CLOUD_CREDENTIALS_FILE`, and the selected profile can be overridden
+/// by `ALIBABA_CLOUD_PROFILE`.
+#[derive(Debug, Clone)]
+pub struct CredentialsFileCredentialProvider {
+ profile: Option<String>,
+ credentials_file: Option<String>,
+}
+
+impl Default for CredentialsFileCredentialProvider {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl CredentialsFileCredentialProvider {
+ /// Create a new credentials file provider.
+ pub fn new() -> Self {
+ Self {
+ profile: None,
+ credentials_file: None,
+ }
+ }
+
+ /// Set the profile name to use when `ALIBABA_CLOUD_PROFILE` is not set.
+ pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
+ self.profile = Some(profile.into());
+ self
+ }
+
+ /// Set the path to the shared credentials file.
+ pub fn with_credentials_file(mut self, path: impl Into<String>) -> Self {
+ self.credentials_file = Some(path.into());
+ self
+ }
+
+ #[cfg(not(target_arch = "wasm32"))]
+ async fn load_from_path(
+ &self,
+ ctx: &Context,
+ path: &str,
+ profile: &str,
+ ) -> Result<Option<Credential>> {
+ 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.to_string()
+ };
+
+ let content = match ctx.file_read(&expanded_path).await {
+ Ok(content) => content,
+ Err(err) => {
+ debug!("failed to read shared credentials file {expanded_path}: {err:?}");
+ if err.kind() != ErrorKind::ConfigInvalid {
+ return Ok(None);
+ }
+ return Ok(None);
+ }
+ };
+
+ let conf = Ini::load_from_str(&String::from_utf8_lossy(&content)).map_err(|e| {
+ Error::config_invalid("failed to parse Alibaba shared credentials file").with_source(e)
+ })?;
+
+ let props = match conf.section(Some(profile)) {
+ Some(props) => props,
+ None => {
+ debug!("profile {profile} not found in shared credentials file");
+ return Ok(None);
+ }
+ };
+
+ if !is_enabled(props.get("enable")) {
+ debug!("profile {profile} is disabled in shared credentials file");
+ return Ok(None);
+ }
+
+ match props.get("type") {
+ Some(mode) if !is_supported_credentials_mode(mode) => {
+ debug!("shared credentials profile {profile} uses unsupported type {mode}");
+ Ok(None)
+ }
+ _ => parse_static_credential(props),
+ }
+ }
+}
+
+impl ProvideCredential for CredentialsFileCredentialProvider {
+ type Credential = Credential;
+
+ async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
+ #[cfg(target_arch = "wasm32")]
+ {
+ let _ = ctx;
+ Ok(None)
+ }
+
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ let profile = ctx
+ .env_var(ALIBABA_CLOUD_PROFILE)
+ .or_else(|| self.profile.clone())
+ .unwrap_or_else(|| "default".to_string());
+
+ if let Some(path) = self
+ .credentials_file
+ .clone()
+ .or_else(|| ctx.env_var(ALIBABA_CLOUD_CREDENTIALS_FILE))
+ {
+ return self.load_from_path(ctx, &path, &profile).await;
+ }
+
+ for path in [
+ "~/.alibabacloud/credentials.ini",
+ "~/.aliyun/credentials.ini",
+ ] {
+ if let Some(cred) = self.load_from_path(ctx, path, &profile).await? {
+ return Ok(Some(cred));
+ }
+ }
+
+ Ok(None)
+ }
+ }
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn is_enabled(value: Option<&str>) -> bool {
+ match value {
+ None => true,
+ Some(value) => matches!(
+ value.trim().to_ascii_lowercase().as_str(),
+ "true" | "1" | "yes" | "on"
+ ),
+ }
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn is_supported_credentials_mode(mode: &str) -> bool {
+ matches!(
+ mode.trim().to_ascii_lowercase().as_str(),
+ "access_key" | "sts" | "sts_token"
+ )
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn parse_static_credential(props: &ini::Properties) -> Result<Option<Credential>> {
+ let access_key_id = props.get("access_key_id");
+ let access_key_secret = props.get("access_key_secret");
+
+ match (access_key_id, access_key_secret) {
+ (Some(ak), Some(sk)) => Ok(Some(Credential {
+ access_key_id: ak.to_string(),
+ access_key_secret: sk.to_string(),
+ security_token: props
+ .get("security_token")
+ .or_else(|| props.get("sts_token"))
+ .map(ToString::to_string),
+ expires_in: None,
+ })),
+ _ => Ok(None),
+ }
+}
+
+#[cfg(test)]
+#[cfg(not(target_arch = "wasm32"))]
+mod tests {
+ use super::*;
+ use reqsign_core::{FileRead, StaticEnv};
+ use std::collections::HashMap;
+
+ #[derive(Debug)]
+ struct TestFileRead {
+ files: HashMap<String, Vec<u8>>,
+ }
+
+ impl TestFileRead {
+ fn new(files: HashMap<String, Vec<u8>>) -> Self {
+ Self { files }
+ }
+ }
+
+ impl FileRead for TestFileRead {
+ async fn file_read(&self, path: &str) -> Result<Vec<u8>> {
+ self.files.get(path).cloned().ok_or_else(|| {
+ reqsign_core::Error::config_invalid(format!("unexpected file path: {path}"))
+ })
+ }
+ }
+
+ #[tokio::test]
+ async fn test_credentials_file_provider_reads_default_profile() -> anyhow::Result<()> {
+ let ctx = Context::new()
+ .with_file_read(TestFileRead::new(HashMap::from([(
+ "/mock/credentials.ini".to_string(),
+ br#"[default]
+enable=true
+type=access_key
+access_key_id=shared_access_key
+access_key_secret=shared_secret_key
+"#
+ .to_vec(),
+ )])))
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from([(
+ ALIBABA_CLOUD_CREDENTIALS_FILE.to_string(),
+ "/mock/credentials.ini".to_string(),
+ )]),
+ });
+
+ let cred = CredentialsFileCredentialProvider::new()
+ .provide_credential(&ctx)
+ .await?
+ .unwrap();
+
+ assert_eq!(cred.access_key_id, "shared_access_key");
+ assert_eq!(cred.access_key_secret, "shared_secret_key");
+ assert_eq!(cred.security_token, None);
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_credentials_file_provider_env_profile_overrides_builder_profile()
+ -> anyhow::Result<()> {
+ let ctx = Context::new()
+ .with_file_read(TestFileRead::new(HashMap::from([(
+ "/mock/credentials.ini".to_string(),
+ br#"[default]
+enable=true
+type=access_key
+access_key_id=default_access_key
+access_key_secret=default_secret_key
+
+[prod]
+enable=true
+type=sts_token
+access_key_id=prod_access_key
+access_key_secret=prod_secret_key
+sts_token=prod_token
+"#
+ .to_vec(),
+ )])))
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from([
+ (
+ ALIBABA_CLOUD_CREDENTIALS_FILE.to_string(),
+ "/mock/credentials.ini".to_string(),
+ ),
+ (ALIBABA_CLOUD_PROFILE.to_string(), "prod".to_string()),
+ ]),
+ });
+
+ let cred = CredentialsFileCredentialProvider::new()
+ .with_profile("default")
+ .provide_credential(&ctx)
+ .await?
+ .unwrap();
+
+ assert_eq!(cred.access_key_id, "prod_access_key");
+ assert_eq!(cred.access_key_secret, "prod_secret_key");
+ assert_eq!(cred.security_token, Some("prod_token".to_string()));
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_credentials_file_provider_ignores_unsupported_mode() -> anyhow::Result<()> {
+ let ctx = Context::new()
+ .with_file_read(TestFileRead::new(HashMap::from([(
+ "/mock/credentials.ini".to_string(),
+ br#"[default]
+enable=true
+type=ram_role_arn
+access_key_id=shared_access_key
+access_key_secret=shared_secret_key
+"#
+ .to_vec(),
+ )])))
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from([(
+ ALIBABA_CLOUD_CREDENTIALS_FILE.to_string(),
+ "/mock/credentials.ini".to_string(),
+ )]),
+ });
+
+ let cred = CredentialsFileCredentialProvider::new()
+ .provide_credential(&ctx)
+ .await?;
+
+ assert!(cred.is_none());
+
+ Ok(())
+ }
+}
diff --git a/services/aliyun-oss/src/provide_credential/default.rs b/services/aliyun-oss/src/provide_credential/default.rs
index 1d1e611..41c581f 100644
--- a/services/aliyun-oss/src/provide_credential/default.rs
+++ b/services/aliyun-oss/src/provide_credential/default.rs
@@ -17,7 +17,8 @@
use crate::Credential;
use crate::provide_credential::{
- AssumeRoleWithOidcCredentialProvider, EnvCredentialProvider, OssProfileCredentialProvider,
+ AssumeRoleWithOidcCredentialProvider, ConfigFileCredentialProvider,
+ CredentialsFileCredentialProvider, EnvCredentialProvider, OssProfileCredentialProvider,
};
use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain, Result};
@@ -27,7 +28,9 @@ use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain, Result};
///
/// 1. Environment variables
/// 2. OSS profile file
-/// 3. Assume Role with OIDC
+/// 3. Alibaba shared credentials file
+/// 4. Alibaba CLI config file
+/// 5. Assume Role with OIDC
#[derive(Debug)]
pub struct DefaultCredentialProvider {
chain: ProvideCredentialChain<Credential>,
@@ -84,6 +87,8 @@ impl DefaultCredentialProvider {
pub struct DefaultCredentialProviderBuilder {
env: Option<EnvCredentialProvider>,
oss_profile: Option<OssProfileCredentialProvider>,
+ credentials_file: Option<CredentialsFileCredentialProvider>,
+ config_file: Option<ConfigFileCredentialProvider>,
oidc: Option<AssumeRoleWithOidcCredentialProvider>,
}
@@ -92,6 +97,8 @@ impl Default for DefaultCredentialProviderBuilder {
Self {
env: Some(EnvCredentialProvider::new()),
oss_profile: Some(OssProfileCredentialProvider::new()),
+ credentials_file: Some(CredentialsFileCredentialProvider::new()),
+ config_file: Some(ConfigFileCredentialProvider::new()),
oidc: Some(AssumeRoleWithOidcCredentialProvider::new()),
}
}
@@ -127,6 +134,29 @@ impl DefaultCredentialProviderBuilder {
self
}
+ /// Set the Alibaba shared credentials file provider slot.
+ pub fn credentials_file(mut self, provider: CredentialsFileCredentialProvider) -> Self {
+ self.credentials_file = Some(provider);
+ self
+ }
+
+ /// Remove the Alibaba shared credentials file provider slot.
+ pub fn no_credentials_file(mut self) -> Self {
+ self.credentials_file = None;
+ self
+ }
+
+ /// Set the Alibaba config file provider slot.
+ pub fn config_file(mut self, provider: ConfigFileCredentialProvider) -> Self {
+ self.config_file = Some(provider);
+ self
+ }
+
+ /// Remove the Alibaba config file provider slot.
+ pub fn no_config_file(mut self) -> Self {
+ self.config_file = None;
+ self
+ }
/// Set the OIDC credential provider slot.
pub fn oidc(mut self, provider: AssumeRoleWithOidcCredentialProvider) -> Self {
self.oidc = Some(provider);
@@ -148,6 +178,12 @@ impl DefaultCredentialProviderBuilder {
if let Some(p) = self.oss_profile {
chain = chain.push(p);
}
+ if let Some(p) = self.credentials_file {
+ chain = chain.push(p);
+ }
+ if let Some(p) = self.config_file {
+ chain = chain.push(p);
+ }
if let Some(p) = self.oidc {
chain = chain.push(p);
}
@@ -435,6 +471,222 @@ access_key_secret = profile_secret_key
assert_eq!(0, http_send.calls());
}
+ #[tokio::test]
+ async fn test_default_loader_prefers_credentials_file_over_config_file() {
+ let file_read = CountingFileRead::new(HashMap::from([
+ (
+ "/mock/credentials.ini".to_string(),
+ br#"[default]
+enable=true
+type=access_key
+access_key_id=shared_access_key
+access_key_secret=shared_secret_key
+"#
+ .to_vec(),
+ ),
+ (
+ "/mock/config.json".to_string(),
+ br#"{
+ "profiles": [
+ {
+ "name": "default",
+ "mode": "AK",
+ "access_key_id": "config_access_key",
+ "access_key_secret": "config_secret_key"
+ }
+ ]
+}"#
+ .to_vec(),
+ ),
+ ]));
+ let ctx = Context::new()
+ .with_file_read(file_read)
+ .with_http_send(ReqwestHttpSend::default())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from_iter([
+ (
+ ALIBABA_CLOUD_CREDENTIALS_FILE.to_string(),
+ "/mock/credentials.ini".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_CONFIG_FILE.to_string(),
+ "/mock/config.json".to_string(),
+ ),
+ ]),
+ });
+
+ let credential = DefaultCredentialProvider::builder()
+ .no_env()
+ .no_oss_profile()
+ .no_oidc()
+ .build()
+ .provide_credential(&ctx)
+ .await
+ .unwrap()
+ .unwrap();
+
+ assert_eq!("shared_access_key", credential.access_key_id);
+ assert_eq!("shared_secret_key", credential.access_key_secret);
+ }
+
+ #[tokio::test]
+ async fn test_builder_no_credentials_file_removes_credentials_file_provider() {
+ let file_read = CountingFileRead::new(HashMap::from([(
+ "/mock/config.json".to_string(),
+ br#"{
+ "profiles": [
+ {
+ "name": "default",
+ "mode": "AK",
+ "access_key_id": "config_access_key",
+ "access_key_secret": "config_secret_key"
+ }
+ ]
+}"#
+ .to_vec(),
+ )]));
+ let ctx = Context::new()
+ .with_file_read(file_read)
+ .with_http_send(ReqwestHttpSend::default())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from([(
+ ALIBABA_CLOUD_CONFIG_FILE.to_string(),
+ "/mock/config.json".to_string(),
+ )]),
+ });
+
+ let credential = DefaultCredentialProvider::builder()
+ .no_env()
+ .no_oss_profile()
+ .no_credentials_file()
+ .no_oidc()
+ .build()
+ .provide_credential(&ctx)
+ .await
+ .unwrap()
+ .unwrap();
+
+ assert_eq!("config_access_key", credential.access_key_id);
+ assert_eq!("config_secret_key", credential.access_key_secret);
+ }
+
+ #[tokio::test]
+ async fn test_builder_no_config_file_removes_config_file_provider() {
+ let file_read = CountingFileRead::new(HashMap::from([(
+ "/mock/config.json".to_string(),
+ br#"{
+ "profiles": [
+ {
+ "name": "default",
+ "mode": "AK",
+ "access_key_id": "config_access_key",
+ "access_key_secret": "config_secret_key"
+ }
+ ]
+}"#
+ .to_vec(),
+ )]));
+ let ctx = Context::new()
+ .with_file_read(file_read)
+ .with_http_send(ReqwestHttpSend::default())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from([(
+ ALIBABA_CLOUD_CONFIG_FILE.to_string(),
+ "/mock/config.json".to_string(),
+ )]),
+ });
+
+ let credential = DefaultCredentialProvider::builder()
+ .no_env()
+ .no_oss_profile()
+ .no_credentials_file()
+ .no_oidc()
+ .build()
+ .provide_credential(&ctx)
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!("config_access_key", credential.access_key_id);
+
+ let credential = DefaultCredentialProvider::builder()
+ .no_env()
+ .no_oss_profile()
+ .no_credentials_file()
+ .no_config_file()
+ .no_oidc()
+ .build()
+ .provide_credential(&ctx)
+ .await
+ .unwrap();
+ assert!(credential.is_none());
+ }
+
+ #[tokio::test]
+ async fn test_default_loader_prefers_config_file_over_oidc() {
+ let file_read = CountingFileRead::new(HashMap::from([
+ (
+ "/mock/config.json".to_string(),
+ br#"{
+ "profiles": [
+ {
+ "name": "default",
+ "mode": "AK",
+ "access_key_id": "config_access_key",
+ "access_key_secret": "config_secret_key"
+ }
+ ]
+}"#
+ .to_vec(),
+ ),
+ ("/mock/token".to_string(), b"token".to_vec()),
+ ]));
+ let http_send = CountingHttpSend::new(
+ br#"{"Credentials":{"SecurityToken":"security_token","Expiration":"2124-05-25T11:45:17Z","AccessKeySecret":"oidc_secret_key","AccessKeyId":"oidc_access_key"}}"#
+ .to_vec(),
+ );
+ let ctx = Context::new()
+ .with_file_read(file_read)
+ .with_http_send(http_send.clone())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from_iter([
+ (
+ ALIBABA_CLOUD_CONFIG_FILE.to_string(),
+ "/mock/config.json".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_OIDC_TOKEN_FILE.to_string(),
+ "/mock/token".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_ROLE_ARN.to_string(),
+ "acs:ram::123456789012:role/test-role".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_OIDC_PROVIDER_ARN.to_string(),
+ "acs:ram::123456789012:oidc-provider/test-provider".to_string(),
+ ),
+ ]),
+ });
+
+ let credential = DefaultCredentialProvider::builder()
+ .no_env()
+ .no_oss_profile()
+ .no_credentials_file()
+ .build()
+ .provide_credential(&ctx)
+ .await
+ .unwrap()
+ .unwrap();
+
+ assert_eq!("config_access_key", credential.access_key_id);
+ assert_eq!("config_secret_key", credential.access_key_secret);
+ assert_eq!(0, http_send.calls());
+ }
+
#[tokio::test]
async fn test_builder_no_oidc_removes_oidc_provider() {
let file_read = CountingFileRead::new(HashMap::from([(
diff --git a/services/aliyun-oss/src/provide_credential/mod.rs b/services/aliyun-oss/src/provide_credential/mod.rs
index a54db4b..92a6704 100644
--- a/services/aliyun-oss/src/provide_credential/mod.rs
+++ b/services/aliyun-oss/src/provide_credential/mod.rs
@@ -18,6 +18,12 @@
mod assume_role_with_oidc;
pub use assume_role_with_oidc::AssumeRoleWithOidcCredentialProvider;
+mod config_file;
+pub use config_file::ConfigFileCredentialProvider;
+
+mod credentials_file;
+pub use credentials_file::CredentialsFileCredentialProvider;
+
mod default;
pub use default::{DefaultCredentialProvider, DefaultCredentialProviderBuilder};