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