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:27:36 UTC

(opendal-reqsign) 01/01: feat(aliyun-oss): add signature v4 signing support

This is an automated email from the ASF dual-hosted git repository.

xuanwo pushed a commit to branch xuanwo/oss-pr7-signature-v4
in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git

commit f3f18ed08631a1a2b8025f8f005ee9b8037cf2b0
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Thu Mar 19 01:27:22 2026 +0800

    feat(aliyun-oss): add signature v4 signing support
---
 services/aliyun-oss/README.md           |  21 +-
 services/aliyun-oss/src/lib.rs          |  14 +-
 services/aliyun-oss/src/sign_request.rs | 617 +++++++++++++++++++++++++++++++-
 3 files changed, 623 insertions(+), 29 deletions(-)

diff --git a/services/aliyun-oss/README.md b/services/aliyun-oss/README.md
index 1b05c41..c7d493d 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,
+    RequestSigner, SigningVersion, StaticCredentialProvider,
 };
 use reqsign_core::{Context, Result, Signer};
 use reqsign_file_read_tokio::TokioFileRead;
@@ -35,11 +35,13 @@ async fn main() -> Result<()> {
     // );
 
     let signer = Signer::new(ctx, loader, RequestSigner::new("bucket"));
-    // For future signing versions, region can be configured in advance:
+    // Or opt into V4 signing:
     // let signer = Signer::new(
     //     ctx,
     //     loader,
-    //     RequestSigner::new("bucket").with_region("oss-cn-beijing"),
+    //     RequestSigner::new("bucket")
+    //         .with_region("cn-beijing")
+    //         .with_signing_version(SigningVersion::V4),
     // );
 
     let mut req = http::Request::get("https://bucket.oss-cn-beijing.aliyuncs.com/object.txt")
@@ -55,7 +57,7 @@ async fn main() -> Result<()> {
 
 ## Features
 
-- **HMAC-SHA1 Signing**: Complete implementation of Aliyun's signing algorithm
+- **V1 and V4 Signing**: Supports both legacy OSS V1 signatures and Signature V4
 - **Multiple Credential Sources**: Environment variables and OIDC-based STS exchange
 - **STS Support**: Temporary credentials via Security Token Service
 - **All OSS Operations**: Object, bucket, and multipart operations
@@ -64,16 +66,17 @@ async fn main() -> Result<()> {
 
 `RequestSigner::new("bucket")` keeps the current V1 behavior.
 
-If you want to wire configuration that future signing versions will need, you
-can set the region ahead of time:
+To opt into V4 signing, configure both the region and signing version:
 
 ```rust
-use reqsign_aliyun_oss::RequestSigner;
+use reqsign_aliyun_oss::{RequestSigner, SigningVersion};
 
-let signer = RequestSigner::new("bucket").with_region("oss-cn-beijing");
+let signer = RequestSigner::new("bucket")
+    .with_region("cn-beijing")
+    .with_signing_version(SigningVersion::V4);
 ```
 
-The region is currently a no-op for V1 signing.
+The region remains a no-op for V1 signing.
 
 ## Credential Sources
 
diff --git a/services/aliyun-oss/src/lib.rs b/services/aliyun-oss/src/lib.rs
index 93485fc..16969ec 100644
--- a/services/aliyun-oss/src/lib.rs
+++ b/services/aliyun-oss/src/lib.rs
@@ -26,15 +26,15 @@
 //! the complete signing process along with credential loading from various sources
 //! including environment variables, configuration files, and STS tokens.
 //!
-//! `RequestSigner` defaults to V1 signing and also accepts an optional region
-//! for future signing versions that require it.
+//! `RequestSigner` defaults to V1 signing and supports opting into V4 signing
+//! when a region is configured.
 //!
 //! ## Quick Start
 //!
 //! ```no_run
 //! use reqsign_aliyun_oss::{
 //!     AssumeRoleWithOidcCredentialProvider, DefaultCredentialProvider, EnvCredentialProvider,
-//!     RequestSigner, StaticCredentialProvider,
+//!     RequestSigner, SigningVersion, StaticCredentialProvider,
 //! };
 //! use reqsign_core::{Context, Signer, Result};
 //! use reqsign_file_read_tokio::TokioFileRead;
@@ -61,8 +61,10 @@
 //!
 //!     // Create request builder
 //!     let builder = RequestSigner::new("bucket");
-//!     // For future signing versions, region can be wired now:
-//!     // let builder = RequestSigner::new("bucket").with_region("oss-cn-beijing");
+//!     // Or opt into V4:
+//!     // let builder = RequestSigner::new("bucket")
+//!     //     .with_region("cn-beijing")
+//!     //     .with_signing_version(SigningVersion::V4);
 //!
 //!     // Create the signer
 //!     let signer = Signer::new(ctx, loader, builder);
@@ -182,7 +184,7 @@ mod credential;
 pub use credential::Credential;
 
 mod sign_request;
-pub use sign_request::RequestSigner;
+pub use sign_request::{RequestSigner, SigningVersion};
 
 mod provide_credential;
 pub use provide_credential::*;
diff --git a/services/aliyun-oss/src/sign_request.rs b/services/aliyun-oss/src/sign_request.rs
index f33596e..00b38a8 100644
--- a/services/aliyun-oss/src/sign_request.rs
+++ b/services/aliyun-oss/src/sign_request.rs
@@ -17,24 +17,58 @@
 
 use crate::credential::Credential;
 use http::HeaderValue;
-use http::header::{AUTHORIZATION, CONTENT_TYPE, DATE};
-use percent_encoding::utf8_percent_encode;
+use http::header::{AUTHORIZATION, CONTENT_TYPE, DATE, HOST};
+use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
 use reqsign_core::Result;
-use reqsign_core::hash::base64_hmac_sha1;
+use reqsign_core::hash::{base64_hmac_sha1, hex_hmac_sha256, hex_sha256, hmac_sha256};
 use reqsign_core::time::Timestamp;
-use reqsign_core::{Context, SignRequest};
+use reqsign_core::{Context, Error, SignRequest, SigningRequest};
 use std::collections::HashSet;
 use std::fmt::Write;
 use std::sync::LazyLock;
 use std::time::Duration;
 
 const CONTENT_MD5: &str = "content-md5";
+const OSS_V4_ALGORITHM: &str = "OSS4-HMAC-SHA256";
+const OSS_V4_REQUEST: &str = "aliyun_v4_request";
+const OSS_V4_SERVICE: &str = "oss";
+const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
+const X_OSS_ADDITIONAL_HEADERS: &str = "x-oss-additional-headers";
+const X_OSS_CONTENT_SHA256: &str = "x-oss-content-sha256";
+const X_OSS_CREDENTIAL: &str = "x-oss-credential";
+const X_OSS_DATE: &str = "x-oss-date";
+const X_OSS_EXPIRES: &str = "x-oss-expires";
+const X_OSS_SECURITY_TOKEN: &str = "x-oss-security-token";
+const X_OSS_SIGNATURE: &str = "x-oss-signature";
+const X_OSS_SIGNATURE_VERSION: &str = "x-oss-signature-version";
+
+static OSS_V4_URI_ENCODE_SET: AsciiSet = NON_ALPHANUMERIC
+    .remove(b'/')
+    .remove(b'-')
+    .remove(b'.')
+    .remove(b'_')
+    .remove(b'~');
+
+static OSS_V4_QUERY_ENCODE_SET: AsciiSet = NON_ALPHANUMERIC
+    .remove(b'-')
+    .remove(b'.')
+    .remove(b'_')
+    .remove(b'~');
+
+/// SigningVersion controls which OSS signing algorithm the signer uses.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[non_exhaustive]
+pub enum SigningVersion {
+    V1,
+    V4,
+}
 
 /// RequestSigner for Aliyun OSS signature.
 #[derive(Debug)]
 pub struct RequestSigner {
     bucket: String,
     region: Option<String>,
+    signing_version: SigningVersion,
     time: Option<Timestamp>,
 }
 
@@ -44,21 +78,28 @@ impl RequestSigner {
         Self {
             bucket: bucket.to_string(),
             region: None,
+            signing_version: SigningVersion::V1,
             time: None,
         }
     }
 
     /// Set the OSS region.
     ///
-    /// The current V1 signing flow does not use the region, but future signing
-    /// versions such as V4 require it. Keeping this setting on the signer lets
-    /// callers wire stable configuration now without changing the construction
-    /// flow later.
+    /// Signature V4 requires this value. V1 keeps ignoring it.
     pub fn with_region(mut self, region: impl Into<String>) -> Self {
         self.region = Some(region.into());
         self
     }
 
+    /// Set the signing version.
+    ///
+    /// The signer defaults to V1. Use V4 together with [`RequestSigner::with_region`]
+    /// to opt into OSS Signature Version 4.
+    pub fn with_signing_version(mut self, signing_version: SigningVersion) -> Self {
+        self.signing_version = signing_version;
+        self
+    }
+
     /// Specify the signing time.
     ///
     /// # Note
@@ -91,11 +132,17 @@ impl SignRequest for RequestSigner {
 
         let signing_time = self.get_time();
 
-        // Determine signing method based on expires_in
-        if let Some(expires) = expires_in {
-            self.sign_query(req, cred, signing_time, expires)?;
-        } else {
-            self.sign_header(req, cred, signing_time)?;
+        match self.signing_version {
+            SigningVersion::V1 => {
+                if let Some(expires) = expires_in {
+                    self.sign_query(req, cred, signing_time, expires)?;
+                } else {
+                    self.sign_header(req, cred, signing_time)?;
+                }
+            }
+            SigningVersion::V4 => {
+                self.sign_v4(req, cred, signing_time, expires_in)?;
+            }
         }
 
         Ok(())
@@ -261,6 +308,321 @@ impl RequestSigner {
         Ok(s)
     }
 
+    fn sign_v4(
+        &self,
+        req: &mut http::request::Parts,
+        cred: &Credential,
+        signing_time: Timestamp,
+        expires_in: Option<Duration>,
+    ) -> Result<()> {
+        let region = self.region.as_deref().ok_or_else(|| {
+            Error::config_invalid(
+                "OSS V4 signing requires region; call RequestSigner::with_region(...)",
+            )
+        })?;
+
+        let mut signing_req = SigningRequest::build(req)?;
+        self.canonicalize_v4_headers(&mut signing_req, cred, signing_time, expires_in.is_some())?;
+        let additional_headers = self.v4_additional_headers(&signing_req, expires_in.is_some())?;
+        self.canonicalize_v4_query(
+            &mut signing_req,
+            cred,
+            signing_time,
+            expires_in,
+            region,
+            &additional_headers,
+        )?;
+
+        let canonical_request = self.build_v4_canonical_request(
+            &signing_req,
+            expires_in.is_some(),
+            &additional_headers,
+        )?;
+        let scope = self.v4_scope(signing_time, region);
+        let string_to_sign =
+            self.build_v4_string_to_sign(signing_time, &scope, &canonical_request)?;
+        let signature = self.build_v4_signature(cred, signing_time, region, &string_to_sign);
+
+        if expires_in.is_some() {
+            signing_req.query_push(X_OSS_SIGNATURE, signature);
+        } else {
+            let authorization = format!(
+                "{OSS_V4_ALGORITHM} Credential={}/{}, AdditionalHeaders={}, Signature={}",
+                cred.access_key_id,
+                scope,
+                additional_headers.join(";"),
+                signature
+            );
+            let mut value: HeaderValue = authorization.parse()?;
+            value.set_sensitive(true);
+            signing_req.headers.insert(AUTHORIZATION, value);
+        }
+
+        signing_req.apply(req)
+    }
+
+    fn canonicalize_v4_headers(
+        &self,
+        req: &mut SigningRequest,
+        cred: &Credential,
+        signing_time: Timestamp,
+        is_presign: bool,
+    ) -> Result<()> {
+        for (_, value) in req.headers.iter_mut() {
+            SigningRequest::header_value_normalize(value);
+        }
+
+        if is_presign && req.headers.get(HOST).is_none() {
+            req.headers.insert(
+                HOST,
+                req.authority.as_str().parse().map_err(|e| {
+                    Error::request_invalid("invalid authority for Host header").with_source(e)
+                })?,
+            );
+        }
+
+        if !is_presign {
+            if req.headers.get(X_OSS_DATE).is_none() {
+                req.headers
+                    .insert(X_OSS_DATE, signing_time.format_iso8601().parse()?);
+            }
+            if req.headers.get(X_OSS_CONTENT_SHA256).is_none() {
+                req.headers
+                    .insert(X_OSS_CONTENT_SHA256, UNSIGNED_PAYLOAD.parse()?);
+            }
+            if let Some(token) = &cred.security_token {
+                if req.headers.get(X_OSS_SECURITY_TOKEN).is_none() {
+                    req.headers.insert(X_OSS_SECURITY_TOKEN, token.parse()?);
+                }
+            }
+        }
+
+        Ok(())
+    }
+
+    fn canonicalize_v4_query(
+        &self,
+        req: &mut SigningRequest,
+        cred: &Credential,
+        signing_time: Timestamp,
+        expires_in: Option<Duration>,
+        region: &str,
+        additional_headers: &[String],
+    ) -> Result<()> {
+        let mut query_pairs = req
+            .query
+            .iter()
+            .enumerate()
+            .map(|(idx, (k, v))| {
+                (
+                    idx,
+                    utf8_percent_encode(k, &OSS_V4_QUERY_ENCODE_SET).to_string(),
+                    utf8_percent_encode(v, &OSS_V4_QUERY_ENCODE_SET).to_string(),
+                )
+            })
+            .collect::<Vec<_>>();
+
+        if let Some(expires) = expires_in {
+            let scope = self.v4_scope(signing_time, region);
+            let mut next_idx = query_pairs.len();
+            query_pairs.push((
+                next_idx,
+                X_OSS_SIGNATURE_VERSION.to_string(),
+                OSS_V4_ALGORITHM.to_string(),
+            ));
+            next_idx += 1;
+            query_pairs.push((
+                next_idx,
+                X_OSS_CREDENTIAL.to_string(),
+                utf8_percent_encode(
+                    &format!("{}/{}", cred.access_key_id, scope),
+                    &OSS_V4_QUERY_ENCODE_SET,
+                )
+                .to_string(),
+            ));
+            next_idx += 1;
+            query_pairs.push((
+                next_idx,
+                X_OSS_DATE.to_string(),
+                signing_time.format_iso8601(),
+            ));
+            next_idx += 1;
+            query_pairs.push((
+                next_idx,
+                X_OSS_EXPIRES.to_string(),
+                expires.as_secs().to_string(),
+            ));
+            next_idx += 1;
+            query_pairs.push((
+                next_idx,
+                X_OSS_ADDITIONAL_HEADERS.to_string(),
+                utf8_percent_encode(&additional_headers.join(";"), &OSS_V4_QUERY_ENCODE_SET)
+                    .to_string(),
+            ));
+            next_idx += 1;
+            if let Some(token) = &cred.security_token {
+                query_pairs.push((
+                    next_idx,
+                    X_OSS_SECURITY_TOKEN.to_string(),
+                    utf8_percent_encode(token, &OSS_V4_QUERY_ENCODE_SET).to_string(),
+                ));
+            }
+        }
+
+        query_pairs.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
+
+        req.query = query_pairs
+            .into_iter()
+            .map(|(_, k, v)| (k, v))
+            .collect::<Vec<_>>();
+
+        Ok(())
+    }
+
+    fn build_v4_canonical_request(
+        &self,
+        req: &SigningRequest,
+        is_presign: bool,
+        additional_headers: &[String],
+    ) -> Result<String> {
+        let mut canonical_headers = Vec::new();
+        for (name, value) in &req.headers {
+            let name = name.as_str().to_ascii_lowercase();
+            if name == AUTHORIZATION.as_str() {
+                continue;
+            }
+            if self.should_include_v4_header(&name, is_presign)
+                || additional_headers.binary_search(&name).is_ok()
+            {
+                canonical_headers.push((
+                    name,
+                    value
+                        .to_str()
+                        .map_err(|e| {
+                            Error::request_invalid("invalid header value for V4 signing")
+                                .with_source(e)
+                        })?
+                        .to_string(),
+                ));
+            }
+        }
+        canonical_headers.sort_by(|a, b| a.0.cmp(&b.0));
+
+        let mut s = String::new();
+        writeln!(&mut s, "{}", req.method)?;
+        writeln!(
+            &mut s,
+            "{}",
+            self.v4_canonical_uri(&req.path, req.authority.as_str())?
+        )?;
+        writeln!(
+            &mut s,
+            "{}",
+            req.query
+                .iter()
+                .map(|(k, v)| {
+                    if v.is_empty() {
+                        k.clone()
+                    } else {
+                        format!("{k}={v}")
+                    }
+                })
+                .collect::<Vec<_>>()
+                .join("&")
+        )?;
+        for (name, value) in canonical_headers {
+            writeln!(&mut s, "{name}:{value}")?;
+        }
+        writeln!(&mut s)?;
+        writeln!(&mut s, "{}", additional_headers.join(";"))?;
+        write!(&mut s, "{UNSIGNED_PAYLOAD}")?;
+
+        Ok(s)
+    }
+
+    fn build_v4_string_to_sign(
+        &self,
+        signing_time: Timestamp,
+        scope: &str,
+        canonical_request: &str,
+    ) -> Result<String> {
+        let mut s = String::new();
+        writeln!(&mut s, "{OSS_V4_ALGORITHM}")?;
+        writeln!(&mut s, "{}", signing_time.format_iso8601())?;
+        writeln!(&mut s, "{scope}")?;
+        write!(&mut s, "{}", hex_sha256(canonical_request.as_bytes()))?;
+        Ok(s)
+    }
+
+    fn build_v4_signature(
+        &self,
+        cred: &Credential,
+        signing_time: Timestamp,
+        region: &str,
+        string_to_sign: &str,
+    ) -> String {
+        let date_key = hmac_sha256(
+            format!("aliyun_v4{}", cred.access_key_secret).as_bytes(),
+            signing_time.format_date().as_bytes(),
+        );
+        let region_key = hmac_sha256(&date_key, region.as_bytes());
+        let service_key = hmac_sha256(&region_key, OSS_V4_SERVICE.as_bytes());
+        let signing_key = hmac_sha256(&service_key, OSS_V4_REQUEST.as_bytes());
+        hex_hmac_sha256(&signing_key, string_to_sign.as_bytes())
+    }
+
+    fn v4_scope(&self, signing_time: Timestamp, region: &str) -> String {
+        format!(
+            "{}/{}/{}/{}",
+            signing_time.format_date(),
+            region,
+            OSS_V4_SERVICE,
+            OSS_V4_REQUEST
+        )
+    }
+
+    fn v4_additional_headers(&self, req: &SigningRequest, is_presign: bool) -> Result<Vec<String>> {
+        let mut headers = Vec::new();
+        for name in req.headers.keys() {
+            let name = name.as_str().to_ascii_lowercase();
+            if name == AUTHORIZATION.as_str() {
+                continue;
+            }
+            if !self.should_include_v4_header(&name, is_presign) {
+                headers.push(name);
+            }
+        }
+        headers.sort();
+        headers.dedup();
+
+        Ok(headers)
+    }
+
+    fn should_include_v4_header(&self, header: &str, is_presign: bool) -> bool {
+        if header == CONTENT_MD5 || header == CONTENT_TYPE.as_str() {
+            return true;
+        }
+
+        if header.starts_with("x-oss-") {
+            return !is_presign || header != X_OSS_CONTENT_SHA256;
+        }
+
+        false
+    }
+
+    fn v4_canonical_uri(&self, path: &str, authority: &str) -> Result<String> {
+        let decoded_path = percent_decode_str(path)
+            .decode_utf8()
+            .map_err(|e| Error::request_invalid("invalid request path").with_source(e))?;
+        let resource_path = if authority.starts_with(&format!("{}.", self.bucket)) {
+            format!("/{}{}", self.bucket, decoded_path)
+        } else {
+            decoded_path.into_owned()
+        };
+
+        Ok(utf8_percent_encode(&resource_path, &OSS_V4_URI_ENCODE_SET).to_string())
+    }
+
     fn canonicalize_headers(&self, req: &http::request::Parts, cred: &Credential) -> String {
         let mut oss_headers = Vec::new();
 
@@ -456,13 +818,17 @@ static SUBRESOURCES: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
 mod tests {
     use super::*;
     use crate::Credential;
+    use reqsign_core::{Context, SigningRequest};
 
     #[test]
     fn test_request_signer_accepts_region_configuration() {
-        let signer = RequestSigner::new("bucket").with_region("oss-cn-beijing");
+        let signer = RequestSigner::new("bucket")
+            .with_region("oss-cn-beijing")
+            .with_signing_version(SigningVersion::V4);
 
         assert_eq!(signer.bucket, "bucket");
         assert_eq!(signer.region.as_deref(), Some("oss-cn-beijing"));
+        assert_eq!(signer.signing_version, SigningVersion::V4);
     }
 
     #[test]
@@ -507,4 +873,227 @@ mod tests {
             with_region.headers.get(DATE)
         );
     }
+
+    #[tokio::test]
+    async fn test_v4_signing_requires_region() {
+        let mut req =
+            http::Request::get("https://examplebucket.oss-cn-hangzhou.aliyuncs.com/exampleobject")
+                .body(())
+                .expect("request must build")
+                .into_parts()
+                .0;
+        let credential = Credential {
+            access_key_id: "testid".to_string(),
+            access_key_secret: "yourAccessKeySecret".to_string(),
+            security_token: None,
+            expires_in: None,
+        };
+
+        let err = RequestSigner::new("examplebucket")
+            .with_signing_version(SigningVersion::V4)
+            .with_time(Timestamp::from_second(1_744_353_684).expect("timestamp must build"))
+            .sign_request(&Context::new(), &mut req, Some(&credential), None)
+            .await
+            .expect_err("v4 without region must fail");
+        assert!(err.to_string().contains("OSS V4 signing requires region"));
+    }
+
+    #[test]
+    fn test_v4_header_signature_matches_golden_output() {
+        let credential = Credential {
+            access_key_id: "testid".to_string(),
+            access_key_secret: "yourAccessKeySecret".to_string(),
+            security_token: None,
+            expires_in: None,
+        };
+        let time = Timestamp::from_second(1_744_353_684).expect("timestamp must build");
+        let signer = RequestSigner::new("examplebucket")
+            .with_region("cn-hangzhou")
+            .with_signing_version(SigningVersion::V4)
+            .with_time(time);
+
+        let mut canonical_req =
+            http::Request::put("https://examplebucket.oss-cn-hangzhou.aliyuncs.com/exampleobject")
+                .header("Content-Disposition", "attachment")
+                .header("Content-Length", "3")
+                .header("Content-MD5", "ICy5YqxZB1uWSwcVLSNLcA==")
+                .header("Content-Type", "text/plain")
+                .body(())
+                .expect("request must build")
+                .into_parts()
+                .0;
+        let mut signing_req =
+            SigningRequest::build(&mut canonical_req).expect("request must build");
+        signer
+            .canonicalize_v4_headers(&mut signing_req, &credential, time, false)
+            .expect("canonical headers must build");
+        let additional_headers = signer
+            .v4_additional_headers(&signing_req, false)
+            .expect("additional headers must build");
+        signer
+            .canonicalize_v4_query(
+                &mut signing_req,
+                &credential,
+                time,
+                None,
+                "cn-hangzhou",
+                &additional_headers,
+            )
+            .expect("canonical query must build");
+        let canonical_request = signer
+            .build_v4_canonical_request(&signing_req, false, &additional_headers)
+            .expect("canonical request must build");
+        assert_eq!(
+            canonical_request,
+            "PUT\n/examplebucket/exampleobject\n\ncontent-disposition:attachment\ncontent-length:3\ncontent-md5:ICy5YqxZB1uWSwcVLSNLcA==\ncontent-type:text/plain\nx-oss-content-sha256:UNSIGNED-PAYLOAD\nx-oss-date:20250411T064124Z\n\ncontent-disposition;content-length\nUNSIGNED-PAYLOAD"
+        );
+        let string_to_sign = signer
+            .build_v4_string_to_sign(
+                time,
+                &signer.v4_scope(time, "cn-hangzhou"),
+                &canonical_request,
+            )
+            .expect("string to sign must build");
+        assert_eq!(
+            string_to_sign,
+            "OSS4-HMAC-SHA256\n20250411T064124Z\n20250411/cn-hangzhou/oss/aliyun_v4_request\nc46d96390bdbc2d739ac9363293ae9d710b14e48081fcb22cd8ad54b63136eca"
+        );
+
+        let mut req =
+            http::Request::put("https://examplebucket.oss-cn-hangzhou.aliyuncs.com/exampleobject")
+                .header("Content-Disposition", "attachment")
+                .header("Content-Length", "3")
+                .header("Content-MD5", "ICy5YqxZB1uWSwcVLSNLcA==")
+                .header("Content-Type", "text/plain")
+                .body(())
+                .expect("request must build")
+                .into_parts()
+                .0;
+
+        signer
+            .sign_v4(&mut req, &credential, time, None)
+            .expect("v4 header signing must succeed");
+
+        assert_eq!(
+            req.headers.get(X_OSS_DATE).and_then(|v| v.to_str().ok()),
+            Some("20250411T064124Z")
+        );
+        assert_eq!(
+            req.headers
+                .get(X_OSS_CONTENT_SHA256)
+                .and_then(|v| v.to_str().ok()),
+            Some(UNSIGNED_PAYLOAD)
+        );
+        assert_eq!(
+            req.headers.get(AUTHORIZATION).and_then(|v| v.to_str().ok()),
+            Some(
+                "OSS4-HMAC-SHA256 Credential=testid/20250411/cn-hangzhou/oss/aliyun_v4_request, AdditionalHeaders=content-disposition;content-length, Signature=d3694c2dfc5371ee6acd35e88c4871ac95a7ba01d3a2f476768fe61218590097"
+            )
+        );
+    }
+
+    #[test]
+    fn test_v4_presign_signature_matches_golden_output() {
+        let credential = Credential {
+            access_key_id: "testid".to_string(),
+            access_key_secret: "yourAccessKeySecret".to_string(),
+            security_token: None,
+            expires_in: None,
+        };
+        let time = Timestamp::from_second(1_733_196_187).expect("timestamp must build");
+        let signer = RequestSigner::new("examplebucket")
+            .with_region("cn-hangzhou")
+            .with_signing_version(SigningVersion::V4)
+            .with_time(time);
+
+        let mut canonical_req =
+            http::Request::get("https://examplebucket.oss-cn-hangzhou.aliyuncs.com/exampleobject")
+                .body(())
+                .expect("request must build")
+                .into_parts()
+                .0;
+        let mut signing_req =
+            SigningRequest::build(&mut canonical_req).expect("request must build");
+        signer
+            .canonicalize_v4_headers(&mut signing_req, &credential, time, true)
+            .expect("canonical headers must build");
+        let additional_headers = signer
+            .v4_additional_headers(&signing_req, true)
+            .expect("additional headers must build");
+        signer
+            .canonicalize_v4_query(
+                &mut signing_req,
+                &credential,
+                time,
+                Some(Duration::from_secs(86_400)),
+                "cn-hangzhou",
+                &additional_headers,
+            )
+            .expect("canonical query must build");
+        let canonical_request = signer
+            .build_v4_canonical_request(&signing_req, true, &additional_headers)
+            .expect("canonical request must build");
+        assert_eq!(
+            canonical_request,
+            "GET\n/examplebucket/exampleobject\nx-oss-additional-headers=host&x-oss-credential=testid%2F20241203%2Fcn-hangzhou%2Foss%2Faliyun_v4_request&x-oss-date=20241203T032307Z&x-oss-expires=86400&x-oss-signature-version=OSS4-HMAC-SHA256\nhost:examplebucket.oss-cn-hangzhou.aliyuncs.com\n\nhost\nUNSIGNED-PAYLOAD"
+        );
+
+        let mut req =
+            http::Request::get("https://examplebucket.oss-cn-hangzhou.aliyuncs.com/exampleobject")
+                .body(())
+                .expect("request must build")
+                .into_parts()
+                .0;
+        RequestSigner::new("examplebucket")
+            .with_region("cn-hangzhou")
+            .with_signing_version(SigningVersion::V4)
+            .with_time(time)
+            .sign_v4(
+                &mut req,
+                &credential,
+                time,
+                Some(Duration::from_secs(86_400)),
+            )
+            .expect("v4 presign must succeed");
+
+        let query = req.uri.query().expect("query must exist");
+        assert_eq!(
+            query,
+            "x-oss-additional-headers=host&x-oss-credential=testid%2F20241203%2Fcn-hangzhou%2Foss%2Faliyun_v4_request&x-oss-date=20241203T032307Z&x-oss-expires=86400&x-oss-signature-version=OSS4-HMAC-SHA256&x-oss-signature=a9ad2ce702a93c7c36ace35dd4e1b80cb76a999c890d7dfe78ff342a23dac8e0"
+        );
+    }
+
+    #[test]
+    fn test_v4_presign_canonical_query_sorts_repeated_keys() {
+        let credential = Credential {
+            access_key_id: "testid".to_string(),
+            access_key_secret: "yourAccessKeySecret".to_string(),
+            security_token: None,
+            expires_in: None,
+        };
+        let time = Timestamp::from_second(1_733_196_187).expect("timestamp must build");
+        let mut req = http::Request::get(
+            "https://examplebucket.oss-cn-hangzhou.aliyuncs.com/exampleobject?prefix=b&acl&prefix=a%20value",
+        )
+        .body(())
+        .expect("request must build")
+        .into_parts()
+        .0;
+
+        RequestSigner::new("examplebucket")
+            .with_region("cn-hangzhou")
+            .with_signing_version(SigningVersion::V4)
+            .with_time(time)
+            .sign_v4(&mut req, &credential, time, Some(Duration::from_secs(60)))
+            .expect("v4 presign must succeed");
+
+        let query = req.uri.query().expect("query must exist");
+        let acl = query.find("acl").expect("acl must exist");
+        let prefix_b = query.find("prefix=b").expect("prefix=b must exist");
+        let prefix_a = query
+            .find("prefix=a%20value")
+            .expect("encoded prefix must exist");
+        assert!(acl < prefix_a);
+        assert!(prefix_b < prefix_a);
+    }
 }