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()
+}


Reply via email to