use crate::Credential;
use http::request::Parts;
use http::{HeaderValue, header};
use log::debug;
use reqsign_aws_core::signing::{
append_query_fragment, append_query_pairs, canonical_request_string, canonicalize_headers,
canonicalize_headers_with_standard_session_token, canonicalize_query,
};
use reqsign_core::hash::{hex_hmac_sha256, hex_sha256, hmac_sha256};
use reqsign_core::time::Timestamp;
use reqsign_core::{Context, Result, SignRequest, SigningCredential, SigningRequest};
use std::fmt::Write;
use std::time::Duration;
const CREDENTIAL_OPERATION_HEADROOM: Duration = Duration::from_secs(10);
#[derive(Debug)]
pub struct RequestSigner {
service: String,
region: String,
use_standard_session_token: bool,
time: Option<Timestamp>,
}
impl RequestSigner {
pub fn new(service: &str, region: &str) -> Self {
Self {
service: service.into(),
region: region.into(),
use_standard_session_token: false,
time: None,
}
}
pub fn with_standard_session_token(mut self) -> Self {
self.use_standard_session_token = true;
self
}
#[cfg(test)]
pub fn with_time(mut self, time: Timestamp) -> Self {
self.time = Some(time);
self
}
fn get_time(&self) -> Timestamp {
self.time.unwrap_or_else(Timestamp::now)
}
fn required_valid_until_at(
&self,
signing_time: Timestamp,
expires_in: Option<Duration>,
) -> Timestamp {
signing_time + expires_in.unwrap_or(CREDENTIAL_OPERATION_HEADROOM)
}
}
impl SignRequest for RequestSigner {
type Credential = Credential;
fn required_valid_until(
&self,
_credential: &Self::Credential,
expires_in: Option<Duration>,
) -> Timestamp {
self.required_valid_until_at(self.get_time(), expires_in)
}
async fn sign_request(
&self,
_: &Context,
req: &mut Parts,
credential: Option<&Self::Credential>,
expires_in: Option<Duration>,
) -> Result<()> {
let Some(cred) = credential else {
return Ok(());
};
let now = self.get_time();
let required_until = self.required_valid_until_at(now, expires_in);
if !cred.is_valid_at(required_until) {
return Err(reqsign_core::Error::credential_invalid(
"credential expires before the requested signing operation deadline",
));
}
let original_uri = req.uri.clone();
let mut signed_req = SigningRequest::build(req)?;
if self.use_standard_session_token {
canonicalize_headers_with_standard_session_token(
&mut signed_req,
cred,
expires_in,
now,
)?;
} else {
canonicalize_headers(&mut signed_req, cred, expires_in, now)?;
}
let authentication_query = authentication_query(
&signed_req,
cred,
expires_in,
now,
&self.service,
&self.region,
self.use_standard_session_token,
);
let canonical_query = canonicalize_query(&signed_req, &authentication_query);
let creq = canonical_request_string(&signed_req, &canonical_query)?;
let encoded_req = hex_sha256(creq.as_bytes());
let scope = format!(
"{}/{}/{}/aws4_request",
now.format_date(),
self.region,
self.service
);
debug!("calculated scope: {scope}");
let string_to_sign = {
let mut f = String::new();
writeln!(f, "AWS4-HMAC-SHA256").map_err(|e| {
reqsign_core::Error::unexpected(format!("failed to write algorithm: {e}"))
})?;
writeln!(f, "{}", now.format_iso8601()).map_err(|e| {
reqsign_core::Error::unexpected(format!("failed to write timestamp: {e}"))
})?;
writeln!(f, "{}", scope).map_err(|e| {
reqsign_core::Error::unexpected(format!("failed to write scope: {e}"))
})?;
write!(f, "{}", encoded_req).map_err(|e| {
reqsign_core::Error::unexpected(format!("failed to write encoded request: {e}"))
})?;
f
};
debug!("calculated string to sign: {string_to_sign}");
let signing_key =
generate_signing_key(&cred.secret_access_key, now, &self.region, &self.service);
let signature = hex_hmac_sha256(&signing_key, string_to_sign.as_bytes());
let final_uri = if expires_in.is_some() {
let unsigned_uri = append_query_pairs(&original_uri, &authentication_query)?;
Some(append_query_fragment(
&unsigned_uri,
&format!("X-Amz-Signature={signature}"),
)?)
} else {
let mut authorization = HeaderValue::from_str(&format!(
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
cred.access_key_id,
scope,
signed_req.header_name_to_vec_sorted().join(";"),
signature
))
.map_err(|e| {
reqsign_core::Error::unexpected(format!(
"failed to create authorization header: {e}"
))
})?;
authorization.set_sensitive(true);
signed_req
.headers
.insert(header::AUTHORIZATION, authorization);
None
};
signed_req.apply(req)?;
if let Some(uri) = final_uri {
req.uri = uri;
}
Ok(())
}
}
fn authentication_query(
ctx: &SigningRequest,
cred: &Credential,
expires_in: Option<Duration>,
now: Timestamp,
service: &str,
region: &str,
use_standard_session_token: bool,
) -> Vec<(String, String)> {
let mut query = Vec::new();
if let Some(expire) = expires_in {
query.push(("X-Amz-Algorithm".into(), "AWS4-HMAC-SHA256".into()));
query.push((
"X-Amz-Credential".into(),
format!(
"{}/{}/{}/{}/aws4_request",
cred.access_key_id,
now.format_date(),
region,
service
),
));
query.push(("X-Amz-Date".into(), now.format_iso8601()));
query.push(("X-Amz-Expires".into(), expire.as_secs().to_string()));
query.push((
"X-Amz-SignedHeaders".into(),
ctx.header_name_to_vec_sorted().join(";"),
));
if let Some(token) = &cred.session_token {
let is_s3_express = !use_standard_session_token
&& (ctx.authority.as_str().contains("s3express")
|| ctx.authority.as_str().contains("--x-s3"));
let name = if is_s3_express {
"X-Amz-S3session-Token"
} else {
"X-Amz-Security-Token"
};
query.push((name.into(), token.into()));
}
}
query
}
fn generate_signing_key(secret: &str, time: Timestamp, region: &str, service: &str) -> Vec<u8> {
let secret = format!("AWS4{secret}");
let sign_date = hmac_sha256(secret.as_bytes(), time.format_date().as_bytes());
let sign_region = hmac_sha256(sign_date.as_slice(), region.as_bytes());
let sign_service = hmac_sha256(sign_region.as_slice(), service.as_bytes());
hmac_sha256(sign_service.as_slice(), "aws4_request".as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::StaticCredentialProvider;
use anyhow::Result;
use aws_credential_types::Credentials;
use aws_sigv4::http_request::PayloadChecksumKind;
use aws_sigv4::http_request::PercentEncodingMode;
use aws_sigv4::http_request::SignableBody;
use aws_sigv4::http_request::SignableRequest;
use aws_sigv4::http_request::SignatureLocation;
use aws_sigv4::http_request::SigningSettings;
use aws_sigv4::sign::v4;
use http::Request;
use http::header;
use reqsign_aws_core::constants::X_AMZ_CONTENT_SHA_256;
use reqsign_aws_core::signing::canonical_uri;
use reqsign_core::{ErrorKind, ProvideCredential, Signer};
use reqsign_file_read_tokio::TokioFileRead;
use reqsign_http_send_reqwest::ReqwestHttpSend;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
const RAW_QUERY: &str = "slash=%2F&hash=%23&=%26&equals=%3D&space=%20&encoded-plus=%2B&literal-plus=+&double=%252F&dup=first&dup=second&=empty-key&empty=&flag&flag=&";
const S3_EXPRESS_URI: &str =
"https://bucket--use2-az1--x-s3.s3express-use2-az1.us-east-2.amazonaws.com/object";
const S3_EXPRESS_REGION: &str = "us-east-2";
const TEST_ACCESS_KEY: &str = "AKIDEXAMPLE";
const TEST_SECRET_KEY: &str = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
const TEST_SESSION_TOKEN: &str = "test-session-token";
fn s3_express_credential(now: Timestamp) -> Credential {
Credential {
access_key_id: TEST_ACCESS_KEY.to_string(),
secret_access_key: TEST_SECRET_KEY.to_string(),
session_token: Some(TEST_SESSION_TOKEN.to_string()),
expires_in: Some(now + Duration::from_secs(3600)),
}
}
fn aws_signed_s3_express_request(
mut request: Request<&'static str>,
now: Timestamp,
presigned: bool,
session_token_name_override: Option<&'static str>,
) -> Result<Request<&'static str>> {
let mut settings = SigningSettings::default();
settings.percent_encoding_mode = PercentEncodingMode::Double;
settings.payload_checksum_kind = PayloadChecksumKind::XAmzSha256;
settings.session_token_name_override = session_token_name_override;
if presigned {
settings.signature_location = SignatureLocation::QueryParams;
settings.expires_in = Some(Duration::from_secs(60));
}
let identity = Credentials::new(
TEST_ACCESS_KEY,
TEST_SECRET_KEY,
Some(TEST_SESSION_TOKEN.to_string()),
None,
"hardcoded-credentials",
)
.into();
let params = v4::SigningParams::builder()
.identity(&identity)
.region(S3_EXPRESS_REGION)
.name("s3express")
.time(now.as_system_time())
.settings(settings)
.build()
.expect("signing params must be valid");
let output = aws_sigv4::http_request::sign(
SignableRequest::new(
request.method().as_str(),
request.uri().to_string(),
request
.headers()
.iter()
.map(|(name, value)| (name.as_str(), value.to_str().unwrap())),
SignableBody::UnsignedPayload,
)?,
¶ms.into(),
)?;
let (instructions, _) = output.into_parts();
instructions.apply_to_request_http1x(&mut request);
Ok(request)
}
fn reqsign_s3_express_canonical_request(
now: Timestamp,
presigned: bool,
use_standard_session_token: bool,
) -> Result<String> {
let credential = s3_express_credential(now);
let mut parts = Request::get(S3_EXPRESS_URI).body(())?.into_parts().0;
let mut signing_request = SigningRequest::build(&mut parts)?;
let expires_in = presigned.then_some(Duration::from_secs(60));
if use_standard_session_token {
canonicalize_headers_with_standard_session_token(
&mut signing_request,
&credential,
expires_in,
now,
)?;
} else {
canonicalize_headers(&mut signing_request, &credential, expires_in, now)?;
}
let authentication_query = authentication_query(
&signing_request,
&credential,
expires_in,
now,
"s3express",
S3_EXPRESS_REGION,
use_standard_session_token,
);
let canonical_query = canonicalize_query(&signing_request, &authentication_query);
canonical_request_string(&signing_request, &canonical_query).map_err(Into::into)
}
#[derive(Debug)]
struct SequenceProvider {
credentials: Mutex<VecDeque<Credential>>,
calls: Arc<AtomicUsize>,
}
impl ProvideCredential for SequenceProvider {
type Credential = Credential;
async fn provide_credential(
&self,
_ctx: &Context,
) -> reqsign_core::Result<Option<Self::Credential>> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(self.credentials.lock().unwrap().pop_front())
}
}
#[test]
fn presign_deadline_uses_signing_time_and_requested_expiry() {
let now: Timestamp = "2026-07-22T00:00:00Z"
.parse()
.expect("timestamp must parse");
let signer = RequestSigner::new("s3", "test").with_time(now);
let credential = Credential::default();
assert_eq!(
signer.required_valid_until(&credential, Some(Duration::from_secs(3600))),
now + Duration::from_secs(3600)
);
assert_eq!(
signer.required_valid_until(&credential, None),
now + CREDENTIAL_OPERATION_HEADROOM
);
}
#[tokio::test]
async fn s3_express_header_token_modes_match_aws_sigv4() -> Result<()> {
let now: Timestamp = "2026-07-22T00:00:00Z".parse()?;
let credential = s3_express_credential(now);
let expected_session = aws_signed_s3_express_request(
Request::get(S3_EXPRESS_URI).body("")?,
now,
false,
Some("x-amz-s3session-token"),
)?;
let (mut session_parts, body) = Request::get(S3_EXPRESS_URI).body("")?.into_parts();
RequestSigner::new("s3express", S3_EXPRESS_REGION)
.with_time(now)
.sign_request(&Context::new(), &mut session_parts, Some(&credential), None)
.await?;
let actual_session = Request::from_parts(session_parts, body);
compare_request(
"S3 Express CreateSession header mode",
&expected_session,
&actual_session,
);
assert_eq!(
actual_session.headers()["x-amz-s3session-token"],
TEST_SESSION_TOKEN
);
assert!(actual_session.headers()["x-amz-s3session-token"].is_sensitive());
assert!(
!actual_session
.headers()
.contains_key("x-amz-security-token")
);
let expected_iam = aws_signed_s3_express_request(
Request::get(S3_EXPRESS_URI).body("")?,
now,
false,
None,
)?;
let (mut iam_parts, body) = Request::get(S3_EXPRESS_URI).body("")?.into_parts();
RequestSigner::new("s3express", S3_EXPRESS_REGION)
.with_standard_session_token()
.with_time(now)
.sign_request(&Context::new(), &mut iam_parts, Some(&credential), None)
.await?;
let actual_iam = Request::from_parts(iam_parts, body);
compare_request("S3 Express IAM header mode", &expected_iam, &actual_iam);
assert_eq!(
actual_iam.headers()["x-amz-security-token"],
TEST_SESSION_TOKEN
);
assert!(actual_iam.headers()["x-amz-security-token"].is_sensitive());
assert!(!actual_iam.headers().contains_key("x-amz-s3session-token"));
let session_authorization = actual_session.headers()[header::AUTHORIZATION].to_str()?;
let iam_authorization = actual_iam.headers()[header::AUTHORIZATION].to_str()?;
assert!(session_authorization.contains("x-amz-s3session-token"));
assert!(iam_authorization.contains("x-amz-security-token"));
assert_ne!(session_authorization, iam_authorization);
let session_canonical = reqsign_s3_express_canonical_request(now, false, false)?;
let iam_canonical = reqsign_s3_express_canonical_request(now, false, true)?;
assert_eq!(
session_canonical,
format!(
"GET\n/object\n\nhost:bucket--use2-az1--x-s3.s3express-use2-az1.us-east-2.amazonaws.com\nx-amz-content-sha256:UNSIGNED-PAYLOAD\nx-amz-date:20260722T000000Z\nx-amz-s3session-token:{TEST_SESSION_TOKEN}\n\nhost;x-amz-content-sha256;x-amz-date;x-amz-s3session-token\nUNSIGNED-PAYLOAD"
)
);
assert_eq!(
iam_canonical,
format!(
"GET\n/object\n\nhost:bucket--use2-az1--x-s3.s3express-use2-az1.us-east-2.amazonaws.com\nx-amz-content-sha256:UNSIGNED-PAYLOAD\nx-amz-date:20260722T000000Z\nx-amz-security-token:{TEST_SESSION_TOKEN}\n\nhost;x-amz-content-sha256;x-amz-date;x-amz-security-token\nUNSIGNED-PAYLOAD"
)
);
assert_ne!(session_canonical, iam_canonical);
assert_eq!(
credential.session_token.as_deref(),
Some(TEST_SESSION_TOKEN)
);
Ok(())
}
#[tokio::test]
async fn s3_express_query_token_modes_match_aws_sigv4() -> Result<()> {
let now: Timestamp = "2026-07-22T00:00:00Z".parse()?;
let credential = s3_express_credential(now);
let expires_in = Some(Duration::from_secs(60));
let expected_session = aws_signed_s3_express_request(
Request::get(S3_EXPRESS_URI).body("")?,
now,
true,
Some("X-Amz-S3session-Token"),
)?;
let (mut session_parts, body) = Request::get(S3_EXPRESS_URI).body("")?.into_parts();
RequestSigner::new("s3express", S3_EXPRESS_REGION)
.with_time(now)
.sign_request(
&Context::new(),
&mut session_parts,
Some(&credential),
expires_in,
)
.await?;
let actual_session = Request::from_parts(session_parts, body);
compare_request(
"S3 Express CreateSession query mode",
&expected_session,
&actual_session,
);
let expected_iam =
aws_signed_s3_express_request(Request::get(S3_EXPRESS_URI).body("")?, now, true, None)?;
let (mut iam_parts, body) = Request::get(S3_EXPRESS_URI).body("")?.into_parts();
RequestSigner::new("s3express", S3_EXPRESS_REGION)
.with_standard_session_token()
.with_time(now)
.sign_request(
&Context::new(),
&mut iam_parts,
Some(&credential),
expires_in,
)
.await?;
let actual_iam = Request::from_parts(iam_parts, body);
compare_request("S3 Express IAM query mode", &expected_iam, &actual_iam);
let session_query = actual_session.uri().query().expect("query must exist");
let iam_query = actual_iam.uri().query().expect("query must exist");
assert!(session_query.contains("X-Amz-S3session-Token="));
assert!(!session_query.contains("X-Amz-Security-Token="));
assert!(iam_query.contains("X-Amz-Security-Token="));
assert!(!iam_query.contains("X-Amz-S3session-Token="));
let session_signature = form_urlencoded::parse(session_query.as_bytes())
.find(|(name, _)| name == "X-Amz-Signature")
.expect("signature must exist")
.1;
let iam_signature = form_urlencoded::parse(iam_query.as_bytes())
.find(|(name, _)| name == "X-Amz-Signature")
.expect("signature must exist")
.1;
assert_ne!(session_signature, iam_signature);
let session_canonical = reqsign_s3_express_canonical_request(now, true, false)?;
let iam_canonical = reqsign_s3_express_canonical_request(now, true, true)?;
assert_eq!(
session_canonical,
format!(
"GET\n/object\nX-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIDEXAMPLE%2F20260722%2Fus-east-2%2Fs3express%2Faws4_request&X-Amz-Date=20260722T000000Z&X-Amz-Expires=60&X-Amz-S3session-Token={TEST_SESSION_TOKEN}&X-Amz-SignedHeaders=host\nhost:bucket--use2-az1--x-s3.s3express-use2-az1.us-east-2.amazonaws.com\n\nhost\nUNSIGNED-PAYLOAD"
)
);
assert_eq!(
iam_canonical,
format!(
"GET\n/object\nX-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIDEXAMPLE%2F20260722%2Fus-east-2%2Fs3express%2Faws4_request&X-Amz-Date=20260722T000000Z&X-Amz-Expires=60&X-Amz-Security-Token={TEST_SESSION_TOKEN}&X-Amz-SignedHeaders=host\nhost:bucket--use2-az1--x-s3.s3express-use2-az1.us-east-2.amazonaws.com\n\nhost\nUNSIGNED-PAYLOAD"
)
);
assert_ne!(session_canonical, iam_canonical);
assert_eq!(
credential.session_token.as_deref(),
Some(TEST_SESSION_TOKEN)
);
Ok(())
}
#[tokio::test]
async fn session_token_is_redacted_from_debug_and_signing_errors() -> Result<()> {
const TOKEN_MARKER: &str = "SENSITIVE_SESSION_TOKEN";
let now: Timestamp = "2026-07-22T00:00:00Z".parse()?;
let credential = Credential {
session_token: Some(format!("{TOKEN_MARKER}\n")),
..s3_express_credential(now)
};
assert!(!format!("{credential:?}").contains(TOKEN_MARKER));
for use_standard_session_token in [false, true] {
let signer = RequestSigner::new("s3express", S3_EXPRESS_REGION);
let signer = if use_standard_session_token {
signer.with_standard_session_token()
} else {
signer
}
.with_time(now);
assert!(!format!("{signer:?}").contains(TOKEN_MARKER));
let mut parts = Request::get(S3_EXPRESS_URI).body(())?.into_parts().0;
let original = parts.clone();
let error = signer
.sign_request(&Context::new(), &mut parts, Some(&credential), None)
.await
.expect_err("invalid session token must be rejected");
assert!(!format!("{error}").contains(TOKEN_MARKER));
assert!(!format!("{error:?}").contains(TOKEN_MARKER));
assert_eq!(parts.uri, original.uri);
assert_eq!(parts.headers, original.headers);
assert_eq!(
credential.session_token.as_deref(),
Some("SENSITIVE_SESSION_TOKEN\n")
);
}
Ok(())
}
#[tokio::test]
async fn presign_refreshes_credential_that_cannot_cover_url_lifetime() -> Result<()> {
let now = Timestamp::now();
let short_lived = Credential {
access_key_id: "short-lived-ak".to_string(),
secret_access_key: "short-lived-sk".to_string(),
session_token: Some("short-lived-token".to_string()),
expires_in: Some(now + Duration::from_secs(600)),
};
let long_lived = Credential {
access_key_id: "long-lived-ak".to_string(),
secret_access_key: "long-lived-sk".to_string(),
session_token: Some("long-lived-token".to_string()),
expires_in: Some(now + Duration::from_secs(7200)),
};
let calls = Arc::new(AtomicUsize::new(0));
let provider = SequenceProvider {
credentials: Mutex::new(VecDeque::from([short_lived, long_lived])),
calls: calls.clone(),
};
let signer = Signer::new(
Context::new(),
provider,
RequestSigner::new("s3", "test").with_time(now),
);
let mut header_parts = Request::get("https://example.com/object")
.body(())?
.into_parts()
.0;
signer.sign(&mut header_parts, None).await?;
let mut query_parts = Request::get("https://example.com/object")
.body(())?
.into_parts()
.0;
signer
.sign(&mut query_parts, Some(Duration::from_secs(3600)))
.await?;
assert_eq!(calls.load(Ordering::SeqCst), 2);
assert!(
query_parts
.uri
.query()
.expect("presigned query must exist")
.contains("X-Amz-Credential=long-lived-ak%2F")
);
Ok(())
}
#[tokio::test]
async fn presign_rejects_refreshed_credential_that_cannot_cover_url_lifetime() -> Result<()> {
let now = Timestamp::now();
let short_lived = Credential {
access_key_id: "short-lived-ak".to_string(),
secret_access_key: "short-lived-sk".to_string(),
session_token: Some("short-lived-token".to_string()),
expires_in: Some(now + Duration::from_secs(600)),
};
let calls = Arc::new(AtomicUsize::new(0));
let provider = SequenceProvider {
credentials: Mutex::new(VecDeque::from([short_lived])),
calls: calls.clone(),
};
let signer = Signer::new(
Context::new(),
provider,
RequestSigner::new("s3", "test").with_time(now),
);
let mut parts = Request::get("https://example.com/object")
.body(())?
.into_parts()
.0;
let original = parts.clone();
let err = signer
.sign(&mut parts, Some(Duration::from_secs(3600)))
.await
.expect_err("credential must cover the entire presigned URL lifetime");
assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert_eq!(parts.uri, original.uri);
assert_eq!(parts.headers, original.headers);
let mut header_parts = Request::get("https://example.com/object")
.body(())?
.into_parts()
.0;
signer.sign(&mut header_parts, None).await?;
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(
header_parts
.headers
.get(header::AUTHORIZATION)
.expect("authorization header must exist")
.to_str()?
.contains("Credential=short-lived-ak/")
);
Ok(())
}
type TestCase = (&'static str, fn() -> Request<&'static str>);
fn test_cases() -> Vec<TestCase> {
vec![
("get_request", test_get_request),
("get_request_with_sse", test_get_request_with_sse),
("get_request_with_query", test_get_request_with_query),
("get_request_virtual_host", test_get_request_virtual_host),
(
"get_request_with_query_virtual_host",
test_get_request_with_query_virtual_host,
),
("put_request", test_put_request),
(
"put_request_with_body_digest",
test_put_request_with_body_digest,
),
("put_request_virtual_host", test_put_request_virtual_host),
]
}
fn test_get_request() -> Request<&'static str> {
let mut req = Request::new("");
*req.method_mut() = http::Method::GET;
*req.uri_mut() = "http://127.0.0.1:9000/hello"
.parse()
.expect("url must be valid");
req
}
fn test_get_request_with_sse() -> Request<&'static str> {
let mut req = Request::new("");
*req.method_mut() = http::Method::GET;
*req.uri_mut() = "http://127.0.0.1:9000/hello"
.parse()
.expect("url must be valid");
req.headers_mut().insert(
"x-amz-server-side-encryption",
"a".parse().expect("must be valid"),
);
req.headers_mut().insert(
"x-amz-server-side-encryption-customer-algorithm",
"b".parse().expect("must be valid"),
);
req.headers_mut().insert(
"x-amz-server-side-encryption-customer-key",
"c".parse().expect("must be valid"),
);
req.headers_mut().insert(
"x-amz-server-side-encryption-customer-key-md5",
"d".parse().expect("must be valid"),
);
req.headers_mut().insert(
"x-amz-server-side-encryption-aws-kms-key-id",
"e".parse().expect("must be valid"),
);
req
}
fn test_get_request_with_query() -> Request<&'static str> {
let mut req = Request::new("");
*req.method_mut() = http::Method::GET;
*req.uri_mut() = "http://127.0.0.1:9000/hello?list-type=2&max-keys=3&prefix=CI/&start-after=ExampleGuide.pdf"
.parse()
.expect("url must be valid");
req
}
fn test_get_request_virtual_host() -> Request<&'static str> {
let mut req = Request::new("");
*req.method_mut() = http::Method::GET;
*req.uri_mut() = "http://hello.s3.test.example.com"
.parse()
.expect("url must be valid");
req
}
fn test_get_request_with_query_virtual_host() -> Request<&'static str> {
let mut req = Request::new("");
*req.method_mut() = http::Method::GET;
*req.uri_mut() = "http://hello.s3.test.example.com?list-type=2&max-keys=3&prefix=CI/&start-after=ExampleGuide.pdf"
.parse()
.expect("url must be valid");
req
}
fn test_put_request() -> Request<&'static str> {
let content = "Hello,World!";
let mut req = Request::new(content);
*req.method_mut() = http::Method::PUT;
*req.uri_mut() = "http://127.0.0.1:9000/hello"
.parse()
.expect("url must be valid");
req.headers_mut().insert(
header::CONTENT_LENGTH,
HeaderValue::from_str(&content.len().to_string()).expect("must be valid"),
);
req
}
fn test_put_request_with_body_digest() -> Request<&'static str> {
let content = "Hello,World!";
let mut req = Request::new(content);
*req.method_mut() = http::Method::PUT;
*req.uri_mut() = "http://127.0.0.1:9000/hello"
.parse()
.expect("url must be valid");
req.headers_mut().insert(
header::CONTENT_LENGTH,
HeaderValue::from_str(&content.len().to_string()).expect("must be valid"),
);
let body = hex_sha256(content.as_bytes());
req.headers_mut().insert(
"x-amz-content-sha256",
HeaderValue::from_str(&body).expect("must be valid"),
);
req
}
fn test_put_request_virtual_host() -> Request<&'static str> {
let content = "Hello,World!";
let mut req = Request::new(content);
*req.method_mut() = http::Method::PUT;
*req.uri_mut() = "http://hello.s3.test.example.com"
.parse()
.expect("url must be valid");
req.headers_mut().insert(
header::CONTENT_LENGTH,
HeaderValue::from_str(&content.len().to_string()).expect("must be valid"),
);
req
}
#[track_caller]
fn compare_request(name: &str, l: &Request<&str>, r: &Request<&str>) {
fn format_headers(req: &Request<&str>) -> Vec<String> {
let mut hs = req
.headers()
.iter()
.map(|(k, v)| format!("{}:{}", k, v.to_str().expect("must be valid")))
.collect::<Vec<_>>();
if !hs.contains(&format!("host:{}", req.uri().authority().unwrap())) {
hs.push(format!("host:{}", req.uri().authority().unwrap()))
}
hs.sort();
hs
}
assert_eq!(
format_headers(l),
format_headers(r),
"{name} header mismatch"
);
fn format_query(req: &Request<&str>) -> Vec<String> {
let query = req.uri().query().unwrap_or_default();
let mut query = form_urlencoded::parse(query.as_bytes())
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>();
query.sort();
query
}
assert_eq!(format_query(l), format_query(r), "{name} query mismatch");
}
#[tokio::test]
async fn canonicalization_preserves_wire_uri() -> Result<()> {
let credential = Credential {
access_key_id: "access_key_id".to_string(),
secret_access_key: "secret_access_key".to_string(),
..Default::default()
};
let now: Timestamp = "2026-07-22T00:00:00Z".parse()?;
let signer = RequestSigner::new("s3", "test").with_time(now);
let original_uri = format!("https://example.com/object%2Fname?{RAW_QUERY}");
let mut canonical_parts = Request::get(&original_uri).body(())?.into_parts().0;
let mut signing_req = SigningRequest::build(&mut canonical_parts)?;
canonicalize_headers(
&mut signing_req,
&credential,
Some(Duration::from_secs(60)),
now,
)?;
let auth_query = authentication_query(
&signing_req,
&credential,
Some(Duration::from_secs(60)),
now,
"s3",
"test",
false,
);
let canonical_query = canonicalize_query(&signing_req, &auth_query);
assert_eq!(canonical_uri(&signing_req.path)?, "/object%2Fname");
assert!(canonical_query.contains(&("literal-plus".to_string(), "%2B".to_string())));
assert!(canonical_query.contains(&("double".to_string(), "%252F".to_string())));
assert!(canonical_query.contains(&(String::new(), "empty-key".to_string())));
assert!(canonical_query.contains(&("flag".to_string(), String::new())));
let mut header_parts = Request::get(&original_uri)
.header("x-custom", " value ")
.body(())?
.into_parts()
.0;
signer
.sign_request(&Context::new(), &mut header_parts, Some(&credential), None)
.await?;
assert_eq!(header_parts.uri.to_string(), original_uri);
assert_eq!(header_parts.headers["x-custom"], " value ");
let mut query_parts = Request::get(&original_uri).body(())?.into_parts().0;
signer
.sign_request(
&Context::new(),
&mut query_parts,
Some(&credential),
Some(Duration::from_secs(60)),
)
.await?;
assert!(
query_parts
.uri
.to_string()
.starts_with(&format!("{original_uri}X-Amz-Algorithm="))
);
assert!(
query_parts
.uri
.query()
.unwrap()
.contains("X-Amz-Signature=")
);
Ok(())
}
#[tokio::test]
async fn test() -> Result<()> {
for (name, req) in test_cases() {
calculate(req)
.await
.unwrap_or_else(|err| panic!("calculate {name} should pass: {err:?}"));
calculate_in_query(req)
.await
.unwrap_or_else(|err| panic!("calculate_in_query {name} should pass: {err:?}"));
test_calculate_with_token(req).await.unwrap_or_else(|err| {
panic!("test_calculate_with_token {name} should pass: {err:?}")
});
test_calculate_with_token_in_query(req)
.await
.unwrap_or_else(|err| {
panic!("test_calculate_with_token_in_query {name} should pass: {err:?}")
});
}
Ok(())
}
async fn calculate(req_fn: fn() -> Request<&'static str>) -> Result<()> {
let _ = env_logger::builder().is_test(true).try_init();
let mut req = req_fn();
let name = format!(
"{} {} {:?}",
req.method(),
req.uri().path(),
req.uri().query(),
);
let now = Timestamp::now();
let mut ss = SigningSettings::default();
ss.percent_encoding_mode = PercentEncodingMode::Double;
ss.payload_checksum_kind = PayloadChecksumKind::XAmzSha256;
let id = Credentials::new(
"access_key_id",
"secret_access_key",
None,
None,
"hardcoded-credentials",
)
.into();
let sp = v4::SigningParams::builder()
.identity(&id)
.region("test")
.name("s3")
.time(now.as_system_time())
.settings(ss)
.build()
.expect("signing params must be valid");
let mut body = SignableBody::UnsignedPayload;
if req.headers().get(X_AMZ_CONTENT_SHA_256).is_some() {
body = SignableBody::Bytes(req.body().as_bytes());
}
let output = aws_sigv4::http_request::sign(
SignableRequest::new(
req.method().as_str(),
req.uri().to_string(),
req.headers()
.iter()
.map(|(k, v)| (k.as_str(), std::str::from_utf8(v.as_bytes()).unwrap())),
body,
)
.unwrap(),
&sp.into(),
)?;
let (aws_sig, _) = output.into_parts();
aws_sig.apply_to_request_http1x(&mut req);
let expected_req = req;
let req = req_fn();
let (mut parts, body) = req.into_parts();
let ctx = Context::new()
.with_file_read(TokioFileRead)
.with_http_send(ReqwestHttpSend::default());
let loader = StaticCredentialProvider::new("access_key_id", "secret_access_key");
let cred = loader.provide_credential(&ctx).await?.unwrap();
let builder = RequestSigner::new("s3", "test").with_time(now);
builder
.sign_request(&ctx, &mut parts, Some(&cred), None)
.await
.expect("must apply success");
let actual_req = Request::from_parts(parts, body);
compare_request(&name, &expected_req, &actual_req);
Ok(())
}
async fn calculate_in_query(req_fn: fn() -> Request<&'static str>) -> Result<()> {
let _ = env_logger::builder().is_test(true).try_init();
let mut req = req_fn();
let name = format!(
"{} {} {:?}",
req.method(),
req.uri().path(),
req.uri().query(),
);
let now = Timestamp::now();
let mut ss = SigningSettings::default();
ss.percent_encoding_mode = PercentEncodingMode::Double;
ss.payload_checksum_kind = PayloadChecksumKind::XAmzSha256;
ss.signature_location = SignatureLocation::QueryParams;
ss.expires_in = Some(Duration::from_secs(3600));
let id = Credentials::new(
"access_key_id",
"secret_access_key",
None,
None,
"hardcoded-credentials",
)
.into();
let sp = v4::SigningParams::builder()
.identity(&id)
.region("test")
.name("s3")
.time(now.as_system_time())
.settings(ss)
.build()
.expect("signing params must be valid");
let mut body = SignableBody::UnsignedPayload;
if req.headers().get(X_AMZ_CONTENT_SHA_256).is_some() {
body = SignableBody::Bytes(req.body().as_bytes());
}
let output = aws_sigv4::http_request::sign(
SignableRequest::new(
req.method().as_str(),
req.uri().to_string(),
req.headers()
.iter()
.map(|(k, v)| (k.as_str(), std::str::from_utf8(v.as_bytes()).unwrap())),
body,
)
.unwrap(),
&sp.into(),
)
.expect("signing must succeed");
let (aws_sig, _) = output.into_parts();
aws_sig.apply_to_request_http1x(&mut req);
let expected_req = req;
let req = req_fn();
let (mut parts, body) = req.into_parts();
let ctx = Context::new()
.with_file_read(TokioFileRead)
.with_http_send(ReqwestHttpSend::default());
let loader = StaticCredentialProvider::new("access_key_id", "secret_access_key");
let cred = loader.provide_credential(&ctx).await?.unwrap();
let builder = RequestSigner::new("s3", "test").with_time(now);
builder
.sign_request(
&ctx,
&mut parts,
Some(&cred),
Some(Duration::from_secs(3600)),
)
.await?;
let actual_req = Request::from_parts(parts, body);
compare_request(&name, &expected_req, &actual_req);
Ok(())
}
async fn test_calculate_with_token(req_fn: fn() -> Request<&'static str>) -> Result<()> {
let _ = env_logger::builder().is_test(true).try_init();
let mut req = req_fn();
let name = format!(
"{} {} {:?}",
req.method(),
req.uri().path(),
req.uri().query(),
);
let now = Timestamp::now();
let mut ss = SigningSettings::default();
ss.percent_encoding_mode = PercentEncodingMode::Double;
ss.payload_checksum_kind = PayloadChecksumKind::XAmzSha256;
let id = Credentials::new(
"access_key_id",
"secret_access_key",
Some("security_token".to_string()),
None,
"hardcoded-credentials",
)
.into();
let sp = v4::SigningParams::builder()
.identity(&id)
.region("test")
.name("s3")
.time(now.as_system_time())
.settings(ss)
.build()
.expect("signing params must be valid");
let mut body = SignableBody::UnsignedPayload;
if req.headers().get(X_AMZ_CONTENT_SHA_256).is_some() {
body = SignableBody::Bytes(req.body().as_bytes());
}
let output = aws_sigv4::http_request::sign(
SignableRequest::new(
req.method().as_str(),
req.uri().to_string(),
req.headers()
.iter()
.map(|(k, v)| (k.as_str(), std::str::from_utf8(v.as_bytes()).unwrap())),
body,
)
.unwrap(),
&sp.into(),
)
.expect("signing must succeed");
let (aws_sig, _) = output.into_parts();
aws_sig.apply_to_request_http1x(&mut req);
let expected_req = req;
let req = req_fn();
let (mut parts, body) = req.into_parts();
let ctx = Context::new()
.with_file_read(TokioFileRead)
.with_http_send(ReqwestHttpSend::default());
let loader = StaticCredentialProvider::new("access_key_id", "secret_access_key")
.with_session_token("security_token");
let cred = loader.provide_credential(&ctx).await?.unwrap();
let builder = RequestSigner::new("s3", "test").with_time(now);
builder
.sign_request(&ctx, &mut parts, Some(&cred), None)
.await
.expect("must apply success");
let actual_req = Request::from_parts(parts, body);
compare_request(&name, &expected_req, &actual_req);
Ok(())
}
async fn test_calculate_with_token_in_query(
req_fn: fn() -> Request<&'static str>,
) -> Result<()> {
let _ = env_logger::builder().is_test(true).try_init();
let mut req = req_fn();
let name = format!(
"{} {} {:?}",
req.method(),
req.uri().path(),
req.uri().query(),
);
let now = Timestamp::now();
let mut ss = SigningSettings::default();
ss.percent_encoding_mode = PercentEncodingMode::Double;
ss.payload_checksum_kind = PayloadChecksumKind::XAmzSha256;
ss.signature_location = SignatureLocation::QueryParams;
ss.expires_in = Some(Duration::from_secs(3600));
let id = Credentials::new(
"access_key_id",
"secret_access_key",
Some("security_token".to_string()),
None,
"hardcoded-credentials",
)
.into();
let sp = v4::SigningParams::builder()
.identity(&id)
.region("test")
.name("s3")
.time(now.as_system_time())
.settings(ss)
.build()
.expect("signing params must be valid");
let mut body = SignableBody::UnsignedPayload;
if req.headers().get(X_AMZ_CONTENT_SHA_256).is_some() {
body = SignableBody::Bytes(req.body().as_bytes());
}
let output = aws_sigv4::http_request::sign(
SignableRequest::new(
req.method().as_str(),
req.uri().to_string(),
req.headers()
.iter()
.map(|(k, v)| (k.as_str(), std::str::from_utf8(v.as_bytes()).unwrap())),
body,
)
.unwrap(),
&sp.into(),
)
.expect("signing must succeed");
let (aws_sig, _) = output.into_parts();
aws_sig.apply_to_request_http1x(&mut req);
let expected_req = req;
let req = req_fn();
let (mut parts, body) = req.into_parts();
let ctx = Context::new()
.with_file_read(TokioFileRead)
.with_http_send(ReqwestHttpSend::default());
let loader = StaticCredentialProvider::new("access_key_id", "secret_access_key")
.with_session_token("security_token");
let cred = loader.provide_credential(&ctx).await?.unwrap();
let builder = RequestSigner::new("s3", "test").with_time(now);
builder
.sign_request(
&ctx,
&mut parts,
Some(&cred),
Some(Duration::from_secs(3600)),
)
.await
.expect("must apply success");
let actual_req = Request::from_parts(parts, body);
compare_request(&name, &expected_req, &actual_req);
Ok(())
}
}