use std::convert::Infallible;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use bytes::Bytes;
use http_body_util::Full;
use hyper::body::Incoming;
use hyper::service::Service;
use hyper::{Method, Request, Response, StatusCode};
use metrics_exporter_prometheus::PrometheusHandle;
use crate::cors::{CorsManager, CorsRule};
use crate::service::SigV4aGate;
pub type ReadyCheck =
Arc<dyn Fn() -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>> + Send + Sync>;
#[derive(Clone)]
pub struct HealthRouter<S> {
pub inner: S,
pub ready_check: Option<ReadyCheck>,
pub metrics_handle: Option<PrometheusHandle>,
pub cors_manager: Option<Arc<CorsManager>>,
pub sigv4a_gate: Option<Arc<SigV4aGate>>,
pub region: String,
}
impl<S> HealthRouter<S> {
pub fn new(inner: S, ready_check: Option<ReadyCheck>) -> Self {
Self {
inner,
ready_check,
metrics_handle: None,
cors_manager: None,
sigv4a_gate: None,
region: "us-east-1".to_string(),
}
}
#[must_use]
pub fn with_metrics(mut self, handle: PrometheusHandle) -> Self {
self.metrics_handle = Some(handle);
self
}
#[must_use]
pub fn with_cors_manager(mut self, mgr: Arc<CorsManager>) -> Self {
self.cors_manager = Some(mgr);
self
}
#[must_use]
pub fn with_sigv4a_gate(mut self, gate: Arc<SigV4aGate>) -> Self {
self.sigv4a_gate = Some(gate);
self
}
#[must_use]
pub fn with_region(mut self, region: impl Into<String>) -> Self {
self.region = region.into();
self
}
}
#[must_use]
pub fn try_handle_preflight<B>(
req: &Request<B>,
cors: Option<&Arc<CorsManager>>,
) -> Option<Response<s3s::Body>> {
if req.method() != Method::OPTIONS {
return None;
}
let mgr = cors?;
let path = req.uri().path();
let bucket = path.trim_start_matches('/').split('/').next()?;
if bucket.is_empty() {
return None;
}
let origin = req.headers().get("origin")?.to_str().ok()?;
let method = req
.headers()
.get("access-control-request-method")?
.to_str()
.ok()?;
let req_headers: Vec<String> = req
.headers()
.get("access-control-request-headers")
.and_then(|h| h.to_str().ok())
.map(|s| {
s.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect()
})
.unwrap_or_default();
let _ = mgr.get(bucket)?;
match mgr.match_preflight(bucket, origin, method, &req_headers) {
Some(rule) => Some(build_preflight_allow_response(&rule, origin)),
None => Some(build_preflight_deny_response()),
}
}
fn build_preflight_allow_response(rule: &CorsRule, origin: &str) -> Response<s3s::Body> {
let mut builder = Response::builder().status(StatusCode::OK);
let allow_origin: String = if rule.allowed_origins.iter().any(|o| o == "*") {
"*".into()
} else {
origin.to_owned()
};
builder = builder.header("Access-Control-Allow-Origin", allow_origin);
builder = builder.header(
"Access-Control-Allow-Methods",
rule.allowed_methods.join(", "),
);
if !rule.allowed_headers.is_empty() {
builder = builder.header(
"Access-Control-Allow-Headers",
rule.allowed_headers.join(", "),
);
}
if !rule.expose_headers.is_empty() {
builder = builder.header(
"Access-Control-Expose-Headers",
rule.expose_headers.join(", "),
);
}
if let Some(secs) = rule.max_age_seconds {
builder = builder.header("Access-Control-Max-Age", secs.to_string());
}
let bytes = Bytes::new();
builder = builder.header("content-length", "0");
builder
.body(s3s::Body::http_body(
Full::new(bytes).map_err(|never| match never {}),
))
.expect("preflight response builder")
}
fn build_preflight_deny_response() -> Response<s3s::Body> {
let body = Bytes::from_static(b"CORSResponse: This CORS request is not allowed.");
Response::builder()
.status(StatusCode::FORBIDDEN)
.header("content-type", "text/plain; charset=utf-8")
.header("content-length", body.len().to_string())
.body(s3s::Body::http_body(
Full::new(body).map_err(|never| match never {}),
))
.expect("preflight deny response builder")
}
pub fn try_sigv4a_verify<B>(
req: &Request<B>,
gate: Option<&Arc<SigV4aGate>>,
requested_region: &str,
) -> Option<Result<(), Response<s3s::Body>>> {
let gate = gate?;
if !crate::sigv4a::detect(req) {
return None;
}
let auth_hdr = req
.headers()
.get(http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok());
let signed_headers: Vec<String> = match auth_hdr
.and_then(crate::sigv4a::parse_authorization_header)
{
Some(parsed) => parsed.signed_headers,
None => {
return Some(Err(build_sigv4a_error_response(
"SignatureDoesNotMatch",
"missing or malformed Authorization header for SigV4a request",
)));
}
};
let canonical = build_canonical_request_bytes(req, &signed_headers);
match gate.pre_route(req, requested_region, &canonical) {
Ok(()) => Some(Ok(())),
Err(err) => {
tracing::warn!(error = %err, "SigV4a verify rejected request");
Some(Err(build_sigv4a_error_response(
err.s3_error_code(),
&err.to_string(),
)))
}
}
}
fn build_canonical_request_bytes<B>(
req: &Request<B>,
signed_headers: &[String],
) -> Vec<u8> {
let mut buf = String::with_capacity(512);
buf.push_str(req.method().as_str());
buf.push('\n');
buf.push_str(req.uri().path());
buf.push('\n');
buf.push_str(&canonical_query_string(req.uri().query().unwrap_or("")));
buf.push('\n');
for name in signed_headers {
let value = req
.headers()
.get(name.as_str())
.and_then(|v| v.to_str().ok())
.unwrap_or("");
buf.push_str(name);
buf.push(':');
buf.push_str(&trim_collapse_ws(value));
buf.push('\n');
}
buf.push('\n');
buf.push_str(&signed_headers.join(";"));
buf.push('\n');
let payload_hash = req
.headers()
.get("x-amz-content-sha256")
.and_then(|v| v.to_str().ok())
.unwrap_or("UNSIGNED-PAYLOAD");
buf.push_str(payload_hash);
buf.into_bytes()
}
fn canonical_query_string(query: &str) -> String {
if query.is_empty() {
return String::new();
}
let mut pairs: Vec<(&str, &str)> = query
.split('&')
.filter(|s| !s.is_empty())
.map(|kv| match kv.split_once('=') {
Some((k, v)) => (k, v),
None => (kv, ""),
})
.collect();
pairs.sort_by(|a, b| a.0.cmp(b.0).then_with(|| a.1.cmp(b.1)));
let mut out = String::with_capacity(query.len());
for (i, (k, v)) in pairs.iter().enumerate() {
if i > 0 {
out.push('&');
}
out.push_str(k);
out.push('=');
out.push_str(v);
}
out
}
fn trim_collapse_ws(s: &str) -> String {
let trimmed = s.trim();
let mut out = String::with_capacity(trimmed.len());
let mut prev_ws = false;
for c in trimmed.chars() {
if c.is_whitespace() {
if !prev_ws {
out.push(' ');
}
prev_ws = true;
} else {
out.push(c);
prev_ws = false;
}
}
out
}
fn build_sigv4a_error_response(code: &str, message: &str) -> Response<s3s::Body> {
let body_str = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<Error>\n <Code>{code}</Code>\n <Message>{message}</Message>\n</Error>"
);
let bytes = Bytes::from(body_str.into_bytes());
Response::builder()
.status(StatusCode::FORBIDDEN)
.header("content-type", "application/xml")
.header("content-length", bytes.len().to_string())
.body(s3s::Body::http_body(
Full::new(bytes).map_err(|never| match never {}),
))
.expect("sigv4a error response builder")
}
type RespBody = s3s::Body;
fn make_text_response(status: StatusCode, body: &'static str) -> Response<RespBody> {
let bytes = Bytes::from_static(body.as_bytes());
Response::builder()
.status(status)
.header("content-type", "text/plain; charset=utf-8")
.header("content-length", bytes.len().to_string())
.body(s3s::Body::http_body(
Full::new(bytes).map_err(|never| match never {}),
))
.expect("static response")
}
fn make_owned_text_response(
status: StatusCode,
content_type: &'static str,
body: String,
) -> Response<RespBody> {
let bytes = Bytes::from(body.into_bytes());
Response::builder()
.status(status)
.header("content-type", content_type)
.header("content-length", bytes.len().to_string())
.body(s3s::Body::http_body(
Full::new(bytes).map_err(|never| match never {}),
))
.expect("owned response")
}
impl<S> Service<Request<Incoming>> for HealthRouter<S>
where
S: Service<Request<Incoming>, Response = Response<s3s::Body>, Error = s3s::HttpError>
+ Clone
+ Send
+ 'static,
S::Future: Send + 'static,
{
type Response = Response<RespBody>;
type Error = s3s::HttpError;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn call(&self, req: Request<Incoming>) -> Self::Future {
if let Some(resp) = try_handle_preflight(&req, self.cors_manager.as_ref()) {
return Box::pin(async move { Ok(resp) });
}
if let Some(result) =
try_sigv4a_verify(&req, self.sigv4a_gate.as_ref(), &self.region)
{
match result {
Ok(()) => {
}
Err(resp) => return Box::pin(async move { Ok(resp) }),
}
}
let path = req.uri().path();
match (req.method(), path) {
(&hyper::Method::GET, "/health") | (&hyper::Method::HEAD, "/health") => {
Box::pin(async { Ok(make_text_response(StatusCode::OK, "ok\n")) })
}
(&hyper::Method::GET, "/metrics") | (&hyper::Method::HEAD, "/metrics") => {
let handle = self.metrics_handle.clone();
Box::pin(async move {
match handle {
Some(h) => {
let body = h.render();
Ok(make_owned_text_response(
StatusCode::OK,
"text/plain; version=0.0.4; charset=utf-8",
body,
))
}
None => Ok(make_text_response(
StatusCode::SERVICE_UNAVAILABLE,
"metrics not configured\n",
)),
}
})
}
(&hyper::Method::GET, "/ready") | (&hyper::Method::HEAD, "/ready") => {
let check = self.ready_check.clone();
Box::pin(async move {
match check {
Some(f) => match f().await {
Ok(()) => Ok(make_text_response(StatusCode::OK, "ready\n")),
Err(reason) => {
tracing::warn!(%reason, "readiness check failed");
Ok(make_text_response(
StatusCode::SERVICE_UNAVAILABLE,
"not ready\n",
))
}
},
None => Ok(make_text_response(StatusCode::OK, "ready (no check)\n")),
}
})
}
_ => {
let inner = self.inner.clone();
Box::pin(async move { inner.call(req).await })
}
}
}
}
trait FullExt<B> {
fn map_err<E, F: FnMut(Infallible) -> E>(
self,
f: F,
) -> http_body_util::combinators::MapErr<Self, F>
where
Self: Sized;
}
impl<B> FullExt<B> for Full<B>
where
B: bytes::Buf,
{
fn map_err<E, F: FnMut(Infallible) -> E>(
self,
f: F,
) -> http_body_util::combinators::MapErr<Self, F>
where
Self: Sized,
{
http_body_util::BodyExt::map_err(self, f)
}
}
#[cfg(test)]
mod preflight_tests {
use super::*;
use crate::cors::{CorsConfig, CorsManager, CorsRule};
fn rule(origins: &[&str], methods: &[&str], headers: &[&str]) -> CorsRule {
CorsRule {
allowed_origins: origins.iter().map(|s| (*s).to_owned()).collect(),
allowed_methods: methods.iter().map(|s| (*s).to_owned()).collect(),
allowed_headers: headers.iter().map(|s| (*s).to_owned()).collect(),
expose_headers: vec!["ETag".into()],
max_age_seconds: Some(600),
id: Some("test".into()),
}
}
fn req(
method: Method,
path: &str,
headers: &[(&str, &str)],
) -> Request<()> {
let mut b = Request::builder().method(method).uri(path);
for (k, v) in headers {
b = b.header(*k, *v);
}
b.body(()).expect("request builder")
}
fn manager_with_rule() -> Arc<CorsManager> {
let mgr = CorsManager::new();
mgr.put(
"b",
CorsConfig {
rules: vec![rule(
&["https://app.example.com"],
&["GET", "PUT", "DELETE"],
&["Content-Type", "X-Amz-Date"],
)],
},
);
Arc::new(mgr)
}
#[test]
fn preflight_match_returns_allow_response() {
let mgr = manager_with_rule();
let r = req(
Method::OPTIONS,
"/b/key.txt",
&[
("origin", "https://app.example.com"),
("access-control-request-method", "PUT"),
("access-control-request-headers", "content-type, x-amz-date"),
],
);
let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
assert_eq!(resp.status(), StatusCode::OK);
let h = resp.headers();
assert_eq!(
h.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok()),
Some("https://app.example.com")
);
assert_eq!(
h.get("access-control-allow-methods")
.and_then(|v| v.to_str().ok()),
Some("GET, PUT, DELETE")
);
assert_eq!(
h.get("access-control-allow-headers")
.and_then(|v| v.to_str().ok()),
Some("Content-Type, X-Amz-Date")
);
assert_eq!(
h.get("access-control-max-age")
.and_then(|v| v.to_str().ok()),
Some("600")
);
assert_eq!(
h.get("access-control-expose-headers")
.and_then(|v| v.to_str().ok()),
Some("ETag")
);
}
#[test]
fn preflight_no_match_returns_403() {
let mgr = manager_with_rule();
let r = req(
Method::OPTIONS,
"/b/key.txt",
&[
("origin", "https://evil.example.com"),
("access-control-request-method", "PUT"),
],
);
let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
assert!(resp.headers().get("access-control-allow-origin").is_none());
}
#[test]
fn preflight_no_origin_falls_through() {
let mgr = manager_with_rule();
let r = req(
Method::OPTIONS,
"/b/key.txt",
&[("access-control-request-method", "PUT")],
);
assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
}
#[test]
fn non_options_falls_through() {
let mgr = manager_with_rule();
let r = req(
Method::GET,
"/b/key.txt",
&[
("origin", "https://app.example.com"),
("access-control-request-method", "PUT"),
],
);
assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
}
#[test]
fn no_cors_config_for_bucket_falls_through() {
let mgr = manager_with_rule();
let r = req(
Method::OPTIONS,
"/ghost/key.txt",
&[
("origin", "https://app.example.com"),
("access-control-request-method", "PUT"),
],
);
assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
}
#[test]
fn no_manager_attached_falls_through() {
let r = req(
Method::OPTIONS,
"/b/key.txt",
&[
("origin", "https://app.example.com"),
("access-control-request-method", "PUT"),
],
);
assert!(try_handle_preflight(&r, None).is_none());
}
#[test]
fn preflight_wildcard_origin_echoes_star() {
let mgr = CorsManager::new();
mgr.put(
"b",
CorsConfig {
rules: vec![rule(&["*"], &["GET", "PUT"], &["*"])],
},
);
let mgr = Arc::new(mgr);
let r = req(
Method::OPTIONS,
"/b/key",
&[
("origin", "https://anywhere.example"),
("access-control-request-method", "PUT"),
("access-control-request-headers", "x-custom-header"),
],
);
let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers()
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok()),
Some("*"),
"wildcard rule must echo literal '*' instead of requesting origin"
);
}
#[test]
fn preflight_empty_path_falls_through() {
let mgr = manager_with_rule();
let r = req(
Method::OPTIONS,
"/",
&[
("origin", "https://app.example.com"),
("access-control-request-method", "PUT"),
],
);
assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
}
}
#[cfg(test)]
mod sigv4a_gate_tests {
use super::*;
use std::collections::HashMap;
use http_body_util::BodyExt;
use p256::ecdsa::SigningKey;
use p256::ecdsa::signature::Signer;
use rand::rngs::OsRng;
use crate::service::SigV4aGate;
use crate::sigv4a::{REGION_SET_HEADER, SigV4aCredentialStore};
fn lower_hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
fn req(method: Method, path: &str, headers: &[(&str, &str)]) -> Request<()> {
let mut b = Request::builder().method(method).uri(path);
for (k, v) in headers {
b = b.header(*k, *v);
}
b.body(()).expect("request builder")
}
fn build_auth_header(access_key: &str, signed_headers: &[&str], sig_hex: &str) -> String {
format!(
"AWS4-ECDSA-P256-SHA256 \
Credential={access_key}/20260513/s3/aws4_request, \
SignedHeaders={}, \
Signature={sig_hex}",
signed_headers.join(";")
)
}
fn make_signed_request(
access_key: &str,
method: Method,
path: &str,
region_set: &str,
) -> (Request<()>, p256::ecdsa::VerifyingKey) {
let signing = SigningKey::random(&mut OsRng);
let verifying = p256::ecdsa::VerifyingKey::from(&signing);
let signed_headers_list = ["host", "x-amz-content-sha256", "x-amz-date", REGION_SET_HEADER];
let pre = Request::builder()
.method(method.clone())
.uri(path)
.header("host", "s3.example.com")
.header(
"x-amz-content-sha256",
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
)
.header("x-amz-date", "20260513T120000Z")
.header(REGION_SET_HEADER, region_set)
.body(())
.expect("pre-request");
let signed_headers: Vec<String> =
signed_headers_list.iter().map(|s| (*s).to_string()).collect();
let canonical = build_canonical_request_bytes(&pre, &signed_headers);
let sig: p256::ecdsa::Signature = signing.sign(&canonical);
let sig_hex = lower_hex(sig.to_der().as_bytes());
let auth = build_auth_header(access_key, &signed_headers_list, &sig_hex);
let r = Request::builder()
.method(method)
.uri(path)
.header("host", "s3.example.com")
.header(
"x-amz-content-sha256",
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
)
.header("x-amz-date", "20260513T120000Z")
.header(REGION_SET_HEADER, region_set)
.header("authorization", auth)
.body(())
.expect("signed request");
(r, verifying)
}
fn make_gate_with(access_key: &str, vk: p256::ecdsa::VerifyingKey) -> Arc<SigV4aGate> {
let mut m = HashMap::new();
m.insert(access_key.to_string(), vk);
let store = Arc::new(SigV4aCredentialStore::from_map(m));
Arc::new(SigV4aGate::new(store))
}
async fn body_to_bytes(resp: Response<s3s::Body>) -> Vec<u8> {
resp.into_body()
.collect()
.await
.expect("body collect")
.to_bytes()
.to_vec()
}
#[test]
fn no_sigv4a_prefix_returns_none() {
let (_, vk) = (
(),
p256::ecdsa::VerifyingKey::from(&SigningKey::random(&mut OsRng)),
);
let gate = make_gate_with("AKIAOK", vk);
let r = req(
Method::GET,
"/bucket/key",
&[(
"authorization",
"AWS4-HMAC-SHA256 Credential=AKIA/20260513/us-east-1/s3/aws4_request, \
SignedHeaders=host, Signature=deadbeef",
)],
);
assert!(
try_sigv4a_verify(&r, Some(&gate), "us-east-1").is_none(),
"plain SigV4 request must fall through to the inner service"
);
}
#[test]
fn sigv4a_valid_signature_returns_ok() {
let (r, vk) =
make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1,us-west-2");
let gate = make_gate_with("AKIAOK", vk);
let result = try_sigv4a_verify(&r, Some(&gate), "us-east-1")
.expect("must intercept SigV4a request");
assert!(
result.is_ok(),
"valid SigV4a signature must verify: {result:?}"
);
}
#[tokio::test]
async fn sigv4a_tampered_signature_returns_403() {
let (r, vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
let gate = make_gate_with("AKIAOK", vk);
let auth = r
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.expect("auth header")
.to_string();
let mut chars: Vec<char> = auth.chars().collect();
let last = chars.len() - 1;
chars[last] = if chars[last] == '0' { '1' } else { '0' };
let tampered_auth: String = chars.into_iter().collect();
let tampered = req(
Method::GET,
"/bucket/key",
&[
("host", "s3.example.com"),
(
"x-amz-content-sha256",
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
),
("x-amz-date", "20260513T120000Z"),
(REGION_SET_HEADER, "us-east-1"),
("authorization", &tampered_auth),
],
);
let result = try_sigv4a_verify(&tampered, Some(&gate), "us-east-1")
.expect("must intercept SigV4a request");
let resp = result.expect_err("tampered signature must surface a 403 response");
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let body = body_to_bytes(resp).await;
let body_str = String::from_utf8(body).expect("xml utf-8");
assert!(
body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
"403 body must surface SignatureDoesNotMatch: {body_str}"
);
}
#[tokio::test]
async fn sigv4a_region_set_mismatch_returns_403() {
let (r, vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
let gate = make_gate_with("AKIAOK", vk);
let result = try_sigv4a_verify(&r, Some(&gate), "eu-west-1")
.expect("must intercept SigV4a request");
let resp = result.expect_err("region mismatch must produce 403");
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let body = body_to_bytes(resp).await;
let body_str = String::from_utf8(body).expect("xml utf-8");
assert!(
body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
"region-set mismatch must surface SignatureDoesNotMatch: {body_str}"
);
}
#[test]
fn no_gate_attached_returns_none() {
let (r, _vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
assert!(
try_sigv4a_verify(&r, None, "us-east-1").is_none(),
"missing gate must defer to inner service"
);
}
#[tokio::test]
async fn unknown_access_key_returns_403_invalid_access_key_id() {
let (r, _vk_unused) =
make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
let other_signing = SigningKey::random(&mut OsRng);
let other_vk = p256::ecdsa::VerifyingKey::from(&other_signing);
let gate = make_gate_with("AKIASOMEONEELSE", other_vk);
let result = try_sigv4a_verify(&r, Some(&gate), "us-east-1")
.expect("must intercept SigV4a request");
let resp = result.expect_err("unknown key must produce 403");
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let body = body_to_bytes(resp).await;
let body_str = String::from_utf8(body).expect("xml utf-8");
assert!(
body_str.contains("<Code>InvalidAccessKeyId</Code>"),
"unknown access-key must surface InvalidAccessKeyId: {body_str}"
);
}
#[tokio::test]
async fn region_set_header_only_without_sigv4a_auth_returns_403() {
let signing = SigningKey::random(&mut OsRng);
let vk = p256::ecdsa::VerifyingKey::from(&signing);
let gate = make_gate_with("AKIAOK", vk);
let r = req(
Method::GET,
"/bucket/key",
&[
(
"authorization",
"AWS4-HMAC-SHA256 Credential=AKIA/20260513/us-east-1/s3/aws4_request, \
SignedHeaders=host, Signature=deadbeef",
),
(REGION_SET_HEADER, "us-east-1"),
],
);
let result = try_sigv4a_verify(&r, Some(&gate), "us-east-1")
.expect("must intercept SigV4a-shaped request");
let resp = result.expect_err("region-set without sigv4a auth must produce 403");
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let body = body_to_bytes(resp).await;
let body_str = String::from_utf8(body).expect("xml utf-8");
assert!(
body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
"missing/malformed Authorization for SigV4a-shaped request must fail closed: {body_str}"
);
}
#[test]
fn canonical_request_bytes_format() {
let r = req(
Method::PUT,
"/bucket/key?z=1&a=2",
&[
("host", "s3.example.com"),
("x-amz-content-sha256", "UNSIGNED-PAYLOAD"),
("x-amz-date", " 20260513T120000Z "),
],
);
let signed: Vec<String> =
["host", "x-amz-content-sha256", "x-amz-date"].iter().map(|s| (*s).into()).collect();
let bytes = build_canonical_request_bytes(&r, &signed);
let s = std::str::from_utf8(&bytes).expect("utf-8");
let expected = "PUT\n\
/bucket/key\n\
a=2&z=1\n\
host:s3.example.com\n\
x-amz-content-sha256:UNSIGNED-PAYLOAD\n\
x-amz-date:20260513T120000Z\n\
\n\
host;x-amz-content-sha256;x-amz-date\n\
UNSIGNED-PAYLOAD";
assert_eq!(s, expected, "canonical request bytes mismatch:\n{s}");
}
}