Skip to main content

s4_server/
routing.rs

1//! `/health` と `/ready` の HTTP routing layer + CORS OPTIONS preflight
2//! interceptor + SigV4a verify gate。
3//!
4//! S3 server と同じポートで health probe に応答できると AWS ALB / NLB / k8s
5//! readiness probe との統合が単純になる。
6//!
7//! - `GET /health` → 常に `200 OK` (server プロセスが生きていれば返す)
8//! - `GET /ready` → `ready_check` future を await し、`Ok(())` なら 200、
9//!   それ以外 (backend 不通等) は 503。
10//! - `OPTIONS /<bucket>[/<key>]` (Origin + Access-Control-Request-Method 付き)
11//!   → v0.7 #44: `cors_manager` が attach されていれば、bucket の登録された
12//!   rule list に対して preflight match を実行し、200 + Allow-* header を
13//!   組み立てて返す (no match なら 403)。s3s framework は OPTIONS verb を
14//!   typed handler として持たないため、HTTP-level の interceptor で寄せる。
15//! - `Authorization: AWS4-ECDSA-P256-SHA256 ...` (SigV4a) を持つ request
16//!   → v0.7 #47: `sigv4a_gate` が attach されていれば、listener 側で署名を
17//!   verify し、success なら inner S3Service へ forward、failure なら 403
18//!   `SignatureDoesNotMatch` / `InvalidAccessKeyId` を直接返す。s3s 既存の
19//!   SigV4 verifier は `AWS4-ECDSA-P256-SHA256` を "unknown algorithm" として
20//!   reject するため、middleware を挟まないと SigV4a request は届かない。
21//! - その他のパス → inner S3Service へ委譲
22
23use std::convert::Infallible;
24use std::future::Future;
25use std::pin::Pin;
26use std::sync::Arc;
27
28use bytes::Bytes;
29use http_body_util::Full;
30use hyper::body::Incoming;
31use hyper::service::Service;
32use hyper::{Method, Request, Response, StatusCode};
33use metrics_exporter_prometheus::PrometheusHandle;
34
35use crate::cors::{CorsManager, CorsRule};
36use crate::service::SigV4aGate;
37
38/// readiness check 関数。bound is `Send + Sync` for cross-task use.
39pub type ReadyCheck =
40    Arc<dyn Fn() -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>> + Send + Sync>;
41
42/// inner service と health/ready/metrics + CORS preflight handler +
43/// SigV4a verify gate を合成する hyper Service。
44#[derive(Clone)]
45pub struct HealthRouter<S> {
46    pub inner: S,
47    pub ready_check: Option<ReadyCheck>,
48    pub metrics_handle: Option<PrometheusHandle>,
49    /// v0.7 #44: optional CORS bucket-config manager. When attached,
50    /// OPTIONS requests carrying `Origin` + `Access-Control-Request-Method`
51    /// are intercepted before reaching the s3s service and answered
52    /// directly with Access-Control-Allow-* headers (or 403 if no rule
53    /// matches). When `None`, OPTIONS falls through to the inner service
54    /// (s3s typically returns 405 since no S3 handler maps to OPTIONS).
55    pub cors_manager: Option<Arc<CorsManager>>,
56    /// v0.7 #47: optional SigV4a verify gate. When attached, requests
57    /// whose `Authorization` header begins with `AWS4-ECDSA-P256-SHA256`
58    /// (or that carry `X-Amz-Region-Set`) are verified at the HTTP
59    /// layer using the configured ECDSA-P-256 credential store; on
60    /// failure the listener returns 403 directly. When `None`, the
61    /// gate is a no-op so plain SigV4 deployments are unaffected.
62    pub sigv4a_gate: Option<Arc<SigV4aGate>>,
63    /// v0.7 #47: region name used when checking
64    /// `X-Amz-Region-Set` membership during SigV4a verification. The
65    /// listener is single-region in this milestone — operators that
66    /// front S4 with a Multi-Region Access Point set this to the
67    /// canonical "this listener's region" string. Defaults to
68    /// `"us-east-1"` (the AWS-default region when none is configured).
69    pub region: String,
70}
71
72impl<S> HealthRouter<S> {
73    pub fn new(inner: S, ready_check: Option<ReadyCheck>) -> Self {
74        Self {
75            inner,
76            ready_check,
77            metrics_handle: None,
78            cors_manager: None,
79            sigv4a_gate: None,
80            region: "us-east-1".to_string(),
81        }
82    }
83
84    #[must_use]
85    pub fn with_metrics(mut self, handle: PrometheusHandle) -> Self {
86        self.metrics_handle = Some(handle);
87        self
88    }
89
90    /// v0.7 #44: attach an `Arc<CorsManager>` so OPTIONS preflight
91    /// requests are handled at the HTTP layer instead of falling through
92    /// to s3s.
93    #[must_use]
94    pub fn with_cors_manager(mut self, mgr: Arc<CorsManager>) -> Self {
95        self.cors_manager = Some(mgr);
96        self
97    }
98
99    /// v0.7 #47: attach an `Arc<SigV4aGate>` so `AWS4-ECDSA-P256-SHA256`
100    /// requests are verified at the HTTP layer instead of being
101    /// rejected by s3s' SigV4 verifier as "unknown algorithm".
102    #[must_use]
103    pub fn with_sigv4a_gate(mut self, gate: Arc<SigV4aGate>) -> Self {
104        self.sigv4a_gate = Some(gate);
105        self
106    }
107
108    /// v0.7 #47: override the listener's "served region" string used
109    /// to check `X-Amz-Region-Set` membership during SigV4a
110    /// verification. Defaults to `"us-east-1"`.
111    #[must_use]
112    pub fn with_region(mut self, region: impl Into<String>) -> Self {
113        self.region = region.into();
114        self
115    }
116}
117
118/// v0.7 #44: HTTP-level OPTIONS preflight interceptor.
119///
120/// Returns:
121/// - `Some(response)` if `req` is an OPTIONS preflight (Origin +
122///   Access-Control-Request-Method headers present) targeting a bucket
123///   with CORS configured. The response is 200 with Allow-* headers
124///   when a rule matches, or 403 when no rule matches the
125///   (origin, method, headers) triple.
126/// - `None` if the request is not a preflight, or no CORS config is
127///   registered for the target bucket — caller forwards to the s3s
128///   service.
129///
130/// `cors` is `Option<&Arc<CorsManager>>` so callers can pass through
131/// the inner service's optional manager without unwrapping first.
132///
133/// Generic over the request body type `B` so unit tests can drive the
134/// matcher with `Request<()>` without constructing a real `Incoming`
135/// stream (only headers, method, and URI are inspected).
136#[must_use]
137pub fn try_handle_preflight<B>(
138    req: &Request<B>,
139    cors: Option<&Arc<CorsManager>>,
140) -> Option<Response<s3s::Body>> {
141    if req.method() != Method::OPTIONS {
142        return None;
143    }
144    let mgr = cors?;
145    // Path is `/<bucket>` or `/<bucket>/<key>` — first segment is bucket.
146    // Empty path or a query-only request has no bucket and is not a
147    // preflight we can answer.
148    let path = req.uri().path();
149    let bucket = path.trim_start_matches('/').split('/').next()?;
150    if bucket.is_empty() {
151        return None;
152    }
153    let origin = req.headers().get("origin")?.to_str().ok()?;
154    let method = req
155        .headers()
156        .get("access-control-request-method")?
157        .to_str()
158        .ok()?;
159    // Access-Control-Request-Headers is a comma-separated list, optional
160    // (browsers omit it when no custom headers are being sent).
161    let req_headers: Vec<String> = req
162        .headers()
163        .get("access-control-request-headers")
164        .and_then(|h| h.to_str().ok())
165        .map(|s| {
166            s.split(',')
167                .map(|t| t.trim().to_string())
168                .filter(|t| !t.is_empty())
169                .collect()
170        })
171        .unwrap_or_default();
172    // No config for this bucket → not our problem (let s3s handle / 404).
173    // We need to distinguish "no config" from "config but no rule matches"
174    // to correctly fall through vs. return 403.
175    let _ = mgr.get(bucket)?;
176    match mgr.match_preflight(bucket, origin, method, &req_headers) {
177        Some(rule) => Some(build_preflight_allow_response(&rule, origin)),
178        None => Some(build_preflight_deny_response()),
179    }
180}
181
182/// 200 response with the matched rule's Allow-* headers.
183fn build_preflight_allow_response(rule: &CorsRule, origin: &str) -> Response<s3s::Body> {
184    let mut builder = Response::builder().status(StatusCode::OK);
185    // Echo the matched origin: literal "*" if the rule used a wildcard,
186    // otherwise the requesting origin verbatim (S3 spec).
187    let allow_origin: String = if rule.allowed_origins.iter().any(|o| o == "*") {
188        "*".into()
189    } else {
190        origin.to_owned()
191    };
192    builder = builder.header("Access-Control-Allow-Origin", allow_origin);
193    builder = builder.header(
194        "Access-Control-Allow-Methods",
195        rule.allowed_methods.join(", "),
196    );
197    if !rule.allowed_headers.is_empty() {
198        builder = builder.header(
199            "Access-Control-Allow-Headers",
200            rule.allowed_headers.join(", "),
201        );
202    }
203    if !rule.expose_headers.is_empty() {
204        builder = builder.header(
205            "Access-Control-Expose-Headers",
206            rule.expose_headers.join(", "),
207        );
208    }
209    if let Some(secs) = rule.max_age_seconds {
210        builder = builder.header("Access-Control-Max-Age", secs.to_string());
211    }
212    // Empty body, but set content-length explicitly for clarity.
213    let bytes = Bytes::new();
214    builder = builder.header("content-length", "0");
215    builder
216        .body(s3s::Body::http_body(
217            Full::new(bytes).map_err(|never| match never {}),
218        ))
219        .expect("preflight response builder")
220}
221
222/// 403 response when an OPTIONS preflight reaches a bucket with CORS
223/// configured but no rule matches the (origin, method, headers) triple.
224fn build_preflight_deny_response() -> Response<s3s::Body> {
225    let body = Bytes::from_static(b"CORSResponse: This CORS request is not allowed.");
226    Response::builder()
227        .status(StatusCode::FORBIDDEN)
228        .header("content-type", "text/plain; charset=utf-8")
229        .header("content-length", body.len().to_string())
230        .body(s3s::Body::http_body(
231            Full::new(body).map_err(|never| match never {}),
232        ))
233        .expect("preflight deny response builder")
234}
235
236// ===========================================================================
237// v0.7 #47 — SigV4a verify gate middleware.
238// ===========================================================================
239
240/// v0.7 #47: Try to verify the request as SigV4a-signed.
241///
242/// Returns:
243/// - `None` if the request is not SigV4a-signed (no `AWS4-ECDSA-P256-SHA256`
244///   `Authorization` prefix and no `X-Amz-Region-Set` header) — the
245///   caller forwards the request to s3s for the default SigV4 path.
246/// - `Some(Ok(()))` if SigV4a verify succeeded — the caller forwards to
247///   the inner service so the S3 handler runs.
248/// - `Some(Err(response))` if SigV4a verify failed — the caller returns
249///   the 403 response directly without ever invoking the inner service.
250///
251/// `gate` is `Option<&Arc<SigV4aGate>>` so callers can pass through the
252/// router's optional gate without unwrapping first; when `None`, this
253/// function always returns `None` (no SigV4a verification configured).
254///
255/// `requested_region` is the listener's served region (used to validate
256/// the request's `X-Amz-Region-Set` header membership).
257///
258/// Generic over the request body type `B` so unit tests can drive the
259/// matcher with `Request<()>` without constructing a real `Incoming`
260/// stream — only headers, method, and URI participate in the canonical
261/// request bytes built here.
262///
263/// # Canonical request bytes
264///
265/// We build a SigV4-shaped canonical request from the HTTP-layer
266/// signal alone (method, URI path, sorted query string, headers in the
267/// order listed by `SignedHeaders=`, and `x-amz-content-sha256` as the
268/// payload hash — the standard "client-supplied body hash" convention
269/// every AWS SDK uses). Reading the body would force a `Request<Bytes>`
270/// rebuild and break the s3s framework's streaming-body assumptions, so
271/// the payload-hash header is the only correct source for SigV4a.
272///
273/// Clients that want to sign over the body must include the actual
274/// SHA-256 of the body in `x-amz-content-sha256`; clients that don't
275/// (most S3 SDKs default to `UNSIGNED-PAYLOAD` for streaming PUTs) sign
276/// over that literal string instead. Either way the bytes the gate
277/// compares against are exactly what the client computed.
278pub fn try_sigv4a_verify<B>(
279    req: &Request<B>,
280    gate: Option<&Arc<SigV4aGate>>,
281    requested_region: &str,
282) -> Option<Result<(), Response<s3s::Body>>> {
283    try_sigv4a_verify_at(req, gate, requested_region, chrono::Utc::now())
284}
285
286/// v0.8.4 #76: like [`try_sigv4a_verify`] but takes an explicit `now`
287/// for tests that need to pin the freshness clock without time-warping
288/// the system clock. Production callers always reach this via
289/// `try_sigv4a_verify` (which calls `chrono::Utc::now()`).
290pub fn try_sigv4a_verify_at<B>(
291    req: &Request<B>,
292    gate: Option<&Arc<SigV4aGate>>,
293    requested_region: &str,
294    now: chrono::DateTime<chrono::Utc>,
295) -> Option<Result<(), Response<s3s::Body>>> {
296    let gate = gate?;
297    if !crate::sigv4a::detect(req) {
298        // Not a SigV4a request — caller forwards to the SigV4 path.
299        return None;
300    }
301    // Pre-parse the Authorization header so we know which signed-headers
302    // list to canonicalise in. If the header is malformed, fail fast
303    // with 403 rather than building canonical bytes that can never
304    // verify.
305    //
306    // v0.8.4 #76: `parse_authorization_header` now returns `Result`
307    // (was `Option`) so the gate can surface scope-shape failures
308    // (`InvalidCredentialScope`, `WrongService`, etc.) as 400
309    // InvalidRequest. Any non-Ok parse falls through to the
310    // SignatureDoesNotMatch 403 the original code returned, since at
311    // this point we can't extract a `signed_headers` list to feed the
312    // canonical-request builder.
313    let auth_hdr = req
314        .headers()
315        .get(http::header::AUTHORIZATION)
316        .and_then(|v| v.to_str().ok());
317    let signed_headers: Vec<String> =
318        match auth_hdr.and_then(|hdr| crate::sigv4a::parse_authorization_header(hdr).ok()) {
319            Some(parsed) => parsed.signed_headers,
320            None => {
321                // No / unparseable Authorization header but `detect` flagged
322                // it as SigV4a-shaped (e.g. only the region-set header is
323                // present) — surface as SignatureDoesNotMatch directly.
324                return Some(Err(build_sigv4a_error_response(
325                    StatusCode::FORBIDDEN,
326                    "SignatureDoesNotMatch",
327                    "missing or malformed Authorization header for SigV4a request",
328                )));
329            }
330        };
331    let canonical = build_canonical_request_bytes(req, &signed_headers);
332    match gate.pre_route_at(req, requested_region, &canonical, now) {
333        Ok(()) => Some(Ok(())),
334        Err(err) => {
335            tracing::warn!(error = %err, "SigV4a verify rejected request");
336            Some(Err(build_sigv4a_error_response(
337                err.http_status(),
338                err.s3_error_code(),
339                &err.to_string(),
340            )))
341        }
342    }
343}
344
345/// v0.7 #47: build a SigV4-shaped canonical request from the HTTP
346/// surface alone (no body access). Returns the bytes that the
347/// SigV4a gate will check the ECDSA signature against.
348///
349/// Format (one element per line, joined with `\n`):
350/// 1. HTTP method (uppercase)
351/// 2. canonical URI (path; we leave it untouched since AWS SDKs
352///    pre-encode it the same way s3s receives it)
353/// 3. canonical query string (sorted by name, name=value pairs joined
354///    by `&`; empty when no query string)
355/// 4. canonical headers (one `name:trimmed-value\n` per signed header,
356///    in the **order** they appear in `SignedHeaders=`)
357/// 5. signed headers list (lowercase names joined by `;`)
358/// 6. payload hash (value of `x-amz-content-sha256`, or `UNSIGNED-PAYLOAD`
359///    if absent)
360fn build_canonical_request_bytes<B>(req: &Request<B>, signed_headers: &[String]) -> Vec<u8> {
361    let mut buf = String::with_capacity(512);
362    buf.push_str(req.method().as_str());
363    buf.push('\n');
364    buf.push_str(req.uri().path());
365    buf.push('\n');
366    buf.push_str(&canonical_query_string(req.uri().query().unwrap_or("")));
367    buf.push('\n');
368    for name in signed_headers {
369        let value = req
370            .headers()
371            .get(name.as_str())
372            .and_then(|v| v.to_str().ok())
373            .unwrap_or("");
374        buf.push_str(name);
375        buf.push(':');
376        // Trim whitespace and collapse repeated inner whitespace per
377        // SigV4 canonicalisation rules. This is the same trimming AWS
378        // SDKs do when they sign.
379        buf.push_str(&trim_collapse_ws(value));
380        buf.push('\n');
381    }
382    buf.push('\n');
383    buf.push_str(&signed_headers.join(";"));
384    buf.push('\n');
385    let payload_hash = req
386        .headers()
387        .get("x-amz-content-sha256")
388        .and_then(|v| v.to_str().ok())
389        .unwrap_or("UNSIGNED-PAYLOAD");
390    buf.push_str(payload_hash);
391    buf.into_bytes()
392}
393
394/// SigV4 canonical query string: split on `&`, parse each `k=v` (or
395/// `k`), sort lexicographically by name (then by value), re-join with
396/// `&`. Empty input → empty string. We do **not** re-encode the values
397/// — they already arrived URL-encoded over the wire, and AWS SDKs
398/// expect the server to compare the bytes verbatim.
399fn canonical_query_string(query: &str) -> String {
400    if query.is_empty() {
401        return String::new();
402    }
403    let mut pairs: Vec<(&str, &str)> = query
404        .split('&')
405        .filter(|s| !s.is_empty())
406        .map(|kv| match kv.split_once('=') {
407            Some((k, v)) => (k, v),
408            None => (kv, ""),
409        })
410        .collect();
411    pairs.sort_by(|a, b| a.0.cmp(b.0).then_with(|| a.1.cmp(b.1)));
412    let mut out = String::with_capacity(query.len());
413    for (i, (k, v)) in pairs.iter().enumerate() {
414        if i > 0 {
415            out.push('&');
416        }
417        out.push_str(k);
418        out.push('=');
419        out.push_str(v);
420    }
421    out
422}
423
424/// SigV4 header-value canonicalisation: trim leading + trailing
425/// whitespace and collapse runs of internal whitespace to a single
426/// space. This mirrors what AWS SDKs do client-side when computing the
427/// canonical request — without it, a header value with extra spaces
428/// would canonicalise differently on each side.
429fn trim_collapse_ws(s: &str) -> String {
430    let trimmed = s.trim();
431    let mut out = String::with_capacity(trimmed.len());
432    let mut prev_ws = false;
433    for c in trimmed.chars() {
434        if c.is_whitespace() {
435            if !prev_ws {
436                out.push(' ');
437            }
438            prev_ws = true;
439        } else {
440            out.push(c);
441            prev_ws = false;
442        }
443    }
444    out
445}
446
447/// v0.7 #47: build an AWS-shaped XML response for a SigV4a verify
448/// failure. The response body matches the wire format AWS S3 emits for
449/// the same conditions so SDKs surface the right exception class to the
450/// caller.
451///
452/// v0.8.4 #76: now takes `status` so the gate can return 400
453/// InvalidRequest for malformed-input failures (missing x-amz-date,
454/// wrong service scope, etc.) and 403 for actual auth failures.
455fn build_sigv4a_error_response(
456    status: StatusCode,
457    code: &str,
458    message: &str,
459) -> Response<s3s::Body> {
460    let body_str = format!(
461        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
462         <Error>\n  <Code>{code}</Code>\n  <Message>{message}</Message>\n</Error>"
463    );
464    let bytes = Bytes::from(body_str.into_bytes());
465    Response::builder()
466        .status(status)
467        .header("content-type", "application/xml")
468        .header("content-length", bytes.len().to_string())
469        .body(s3s::Body::http_body(
470            Full::new(bytes).map_err(|never| match never {}),
471        ))
472        .expect("sigv4a error response builder")
473}
474
475/// `/health` と `/ready` のレスポンス Body。
476/// inner S3Service の Body と互換する形にするために `s3s::Body` でラップ可能な
477/// `Full<Bytes>` を `s3s::Body::http_body` 経由で構築する。
478type RespBody = s3s::Body;
479
480fn make_text_response(status: StatusCode, body: &'static str) -> Response<RespBody> {
481    let bytes = Bytes::from_static(body.as_bytes());
482    Response::builder()
483        .status(status)
484        .header("content-type", "text/plain; charset=utf-8")
485        .header("content-length", bytes.len().to_string())
486        .body(s3s::Body::http_body(
487            Full::new(bytes).map_err(|never| match never {}),
488        ))
489        .expect("static response")
490}
491
492fn make_owned_text_response(
493    status: StatusCode,
494    content_type: &'static str,
495    body: String,
496) -> Response<RespBody> {
497    let bytes = Bytes::from(body.into_bytes());
498    Response::builder()
499        .status(status)
500        .header("content-type", content_type)
501        .header("content-length", bytes.len().to_string())
502        .body(s3s::Body::http_body(
503            Full::new(bytes).map_err(|never| match never {}),
504        ))
505        .expect("owned response")
506}
507
508impl<S> Service<Request<Incoming>> for HealthRouter<S>
509where
510    S: Service<Request<Incoming>, Response = Response<s3s::Body>, Error = s3s::HttpError>
511        + Clone
512        + Send
513        + 'static,
514    S::Future: Send + 'static,
515{
516    type Response = Response<RespBody>;
517    type Error = s3s::HttpError;
518    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
519
520    fn call(&self, req: Request<Incoming>) -> Self::Future {
521        // v0.7 #44: short-circuit CORS OPTIONS preflight at the HTTP layer
522        // before health/metrics dispatch. Preflight must run only for
523        // OPTIONS requests, and only when a CORS manager is attached and
524        // a config exists for the requested bucket; otherwise fall
525        // through to the existing routing logic.
526        if let Some(resp) = try_handle_preflight(&req, self.cors_manager.as_ref()) {
527            return Box::pin(async move { Ok(resp) });
528        }
529        // v0.7 #47: SigV4a verify gate. When the request is signed with
530        // `AWS4-ECDSA-P256-SHA256` and a credential store is configured,
531        // verify here at the HTTP layer (s3s' SigV4 verifier would
532        // otherwise reject the request as "unknown algorithm" before
533        // any handler ran). Plain SigV4 (HMAC) requests return `None`
534        // and fall through to the inner service untouched.
535        if let Some(result) = try_sigv4a_verify(&req, self.sigv4a_gate.as_ref(), &self.region) {
536            match result {
537                Ok(()) => {
538                    // verified — fall through to the path-routing logic
539                    // below (the health/metrics/inner-service dispatch).
540                }
541                Err(resp) => return Box::pin(async move { Ok(resp) }),
542            }
543        }
544        let path = req.uri().path();
545        match (req.method(), path) {
546            (&hyper::Method::GET, "/health") | (&hyper::Method::HEAD, "/health") => {
547                Box::pin(async { Ok(make_text_response(StatusCode::OK, "ok\n")) })
548            }
549            (&hyper::Method::GET, "/metrics") | (&hyper::Method::HEAD, "/metrics") => {
550                let handle = self.metrics_handle.clone();
551                Box::pin(async move {
552                    match handle {
553                        Some(h) => {
554                            let body = h.render();
555                            Ok(make_owned_text_response(
556                                StatusCode::OK,
557                                "text/plain; version=0.0.4; charset=utf-8",
558                                body,
559                            ))
560                        }
561                        None => Ok(make_text_response(
562                            StatusCode::SERVICE_UNAVAILABLE,
563                            "metrics not configured\n",
564                        )),
565                    }
566                })
567            }
568            (&hyper::Method::GET, "/ready") | (&hyper::Method::HEAD, "/ready") => {
569                let check = self.ready_check.clone();
570                Box::pin(async move {
571                    match check {
572                        Some(f) => match f().await {
573                            Ok(()) => Ok(make_text_response(StatusCode::OK, "ready\n")),
574                            Err(reason) => {
575                                tracing::warn!(%reason, "readiness check failed");
576                                Ok(make_text_response(
577                                    StatusCode::SERVICE_UNAVAILABLE,
578                                    "not ready\n",
579                                ))
580                            }
581                        },
582                        None => Ok(make_text_response(StatusCode::OK, "ready (no check)\n")),
583                    }
584                })
585            }
586            _ => {
587                let inner = self.inner.clone();
588                Box::pin(async move { inner.call(req).await })
589            }
590        }
591    }
592}
593
594/// `Infallible` を anything に変換するためのトリック (`Full::map_err` 用)
595trait FullExt<B> {
596    fn map_err<E, F: FnMut(Infallible) -> E>(
597        self,
598        f: F,
599    ) -> http_body_util::combinators::MapErr<Self, F>
600    where
601        Self: Sized;
602}
603impl<B> FullExt<B> for Full<B>
604where
605    B: bytes::Buf,
606{
607    fn map_err<E, F: FnMut(Infallible) -> E>(
608        self,
609        f: F,
610    ) -> http_body_util::combinators::MapErr<Self, F>
611    where
612        Self: Sized,
613    {
614        http_body_util::BodyExt::map_err(self, f)
615    }
616}
617
618#[cfg(test)]
619mod preflight_tests {
620    //! v0.7 #44: unit tests for the OPTIONS preflight interceptor.
621    //!
622    //! These exercise [`try_handle_preflight`] directly — no hyper
623    //! `Incoming` body is needed because the function is generic over
624    //! the body type. Behavioural matrix:
625    //!
626    //! 1. matching preflight → 200 + Allow-* headers
627    //! 2. no matching rule (config exists, but origin/method/headers fail)
628    //!    → 403
629    //! 3. missing `Origin` header → `None` (not a CORS preflight)
630    //! 4. non-OPTIONS verb → `None`
631    //! 5. no CORS config registered for the bucket → `None`
632    //! 6. no manager attached → `None`
633
634    use super::*;
635    use crate::cors::{CorsConfig, CorsManager, CorsRule};
636
637    fn rule(origins: &[&str], methods: &[&str], headers: &[&str]) -> CorsRule {
638        CorsRule {
639            allowed_origins: origins.iter().map(|s| (*s).to_owned()).collect(),
640            allowed_methods: methods.iter().map(|s| (*s).to_owned()).collect(),
641            allowed_headers: headers.iter().map(|s| (*s).to_owned()).collect(),
642            expose_headers: vec!["ETag".into()],
643            max_age_seconds: Some(600),
644            id: Some("test".into()),
645        }
646    }
647
648    /// Helper: build a `Request<()>` with the given method, path, and
649    /// headers — body is ignored by the matcher.
650    fn req(method: Method, path: &str, headers: &[(&str, &str)]) -> Request<()> {
651        let mut b = Request::builder().method(method).uri(path);
652        for (k, v) in headers {
653            b = b.header(*k, *v);
654        }
655        b.body(()).expect("request builder")
656    }
657
658    fn manager_with_rule() -> Arc<CorsManager> {
659        let mgr = CorsManager::new();
660        mgr.put(
661            "b",
662            CorsConfig {
663                rules: vec![rule(
664                    &["https://app.example.com"],
665                    &["GET", "PUT", "DELETE"],
666                    &["Content-Type", "X-Amz-Date"],
667                )],
668            },
669        );
670        Arc::new(mgr)
671    }
672
673    #[test]
674    fn preflight_match_returns_allow_response() {
675        let mgr = manager_with_rule();
676        let r = req(
677            Method::OPTIONS,
678            "/b/key.txt",
679            &[
680                ("origin", "https://app.example.com"),
681                ("access-control-request-method", "PUT"),
682                ("access-control-request-headers", "content-type, x-amz-date"),
683            ],
684        );
685        let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
686        assert_eq!(resp.status(), StatusCode::OK);
687        let h = resp.headers();
688        assert_eq!(
689            h.get("access-control-allow-origin")
690                .and_then(|v| v.to_str().ok()),
691            Some("https://app.example.com")
692        );
693        assert_eq!(
694            h.get("access-control-allow-methods")
695                .and_then(|v| v.to_str().ok()),
696            Some("GET, PUT, DELETE")
697        );
698        assert_eq!(
699            h.get("access-control-allow-headers")
700                .and_then(|v| v.to_str().ok()),
701            Some("Content-Type, X-Amz-Date")
702        );
703        assert_eq!(
704            h.get("access-control-max-age")
705                .and_then(|v| v.to_str().ok()),
706            Some("600")
707        );
708        assert_eq!(
709            h.get("access-control-expose-headers")
710                .and_then(|v| v.to_str().ok()),
711            Some("ETag")
712        );
713    }
714
715    #[test]
716    fn preflight_no_match_returns_403() {
717        let mgr = manager_with_rule();
718        // Origin not in allow-list → no rule matches but bucket has CORS
719        // config, so we must answer 403 directly (not fall through to
720        // s3s, which would otherwise leak the bucket existence via 405).
721        let r = req(
722            Method::OPTIONS,
723            "/b/key.txt",
724            &[
725                ("origin", "https://evil.example.com"),
726                ("access-control-request-method", "PUT"),
727            ],
728        );
729        let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
730        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
731        // 403 deny response must NOT carry Allow-Origin (RFC 7234 + S3 wire compat).
732        assert!(resp.headers().get("access-control-allow-origin").is_none());
733    }
734
735    #[test]
736    fn preflight_no_origin_falls_through() {
737        // OPTIONS without Origin is a generic OPTIONS (e.g. `OPTIONS *`)
738        // — not a CORS preflight, must not be intercepted.
739        let mgr = manager_with_rule();
740        let r = req(
741            Method::OPTIONS,
742            "/b/key.txt",
743            &[("access-control-request-method", "PUT")],
744        );
745        assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
746    }
747
748    #[test]
749    fn non_options_falls_through() {
750        let mgr = manager_with_rule();
751        // Even with Origin + ACRM headers, GET is not a preflight.
752        let r = req(
753            Method::GET,
754            "/b/key.txt",
755            &[
756                ("origin", "https://app.example.com"),
757                ("access-control-request-method", "PUT"),
758            ],
759        );
760        assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
761    }
762
763    #[test]
764    fn no_cors_config_for_bucket_falls_through() {
765        // Manager attached but no rule registered for "ghost" → fall
766        // through to inner service so backend can respond naturally.
767        let mgr = manager_with_rule();
768        let r = req(
769            Method::OPTIONS,
770            "/ghost/key.txt",
771            &[
772                ("origin", "https://app.example.com"),
773                ("access-control-request-method", "PUT"),
774            ],
775        );
776        assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
777    }
778
779    #[test]
780    fn no_manager_attached_falls_through() {
781        let r = req(
782            Method::OPTIONS,
783            "/b/key.txt",
784            &[
785                ("origin", "https://app.example.com"),
786                ("access-control-request-method", "PUT"),
787            ],
788        );
789        assert!(try_handle_preflight(&r, None).is_none());
790    }
791
792    #[test]
793    fn preflight_wildcard_origin_echoes_star() {
794        // Rule with `*` origin → response echoes literal "*" (S3 spec).
795        let mgr = CorsManager::new();
796        mgr.put(
797            "b",
798            CorsConfig {
799                rules: vec![rule(&["*"], &["GET", "PUT"], &["*"])],
800            },
801        );
802        let mgr = Arc::new(mgr);
803        let r = req(
804            Method::OPTIONS,
805            "/b/key",
806            &[
807                ("origin", "https://anywhere.example"),
808                ("access-control-request-method", "PUT"),
809                ("access-control-request-headers", "x-custom-header"),
810            ],
811        );
812        let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
813        assert_eq!(resp.status(), StatusCode::OK);
814        assert_eq!(
815            resp.headers()
816                .get("access-control-allow-origin")
817                .and_then(|v| v.to_str().ok()),
818            Some("*"),
819            "wildcard rule must echo literal '*' instead of requesting origin"
820        );
821    }
822
823    #[test]
824    fn preflight_empty_path_falls_through() {
825        let mgr = manager_with_rule();
826        let r = req(
827            Method::OPTIONS,
828            "/",
829            &[
830                ("origin", "https://app.example.com"),
831                ("access-control-request-method", "PUT"),
832            ],
833        );
834        assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
835    }
836}
837
838#[cfg(test)]
839mod sigv4a_gate_tests {
840    //! v0.7 #47: unit tests for the SigV4a verify gate middleware.
841    //!
842    //! These exercise [`try_sigv4a_verify`] directly — no hyper
843    //! `Incoming` body is needed because the function is generic over
844    //! the body type. The canonical-request bytes computed by the
845    //! middleware are the same bytes the test signs over (we use the
846    //! `build_canonical_request_bytes` helper for both sides), so the
847    //! happy-path verify is end-to-end byte-exact.
848    //!
849    //! Behavioural matrix:
850    //!
851    //! 1. no `AWS4-ECDSA-P256-SHA256` prefix and no region-set header
852    //!    → `None` (caller forwards to s3s SigV4 path)
853    //! 2. SigV4a Authorization + valid signature → `Some(Ok(()))`
854    //! 3. SigV4a Authorization + tampered signature → `Some(Err(403))`
855    //!    with `SignatureDoesNotMatch` body
856    //! 4. SigV4a Authorization + region-set mismatch → `Some(Err(403))`
857    //! 5. gate is `None` (no credential store) → `None` even when the
858    //!    request looks SigV4a-shaped (caller forwards, and s3s will
859    //!    surface its own "unknown algorithm" error — operator sees the
860    //!    misconfiguration rather than a silent pass)
861    //! 6. unknown access-key-id → `Some(Err(403))` with
862    //!    `InvalidAccessKeyId` body
863    //! 7. SigV4a-shaped (region-set header only, no SigV4a auth header)
864    //!    → `Some(Err(403))` (we cannot verify without a parseable
865    //!    Authorization, fail closed)
866
867    use super::*;
868
869    use std::collections::HashMap;
870
871    use http_body_util::BodyExt;
872    use p256::ecdsa::SigningKey;
873    use p256::ecdsa::signature::Signer;
874    use rand::rngs::OsRng;
875
876    use crate::service::SigV4aGate;
877    use crate::sigv4a::{REGION_SET_HEADER, SigV4aCredentialStore};
878
879    fn lower_hex(bytes: &[u8]) -> String {
880        let mut s = String::with_capacity(bytes.len() * 2);
881        for b in bytes {
882            s.push_str(&format!("{b:02x}"));
883        }
884        s
885    }
886
887    /// Build a `Request<()>` with the given method, path, and headers.
888    fn req(method: Method, path: &str, headers: &[(&str, &str)]) -> Request<()> {
889        let mut b = Request::builder().method(method).uri(path);
890        for (k, v) in headers {
891            b = b.header(*k, *v);
892        }
893        b.body(()).expect("request builder")
894    }
895
896    /// Build the SigV4a Authorization header for the given access-key,
897    /// signed-headers list, and signature (lowercase hex DER).
898    fn build_auth_header(access_key: &str, signed_headers: &[&str], sig_hex: &str) -> String {
899        format!(
900            "AWS4-ECDSA-P256-SHA256 \
901             Credential={access_key}/20260513/s3/aws4_request, \
902             SignedHeaders={}, \
903             Signature={sig_hex}",
904            signed_headers.join(";")
905        )
906    }
907
908    /// Build a fully-signed SigV4a `Request<()>` ready for the gate to
909    /// verify. Returns the request and the verifying key it should be
910    /// loaded against.
911    fn make_signed_request(
912        access_key: &str,
913        method: Method,
914        path: &str,
915        region_set: &str,
916    ) -> (Request<()>, p256::ecdsa::VerifyingKey) {
917        let signing = SigningKey::random(&mut OsRng);
918        let verifying = p256::ecdsa::VerifyingKey::from(&signing);
919        let signed_headers_list = [
920            "host",
921            "x-amz-content-sha256",
922            "x-amz-date",
923            REGION_SET_HEADER,
924        ];
925        // Build the request first WITHOUT the Authorization header so we
926        // can compute canonical bytes and sign them; then re-build the
927        // request with the Authorization header attached.
928        let pre = Request::builder()
929            .method(method.clone())
930            .uri(path)
931            .header("host", "s3.example.com")
932            .header(
933                "x-amz-content-sha256",
934                "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
935            )
936            .header("x-amz-date", "20260513T120000Z")
937            .header(REGION_SET_HEADER, region_set)
938            .body(())
939            .expect("pre-request");
940        let signed_headers: Vec<String> = signed_headers_list
941            .iter()
942            .map(|s| (*s).to_string())
943            .collect();
944        let canonical = build_canonical_request_bytes(&pre, &signed_headers);
945        let sig: p256::ecdsa::Signature = signing.sign(&canonical);
946        let sig_hex = lower_hex(sig.to_der().as_bytes());
947        let auth = build_auth_header(access_key, &signed_headers_list, &sig_hex);
948
949        // Rebuild with the Authorization header — every other header
950        // value is identical so the canonical bytes the gate computes
951        // match what we signed.
952        let r = Request::builder()
953            .method(method)
954            .uri(path)
955            .header("host", "s3.example.com")
956            .header(
957                "x-amz-content-sha256",
958                "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
959            )
960            .header("x-amz-date", "20260513T120000Z")
961            .header(REGION_SET_HEADER, region_set)
962            .header("authorization", auth)
963            .body(())
964            .expect("signed request");
965        (r, verifying)
966    }
967
968    fn make_gate_with(access_key: &str, vk: p256::ecdsa::VerifyingKey) -> Arc<SigV4aGate> {
969        let mut m = HashMap::new();
970        m.insert(access_key.to_string(), vk);
971        let store = Arc::new(SigV4aCredentialStore::from_map(m));
972        Arc::new(SigV4aGate::new(store))
973    }
974
975    /// Drain a `s3s::Body` into bytes for body-content assertions.
976    async fn body_to_bytes(resp: Response<s3s::Body>) -> Vec<u8> {
977        resp.into_body()
978            .collect()
979            .await
980            .expect("body collect")
981            .to_bytes()
982            .to_vec()
983    }
984
985    /// v0.8.4 #76: pinned `now` matching the `x-amz-date: 20260513T120000Z`
986    /// the test fixtures stamp. Without this the freshness check would
987    /// reject every gate test (the timestamp would be days/weeks old by
988    /// the time CI runs). Production callers use `try_sigv4a_verify`
989    /// (which calls `Utc::now()`).
990    fn fixture_now() -> chrono::DateTime<chrono::Utc> {
991        chrono::DateTime::parse_from_rfc3339("2026-05-13T12:00:00Z")
992            .unwrap()
993            .with_timezone(&chrono::Utc)
994    }
995
996    #[test]
997    fn no_sigv4a_prefix_returns_none() {
998        // Plain SigV4 (HMAC-SHA256) request — gate must defer to s3s.
999        let (_, vk) = (
1000            (),
1001            p256::ecdsa::VerifyingKey::from(&SigningKey::random(&mut OsRng)),
1002        );
1003        let gate = make_gate_with("AKIAOK", vk);
1004        let r = req(
1005            Method::GET,
1006            "/bucket/key",
1007            &[(
1008                "authorization",
1009                "AWS4-HMAC-SHA256 Credential=AKIA/20260513/us-east-1/s3/aws4_request, \
1010                 SignedHeaders=host, Signature=deadbeef",
1011            )],
1012        );
1013        assert!(
1014            try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now()).is_none(),
1015            "plain SigV4 request must fall through to the inner service"
1016        );
1017    }
1018
1019    #[test]
1020    fn sigv4a_valid_signature_returns_ok() {
1021        let (r, vk) =
1022            make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1,us-west-2");
1023        let gate = make_gate_with("AKIAOK", vk);
1024        let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now())
1025            .expect("must intercept SigV4a request");
1026        assert!(
1027            result.is_ok(),
1028            "valid SigV4a signature must verify: {result:?}"
1029        );
1030    }
1031
1032    #[tokio::test]
1033    async fn sigv4a_tampered_signature_returns_403() {
1034        let (r, vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1035        let gate = make_gate_with("AKIAOK", vk);
1036
1037        // Tamper one byte of the signature hex inside the Authorization
1038        // header — the DER decode may still succeed, but ECDSA verify
1039        // will fail (or the DER decode itself will fail; both surface
1040        // as `SignatureDoesNotMatch`).
1041        let auth = r
1042            .headers()
1043            .get("authorization")
1044            .and_then(|v| v.to_str().ok())
1045            .expect("auth header")
1046            .to_string();
1047        // Flip the last hex char to corrupt the signature.
1048        let mut chars: Vec<char> = auth.chars().collect();
1049        let last = chars.len() - 1;
1050        chars[last] = if chars[last] == '0' { '1' } else { '0' };
1051        let tampered_auth: String = chars.into_iter().collect();
1052        let tampered = req(
1053            Method::GET,
1054            "/bucket/key",
1055            &[
1056                ("host", "s3.example.com"),
1057                (
1058                    "x-amz-content-sha256",
1059                    "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1060                ),
1061                ("x-amz-date", "20260513T120000Z"),
1062                (REGION_SET_HEADER, "us-east-1"),
1063                ("authorization", &tampered_auth),
1064            ],
1065        );
1066        let result = try_sigv4a_verify_at(&tampered, Some(&gate), "us-east-1", fixture_now())
1067            .expect("must intercept SigV4a request");
1068        let resp = result.expect_err("tampered signature must surface a 403 response");
1069        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1070        let body = body_to_bytes(resp).await;
1071        let body_str = String::from_utf8(body).expect("xml utf-8");
1072        assert!(
1073            body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1074            "403 body must surface SignatureDoesNotMatch: {body_str}"
1075        );
1076    }
1077
1078    #[tokio::test]
1079    async fn sigv4a_region_set_mismatch_returns_403() {
1080        // Sign for `us-east-1` only, then verify with the listener
1081        // region claiming `eu-west-1` — must fail with
1082        // SignatureDoesNotMatch (the region-set check sits inside the
1083        // gate's verify path, and any failure there folds to
1084        // SignatureDoesNotMatch).
1085        let (r, vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1086        let gate = make_gate_with("AKIAOK", vk);
1087        let result = try_sigv4a_verify_at(&r, Some(&gate), "eu-west-1", fixture_now())
1088            .expect("must intercept SigV4a request");
1089        let resp = result.expect_err("region mismatch must produce 403");
1090        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1091        let body = body_to_bytes(resp).await;
1092        let body_str = String::from_utf8(body).expect("xml utf-8");
1093        assert!(
1094            body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1095            "region-set mismatch must surface SignatureDoesNotMatch: {body_str}"
1096        );
1097    }
1098
1099    #[test]
1100    fn no_gate_attached_returns_none() {
1101        // Even a SigV4a-shaped request returns None when no gate is
1102        // installed — the listener will hand it to s3s, which surfaces
1103        // its own "unknown algorithm" error so the misconfiguration is
1104        // visible to the operator.
1105        let (r, _vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1106        assert!(
1107            try_sigv4a_verify_at(&r, None, "us-east-1", fixture_now()).is_none(),
1108            "missing gate must defer to inner service"
1109        );
1110    }
1111
1112    #[tokio::test]
1113    async fn unknown_access_key_returns_403_invalid_access_key_id() {
1114        // Sign with one key but load the credential store with a
1115        // different access-key-id → InvalidAccessKeyId.
1116        let (r, _vk_unused) =
1117            make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1118        let other_signing = SigningKey::random(&mut OsRng);
1119        let other_vk = p256::ecdsa::VerifyingKey::from(&other_signing);
1120        let gate = make_gate_with("AKIASOMEONEELSE", other_vk);
1121        let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now())
1122            .expect("must intercept SigV4a request");
1123        let resp = result.expect_err("unknown key must produce 403");
1124        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1125        let body = body_to_bytes(resp).await;
1126        let body_str = String::from_utf8(body).expect("xml utf-8");
1127        assert!(
1128            body_str.contains("<Code>InvalidAccessKeyId</Code>"),
1129            "unknown access-key must surface InvalidAccessKeyId: {body_str}"
1130        );
1131    }
1132
1133    #[tokio::test]
1134    async fn region_set_header_only_without_sigv4a_auth_returns_403() {
1135        // Some legacy clients stamp the `X-Amz-Region-Set` header
1136        // before swapping the algorithm string. `detect` flags this as
1137        // SigV4a-shaped but we cannot verify without a parseable
1138        // Authorization → fail closed (SignatureDoesNotMatch).
1139        let signing = SigningKey::random(&mut OsRng);
1140        let vk = p256::ecdsa::VerifyingKey::from(&signing);
1141        let gate = make_gate_with("AKIAOK", vk);
1142        let r = req(
1143            Method::GET,
1144            "/bucket/key",
1145            &[
1146                // SigV4 algorithm + region-set header → detected, but
1147                // the Authorization is plain SigV4 so `parse_authorization_header`
1148                // returns None.
1149                (
1150                    "authorization",
1151                    "AWS4-HMAC-SHA256 Credential=AKIA/20260513/us-east-1/s3/aws4_request, \
1152                     SignedHeaders=host, Signature=deadbeef",
1153                ),
1154                (REGION_SET_HEADER, "us-east-1"),
1155            ],
1156        );
1157        let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now())
1158            .expect("must intercept SigV4a-shaped request");
1159        let resp = result.expect_err("region-set without sigv4a auth must produce 403");
1160        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1161        let body = body_to_bytes(resp).await;
1162        let body_str = String::from_utf8(body).expect("xml utf-8");
1163        assert!(
1164            body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1165            "missing/malformed Authorization for SigV4a-shaped request must fail closed: {body_str}"
1166        );
1167    }
1168
1169    /// v0.8.4 #76 (audit H-6): captured-request replay outside the
1170    /// 15-min window → 403 RequestTimeTooSkewed (not
1171    /// SignatureDoesNotMatch). This is the headline gate-level
1172    /// behaviour change; pre-#76 the same captured request would have
1173    /// reached the inner service, allowing destructive replay (DELETE
1174    /// included).
1175    #[tokio::test]
1176    async fn sigv4a_replay_outside_window_returns_403_request_time_too_skewed() {
1177        let (r, vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1178        let gate = make_gate_with("AKIAOK", vk);
1179        // Request stamped 20260513T120000Z; "now" is 30 min later → drift
1180        // 1800s, beyond the 900s default tolerance.
1181        let now = chrono::DateTime::parse_from_rfc3339("2026-05-13T12:30:00Z")
1182            .unwrap()
1183            .with_timezone(&chrono::Utc);
1184        let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", now)
1185            .expect("must intercept SigV4a request");
1186        let resp = result.expect_err("replay outside window must reject");
1187        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1188        let body = body_to_bytes(resp).await;
1189        let body_str = String::from_utf8(body).expect("xml utf-8");
1190        assert!(
1191            body_str.contains("<Code>RequestTimeTooSkewed</Code>"),
1192            "replay outside window must surface RequestTimeTooSkewed: {body_str}"
1193        );
1194    }
1195
1196    /// Cover the canonical-request builder directly: empty query
1197    /// string, sorted multi-pair query, and header value collapsed
1198    /// whitespace all hit the right code paths.
1199    #[test]
1200    fn canonical_request_bytes_format() {
1201        let r = req(
1202            Method::PUT,
1203            "/bucket/key?z=1&a=2",
1204            &[
1205                ("host", "s3.example.com"),
1206                ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"),
1207                ("x-amz-date", "  20260513T120000Z  "),
1208            ],
1209        );
1210        let signed: Vec<String> = ["host", "x-amz-content-sha256", "x-amz-date"]
1211            .iter()
1212            .map(|s| (*s).into())
1213            .collect();
1214        let bytes = build_canonical_request_bytes(&r, &signed);
1215        let s = std::str::from_utf8(&bytes).expect("utf-8");
1216        let expected = "PUT\n\
1217                        /bucket/key\n\
1218                        a=2&z=1\n\
1219                        host:s3.example.com\n\
1220                        x-amz-content-sha256:UNSIGNED-PAYLOAD\n\
1221                        x-amz-date:20260513T120000Z\n\
1222                        \n\
1223                        host;x-amz-content-sha256;x-amz-date\n\
1224                        UNSIGNED-PAYLOAD";
1225        assert_eq!(s, expected, "canonical request bytes mismatch:\n{s}");
1226    }
1227}