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 7b2bbb1 feat: support volcengine tos (#686)
7b2bbb1 is described below
commit 7b2bbb1b8dde3b86ef593547e8e1c05c22c49549
Author: Xin Sun <[email protected]>
AuthorDate: Thu Feb 26 15:34:48 2026 +0800
feat: support volcengine tos (#686)
Closes: https://github.com/apache/opendal/issues/7152
---
Cargo.toml | 1 +
reqsign/Cargo.toml | 4 +-
reqsign/src/lib.rs | 3 +
reqsign/src/volcengine.rs | 67 +++++
services/volcengine-tos/Cargo.toml | 42 +++
.../volcengine-tos/src/constants.rs | 54 ++--
services/volcengine-tos/src/credential.rs | 62 +++++
{reqsign => services/volcengine-tos}/src/lib.rs | 39 +--
.../src/provide_credential/default.rs | 196 +++++++++++++
.../volcengine-tos/src/provide_credential/env.rs | 153 ++++++++++
.../volcengine-tos/src/provide_credential/mod.rs | 38 +--
.../src/provide_credential/static.rs | 100 +++++++
services/volcengine-tos/src/sign_request.rs | 310 +++++++++++++++++++++
.../lib.rs => services/volcengine-tos/src/uri.rs | 39 +--
14 files changed, 982 insertions(+), 126 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
index 59ea37e..d2cf205 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -38,6 +38,7 @@ reqsign-http-send-reqwest = { version = "3.0.0", path =
"context/http-send-reqwe
reqsign-huaweicloud-obs = { version = "2.0.2", path =
"services/huaweicloud-obs" }
reqsign-oracle = { version = "2.0.2", path = "services/oracle" }
reqsign-tencent-cos = { version = "2.0.2", path = "services/tencent-cos" }
+reqsign-volcengine-tos = { version = "2.0.2", path = "services/volcengine-tos"
}
# Crates.io dependencies
anyhow = "1"
diff --git a/reqsign/Cargo.toml b/reqsign/Cargo.toml
index 843c78a..3983c45 100644
--- a/reqsign/Cargo.toml
+++ b/reqsign/Cargo.toml
@@ -44,6 +44,7 @@ reqsign-google = { workspace = true, optional = true }
reqsign-huaweicloud-obs = { workspace = true, optional = true }
reqsign-oracle = { workspace = true, optional = true }
reqsign-tencent-cos = { workspace = true, optional = true }
+reqsign-volcengine-tos = { workspace = true, optional = true }
# Context implementations (optional but included by default)
reqsign-command-execute-tokio = { workspace = true, optional = true }
@@ -66,9 +67,10 @@ google = ["dep:reqsign-google"]
huaweicloud = ["dep:reqsign-huaweicloud-obs"]
oracle = ["dep:reqsign-oracle"]
tencent = ["dep:reqsign-tencent-cos"]
+volcengine = ["dep:reqsign-volcengine-tos"]
# Full feature set
-full = ["aliyun", "aws", "azure", "google", "huaweicloud", "oracle", "tencent"]
+full = ["aliyun", "aws", "azure", "google", "huaweicloud", "oracle",
"tencent", "volcengine"]
[dev-dependencies]
anyhow = "1"
diff --git a/reqsign/src/lib.rs b/reqsign/src/lib.rs
index 9184937..9edc590 100644
--- a/reqsign/src/lib.rs
+++ b/reqsign/src/lib.rs
@@ -48,3 +48,6 @@ pub mod oracle;
#[cfg(feature = "tencent")]
pub mod tencent;
+
+#[cfg(feature = "volcengine")]
+pub mod volcengine;
diff --git a/reqsign/src/volcengine.rs b/reqsign/src/volcengine.rs
new file mode 100644
index 0000000..b9ea771
--- /dev/null
+++ b/reqsign/src/volcengine.rs
@@ -0,0 +1,67 @@
+// 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.
+
+//! Volcengine TOS service support with convenience APIs
+//!
+//! This module provides Volcengine TOS signing functionality along with
convenience
+//! functions for common use cases.
+
+// Re-export all Volcengine TOS signing types
+pub use reqsign_volcengine_tos::*;
+
+#[cfg(feature = "default-context")]
+use crate::{Signer, default_context};
+
+/// Default Volcengine TOS Signer type with commonly used components
+#[cfg(feature = "default-context")]
+pub type DefaultSigner = Signer<Credential>;
+
+/// Create a default Volcengine TOS signer with standard configuration
+///
+/// This function creates a signer with:
+/// - Default context (with Tokio file reader, reqwest HTTP client, OS
environment)
+/// - Default credential provider (reads from env vars)
+/// - Request signer for the specified region
+///
+/// # Example
+///
+/// ```no_run
+/// # #[tokio::main]
+/// # async fn main() -> reqsign_core::Result<()> {
+/// // Create a signer for Volcengine TOS in cn-beijing region
+/// let signer = reqsign::volcengine::default_signer("cn-beijing");
+///
+/// // Sign a request
+/// let mut req = http::Request::builder()
+/// .method("GET")
+/// .uri("https://mybucket.tos-cn-beijing.volces.com/myobject")
+/// .body(())
+/// .unwrap()
+/// .into_parts()
+/// .0;
+///
+/// signer.sign(&mut req, None).await?;
+/// # Ok(())
+/// # }
+/// ```
+#[cfg(feature = "default-context")]
+pub fn default_signer(region: &str) -> DefaultSigner {
+ let ctx = default_context();
+ let provider = DefaultCredentialProvider::new();
+ let signer = RequestSigner::new(region);
+ Signer::new(ctx, provider, signer)
+}
diff --git a/services/volcengine-tos/Cargo.toml
b/services/volcengine-tos/Cargo.toml
new file mode 100644
index 0000000..e24c8b4
--- /dev/null
+++ b/services/volcengine-tos/Cargo.toml
@@ -0,0 +1,42 @@
+# 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.
+
+[package]
+name = "reqsign-volcengine-tos"
+version = "2.0.2"
+
+description = "Volcengine TOS signing implementation for reqsign."
+
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+rust-version.workspace = true
+
+[dependencies]
+anyhow = { workspace = true }
+async-trait = { workspace = true }
+http = { workspace = true }
+log = { workspace = true }
+percent-encoding = { workspace = true }
+reqsign-core = { workspace = true }
+
+[dev-dependencies]
+anyhow = { workspace = true }
+env_logger = { workspace = true }
+reqsign-file-read-tokio = { workspace = true }
+reqsign-http-send-reqwest = { workspace = true }
+tokio = { workspace = true, features = ["full"] }
diff --git a/reqsign/src/lib.rs b/services/volcengine-tos/src/constants.rs
similarity index 54%
copy from reqsign/src/lib.rs
copy to services/volcengine-tos/src/constants.rs
index 9184937..0091e13 100644
--- a/reqsign/src/lib.rs
+++ b/services/volcengine-tos/src/constants.rs
@@ -15,36 +15,24 @@
// specific language governing permissions and limitations
// under the License.
-#![doc = include_str!("../README.md")]
-#![cfg_attr(docsrs, feature(doc_auto_cfg))]
-
-// Re-export core types
-pub use reqsign_core::*;
-
-// Context utilities
-#[cfg(feature = "default-context")]
-mod context;
-#[cfg(feature = "default-context")]
-pub use context::default_context;
-
-// Service modules with convenience APIs
-#[cfg(feature = "aliyun")]
-pub mod aliyun;
-
-#[cfg(feature = "aws")]
-pub mod aws;
-
-#[cfg(feature = "azure")]
-pub mod azure;
-
-#[cfg(feature = "google")]
-pub mod google;
-
-#[cfg(feature = "huaweicloud")]
-pub mod huaweicloud;
-
-#[cfg(feature = "oracle")]
-pub mod oracle;
-
-#[cfg(feature = "tencent")]
-pub mod tencent;
+use percent_encoding::{AsciiSet, NON_ALPHANUMERIC};
+
+pub const ENV_ACCESS_KEY_ID: &str = "VOLCENGINE_ACCESS_KEY_ID";
+pub const ENV_SECRET_ACCESS_KEY: &str = "VOLCENGINE_SECRET_ACCESS_KEY";
+pub const ENV_SESSION_TOKEN: &str = "VOLCENGINE_SESSION_TOKEN";
+
+pub static VOLCENGINE_URI_ENCODE_SET: AsciiSet = NON_ALPHANUMERIC
+ .remove(b'-')
+ .remove(b'_')
+ .remove(b'.')
+ .remove(b'~')
+ .remove(b'/');
+
+pub static VOLCENGINE_QUERY_ENCODE_SET: AsciiSet = NON_ALPHANUMERIC
+ .remove(b'-')
+ .remove(b'_')
+ .remove(b'.')
+ .remove(b'~');
+
+pub const EMPTY_PAYLOAD_SHA256: &str =
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
diff --git a/services/volcengine-tos/src/credential.rs
b/services/volcengine-tos/src/credential.rs
new file mode 100644
index 0000000..c5fb9b5
--- /dev/null
+++ b/services/volcengine-tos/src/credential.rs
@@ -0,0 +1,62 @@
+// 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 reqsign_core::SigningCredential;
+use reqsign_core::utils::Redact;
+use std::fmt::{Debug, Formatter};
+
+/// Credential for Volcengine TOS.
+#[derive(Clone)]
+pub struct Credential {
+ /// Access key id.
+ pub access_key_id: String,
+ /// Secret access key.
+ pub secret_access_key: String,
+ /// Session token.
+ pub session_token: Option<String>,
+}
+
+impl Credential {
+ pub fn new(access_key_id: &str, secret_access_key: &str) -> Self {
+ Self {
+ access_key_id: access_key_id.to_string(),
+ secret_access_key: secret_access_key.to_string(),
+ session_token: None,
+ }
+ }
+
+ pub fn with_session_token(mut self, token: &str) -> Self {
+ self.session_token = Some(token.to_string());
+ self
+ }
+}
+
+impl Debug for Credential {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("Credential")
+ .field("access_key_id", &Redact::from(&self.access_key_id))
+ .field("secret_access_key", &Redact::from(&self.secret_access_key))
+ .field("session_token", &Redact::from(&self.session_token))
+ .finish()
+ }
+}
+
+impl SigningCredential for Credential {
+ fn is_valid(&self) -> bool {
+ !self.access_key_id.is_empty() && !self.secret_access_key.is_empty()
+ }
+}
diff --git a/reqsign/src/lib.rs b/services/volcengine-tos/src/lib.rs
similarity index 56%
copy from reqsign/src/lib.rs
copy to services/volcengine-tos/src/lib.rs
index 9184937..48a3637 100644
--- a/reqsign/src/lib.rs
+++ b/services/volcengine-tos/src/lib.rs
@@ -15,36 +15,17 @@
// specific language governing permissions and limitations
// under the License.
-#![doc = include_str!("../README.md")]
-#![cfg_attr(docsrs, feature(doc_auto_cfg))]
+//! Volcengine TOS signing implementation for reqsign.
-// Re-export core types
-pub use reqsign_core::*;
+mod constants;
+mod credential;
+pub use credential::Credential;
-// Context utilities
-#[cfg(feature = "default-context")]
-mod context;
-#[cfg(feature = "default-context")]
-pub use context::default_context;
+mod provide_credential;
+pub use provide_credential::*;
-// Service modules with convenience APIs
-#[cfg(feature = "aliyun")]
-pub mod aliyun;
+mod sign_request;
+mod uri;
+pub use uri::{percent_encode_path, percent_encode_query};
-#[cfg(feature = "aws")]
-pub mod aws;
-
-#[cfg(feature = "azure")]
-pub mod azure;
-
-#[cfg(feature = "google")]
-pub mod google;
-
-#[cfg(feature = "huaweicloud")]
-pub mod huaweicloud;
-
-#[cfg(feature = "oracle")]
-pub mod oracle;
-
-#[cfg(feature = "tencent")]
-pub mod tencent;
+pub use sign_request::RequestSigner;
diff --git a/services/volcengine-tos/src/provide_credential/default.rs
b/services/volcengine-tos/src/provide_credential/default.rs
new file mode 100644
index 0000000..678596b
--- /dev/null
+++ b/services/volcengine-tos/src/provide_credential/default.rs
@@ -0,0 +1,196 @@
+// 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 async_trait::async_trait;
+use reqsign_core::Result;
+use reqsign_core::{Context, ProvideCredential, ProvideCredentialChain};
+
+use crate::credential::Credential;
+use crate::provide_credential::EnvCredentialProvider;
+
+/// DefaultCredentialProvider will try to load credential from different
sources.
+///
+/// Resolution order:
+///
+/// 1. Environment variables
+#[derive(Debug)]
+pub struct DefaultCredentialProvider {
+ chain: ProvideCredentialChain<Credential>,
+}
+
+impl Default for DefaultCredentialProvider {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl DefaultCredentialProvider {
+ /// Create a builder to configure the default credential chain.
+ pub fn builder() -> DefaultCredentialProviderBuilder {
+ DefaultCredentialProviderBuilder::default()
+ }
+
+ /// Create a new DefaultCredentialProvider using the default chain.
+ pub fn new() -> Self {
+ Self::builder().build()
+ }
+
+ /// Create with a custom credential chain.
+ pub fn with_chain(chain: ProvideCredentialChain<Credential>) -> Self {
+ Self { chain }
+ }
+
+ /// Add a credential provider to the front of the default chain.
+ ///
+ /// This allows adding a high-priority credential source that will be tried
+ /// before all other providers in the default chain.
+ ///
+ /// # Example
+ ///
+ /// ```no_run
+ /// use reqsign_volcengine_tos::{DefaultCredentialProvider,
StaticCredentialProvider};
+ ///
+ /// let provider = DefaultCredentialProvider::new()
+ /// .push_front(StaticCredentialProvider::new("access_key_id",
"secret_access_key"));
+ /// ```
+ pub fn push_front(
+ mut self,
+ provider: impl ProvideCredential<Credential = Credential> + 'static,
+ ) -> Self {
+ self.chain = self.chain.push_front(provider);
+ self
+ }
+}
+
+/// Builder for `DefaultCredentialProvider`.
+///
+/// Use `configure_env` to customize environment loading and
+/// `disable_env(bool)` to control participation, then `build()` to create the
provider.
+#[derive(Default)]
+pub struct DefaultCredentialProviderBuilder {
+ env: Option<EnvCredentialProvider>,
+}
+
+impl DefaultCredentialProviderBuilder {
+ /// Create a new builder with default state.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Configure the environment credential provider.
+ pub fn configure_env<F>(mut self, f: F) -> Self
+ where
+ F: FnOnce(EnvCredentialProvider) -> EnvCredentialProvider,
+ {
+ let p = self.env.take().unwrap_or_default();
+ self.env = Some(f(p));
+ self
+ }
+
+ /// Disable (true) or ensure enabled (false) the environment provider.
+ pub fn disable_env(mut self, disable: bool) -> Self {
+ if disable {
+ self.env = None;
+ } else if self.env.is_none() {
+ self.env = Some(EnvCredentialProvider::new());
+ }
+ self
+ }
+
+ /// Build the `DefaultCredentialProvider` with the configured options.
+ pub fn build(self) -> DefaultCredentialProvider {
+ let mut chain = ProvideCredentialChain::new();
+ if let Some(p) = self.env {
+ chain = chain.push(p);
+ } else {
+ chain = chain.push(EnvCredentialProvider::new());
+ }
+ DefaultCredentialProvider::with_chain(chain)
+ }
+}
+
+#[async_trait]
+impl ProvideCredential for DefaultCredentialProvider {
+ type Credential = Credential;
+
+ async fn provide_credential(&self, ctx: &Context) ->
Result<Option<Self::Credential>> {
+ self.chain.provide_credential(ctx).await
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::constants::*;
+ use reqsign_core::StaticEnv;
+ use std::collections::HashMap;
+
+ #[tokio::test]
+ async fn test_default_loader_without_env() {
+ let ctx = Context::new().with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::new(),
+ });
+
+ let loader = DefaultCredentialProvider::new();
+ let credential = loader.provide_credential(&ctx).await.unwrap();
+
+ assert!(credential.is_none());
+ }
+
+ #[tokio::test]
+ async fn test_default_loader_with_env() {
+ let ctx = Context::new().with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from_iter([
+ (ENV_ACCESS_KEY_ID.to_string(), "access_key_id".to_string()),
+ (
+ ENV_SECRET_ACCESS_KEY.to_string(),
+ "secret_access_key".to_string(),
+ ),
+ ]),
+ });
+
+ let loader = DefaultCredentialProvider::new();
+ let credential =
loader.provide_credential(&ctx).await.unwrap().unwrap();
+
+ assert_eq!("access_key_id", credential.access_key_id);
+ assert_eq!("secret_access_key", credential.secret_access_key);
+ }
+
+ #[tokio::test]
+ async fn test_default_loader_with_security_token() {
+ let ctx = Context::new().with_env(StaticEnv {
+ home_dir: None,
+ envs: HashMap::from_iter([
+ (ENV_ACCESS_KEY_ID.to_string(), "access_key_id".to_string()),
+ (
+ ENV_SECRET_ACCESS_KEY.to_string(),
+ "secret_access_key".to_string(),
+ ),
+ (ENV_SESSION_TOKEN.to_string(), "security_token".to_string()),
+ ]),
+ });
+
+ let loader = DefaultCredentialProvider::new();
+ let credential =
loader.provide_credential(&ctx).await.unwrap().unwrap();
+
+ assert_eq!("access_key_id", credential.access_key_id);
+ assert_eq!("secret_access_key", credential.secret_access_key);
+ assert_eq!("security_token", credential.session_token.unwrap());
+ }
+}
diff --git a/services/volcengine-tos/src/provide_credential/env.rs
b/services/volcengine-tos/src/provide_credential/env.rs
new file mode 100644
index 0000000..5c68b3e
--- /dev/null
+++ b/services/volcengine-tos/src/provide_credential/env.rs
@@ -0,0 +1,153 @@
+// 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 async_trait::async_trait;
+use reqsign_core::Context;
+use reqsign_core::ProvideCredential;
+use reqsign_core::Result;
+
+use super::super::constants::*;
+use super::super::credential::Credential;
+
+/// EnvCredentialProvider loads Volcengine credentials from environment
variables.
+///
+/// This provider looks for the following environment variables:
+/// - `VOLCENGINE_ACCESS_KEY_ID`: The Volcengine access key ID
+/// - `VOLCENGINE_SECRET_ACCESS_KEY`: The Volcengine secret access key
+/// - `VOLCENGINE_SESSION_TOKEN`: The Volcengine session token (optional)
+#[derive(Debug, Default, Clone)]
+pub struct EnvCredentialProvider;
+
+impl EnvCredentialProvider {
+ /// Create a new EnvCredentialProvider.
+ pub fn new() -> Self {
+ Self
+ }
+}
+
+#[async_trait]
+impl ProvideCredential for EnvCredentialProvider {
+ type Credential = Credential;
+
+ async fn provide_credential(&self, ctx: &Context) ->
Result<Option<Self::Credential>> {
+ let env = ctx.env_vars();
+ let access_key_id = env.get(ENV_ACCESS_KEY_ID).cloned();
+ let secret_access_key = env.get(ENV_SECRET_ACCESS_KEY).cloned();
+ let security_token = env.get(ENV_SESSION_TOKEN).cloned();
+
+ match (access_key_id, secret_access_key) {
+ (Some(ak), Some(sk)) => {
+ let mut cred = Credential::new(&ak, &sk);
+ if let Some(token) = security_token {
+ cred = cred.with_session_token(&token);
+ }
+ Ok(Some(cred))
+ }
+ _ => Ok(None),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use reqsign_core::StaticEnv;
+ use std::collections::HashMap;
+
+ #[tokio::test]
+ async fn test_env_credential_provider() -> anyhow::Result<()> {
+ let envs = HashMap::from([
+ (ENV_ACCESS_KEY_ID.to_string(), "test_access_key".to_string()),
+ (
+ ENV_SECRET_ACCESS_KEY.to_string(),
+ "test_secret_key".to_string(),
+ ),
+ ]);
+
+ let ctx = Context::new().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, "test_access_key");
+ assert_eq!(cred.secret_access_key, "test_secret_key");
+ assert!(cred.session_token.is_none());
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_env_credential_provider_with_session_token() ->
anyhow::Result<()> {
+ let envs = HashMap::from([
+ (ENV_ACCESS_KEY_ID.to_string(), "test_access_key".to_string()),
+ (
+ ENV_SECRET_ACCESS_KEY.to_string(),
+ "test_secret_key".to_string(),
+ ),
+ (
+ ENV_SESSION_TOKEN.to_string(),
+ "test_session_token".to_string(),
+ ),
+ ]);
+
+ let ctx = Context::new().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, "test_access_key");
+ assert_eq!(cred.secret_access_key, "test_secret_key");
+ assert_eq!(cred.session_token, Some("test_session_token".to_string()));
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_env_credential_provider_missing_credentials() ->
anyhow::Result<()> {
+ let ctx = Context::new().with_env(StaticEnv::default());
+
+ let provider = EnvCredentialProvider::new();
+ let cred = provider.provide_credential(&ctx).await?;
+ assert!(cred.is_none());
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_env_credential_provider_partial_credentials() ->
anyhow::Result<()> {
+ let envs = HashMap::from([(ENV_ACCESS_KEY_ID.to_string(),
"test_access_key".to_string())]);
+
+ let ctx = Context::new().with_env(StaticEnv {
+ home_dir: None,
+ envs,
+ });
+
+ let provider = EnvCredentialProvider::new();
+ let cred = provider.provide_credential(&ctx).await?;
+ assert!(cred.is_none());
+
+ Ok(())
+ }
+}
diff --git a/reqsign/src/lib.rs
b/services/volcengine-tos/src/provide_credential/mod.rs
similarity index 56%
copy from reqsign/src/lib.rs
copy to services/volcengine-tos/src/provide_credential/mod.rs
index 9184937..4461cd3 100644
--- a/reqsign/src/lib.rs
+++ b/services/volcengine-tos/src/provide_credential/mod.rs
@@ -15,36 +15,10 @@
// specific language governing permissions and limitations
// under the License.
-#![doc = include_str!("../README.md")]
-#![cfg_attr(docsrs, feature(doc_auto_cfg))]
+mod default;
+mod env;
+mod r#static;
-// Re-export core types
-pub use reqsign_core::*;
-
-// Context utilities
-#[cfg(feature = "default-context")]
-mod context;
-#[cfg(feature = "default-context")]
-pub use context::default_context;
-
-// Service modules with convenience APIs
-#[cfg(feature = "aliyun")]
-pub mod aliyun;
-
-#[cfg(feature = "aws")]
-pub mod aws;
-
-#[cfg(feature = "azure")]
-pub mod azure;
-
-#[cfg(feature = "google")]
-pub mod google;
-
-#[cfg(feature = "huaweicloud")]
-pub mod huaweicloud;
-
-#[cfg(feature = "oracle")]
-pub mod oracle;
-
-#[cfg(feature = "tencent")]
-pub mod tencent;
+pub use default::DefaultCredentialProvider;
+pub use env::EnvCredentialProvider;
+pub use r#static::StaticCredentialProvider;
diff --git a/services/volcengine-tos/src/provide_credential/static.rs
b/services/volcengine-tos/src/provide_credential/static.rs
new file mode 100644
index 0000000..42f4df7
--- /dev/null
+++ b/services/volcengine-tos/src/provide_credential/static.rs
@@ -0,0 +1,100 @@
+// 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 async_trait::async_trait;
+use reqsign_core::Context;
+use reqsign_core::ProvideCredential;
+use reqsign_core::Result;
+
+use super::super::credential::Credential;
+
+/// StaticCredentialProvider provides static Volcengine credentials.
+///
+/// This provider is used when you have the access key ID and secret access key
+/// directly and want to use them without any dynamic loading.
+#[derive(Debug, Clone)]
+pub struct StaticCredentialProvider {
+ access_key_id: String,
+ access_key_secret: String,
+ security_token: Option<String>,
+}
+
+impl StaticCredentialProvider {
+ /// Create a new StaticCredentialProvider with access key ID and secret
access key.
+ pub fn new(access_key_id: &str, access_key_secret: &str) -> Self {
+ Self {
+ access_key_id: access_key_id.to_string(),
+ access_key_secret: access_key_secret.to_string(),
+ security_token: None,
+ }
+ }
+
+ /// Set the security token.
+ pub fn with_security_token(mut self, token: &str) -> Self {
+ self.security_token = Some(token.to_string());
+ self
+ }
+}
+
+#[async_trait]
+impl ProvideCredential for StaticCredentialProvider {
+ type Credential = Credential;
+
+ async fn provide_credential(&self, _ctx: &Context) ->
Result<Option<Self::Credential>> {
+ let mut cred = Credential::new(&self.access_key_id,
&self.access_key_secret);
+ if let Some(token) = &self.security_token {
+ cred = cred.with_session_token(token);
+ }
+ Ok(Some(cred))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn test_static_credential_provider() -> anyhow::Result<()> {
+ let ctx = Context::new();
+
+ let provider = StaticCredentialProvider::new("test_access_key",
"test_secret_key");
+ let cred = provider.provide_credential(&ctx).await?;
+ assert!(cred.is_some());
+ let cred = cred.unwrap();
+ assert_eq!(cred.access_key_id, "test_access_key");
+ assert_eq!(cred.secret_access_key, "test_secret_key");
+ assert!(cred.session_token.is_none());
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_static_credential_provider_with_security_token() ->
anyhow::Result<()> {
+ let ctx = Context::new();
+
+ let provider = StaticCredentialProvider::new("test_access_key",
"test_secret_key")
+ .with_security_token("test_security_token");
+ let cred = provider.provide_credential(&ctx).await?;
+ assert!(cred.is_some());
+ let cred = cred.unwrap();
+ assert_eq!(cred.access_key_id, "test_access_key");
+ assert_eq!(cred.secret_access_key, "test_secret_key");
+ assert_eq!(cred.session_token,
Some("test_security_token".to_string()));
+
+ Ok(())
+ }
+}
diff --git a/services/volcengine-tos/src/sign_request.rs
b/services/volcengine-tos/src/sign_request.rs
new file mode 100644
index 0000000..c4e1c42
--- /dev/null
+++ b/services/volcengine-tos/src/sign_request.rs
@@ -0,0 +1,310 @@
+// 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 std::fmt::Write;
+use std::sync::LazyLock;
+
+use async_trait::async_trait;
+use http::header::AUTHORIZATION;
+use http::{HeaderName, HeaderValue, header};
+use log::debug;
+use percent_encoding::percent_decode_str;
+use reqsign_core::hash::{hex_hmac_sha256, hex_sha256, hmac_sha256};
+use reqsign_core::time::Timestamp;
+use reqsign_core::{Context, Result, SignRequest, SigningRequest};
+
+use crate::constants::*;
+use crate::credential::Credential;
+use crate::uri::{percent_encode_path, percent_encode_query};
+
+static HEADER_TOS_DATE: LazyLock<HeaderName> =
+ LazyLock::new(|| HeaderName::from_static("x-tos-date"));
+static HEADER_TOS_SECURITY_TOKEN: LazyLock<HeaderName> =
+ LazyLock::new(|| HeaderName::from_static("x-tos-security-token"));
+
+/// RequestSigner that implements Volcengine TOS signing.
+///
+/// - [Volcengine TOS Signature](https://www.volcengine.com/docs/6349/1747874)
+#[derive(Debug)]
+pub struct RequestSigner {
+ region: String,
+ time: Option<Timestamp>,
+}
+
+impl RequestSigner {
+ /// Create a new RequestSigner for the given region.
+ pub fn new(region: &str) -> Self {
+ Self {
+ region: region.to_string(),
+ time: None,
+ }
+ }
+
+ /// Specify the signing time.
+ ///
+ /// # Note
+ ///
+ /// We should always take current time to sign requests.
+ /// Only use this function for testing.
+ #[cfg(test)]
+ pub fn with_time(mut self, time: Timestamp) -> Self {
+ self.time = Some(time);
+ self
+ }
+}
+
+#[async_trait]
+impl SignRequest for RequestSigner {
+ type Credential = Credential;
+
+ async fn sign_request(
+ &self,
+ _ctx: &Context,
+ req: &mut http::request::Parts,
+ credential: Option<&Self::Credential>,
+ _expires_in: Option<std::time::Duration>,
+ ) -> Result<()> {
+ let Some(cred) = credential else {
+ return Ok(());
+ };
+
+ let now = self.time.unwrap_or_else(Timestamp::now);
+
+ let mut signing_req = SigningRequest::build(req)?;
+
+ // Insert HOST header if not present.
+ if signing_req.headers.get(header::HOST).is_none() {
+ signing_req.headers.insert(
+ header::HOST,
+ signing_req.authority.as_str().parse().map_err(|e| {
+ reqsign_core::Error::unexpected(format!(
+ "failed to parse authority as header value: {e}"
+ ))
+ })?,
+ );
+ }
+
+ let date_str = now.format_iso8601();
+ let date_only = now.format_date();
+
+ signing_req
+ .headers
+ .insert(&*HEADER_TOS_DATE, date_str.parse()?);
+
+ if let Some(token) = &cred.session_token {
+ signing_req
+ .headers
+ .insert(&*HEADER_TOS_SECURITY_TOKEN, token.parse()?);
+ }
+
+ canonicalize_query(&mut signing_req);
+ let (canonical_request_hash, _) = canonical_request_hash(&mut
signing_req)?;
+
+ // Scope: "<date>/<region>/tos/request"
+ let credential_scope = format!("{}/{}/tos/request", date_only,
self.region);
+
+ // StringToSign:
+ //
+ // TOS4-HMAC-SHA256
+ // <iso8601_date>
+ // <scope>
+ // <hashed_canonical_request>
+ let string_to_sign = {
+ let mut s = String::new();
+ writeln!(s, "TOS4-HMAC-SHA256")?;
+ writeln!(s, "{}", date_str)?;
+ writeln!(s, "{}", credential_scope)?;
+ s.push_str(&canonical_request_hash);
+ s
+ };
+
+ debug!("string to sign: {}", &string_to_sign);
+
+ let signed_headers_str =
signing_req.header_name_to_vec_sorted().join(";");
+
+ let signing_key = generate_signing_key(&cred.secret_access_key,
&date_only, &self.region);
+ let signature = hex_hmac_sha256(&signing_key,
string_to_sign.as_bytes());
+
+ let authorization = format!(
+ "TOS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={},
Signature={}",
+ cred.access_key_id, credential_scope, signed_headers_str, signature
+ );
+
+ debug!("authorization: {}", &authorization);
+
+ let mut auth_value: HeaderValue = authorization.parse()?;
+ auth_value.set_sensitive(true);
+ signing_req.headers.insert(AUTHORIZATION, auth_value);
+
+ signing_req.apply(req)
+ }
+}
+
+fn canonicalize_query(ctx: &mut SigningRequest) {
+ ctx.query = ctx
+ .query
+ .iter()
+ .map(|(k, v)| (percent_encode_query(k), percent_encode_query(v)))
+ .collect();
+ // Sort by param name
+ ctx.query.sort();
+}
+
+fn canonical_request_hash(ctx: &mut SigningRequest) -> Result<(String,
String)> {
+ let mut canonical_request = String::with_capacity(256);
+
+ // Insert method
+ canonical_request.push_str(ctx.method.as_str());
+ canonical_request.push('\n');
+
+ // Insert encoded path
+ let path = percent_decode_str(&ctx.path)
+ .decode_utf8()
+ .map_err(|e| reqsign_core::Error::unexpected(format!("failed to decode
path: {e}")))?;
+ let canonical_path = percent_encode_path(&path);
+ canonical_request.push_str(&canonical_path);
+ canonical_request.push('\n');
+
+ // Insert encoded query
+ let query_string = ctx
+ .query
+ .iter()
+ .map(|(k, v)| format!("{}={}", k, v))
+ .collect::<Vec<_>>()
+ .join("&");
+
+ canonical_request.push_str(&query_string);
+ canonical_request.push('\n');
+
+ // Insert signed headers
+ let signed_headers = ctx.header_name_to_vec_sorted();
+
+ for header in &signed_headers {
+ let value = &ctx.headers[*header];
+ canonical_request.push_str(header);
+ canonical_request.push(':');
+ if let Ok(value_str) = value.to_str() {
+ canonical_request.push_str(value_str.trim());
+ }
+ canonical_request.push('\n');
+ }
+
+ canonical_request.push('\n');
+ canonical_request.push_str(signed_headers.join(";").as_str());
+ canonical_request.push('\n');
+
+ canonical_request.push_str(EMPTY_PAYLOAD_SHA256);
+
+ let hash = hex_sha256(canonical_request.as_bytes());
+
+ Ok((hash, canonical_request))
+}
+
+fn generate_signing_key(secret: &str, date: &str, region: &str) -> Vec<u8> {
+ // Sign date
+ let sign_date = hmac_sha256(secret.as_bytes(), date.as_bytes());
+ // Sign region
+ let sign_region = hmac_sha256(sign_date.as_slice(), region.as_bytes());
+ // Sign service
+ let sign_service = hmac_sha256(sign_region.as_slice(), "tos".as_bytes());
+ // Sign request
+ hmac_sha256(sign_service.as_slice(), "request".as_bytes())
+}
+
+#[cfg(test)]
+mod tests {
+ use std::str::FromStr;
+
+ use http::Uri;
+
+ use super::*;
+ use crate::provide_credential::StaticCredentialProvider;
+ use reqsign_core::{Context, OsEnv, Signer};
+ use reqsign_file_read_tokio::TokioFileRead;
+ use reqsign_http_send_reqwest::ReqwestHttpSend;
+
+ #[tokio::test]
+ async fn test_sign_request() -> Result<()> {
+ let _ = env_logger::builder().is_test(true).try_init();
+
+ let loader = StaticCredentialProvider::new("testAK", "testSK");
+ let signer = RequestSigner::new("cn-beijing")
+ .with_time(Timestamp::parse_rfc2822("Sat, 1 Jan 2022 00:00:00
GMT")?);
+
+ let ctx = Context::new()
+ .with_file_read(TokioFileRead)
+ .with_http_send(ReqwestHttpSend::default())
+ .with_env(OsEnv);
+
+ let signer = Signer::new(ctx, loader, signer);
+
+ let get_req =
"https://examplebucket.tos-cn-beijing.volces.com/exampleobject";
+ let mut req = http::Request::get(Uri::from_str(get_req)?).body(())?;
+ req.headers_mut().insert(
+ HeaderName::from_str("x-tos-content-sha256")?,
+ HeaderValue::from_str(
+
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ )?,
+ );
+
+ let (mut parts, _) = req.into_parts();
+ signer.sign(&mut parts, None).await?;
+
+ let headers = parts.headers;
+ let tos_date = headers.get("x-tos-date").unwrap();
+ let auth = headers.get("Authorization").unwrap();
+
+ assert!(
+ tos_date.to_str()?.starts_with("2022"),
+ "x-tos-date should be in ISO8601 format"
+ );
+ assert_eq!(
+ "TOS4-HMAC-SHA256
Credential=testAK/20220101/cn-beijing/tos/request,
SignedHeaders=host;x-tos-content-sha256;x-tos-date,
Signature=d40b66cf0054d1642843670d10fa095e1609c7896f25df217770b0abe717693b",
+ auth.to_str()?
+ );
+
+ Ok(())
+ }
+
+ #[tokio::test]
+ async fn test_sign_list_objects() -> Result<()> {
+ let _ = env_logger::builder().is_test(true).try_init();
+ let loader = StaticCredentialProvider::new("testAK", "testSK");
+
+ let signer =
RequestSigner::new("cn-beijing").with_time("2026-02-03T12:24:12Z".parse()?);
+
+ let ctx = Context::new()
+ .with_file_read(TokioFileRead)
+ .with_http_send(ReqwestHttpSend::default())
+ .with_env(OsEnv);
+ let signer = Signer::new(ctx, loader, signer);
+
+ let req =
http::Request::get("https://bucket.tos-cn-beijing.volces.com?list-type=2&prefix=abc&delimiter=%2F&max-keys=5&continuation-token=whvFnl2rE5vm9cWvQSgxwpc7QXHY7dgUGQ7nxlsVxFymg2%2BK227j5IHQZ32h").body(())?;
+ let (mut parts, _) = req.into_parts();
+
+ signer.sign(&mut parts, None).await?;
+
+ let headers = parts.headers;
+ let auth = headers.get("Authorization").unwrap();
+
+ assert_eq!(
+ "TOS4-HMAC-SHA256
Credential=testAK/20260203/cn-beijing/tos/request,
SignedHeaders=host;x-tos-date,
Signature=db01ee877fa24847ec042703353a76a0e11bd9b6ce68eabe5ccb2924420156b0",
+ auth.to_str()?
+ );
+ Ok(())
+ }
+}
diff --git a/reqsign/src/lib.rs b/services/volcengine-tos/src/uri.rs
similarity index 56%
copy from reqsign/src/lib.rs
copy to services/volcengine-tos/src/uri.rs
index 9184937..e9ea4fb 100644
--- a/reqsign/src/lib.rs
+++ b/services/volcengine-tos/src/uri.rs
@@ -15,36 +15,13 @@
// specific language governing permissions and limitations
// under the License.
-#![doc = include_str!("../README.md")]
-#![cfg_attr(docsrs, feature(doc_auto_cfg))]
+use crate::constants::{VOLCENGINE_QUERY_ENCODE_SET, VOLCENGINE_URI_ENCODE_SET};
+use percent_encoding::utf8_percent_encode;
-// Re-export core types
-pub use reqsign_core::*;
+pub fn percent_encode_path(path: &str) -> String {
+ utf8_percent_encode(path, &VOLCENGINE_URI_ENCODE_SET).to_string()
+}
-// Context utilities
-#[cfg(feature = "default-context")]
-mod context;
-#[cfg(feature = "default-context")]
-pub use context::default_context;
-
-// Service modules with convenience APIs
-#[cfg(feature = "aliyun")]
-pub mod aliyun;
-
-#[cfg(feature = "aws")]
-pub mod aws;
-
-#[cfg(feature = "azure")]
-pub mod azure;
-
-#[cfg(feature = "google")]
-pub mod google;
-
-#[cfg(feature = "huaweicloud")]
-pub mod huaweicloud;
-
-#[cfg(feature = "oracle")]
-pub mod oracle;
-
-#[cfg(feature = "tencent")]
-pub mod tencent;
+pub fn percent_encode_query(query: &str) -> String {
+ utf8_percent_encode(query, &VOLCENGINE_QUERY_ENCODE_SET).to_string()
+}