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:07:30 UTC
(opendal-reqsign) branch xuanwo/oss-pr3-env-and-oss-profile created (now 9b63dbf)
This is an automated email from the ASF dual-hosted git repository.
xuanwo pushed a change to branch xuanwo/oss-pr3-env-and-oss-profile
in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git
at 9b63dbf feat(aliyun-oss): add OSS env aliases and profile provider
This branch includes the following new commits:
new 9b63dbf feat(aliyun-oss): add OSS env aliases and profile provider
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(opendal-reqsign) 01/01: feat(aliyun-oss): add OSS env aliases and profile provider
Posted by xu...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
xuanwo pushed a commit to branch xuanwo/oss-pr3-env-and-oss-profile
in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git
commit 9b63dbfa989ee0cf1c7f77a114cae76bb4309de0
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Thu Mar 19 01:07:18 2026 +0800
feat(aliyun-oss): add OSS env aliases and profile provider
---
services/aliyun-oss/Cargo.toml | 1 +
services/aliyun-oss/README.md | 26 +-
services/aliyun-oss/src/constants.rs | 5 +
services/aliyun-oss/src/lib.rs | 12 +-
.../aliyun-oss/src/provide_credential/default.rs | 235 ++++++++++++++---
services/aliyun-oss/src/provide_credential/env.rs | 103 +++++++-
services/aliyun-oss/src/provide_credential/mod.rs | 3 +
.../src/provide_credential/oss_profile.rs | 284 +++++++++++++++++++++
8 files changed, 621 insertions(+), 48 deletions(-)
diff --git a/services/aliyun-oss/Cargo.toml b/services/aliyun-oss/Cargo.toml
index 23bcf93..6550ad1 100644
--- a/services/aliyun-oss/Cargo.toml
+++ b/services/aliyun-oss/Cargo.toml
@@ -33,6 +33,7 @@ http = { workspace = true }
log = { workspace = true }
percent-encoding = { workspace = true }
reqsign-core = { workspace = true }
+rust-ini = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
diff --git a/services/aliyun-oss/README.md b/services/aliyun-oss/README.md
index b96df82..9afcbb1 100644
--- a/services/aliyun-oss/README.md
+++ b/services/aliyun-oss/README.md
@@ -11,7 +11,7 @@ This crate provides signing support for Alibaba Cloud Object Storage Service (OS
```rust
use reqsign_aliyun_oss::{
AssumeRoleWithOidcCredentialProvider, DefaultCredentialProvider, EnvCredentialProvider,
- RequestSigner, StaticCredentialProvider,
+ OssProfileCredentialProvider, RequestSigner, StaticCredentialProvider,
};
use reqsign_core::{Context, Result, Signer};
use reqsign_file_read_tokio::TokioFileRead;
@@ -25,6 +25,7 @@ async fn main() -> Result<()> {
let loader = DefaultCredentialProvider::builder()
.env(EnvCredentialProvider::new())
+ .oss_profile(OssProfileCredentialProvider::new())
.oidc(AssumeRoleWithOidcCredentialProvider::new())
.build();
@@ -50,7 +51,7 @@ async fn main() -> Result<()> {
## Features
- **HMAC-SHA1 Signing**: Complete implementation of Aliyun's signing algorithm
-- **Multiple Credential Sources**: Environment variables and OIDC-based STS exchange
+- **Multiple Credential Sources**: Environment variables, OSS profile files, and OIDC-based STS exchange
- **STS Support**: Temporary credentials via Security Token Service
- **All OSS Operations**: Object, bucket, and multipart operations
@@ -62,8 +63,28 @@ async fn main() -> Result<()> {
export ALIBABA_CLOUD_ACCESS_KEY_ID=your-access-key-id
export ALIBABA_CLOUD_ACCESS_KEY_SECRET=your-access-key-secret
export ALIBABA_CLOUD_SECURITY_TOKEN=your-sts-token # Optional
+export OSS_ACCESS_KEY_ID=your-access-key-id # Alias
+export OSS_ACCESS_KEY_SECRET=your-access-key-secret # Alias
+export OSS_SESSION_TOKEN=your-sts-token # Alias
```
+### OSS Profile File
+
+Reads from `~/.oss/credentials` by default:
+
+```ini
+[default]
+access_key_id = your-access-key-id
+access_key_secret = your-access-key-secret
+session_token = optional-session-token
+
+[prod]
+access_key_id = prod-access-key-id
+access_key_secret = prod-access-key-secret
+```
+
+Override the file path with `OSS_CREDENTIAL_PROFILES_FILE` and the selected profile with `OSS_PROFILE`.
+
### STS AssumeRole with OIDC
For Kubernetes/ACK environments:
@@ -75,6 +96,7 @@ use reqsign_aliyun_oss::{AssumeRoleWithOidcCredentialProvider, DefaultCredential
// and ALIBABA_CLOUD_OIDC_TOKEN_FILE in the environment.
let loader = DefaultCredentialProvider::builder()
.no_env()
+ .no_oss_profile()
.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 c34501e..ab6b857 100644
--- a/services/aliyun-oss/src/constants.rs
+++ b/services/aliyun-oss/src/constants.rs
@@ -19,6 +19,11 @@
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 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";
+pub const OSS_PROFILE: &str = "OSS_PROFILE";
+pub const OSS_CREDENTIAL_PROFILES_FILE: &str = "OSS_CREDENTIAL_PROFILES_FILE";
pub const ALIBABA_CLOUD_ROLE_ARN: &str = "ALIBABA_CLOUD_ROLE_ARN";
pub const ALIBABA_CLOUD_ROLE_SESSION_NAME: &str = "ALIBABA_CLOUD_ROLE_SESSION_NAME";
pub const ALIBABA_CLOUD_OIDC_PROVIDER_ARN: &str = "ALIBABA_CLOUD_OIDC_PROVIDER_ARN";
diff --git a/services/aliyun-oss/src/lib.rs b/services/aliyun-oss/src/lib.rs
index 1cf179a..00759b7 100644
--- a/services/aliyun-oss/src/lib.rs
+++ b/services/aliyun-oss/src/lib.rs
@@ -31,7 +31,7 @@
//! ```no_run
//! use reqsign_aliyun_oss::{
//! AssumeRoleWithOidcCredentialProvider, DefaultCredentialProvider, EnvCredentialProvider,
-//! RequestSigner, StaticCredentialProvider,
+//! OssProfileCredentialProvider, RequestSigner, StaticCredentialProvider,
//! };
//! use reqsign_core::{Context, Signer, Result};
//! use reqsign_file_read_tokio::TokioFileRead;
@@ -47,6 +47,7 @@
//! // Create credential loader with the default env -> oidc chain.
//! let loader = DefaultCredentialProvider::builder()
//! .env(EnvCredentialProvider::new())
+//! .oss_profile(OssProfileCredentialProvider::new())
//! .oidc(AssumeRoleWithOidcCredentialProvider::new())
//! .build();
//!
@@ -82,12 +83,15 @@
//! export ALIBABA_CLOUD_ACCESS_KEY_ID=your-access-key-id
//! export ALIBABA_CLOUD_ACCESS_KEY_SECRET=your-access-key-secret
//! export ALIBABA_CLOUD_SECURITY_TOKEN=your-sts-token # Optional, for STS
+//! export OSS_ACCESS_KEY_ID=your-access-key-id # Alias
+//! export OSS_ACCESS_KEY_SECRET=your-access-key-secret # Alias
+//! export OSS_SESSION_TOKEN=your-sts-token # Alias
//! ```
//!
-//! ### Configuration File
+//! ### OSS Profile File
//!
-//! The crate can load credentials from the Aliyun CLI configuration file
-//! (typically `~/.aliyun/config.json`).
+//! The crate can load credentials from the OSS profile file
+//! (typically `~/.oss/credentials`).
//!
//! ### ECS RAM Role
//!
diff --git a/services/aliyun-oss/src/provide_credential/default.rs b/services/aliyun-oss/src/provide_credential/default.rs
index 8e24da7..1d1e611 100644
--- a/services/aliyun-oss/src/provide_credential/default.rs
+++ b/services/aliyun-oss/src/provide_credential/default.rs
@@ -16,7 +16,9 @@
// under the License.
use crate::Credential;
-use crate::provide_credential::{AssumeRoleWithOidcCredentialProvider, EnvCredentialProvider};
+use crate::provide_credential::{
+ AssumeRoleWithOidcCredentialProvider, EnvCredentialProvider, OssProfileCredentialProvider,
+};
use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain, Result};
/// DefaultCredentialProvider is a loader that will try to load credential via default chains.
@@ -24,7 +26,8 @@ use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain, Result};
/// Resolution order:
///
/// 1. Environment variables
-/// 2. Assume Role with OIDC
+/// 2. OSS profile file
+/// 3. Assume Role with OIDC
#[derive(Debug)]
pub struct DefaultCredentialProvider {
chain: ProvideCredentialChain<Credential>,
@@ -80,6 +83,7 @@ impl DefaultCredentialProvider {
/// remove it from the chain before calling `build()`.
pub struct DefaultCredentialProviderBuilder {
env: Option<EnvCredentialProvider>,
+ oss_profile: Option<OssProfileCredentialProvider>,
oidc: Option<AssumeRoleWithOidcCredentialProvider>,
}
@@ -87,6 +91,7 @@ impl Default for DefaultCredentialProviderBuilder {
fn default() -> Self {
Self {
env: Some(EnvCredentialProvider::new()),
+ oss_profile: Some(OssProfileCredentialProvider::new()),
oidc: Some(AssumeRoleWithOidcCredentialProvider::new()),
}
}
@@ -110,6 +115,18 @@ impl DefaultCredentialProviderBuilder {
self
}
+ /// Set the OSS profile credential provider slot.
+ pub fn oss_profile(mut self, provider: OssProfileCredentialProvider) -> Self {
+ self.oss_profile = Some(provider);
+ self
+ }
+
+ /// Remove the OSS profile credential provider slot.
+ pub fn no_oss_profile(mut self) -> Self {
+ self.oss_profile = None;
+ self
+ }
+
/// Set the OIDC credential provider slot.
pub fn oidc(mut self, provider: AssumeRoleWithOidcCredentialProvider) -> Self {
self.oidc = Some(provider);
@@ -128,6 +145,9 @@ impl DefaultCredentialProviderBuilder {
if let Some(p) = self.env {
chain = chain.push(p);
}
+ if let Some(p) = self.oss_profile {
+ chain = chain.push(p);
+ }
if let Some(p) = self.oidc {
chain = chain.push(p);
}
@@ -151,6 +171,70 @@ mod tests {
use reqsign_file_read_tokio::TokioFileRead;
use reqsign_http_send_reqwest::ReqwestHttpSend;
use std::collections::HashMap;
+ use std::sync::{
+ Arc,
+ atomic::{AtomicUsize, Ordering},
+ };
+
+ #[derive(Clone, Debug)]
+ struct CountingFileRead {
+ calls: Arc<AtomicUsize>,
+ paths: Arc<HashMap<String, Vec<u8>>>,
+ }
+
+ impl CountingFileRead {
+ fn new(paths: HashMap<String, Vec<u8>>) -> Self {
+ Self {
+ calls: Arc::new(AtomicUsize::new(0)),
+ paths: Arc::new(paths),
+ }
+ }
+
+ fn calls(&self) -> usize {
+ self.calls.load(Ordering::SeqCst)
+ }
+ }
+
+ impl FileRead for CountingFileRead {
+ async fn file_read(&self, path: &str) -> reqsign_core::Result<Vec<u8>> {
+ self.calls.fetch_add(1, Ordering::SeqCst);
+ self.paths.get(path).cloned().ok_or_else(|| {
+ reqsign_core::Error::unexpected(format!("unexpected file path: {path}"))
+ })
+ }
+ }
+
+ #[derive(Clone, Debug)]
+ struct CountingHttpSend {
+ calls: Arc<AtomicUsize>,
+ body: Vec<u8>,
+ }
+
+ impl CountingHttpSend {
+ fn new(body: impl Into<Vec<u8>>) -> Self {
+ Self {
+ calls: Arc::new(AtomicUsize::new(0)),
+ body: body.into(),
+ }
+ }
+
+ fn calls(&self) -> usize {
+ self.calls.load(Ordering::SeqCst)
+ }
+ }
+
+ impl HttpSend for CountingHttpSend {
+ async fn http_send(
+ &self,
+ _req: http::Request<Bytes>,
+ ) -> reqsign_core::Result<http::Response<Bytes>> {
+ self.calls.fetch_add(1, Ordering::SeqCst);
+ Ok(http::Response::builder()
+ .status(http::StatusCode::OK)
+ .body(Bytes::from(self.body.clone()))
+ .expect("response must build"))
+ }
+ }
#[tokio::test]
async fn test_default_loader_without_env() {
@@ -236,30 +320,60 @@ mod tests {
#[tokio::test]
async fn test_builder_no_env_removes_env_provider() {
- let ctx = Context::new().with_env(StaticEnv {
- home_dir: None,
- envs: HashMap::from_iter([
- (
- ALIBABA_CLOUD_ACCESS_KEY_ID.to_string(),
- "access_key_id".to_string(),
- ),
- (
- ALIBABA_CLOUD_ACCESS_KEY_SECRET.to_string(),
- "secret_access_key".to_string(),
- ),
- ]),
- });
+ let file_read = CountingFileRead::new(HashMap::from([(
+ "/mock/credentials".to_string(),
+ br#"[default]
+access_key_id = profile_access_key
+access_key_secret = profile_secret_key
+"#
+ .to_vec(),
+ )]));
+ let ctx = Context::new()
+ .with_file_read(file_read.clone())
+ .with_http_send(ReqwestHttpSend::default())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from_iter([
+ (
+ ALIBABA_CLOUD_ACCESS_KEY_ID.to_string(),
+ "env_access_key".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_ACCESS_KEY_SECRET.to_string(),
+ "env_secret_key".to_string(),
+ ),
+ (
+ OSS_CREDENTIAL_PROFILES_FILE.to_string(),
+ "/mock/credentials".to_string(),
+ ),
+ ]),
+ });
let credential = DefaultCredentialProvider::builder()
.no_oidc()
.build()
.provide_credential(&ctx)
.await
+ .unwrap()
.unwrap();
- assert!(credential.is_some());
+ assert_eq!("env_access_key", credential.access_key_id);
+ assert_eq!(0, file_read.calls());
+
+ let credential = DefaultCredentialProvider::builder()
+ .no_env()
+ .no_oidc()
+ .build()
+ .provide_credential(&ctx)
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!("profile_access_key", credential.access_key_id);
+ assert_eq!("profile_secret_key", credential.access_key_secret);
+ assert_eq!(1, file_read.calls());
let credential = DefaultCredentialProvider::builder()
.no_env()
+ .no_oss_profile()
.no_oidc()
.build()
.provide_credential(&ctx)
@@ -269,32 +383,71 @@ mod tests {
}
#[tokio::test]
- async fn test_builder_no_oidc_removes_oidc_provider() {
- #[derive(Debug)]
- struct TestFileRead;
-
- impl FileRead for TestFileRead {
- async fn file_read(&self, path: &str) -> Result<Vec<u8>> {
- assert_eq!(path, "/mock/token");
- Ok(b"token".to_vec())
- }
- }
-
- #[derive(Debug)]
- struct TestHttpSend;
+ async fn test_default_loader_prefers_profile_over_oidc() {
+ let file_read = CountingFileRead::new(HashMap::from([
+ (
+ "/mock/credentials".to_string(),
+ br#"[default]
+access_key_id = profile_access_key
+access_key_secret = profile_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.clone())
+ .with_http_send(http_send.clone())
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from_iter([
+ (
+ OSS_CREDENTIAL_PROFILES_FILE.to_string(),
+ "/mock/credentials".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(),
+ ),
+ ]),
+ });
- impl HttpSend for TestHttpSend {
- async fn http_send(&self, _req: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
- http::Response::builder()
- .status(http::StatusCode::OK)
- .body(Bytes::from_static(br#"{"Credentials":{"SecurityToken":"security_token","Expiration":"2124-05-25T11:45:17Z","AccessKeySecret":"secret_access_key","AccessKeyId":"access_key_id"}}"#))
- .map_err(Into::into)
- }
- }
+ let credential = DefaultCredentialProvider::new()
+ .provide_credential(&ctx)
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!("profile_access_key", credential.access_key_id);
+ assert_eq!("profile_secret_key", credential.access_key_secret);
+ assert_eq!(1, file_read.calls());
+ assert_eq!(0, http_send.calls());
+ }
+ #[tokio::test]
+ async fn test_builder_no_oidc_removes_oidc_provider() {
+ let file_read = CountingFileRead::new(HashMap::from([(
+ "/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":"secret_access_key","AccessKeyId":"access_key_id"}}"#
+ .to_vec(),
+ );
let ctx = Context::new()
- .with_file_read(TestFileRead)
- .with_http_send(TestHttpSend)
+ .with_file_read(file_read.clone())
+ .with_http_send(http_send.clone())
.with_env(StaticEnv {
home_dir: None,
envs: HashMap::from_iter([
@@ -315,15 +468,19 @@ mod tests {
let credential = DefaultCredentialProvider::builder()
.no_env()
+ .no_oss_profile()
.oidc(AssumeRoleWithOidcCredentialProvider::new())
.build()
.provide_credential(&ctx)
.await
.unwrap();
assert!(credential.is_some());
+ assert_eq!(1, file_read.calls());
+ assert_eq!(1, http_send.calls());
let credential = DefaultCredentialProvider::builder()
.no_env()
+ .no_oss_profile()
.no_oidc()
.build()
.provide_credential(&ctx)
diff --git a/services/aliyun-oss/src/provide_credential/env.rs b/services/aliyun-oss/src/provide_credential/env.rs
index d44020d..0a51cde 100644
--- a/services/aliyun-oss/src/provide_credential/env.rs
+++ b/services/aliyun-oss/src/provide_credential/env.rs
@@ -39,14 +39,28 @@ impl ProvideCredential for EnvCredentialProvider {
async fn provide_credential(&self, ctx: &Context) -> Result<Option<Self::Credential>> {
let envs = ctx.env_vars();
- let access_key_id = envs.get(ALIBABA_CLOUD_ACCESS_KEY_ID);
- let access_key_secret = envs.get(ALIBABA_CLOUD_ACCESS_KEY_SECRET);
+ let (access_key_id, access_key_secret, security_token) = if let (Some(ak), Some(sk)) = (
+ envs.get(ALIBABA_CLOUD_ACCESS_KEY_ID),
+ envs.get(ALIBABA_CLOUD_ACCESS_KEY_SECRET),
+ ) {
+ (
+ Some(ak),
+ Some(sk),
+ envs.get(ALIBABA_CLOUD_SECURITY_TOKEN).cloned(),
+ )
+ } else {
+ (
+ envs.get(OSS_ACCESS_KEY_ID),
+ envs.get(OSS_ACCESS_KEY_SECRET),
+ envs.get(OSS_SESSION_TOKEN).cloned(),
+ )
+ };
match (access_key_id, access_key_secret) {
(Some(ak), Some(sk)) => Ok(Some(Credential {
access_key_id: ak.clone(),
access_key_secret: sk.clone(),
- security_token: envs.get(ALIBABA_CLOUD_SECURITY_TOKEN).cloned(),
+ security_token,
expires_in: None,
})),
_ => Ok(None),
@@ -133,6 +147,89 @@ mod tests {
Ok(())
}
+ #[tokio::test]
+ async fn test_env_credential_provider_supports_oss_aliases() -> anyhow::Result<()> {
+ let envs = HashMap::from([
+ (OSS_ACCESS_KEY_ID.to_string(), "oss_access_key".to_string()),
+ (
+ OSS_ACCESS_KEY_SECRET.to_string(),
+ "oss_secret_key".to_string(),
+ ),
+ (
+ OSS_SESSION_TOKEN.to_string(),
+ "oss_session_token".to_string(),
+ ),
+ ]);
+
+ let ctx = Context::new()
+ .with_file_read(TokioFileRead)
+ .with_http_send(ReqwestHttpSend::default())
+ .with_env(OsEnv)
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs,
+ });
+
+ let provider = EnvCredentialProvider::new();
+ let cred = provider.provide_credential(&ctx).await?;
+ assert!(cred.is_some());
+ let cred = cred.unwrap();
+ assert_eq!(cred.access_key_id, "oss_access_key");
+ assert_eq!(cred.access_key_secret, "oss_secret_key");
+ assert_eq!(cred.security_token, Some("oss_session_token".to_string()));
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_env_credential_provider_prefers_alibaba_cloud_variables() -> anyhow::Result<()> {
+ let envs = HashMap::from([
+ (
+ ALIBABA_CLOUD_ACCESS_KEY_ID.to_string(),
+ "alibaba_access_key".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_ACCESS_KEY_SECRET.to_string(),
+ "alibaba_secret_key".to_string(),
+ ),
+ (
+ ALIBABA_CLOUD_SECURITY_TOKEN.to_string(),
+ "alibaba_security_token".to_string(),
+ ),
+ (OSS_ACCESS_KEY_ID.to_string(), "oss_access_key".to_string()),
+ (
+ OSS_ACCESS_KEY_SECRET.to_string(),
+ "oss_secret_key".to_string(),
+ ),
+ (
+ OSS_SESSION_TOKEN.to_string(),
+ "oss_session_token".to_string(),
+ ),
+ ]);
+
+ let ctx = Context::new()
+ .with_file_read(TokioFileRead)
+ .with_http_send(ReqwestHttpSend::default())
+ .with_env(OsEnv)
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs,
+ });
+
+ let provider = EnvCredentialProvider::new();
+ let cred = provider.provide_credential(&ctx).await?;
+ assert!(cred.is_some());
+ let cred = cred.unwrap();
+ assert_eq!(cred.access_key_id, "alibaba_access_key");
+ assert_eq!(cred.access_key_secret, "alibaba_secret_key");
+ assert_eq!(
+ cred.security_token,
+ Some("alibaba_security_token".to_string())
+ );
+
+ Ok(())
+ }
+
#[tokio::test]
async fn test_env_credential_provider_missing_credentials() -> anyhow::Result<()> {
let ctx = Context::new()
diff --git a/services/aliyun-oss/src/provide_credential/mod.rs b/services/aliyun-oss/src/provide_credential/mod.rs
index 8bb7fe2..a54db4b 100644
--- a/services/aliyun-oss/src/provide_credential/mod.rs
+++ b/services/aliyun-oss/src/provide_credential/mod.rs
@@ -24,5 +24,8 @@ pub use default::{DefaultCredentialProvider, DefaultCredentialProviderBuilder};
mod env;
pub use env::EnvCredentialProvider;
+mod oss_profile;
+pub use oss_profile::OssProfileCredentialProvider;
+
mod r#static;
pub use r#static::StaticCredentialProvider;
diff --git a/services/aliyun-oss/src/provide_credential/oss_profile.rs b/services/aliyun-oss/src/provide_credential/oss_profile.rs
new file mode 100644
index 0000000..2571026
--- /dev/null
+++ b/services/aliyun-oss/src/provide_credential/oss_profile.rs
@@ -0,0 +1,284 @@
+// 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::{OSS_CREDENTIAL_PROFILES_FILE, OSS_PROFILE};
+#[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};
+
+/// OssProfileCredentialProvider loads credentials from OSS profile files.
+///
+/// This provider loads credentials from `~/.oss/credentials` by default. The
+/// file path can be overridden by `OSS_CREDENTIAL_PROFILES_FILE`, and the
+/// selected profile can be overridden by `OSS_PROFILE`.
+#[derive(Debug, Clone)]
+pub struct OssProfileCredentialProvider {
+ profile: String,
+ credentials_file: Option<String>,
+}
+
+impl Default for OssProfileCredentialProvider {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl OssProfileCredentialProvider {
+ /// Create a new profile provider that reads the `default` profile.
+ pub fn new() -> Self {
+ Self {
+ profile: "default".to_string(),
+ credentials_file: None,
+ }
+ }
+
+ /// Set the profile name to use when `OSS_PROFILE` is not set.
+ pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
+ self.profile = profile.into();
+ self
+ }
+
+ /// Set the path to the OSS 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_credentials(&self, ctx: &Context, profile: &str) -> Result<Option<Credential>> {
+ let path = if let Some(path) = &self.credentials_file {
+ path.clone()
+ } else if let Some(path) = ctx.env_var(OSS_CREDENTIAL_PROFILES_FILE) {
+ path
+ } else {
+ "~/.oss/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 OSS 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 OSS credentials file").with_source(e)
+ })?;
+
+ let props = match conf.section(Some(profile)) {
+ Some(props) => props,
+ None => {
+ debug!("profile {profile} not found in OSS credentials file");
+ return Ok(None);
+ }
+ };
+
+ let access_key_id =
+ get_profile_value(props, &["access_key_id", "accessKeyId", "accessKeyID"]);
+ let access_key_secret = get_profile_value(props, &["access_key_secret", "accessKeySecret"]);
+
+ 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: get_profile_value(
+ props,
+ &["session_token", "security_token", "sts_token", "token"],
+ )
+ .map(ToString::to_string),
+ expires_in: None,
+ })),
+ _ => Ok(None),
+ }
+ }
+}
+
+impl ProvideCredential for OssProfileCredentialProvider {
+ 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(OSS_PROFILE)
+ .unwrap_or_else(|| self.profile.clone());
+ self.load_credentials(ctx, &profile).await
+ }
+ }
+}
+
+#[cfg(not(target_arch = "wasm32"))]
+fn get_profile_value<'a>(props: &'a ini::Properties, keys: &[&str]) -> Option<&'a str> {
+ keys.iter().find_map(|key| props.get(key))
+}
+
+#[cfg(test)]
+#[cfg(not(target_arch = "wasm32"))]
+mod tests {
+ use super::*;
+ use reqsign_core::{FileRead, OsEnv, StaticEnv};
+ use reqsign_http_send_reqwest::ReqwestHttpSend;
+ 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::unexpected(format!("unexpected file path: {path}"))
+ })
+ }
+ }
+
+ #[tokio::test]
+ async fn test_profile_provider_reads_default_profile() -> anyhow::Result<()> {
+ let ctx = Context::new()
+ .with_file_read(TestFileRead::new(HashMap::from([(
+ "/mock/credentials".to_string(),
+ br#"[default]
+access_key_id = profile_access_key
+access_key_secret = profile_secret_key
+session_token = profile_token
+"#
+ .to_vec(),
+ )])))
+ .with_http_send(ReqwestHttpSend::default())
+ .with_env(OsEnv)
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from([(
+ OSS_CREDENTIAL_PROFILES_FILE.to_string(),
+ "/mock/credentials".to_string(),
+ )]),
+ });
+
+ let provider = OssProfileCredentialProvider::new();
+ let cred = provider.provide_credential(&ctx).await?;
+
+ assert!(cred.is_some());
+ let cred = cred.unwrap();
+ assert_eq!(cred.access_key_id, "profile_access_key");
+ assert_eq!(cred.access_key_secret, "profile_secret_key");
+ assert_eq!(cred.security_token, Some("profile_token".to_string()));
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_profile_provider_selects_named_profile() -> anyhow::Result<()> {
+ let ctx = Context::new()
+ .with_file_read(TestFileRead::new(HashMap::from([(
+ "/mock/credentials".to_string(),
+ br#"[default]
+access_key_id = default_access_key
+access_key_secret = default_secret_key
+
+[prod]
+accessKeyID = prod_access_key
+accessKeySecret = prod_secret_key
+security_token = prod_token
+"#
+ .to_vec(),
+ )])))
+ .with_http_send(ReqwestHttpSend::default())
+ .with_env(OsEnv)
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from([
+ (
+ OSS_CREDENTIAL_PROFILES_FILE.to_string(),
+ "/mock/credentials".to_string(),
+ ),
+ (OSS_PROFILE.to_string(), "prod".to_string()),
+ ]),
+ });
+
+ let provider = OssProfileCredentialProvider::new();
+ let cred = provider.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_profile_provider_returns_none_for_missing_profile() -> anyhow::Result<()> {
+ let ctx = Context::new()
+ .with_file_read(TestFileRead::new(HashMap::from([(
+ "/mock/credentials".to_string(),
+ br#"[default]
+access_key_id = default_access_key
+access_key_secret = default_secret_key
+"#
+ .to_vec(),
+ )])))
+ .with_http_send(ReqwestHttpSend::default())
+ .with_env(OsEnv)
+ .with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from([
+ (
+ OSS_CREDENTIAL_PROFILES_FILE.to_string(),
+ "/mock/credentials".to_string(),
+ ),
+ (OSS_PROFILE.to_string(), "missing".to_string()),
+ ]),
+ });
+
+ let provider = OssProfileCredentialProvider::new();
+ let cred = provider.provide_credential(&ctx).await?;
+ assert!(cred.is_none());
+
+ Ok(())
+ }
+}