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 = match build_canonical_request_bytes(req, &signed_headers) {
332        Ok(bytes) => bytes,
333        Err(err) => {
334            // v0.8.5 #84 H-4: duplicate signed header (only failure
335            // mode the canonical builder has today). Surface as
336            // `SignatureDoesNotMatch` 403 — the AWS SDKs treat that
337            // as the catch-all auth-failure code, and the diagnostic
338            // is in the response body / server log.
339            tracing::warn!(error = %err, "SigV4a canonical-request build rejected request");
340            return Some(Err(build_sigv4a_error_response(
341                StatusCode::FORBIDDEN,
342                "SignatureDoesNotMatch",
343                &err.to_string(),
344            )));
345        }
346    };
347    match gate.pre_route_at(req, requested_region, &canonical, now) {
348        Ok(()) => Some(Ok(())),
349        Err(err) => {
350            tracing::warn!(error = %err, "SigV4a verify rejected request");
351            Some(Err(build_sigv4a_error_response(
352                err.http_status(),
353                err.s3_error_code(),
354                &err.to_string(),
355            )))
356        }
357    }
358}
359
360/// v0.7 #47: build a SigV4-shaped canonical request from the HTTP
361/// surface alone (no body access). Returns the bytes that the
362/// SigV4a gate will check the ECDSA signature against.
363///
364/// Format (one element per line, joined with `\n`):
365/// 1. HTTP method (uppercase)
366/// 2. canonical URI (path; we leave it untouched since AWS SDKs
367///    pre-encode it the same way s3s receives it)
368/// 3. canonical query string (sorted by name, name=value pairs joined
369///    by `&`; empty when no query string)
370/// 4. canonical headers (one `name:trimmed-value\n` per signed header,
371///    in the **order** they appear in `SignedHeaders=`)
372/// 5. signed headers list (lowercase names joined by `;`)
373/// 6. payload hash (value of `x-amz-content-sha256`, or `UNSIGNED-PAYLOAD`
374///    if absent)
375///
376/// v0.8.5 #84 (audit H-4): every signed header is checked for being
377/// sent **exactly once** on the request. If a header in
378/// `SignedHeaders=` appears more than once we'd have to choose between
379/// the first value (`HeaderMap::get` semantics) and the comma-joined
380/// AWS-canonical form — and any S3 SDK / WAF / sidecar in front of us
381/// would make a different choice, opening "auth confusion" attacks
382/// (sign over the benign first `x-amz-date`, smuggle a second one for
383/// the inner parser). HTTP/1.1 spec already forbids duplicates of
384/// `host` / `x-amz-date` and the AWS SDKs never emit them, so any
385/// duplicate is a malicious or broken request — reject upfront with
386/// [`SigV4aError::DuplicateSignedHeader`].
387fn build_canonical_request_bytes<B>(
388    req: &Request<B>,
389    signed_headers: &[String],
390) -> Result<Vec<u8>, crate::sigv4a::SigV4aError> {
391    let mut buf = String::with_capacity(512);
392    buf.push_str(req.method().as_str());
393    buf.push('\n');
394    // v0.8.15 H-d: canonical URI per RFC 3986 unreserved set. Real
395    // AWS SDKs decode + re-encode (uppercase hex, only unreserved
396    // chars left literal) before hashing, so receiving the same
397    // request through a normalising TLS terminator that lowercases
398    // `%2f` to `%2F` (or vice versa) would otherwise produce a
399    // different canonical form than what the SDK signed. `/`
400    // path-segment separators stay literal — S3 doesn't escape them
401    // in the canonical path.
402    buf.push_str(&canonical_uri_path(req.uri().path()));
403    buf.push('\n');
404    buf.push_str(&canonical_query_string(req.uri().query().unwrap_or("")));
405    buf.push('\n');
406    for name in signed_headers {
407        // v0.8.5 #84 H-4: count occurrences via `get_all` rather than
408        // `get`, which only ever returns the first value. Two
409        // `x-amz-date` headers with `get` would canonicalise to the
410        // first value while a downstream HTTP/1.1 parser might pick
411        // the second — auth confusion. Single-value reject is the
412        // safe choice; comma-join would be the AWS-canonical form
413        // for legitimately multi-valued signed headers, but the AWS
414        // SDKs never sign over comma-joined values for any header
415        // S3 cares about, so refusing duplicates outright matches
416        // every real-world client.
417        let occurrences = req.headers().get_all(name.as_str()).iter().count();
418        if occurrences > 1 {
419            return Err(crate::sigv4a::SigV4aError::DuplicateSignedHeader {
420                header: name.clone(),
421            });
422        }
423        let value = req
424            .headers()
425            .get(name.as_str())
426            .and_then(|v| v.to_str().ok())
427            .unwrap_or("");
428        buf.push_str(name);
429        buf.push(':');
430        // Trim whitespace and collapse repeated inner whitespace per
431        // SigV4 canonicalisation rules. This is the same trimming AWS
432        // SDKs do when they sign.
433        buf.push_str(&trim_collapse_ws(value));
434        buf.push('\n');
435    }
436    buf.push('\n');
437    buf.push_str(&signed_headers.join(";"));
438    buf.push('\n');
439    let payload_hash = req
440        .headers()
441        .get("x-amz-content-sha256")
442        .and_then(|v| v.to_str().ok())
443        .unwrap_or("UNSIGNED-PAYLOAD");
444    buf.push_str(payload_hash);
445    Ok(buf.into_bytes())
446}
447
448/// SigV4 canonical query string: split on `&`, parse each `k=v` (or
449/// `k`), sort lexicographically by name (then by value), re-join with
450/// `&`. Empty input → empty string. We do **not** re-encode the values
451/// — they already arrived URL-encoded over the wire, and AWS SDKs
452/// expect the server to compare the bytes verbatim.
453fn canonical_query_string(query: &str) -> String {
454    if query.is_empty() {
455        return String::new();
456    }
457    // v0.8.15 H-d: AWS SigV4 / SigV4a spec — decode each key/value to
458    // raw bytes, then re-encode with the AWS canonical form (RFC
459    // 3986 unreserved set, uppercase hex), then sort by the encoded
460    // key (and value as tiebreaker). The pre-H-d code took the raw
461    // wire bytes and sorted those, which produced a different
462    // canonical string than the SDK's output for any of these
463    // mismatches:
464    //
465    // 1. Lowercase `%2f` in the wire vs. SDK-canonical uppercase
466    //    `%2F` (some TLS terminators normalise).
467    // 2. Mixed encoding choices (one side encodes `=` as `%3D`, the
468    //    other leaves it bare).
469    // 3. Sort order on raw bytes vs. encoded bytes differs when one
470    //    side encodes a char the other left literal.
471    //
472    // Real AWS SDKs always emit fully-encoded canonical form, so the
473    // pre-H-d "verbatim sort" only matched signatures the gate itself
474    // produced, not signatures real clients ship.
475    let mut pairs: Vec<(String, String)> = query
476        .split('&')
477        .filter(|s| !s.is_empty())
478        .map(|kv| match kv.split_once('=') {
479            Some((k, v)) => (percent_decode_to_string(k), percent_decode_to_string(v)),
480            None => (percent_decode_to_string(kv), String::new()),
481        })
482        .map(|(k, v)| (aws_canonical_encode(&k), aws_canonical_encode(&v)))
483        .collect();
484    pairs.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
485    let mut out = String::with_capacity(query.len());
486    for (i, (k, v)) in pairs.iter().enumerate() {
487        if i > 0 {
488            out.push('&');
489        }
490        out.push_str(k);
491        out.push('=');
492        out.push_str(v);
493    }
494    out
495}
496
497/// v0.8.15 H-d: AWS canonical URI path encoding. Pulls each segment
498/// out of the slash-separated path, decodes any percent-encoded
499/// bytes, then re-encodes with the canonical form. Slashes are
500/// preserved literal (S3 doesn't escape segment separators in the
501/// canonical path).
502fn canonical_uri_path(path: &str) -> String {
503    if path.is_empty() {
504        return "/".to_owned();
505    }
506    let mut out = String::with_capacity(path.len());
507    let mut first = true;
508    for segment in path.split('/') {
509        if !first {
510            out.push('/');
511        }
512        first = false;
513        let decoded = percent_decode_to_string(segment);
514        out.push_str(&aws_canonical_encode(&decoded));
515    }
516    out
517}
518
519/// v0.8.15 H-d: decode a percent-encoded UTF-8 string to its raw
520/// bytes, then interpret as `String` (lossy fallback on bad UTF-8 so
521/// we never panic on weird input). The `percent_encoding` crate
522/// gives us the decode iterator; we just collect.
523fn percent_decode_to_string(s: &str) -> String {
524    percent_encoding::percent_decode_str(s)
525        .decode_utf8_lossy()
526        .into_owned()
527}
528
529/// v0.8.15 H-d: encode a UTF-8 string per AWS SigV4 canonical form.
530/// Per RFC 3986 the unreserved set is `A-Z a-z 0-9 - _ . ~`; every
531/// other byte becomes `%XX` with uppercase hex. AWS treats this set
532/// identically (it's the AWS-canonical set).
533fn aws_canonical_encode(s: &str) -> String {
534    /// AWS canonical set per SigV4 spec — equivalent to RFC 3986
535    /// unreserved. Everything else gets `%XX`.
536    const AWS_CANONICAL_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
537        .remove(b'-')
538        .remove(b'_')
539        .remove(b'.')
540        .remove(b'~');
541    percent_encoding::utf8_percent_encode(s, AWS_CANONICAL_SET).to_string()
542}
543
544/// SigV4 header-value canonicalisation: trim leading + trailing
545/// whitespace and collapse runs of internal whitespace to a single
546/// space. This mirrors what AWS SDKs do client-side when computing the
547/// canonical request — without it, a header value with extra spaces
548/// would canonicalise differently on each side.
549fn trim_collapse_ws(s: &str) -> String {
550    let trimmed = s.trim();
551    let mut out = String::with_capacity(trimmed.len());
552    let mut prev_ws = false;
553    for c in trimmed.chars() {
554        if c.is_whitespace() {
555            if !prev_ws {
556                out.push(' ');
557            }
558            prev_ws = true;
559        } else {
560            out.push(c);
561            prev_ws = false;
562        }
563    }
564    out
565}
566
567/// v0.7 #47: build an AWS-shaped XML response for a SigV4a verify
568/// failure. The response body matches the wire format AWS S3 emits for
569/// the same conditions so SDKs surface the right exception class to the
570/// caller.
571///
572/// v0.8.4 #76: now takes `status` so the gate can return 400
573/// InvalidRequest for malformed-input failures (missing x-amz-date,
574/// wrong service scope, etc.) and 403 for actual auth failures.
575fn build_sigv4a_error_response(
576    status: StatusCode,
577    code: &str,
578    message: &str,
579) -> Response<s3s::Body> {
580    let body_str = format!(
581        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
582         <Error>\n  <Code>{code}</Code>\n  <Message>{message}</Message>\n</Error>"
583    );
584    let bytes = Bytes::from(body_str.into_bytes());
585    Response::builder()
586        .status(status)
587        .header("content-type", "application/xml")
588        .header("content-length", bytes.len().to_string())
589        .body(s3s::Body::http_body(
590            Full::new(bytes).map_err(|never| match never {}),
591        ))
592        .expect("sigv4a error response builder")
593}
594
595/// `/health` と `/ready` のレスポンス Body。
596/// inner S3Service の Body と互換する形にするために `s3s::Body` でラップ可能な
597/// `Full<Bytes>` を `s3s::Body::http_body` 経由で構築する。
598type RespBody = s3s::Body;
599
600fn make_text_response(status: StatusCode, body: &'static str) -> Response<RespBody> {
601    let bytes = Bytes::from_static(body.as_bytes());
602    Response::builder()
603        .status(status)
604        .header("content-type", "text/plain; charset=utf-8")
605        .header("content-length", bytes.len().to_string())
606        .body(s3s::Body::http_body(
607            Full::new(bytes).map_err(|never| match never {}),
608        ))
609        .expect("static response")
610}
611
612fn make_owned_text_response(
613    status: StatusCode,
614    content_type: &'static str,
615    body: String,
616) -> Response<RespBody> {
617    let bytes = Bytes::from(body.into_bytes());
618    Response::builder()
619        .status(status)
620        .header("content-type", content_type)
621        .header("content-length", bytes.len().to_string())
622        .body(s3s::Body::http_body(
623            Full::new(bytes).map_err(|never| match never {}),
624        ))
625        .expect("owned response")
626}
627
628impl<S> Service<Request<Incoming>> for HealthRouter<S>
629where
630    S: Service<Request<Incoming>, Response = Response<s3s::Body>, Error = s3s::HttpError>
631        + Clone
632        + Send
633        + 'static,
634    S::Future: Send + 'static,
635{
636    type Response = Response<RespBody>;
637    type Error = s3s::HttpError;
638    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
639
640    fn call(&self, req: Request<Incoming>) -> Self::Future {
641        // v0.7 #44: short-circuit CORS OPTIONS preflight at the HTTP layer
642        // before health/metrics dispatch. Preflight must run only for
643        // OPTIONS requests, and only when a CORS manager is attached and
644        // a config exists for the requested bucket; otherwise fall
645        // through to the existing routing logic.
646        if let Some(resp) = try_handle_preflight(&req, self.cors_manager.as_ref()) {
647            return Box::pin(async move { Ok(resp) });
648        }
649        // v0.7 #47: SigV4a verify gate. When the request is signed with
650        // `AWS4-ECDSA-P256-SHA256` and a credential store is configured,
651        // verify here at the HTTP layer (s3s' SigV4 verifier would
652        // otherwise reject the request as "unknown algorithm" before
653        // any handler ran). Plain SigV4 (HMAC) requests return `None`
654        // and fall through to the inner service untouched.
655        if let Some(result) = try_sigv4a_verify(&req, self.sigv4a_gate.as_ref(), &self.region) {
656            match result {
657                Ok(()) => {
658                    // verified — fall through to the path-routing logic
659                    // below (the health/metrics/inner-service dispatch).
660                }
661                Err(resp) => return Box::pin(async move { Ok(resp) }),
662            }
663        }
664        let path = req.uri().path();
665        match (req.method(), path) {
666            (&hyper::Method::GET, "/health") | (&hyper::Method::HEAD, "/health") => {
667                Box::pin(async { Ok(make_text_response(StatusCode::OK, "ok\n")) })
668            }
669            (&hyper::Method::GET, "/metrics") | (&hyper::Method::HEAD, "/metrics") => {
670                let handle = self.metrics_handle.clone();
671                Box::pin(async move {
672                    match handle {
673                        Some(h) => {
674                            let body = h.render();
675                            Ok(make_owned_text_response(
676                                StatusCode::OK,
677                                "text/plain; version=0.0.4; charset=utf-8",
678                                body,
679                            ))
680                        }
681                        None => Ok(make_text_response(
682                            StatusCode::SERVICE_UNAVAILABLE,
683                            "metrics not configured\n",
684                        )),
685                    }
686                })
687            }
688            (&hyper::Method::GET, "/ready") | (&hyper::Method::HEAD, "/ready") => {
689                let check = self.ready_check.clone();
690                Box::pin(async move {
691                    match check {
692                        Some(f) => match f().await {
693                            Ok(()) => Ok(make_text_response(StatusCode::OK, "ready\n")),
694                            Err(reason) => {
695                                tracing::warn!(%reason, "readiness check failed");
696                                Ok(make_text_response(
697                                    StatusCode::SERVICE_UNAVAILABLE,
698                                    "not ready\n",
699                                ))
700                            }
701                        },
702                        None => Ok(make_text_response(StatusCode::OK, "ready (no check)\n")),
703                    }
704                })
705            }
706            _ => {
707                let inner = self.inner.clone();
708                Box::pin(async move { inner.call(req).await })
709            }
710        }
711    }
712}
713
714/// `Infallible` を anything に変換するためのトリック (`Full::map_err` 用)
715trait FullExt<B> {
716    fn map_err<E, F: FnMut(Infallible) -> E>(
717        self,
718        f: F,
719    ) -> http_body_util::combinators::MapErr<Self, F>
720    where
721        Self: Sized;
722}
723impl<B> FullExt<B> for Full<B>
724where
725    B: bytes::Buf,
726{
727    fn map_err<E, F: FnMut(Infallible) -> E>(
728        self,
729        f: F,
730    ) -> http_body_util::combinators::MapErr<Self, F>
731    where
732        Self: Sized,
733    {
734        http_body_util::BodyExt::map_err(self, f)
735    }
736}
737
738#[cfg(test)]
739mod preflight_tests {
740    //! v0.7 #44: unit tests for the OPTIONS preflight interceptor.
741    //!
742    //! These exercise [`try_handle_preflight`] directly — no hyper
743    //! `Incoming` body is needed because the function is generic over
744    //! the body type. Behavioural matrix:
745    //!
746    //! 1. matching preflight → 200 + Allow-* headers
747    //! 2. no matching rule (config exists, but origin/method/headers fail)
748    //!    → 403
749    //! 3. missing `Origin` header → `None` (not a CORS preflight)
750    //! 4. non-OPTIONS verb → `None`
751    //! 5. no CORS config registered for the bucket → `None`
752    //! 6. no manager attached → `None`
753
754    use super::*;
755    use crate::cors::{CorsConfig, CorsManager, CorsRule};
756
757    fn rule(origins: &[&str], methods: &[&str], headers: &[&str]) -> CorsRule {
758        CorsRule {
759            allowed_origins: origins.iter().map(|s| (*s).to_owned()).collect(),
760            allowed_methods: methods.iter().map(|s| (*s).to_owned()).collect(),
761            allowed_headers: headers.iter().map(|s| (*s).to_owned()).collect(),
762            expose_headers: vec!["ETag".into()],
763            max_age_seconds: Some(600),
764            id: Some("test".into()),
765        }
766    }
767
768    /// Helper: build a `Request<()>` with the given method, path, and
769    /// headers — body is ignored by the matcher.
770    fn req(method: Method, path: &str, headers: &[(&str, &str)]) -> Request<()> {
771        let mut b = Request::builder().method(method).uri(path);
772        for (k, v) in headers {
773            b = b.header(*k, *v);
774        }
775        b.body(()).expect("request builder")
776    }
777
778    fn manager_with_rule() -> Arc<CorsManager> {
779        let mgr = CorsManager::new();
780        mgr.put(
781            "b",
782            CorsConfig {
783                rules: vec![rule(
784                    &["https://app.example.com"],
785                    &["GET", "PUT", "DELETE"],
786                    &["Content-Type", "X-Amz-Date"],
787                )],
788            },
789        );
790        Arc::new(mgr)
791    }
792
793    #[test]
794    fn preflight_match_returns_allow_response() {
795        let mgr = manager_with_rule();
796        let r = req(
797            Method::OPTIONS,
798            "/b/key.txt",
799            &[
800                ("origin", "https://app.example.com"),
801                ("access-control-request-method", "PUT"),
802                ("access-control-request-headers", "content-type, x-amz-date"),
803            ],
804        );
805        let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
806        assert_eq!(resp.status(), StatusCode::OK);
807        let h = resp.headers();
808        assert_eq!(
809            h.get("access-control-allow-origin")
810                .and_then(|v| v.to_str().ok()),
811            Some("https://app.example.com")
812        );
813        assert_eq!(
814            h.get("access-control-allow-methods")
815                .and_then(|v| v.to_str().ok()),
816            Some("GET, PUT, DELETE")
817        );
818        assert_eq!(
819            h.get("access-control-allow-headers")
820                .and_then(|v| v.to_str().ok()),
821            Some("Content-Type, X-Amz-Date")
822        );
823        assert_eq!(
824            h.get("access-control-max-age")
825                .and_then(|v| v.to_str().ok()),
826            Some("600")
827        );
828        assert_eq!(
829            h.get("access-control-expose-headers")
830                .and_then(|v| v.to_str().ok()),
831            Some("ETag")
832        );
833    }
834
835    #[test]
836    fn preflight_no_match_returns_403() {
837        let mgr = manager_with_rule();
838        // Origin not in allow-list → no rule matches but bucket has CORS
839        // config, so we must answer 403 directly (not fall through to
840        // s3s, which would otherwise leak the bucket existence via 405).
841        let r = req(
842            Method::OPTIONS,
843            "/b/key.txt",
844            &[
845                ("origin", "https://evil.example.com"),
846                ("access-control-request-method", "PUT"),
847            ],
848        );
849        let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
850        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
851        // 403 deny response must NOT carry Allow-Origin (RFC 7234 + S3 wire compat).
852        assert!(resp.headers().get("access-control-allow-origin").is_none());
853    }
854
855    #[test]
856    fn preflight_no_origin_falls_through() {
857        // OPTIONS without Origin is a generic OPTIONS (e.g. `OPTIONS *`)
858        // — not a CORS preflight, must not be intercepted.
859        let mgr = manager_with_rule();
860        let r = req(
861            Method::OPTIONS,
862            "/b/key.txt",
863            &[("access-control-request-method", "PUT")],
864        );
865        assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
866    }
867
868    #[test]
869    fn non_options_falls_through() {
870        let mgr = manager_with_rule();
871        // Even with Origin + ACRM headers, GET is not a preflight.
872        let r = req(
873            Method::GET,
874            "/b/key.txt",
875            &[
876                ("origin", "https://app.example.com"),
877                ("access-control-request-method", "PUT"),
878            ],
879        );
880        assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
881    }
882
883    #[test]
884    fn no_cors_config_for_bucket_falls_through() {
885        // Manager attached but no rule registered for "ghost" → fall
886        // through to inner service so backend can respond naturally.
887        let mgr = manager_with_rule();
888        let r = req(
889            Method::OPTIONS,
890            "/ghost/key.txt",
891            &[
892                ("origin", "https://app.example.com"),
893                ("access-control-request-method", "PUT"),
894            ],
895        );
896        assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
897    }
898
899    #[test]
900    fn no_manager_attached_falls_through() {
901        let r = req(
902            Method::OPTIONS,
903            "/b/key.txt",
904            &[
905                ("origin", "https://app.example.com"),
906                ("access-control-request-method", "PUT"),
907            ],
908        );
909        assert!(try_handle_preflight(&r, None).is_none());
910    }
911
912    #[test]
913    fn preflight_wildcard_origin_echoes_star() {
914        // Rule with `*` origin → response echoes literal "*" (S3 spec).
915        let mgr = CorsManager::new();
916        mgr.put(
917            "b",
918            CorsConfig {
919                rules: vec![rule(&["*"], &["GET", "PUT"], &["*"])],
920            },
921        );
922        let mgr = Arc::new(mgr);
923        let r = req(
924            Method::OPTIONS,
925            "/b/key",
926            &[
927                ("origin", "https://anywhere.example"),
928                ("access-control-request-method", "PUT"),
929                ("access-control-request-headers", "x-custom-header"),
930            ],
931        );
932        let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
933        assert_eq!(resp.status(), StatusCode::OK);
934        assert_eq!(
935            resp.headers()
936                .get("access-control-allow-origin")
937                .and_then(|v| v.to_str().ok()),
938            Some("*"),
939            "wildcard rule must echo literal '*' instead of requesting origin"
940        );
941    }
942
943    #[test]
944    fn preflight_empty_path_falls_through() {
945        let mgr = manager_with_rule();
946        let r = req(
947            Method::OPTIONS,
948            "/",
949            &[
950                ("origin", "https://app.example.com"),
951                ("access-control-request-method", "PUT"),
952            ],
953        );
954        assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
955    }
956}
957
958#[cfg(test)]
959mod sigv4a_gate_tests {
960    //! v0.7 #47: unit tests for the SigV4a verify gate middleware.
961    //!
962    //! These exercise [`try_sigv4a_verify`] directly — no hyper
963    //! `Incoming` body is needed because the function is generic over
964    //! the body type. The canonical-request bytes computed by the
965    //! middleware are the same bytes the test signs over (we use the
966    //! `build_canonical_request_bytes` helper for both sides), so the
967    //! happy-path verify is end-to-end byte-exact.
968    //!
969    //! Behavioural matrix:
970    //!
971    //! 1. no `AWS4-ECDSA-P256-SHA256` prefix and no region-set header
972    //!    → `None` (caller forwards to s3s SigV4 path)
973    //! 2. SigV4a Authorization + valid signature → `Some(Ok(()))`
974    //! 3. SigV4a Authorization + tampered signature → `Some(Err(403))`
975    //!    with `SignatureDoesNotMatch` body
976    //! 4. SigV4a Authorization + region-set mismatch → `Some(Err(403))`
977    //! 5. gate is `None` (no credential store) → `None` even when the
978    //!    request looks SigV4a-shaped (caller forwards, and s3s will
979    //!    surface its own "unknown algorithm" error — operator sees the
980    //!    misconfiguration rather than a silent pass)
981    //! 6. unknown access-key-id → `Some(Err(403))` with
982    //!    `InvalidAccessKeyId` body
983    //! 7. SigV4a-shaped (region-set header only, no SigV4a auth header)
984    //!    → `Some(Err(403))` (we cannot verify without a parseable
985    //!    Authorization, fail closed)
986
987    use super::*;
988
989    use std::collections::HashMap;
990
991    use http_body_util::BodyExt;
992    use p256::ecdsa::SigningKey;
993    use p256::ecdsa::signature::Signer;
994    use rand::rngs::OsRng;
995
996    use crate::service::SigV4aGate;
997    use crate::sigv4a::{REGION_SET_HEADER, SigV4aCredentialStore};
998
999    fn lower_hex(bytes: &[u8]) -> String {
1000        let mut s = String::with_capacity(bytes.len() * 2);
1001        for b in bytes {
1002            s.push_str(&format!("{b:02x}"));
1003        }
1004        s
1005    }
1006
1007    /// Build a `Request<()>` with the given method, path, and headers.
1008    fn req(method: Method, path: &str, headers: &[(&str, &str)]) -> Request<()> {
1009        let mut b = Request::builder().method(method).uri(path);
1010        for (k, v) in headers {
1011            b = b.header(*k, *v);
1012        }
1013        b.body(()).expect("request builder")
1014    }
1015
1016    /// Build the SigV4a Authorization header for the given access-key,
1017    /// signed-headers list, and signature (lowercase hex DER).
1018    fn build_auth_header(access_key: &str, signed_headers: &[&str], sig_hex: &str) -> String {
1019        format!(
1020            "AWS4-ECDSA-P256-SHA256 \
1021             Credential={access_key}/20260513/s3/aws4_request, \
1022             SignedHeaders={}, \
1023             Signature={sig_hex}",
1024            signed_headers.join(";")
1025        )
1026    }
1027
1028    /// Build a fully-signed SigV4a `Request<()>` ready for the gate to
1029    /// verify. Returns the request and the verifying key it should be
1030    /// loaded against.
1031    fn make_signed_request(
1032        access_key: &str,
1033        method: Method,
1034        path: &str,
1035        region_set: &str,
1036    ) -> (Request<()>, p256::ecdsa::VerifyingKey) {
1037        let signing = SigningKey::random(&mut OsRng);
1038        let verifying = p256::ecdsa::VerifyingKey::from(&signing);
1039        let signed_headers_list = [
1040            "host",
1041            "x-amz-content-sha256",
1042            "x-amz-date",
1043            REGION_SET_HEADER,
1044        ];
1045        // Build the request first WITHOUT the Authorization header so we
1046        // can compute canonical bytes and sign them; then re-build the
1047        // request with the Authorization header attached.
1048        let pre = Request::builder()
1049            .method(method.clone())
1050            .uri(path)
1051            .header("host", "s3.example.com")
1052            .header(
1053                "x-amz-content-sha256",
1054                "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1055            )
1056            .header("x-amz-date", "20260513T120000Z")
1057            .header(REGION_SET_HEADER, region_set)
1058            .body(())
1059            .expect("pre-request");
1060        let signed_headers: Vec<String> = signed_headers_list
1061            .iter()
1062            .map(|s| (*s).to_string())
1063            .collect();
1064        let canonical =
1065            build_canonical_request_bytes(&pre, &signed_headers).expect("test fixture canonical");
1066        // v0.8.12 #126 (MED-A): sign the AWS-spec string-to-sign so
1067        // the routing-layer SigV4a fixture matches the new
1068        // `verify_request` body (which hashes the canonical request
1069        // and signs the algo / date / scope / hash concatenation).
1070        let canonical_hash = {
1071            use sha2::{Digest, Sha256};
1072            let mut h = Sha256::new();
1073            h.update(&canonical);
1074            let out = h.finalize();
1075            let mut s = String::with_capacity(out.len() * 2);
1076            for b in out {
1077                use std::fmt::Write as _;
1078                let _ = write!(s, "{b:02x}");
1079            }
1080            s
1081        };
1082        let sts = format!(
1083            "AWS4-ECDSA-P256-SHA256\n20260513T120000Z\n20260513/s3/aws4_request\n{canonical_hash}"
1084        );
1085        let sig: p256::ecdsa::Signature = signing.sign(sts.as_bytes());
1086        let sig_hex = lower_hex(sig.to_der().as_bytes());
1087        let auth = build_auth_header(access_key, &signed_headers_list, &sig_hex);
1088
1089        // Rebuild with the Authorization header — every other header
1090        // value is identical so the canonical bytes the gate computes
1091        // match what we signed.
1092        let r = Request::builder()
1093            .method(method)
1094            .uri(path)
1095            .header("host", "s3.example.com")
1096            .header(
1097                "x-amz-content-sha256",
1098                "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1099            )
1100            .header("x-amz-date", "20260513T120000Z")
1101            .header(REGION_SET_HEADER, region_set)
1102            .header("authorization", auth)
1103            .body(())
1104            .expect("signed request");
1105        (r, verifying)
1106    }
1107
1108    fn make_gate_with(access_key: &str, vk: p256::ecdsa::VerifyingKey) -> Arc<SigV4aGate> {
1109        let mut m = HashMap::new();
1110        m.insert(access_key.to_string(), vk);
1111        let store = Arc::new(SigV4aCredentialStore::from_map(m));
1112        Arc::new(SigV4aGate::new(store))
1113    }
1114
1115    /// Drain a `s3s::Body` into bytes for body-content assertions.
1116    async fn body_to_bytes(resp: Response<s3s::Body>) -> Vec<u8> {
1117        resp.into_body()
1118            .collect()
1119            .await
1120            .expect("body collect")
1121            .to_bytes()
1122            .to_vec()
1123    }
1124
1125    /// v0.8.4 #76: pinned `now` matching the `x-amz-date: 20260513T120000Z`
1126    /// the test fixtures stamp. Without this the freshness check would
1127    /// reject every gate test (the timestamp would be days/weeks old by
1128    /// the time CI runs). Production callers use `try_sigv4a_verify`
1129    /// (which calls `Utc::now()`).
1130    fn fixture_now() -> chrono::DateTime<chrono::Utc> {
1131        chrono::DateTime::parse_from_rfc3339("2026-05-13T12:00:00Z")
1132            .unwrap()
1133            .with_timezone(&chrono::Utc)
1134    }
1135
1136    #[test]
1137    fn no_sigv4a_prefix_returns_none() {
1138        // Plain SigV4 (HMAC-SHA256) request — gate must defer to s3s.
1139        let (_, vk) = (
1140            (),
1141            p256::ecdsa::VerifyingKey::from(&SigningKey::random(&mut OsRng)),
1142        );
1143        let gate = make_gate_with("AKIAOK", vk);
1144        let r = req(
1145            Method::GET,
1146            "/bucket/key",
1147            &[(
1148                "authorization",
1149                "AWS4-HMAC-SHA256 Credential=AKIA/20260513/us-east-1/s3/aws4_request, \
1150                 SignedHeaders=host, Signature=deadbeef",
1151            )],
1152        );
1153        assert!(
1154            try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now()).is_none(),
1155            "plain SigV4 request must fall through to the inner service"
1156        );
1157    }
1158
1159    #[test]
1160    fn sigv4a_valid_signature_returns_ok() {
1161        let (r, vk) =
1162            make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1,us-west-2");
1163        let gate = make_gate_with("AKIAOK", vk);
1164        let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now())
1165            .expect("must intercept SigV4a request");
1166        assert!(
1167            result.is_ok(),
1168            "valid SigV4a signature must verify: {result:?}"
1169        );
1170    }
1171
1172    #[tokio::test]
1173    async fn sigv4a_tampered_signature_returns_403() {
1174        let (r, vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1175        let gate = make_gate_with("AKIAOK", vk);
1176
1177        // Tamper one byte of the signature hex inside the Authorization
1178        // header — the DER decode may still succeed, but ECDSA verify
1179        // will fail (or the DER decode itself will fail; both surface
1180        // as `SignatureDoesNotMatch`).
1181        let auth = r
1182            .headers()
1183            .get("authorization")
1184            .and_then(|v| v.to_str().ok())
1185            .expect("auth header")
1186            .to_string();
1187        // Flip the last hex char to corrupt the signature.
1188        let mut chars: Vec<char> = auth.chars().collect();
1189        let last = chars.len() - 1;
1190        chars[last] = if chars[last] == '0' { '1' } else { '0' };
1191        let tampered_auth: String = chars.into_iter().collect();
1192        let tampered = req(
1193            Method::GET,
1194            "/bucket/key",
1195            &[
1196                ("host", "s3.example.com"),
1197                (
1198                    "x-amz-content-sha256",
1199                    "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1200                ),
1201                ("x-amz-date", "20260513T120000Z"),
1202                (REGION_SET_HEADER, "us-east-1"),
1203                ("authorization", &tampered_auth),
1204            ],
1205        );
1206        let result = try_sigv4a_verify_at(&tampered, Some(&gate), "us-east-1", fixture_now())
1207            .expect("must intercept SigV4a request");
1208        let resp = result.expect_err("tampered signature must surface a 403 response");
1209        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1210        let body = body_to_bytes(resp).await;
1211        let body_str = String::from_utf8(body).expect("xml utf-8");
1212        assert!(
1213            body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1214            "403 body must surface SignatureDoesNotMatch: {body_str}"
1215        );
1216    }
1217
1218    #[tokio::test]
1219    async fn sigv4a_region_set_mismatch_returns_403() {
1220        // Sign for `us-east-1` only, then verify with the listener
1221        // region claiming `eu-west-1` — must fail with
1222        // SignatureDoesNotMatch (the region-set check sits inside the
1223        // gate's verify path, and any failure there folds to
1224        // SignatureDoesNotMatch).
1225        let (r, vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1226        let gate = make_gate_with("AKIAOK", vk);
1227        let result = try_sigv4a_verify_at(&r, Some(&gate), "eu-west-1", fixture_now())
1228            .expect("must intercept SigV4a request");
1229        let resp = result.expect_err("region mismatch must produce 403");
1230        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1231        let body = body_to_bytes(resp).await;
1232        let body_str = String::from_utf8(body).expect("xml utf-8");
1233        assert!(
1234            body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1235            "region-set mismatch must surface SignatureDoesNotMatch: {body_str}"
1236        );
1237    }
1238
1239    #[test]
1240    fn no_gate_attached_returns_none() {
1241        // Even a SigV4a-shaped request returns None when no gate is
1242        // installed — the listener will hand it to s3s, which surfaces
1243        // its own "unknown algorithm" error so the misconfiguration is
1244        // visible to the operator.
1245        let (r, _vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1246        assert!(
1247            try_sigv4a_verify_at(&r, None, "us-east-1", fixture_now()).is_none(),
1248            "missing gate must defer to inner service"
1249        );
1250    }
1251
1252    #[tokio::test]
1253    async fn unknown_access_key_returns_403_invalid_access_key_id() {
1254        // Sign with one key but load the credential store with a
1255        // different access-key-id → InvalidAccessKeyId.
1256        let (r, _vk_unused) =
1257            make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1258        let other_signing = SigningKey::random(&mut OsRng);
1259        let other_vk = p256::ecdsa::VerifyingKey::from(&other_signing);
1260        let gate = make_gate_with("AKIASOMEONEELSE", other_vk);
1261        let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now())
1262            .expect("must intercept SigV4a request");
1263        let resp = result.expect_err("unknown key must produce 403");
1264        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1265        let body = body_to_bytes(resp).await;
1266        let body_str = String::from_utf8(body).expect("xml utf-8");
1267        assert!(
1268            body_str.contains("<Code>InvalidAccessKeyId</Code>"),
1269            "unknown access-key must surface InvalidAccessKeyId: {body_str}"
1270        );
1271    }
1272
1273    #[tokio::test]
1274    async fn region_set_header_only_without_sigv4a_auth_returns_403() {
1275        // Some legacy clients stamp the `X-Amz-Region-Set` header
1276        // before swapping the algorithm string. `detect` flags this as
1277        // SigV4a-shaped but we cannot verify without a parseable
1278        // Authorization → fail closed (SignatureDoesNotMatch).
1279        let signing = SigningKey::random(&mut OsRng);
1280        let vk = p256::ecdsa::VerifyingKey::from(&signing);
1281        let gate = make_gate_with("AKIAOK", vk);
1282        let r = req(
1283            Method::GET,
1284            "/bucket/key",
1285            &[
1286                // SigV4 algorithm + region-set header → detected, but
1287                // the Authorization is plain SigV4 so `parse_authorization_header`
1288                // returns None.
1289                (
1290                    "authorization",
1291                    "AWS4-HMAC-SHA256 Credential=AKIA/20260513/us-east-1/s3/aws4_request, \
1292                     SignedHeaders=host, Signature=deadbeef",
1293                ),
1294                (REGION_SET_HEADER, "us-east-1"),
1295            ],
1296        );
1297        let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now())
1298            .expect("must intercept SigV4a-shaped request");
1299        let resp = result.expect_err("region-set without sigv4a auth must produce 403");
1300        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1301        let body = body_to_bytes(resp).await;
1302        let body_str = String::from_utf8(body).expect("xml utf-8");
1303        assert!(
1304            body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1305            "missing/malformed Authorization for SigV4a-shaped request must fail closed: {body_str}"
1306        );
1307    }
1308
1309    /// v0.8.4 #76 (audit H-6): captured-request replay outside the
1310    /// 15-min window → 403 RequestTimeTooSkewed (not
1311    /// SignatureDoesNotMatch). This is the headline gate-level
1312    /// behaviour change; pre-#76 the same captured request would have
1313    /// reached the inner service, allowing destructive replay (DELETE
1314    /// included).
1315    #[tokio::test]
1316    async fn sigv4a_replay_outside_window_returns_403_request_time_too_skewed() {
1317        let (r, vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1318        let gate = make_gate_with("AKIAOK", vk);
1319        // Request stamped 20260513T120000Z; "now" is 30 min later → drift
1320        // 1800s, beyond the 900s default tolerance.
1321        let now = chrono::DateTime::parse_from_rfc3339("2026-05-13T12:30:00Z")
1322            .unwrap()
1323            .with_timezone(&chrono::Utc);
1324        let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", now)
1325            .expect("must intercept SigV4a request");
1326        let resp = result.expect_err("replay outside window must reject");
1327        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1328        let body = body_to_bytes(resp).await;
1329        let body_str = String::from_utf8(body).expect("xml utf-8");
1330        assert!(
1331            body_str.contains("<Code>RequestTimeTooSkewed</Code>"),
1332            "replay outside window must surface RequestTimeTooSkewed: {body_str}"
1333        );
1334    }
1335
1336    /// Cover the canonical-request builder directly: empty query
1337    /// string, sorted multi-pair query, and header value collapsed
1338    /// whitespace all hit the right code paths.
1339    #[test]
1340    fn canonical_request_bytes_format() {
1341        let r = req(
1342            Method::PUT,
1343            "/bucket/key?z=1&a=2",
1344            &[
1345                ("host", "s3.example.com"),
1346                ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"),
1347                ("x-amz-date", "  20260513T120000Z  "),
1348            ],
1349        );
1350        let signed: Vec<String> = ["host", "x-amz-content-sha256", "x-amz-date"]
1351            .iter()
1352            .map(|s| (*s).into())
1353            .collect();
1354        let bytes =
1355            build_canonical_request_bytes(&r, &signed).expect("canonical request bytes must build");
1356        let s = std::str::from_utf8(&bytes).expect("utf-8");
1357        let expected = "PUT\n\
1358                        /bucket/key\n\
1359                        a=2&z=1\n\
1360                        host:s3.example.com\n\
1361                        x-amz-content-sha256:UNSIGNED-PAYLOAD\n\
1362                        x-amz-date:20260513T120000Z\n\
1363                        \n\
1364                        host;x-amz-content-sha256;x-amz-date\n\
1365                        UNSIGNED-PAYLOAD";
1366        assert_eq!(s, expected, "canonical request bytes mismatch:\n{s}");
1367    }
1368
1369    /// v0.8.5 #84 H-4: duplicate `x-amz-date` headers must be rejected
1370    /// at canonical-request build time (not silently coalesced to the
1371    /// first value). HTTP/1.1 spec already forbids duplicates of
1372    /// `host` / `x-amz-date`; AWS SDKs never emit them; so any
1373    /// duplicate must be malicious or broken — single-value reject is
1374    /// the safe choice (see [`build_canonical_request_bytes`] doc).
1375    #[test]
1376    fn sigv4a_duplicate_x_amz_date_rejected() {
1377        // Two x-amz-date headers — first one matches the signature the
1378        // gate expects, second one is what a downstream parser might
1379        // pick up. This is the textbook auth-confusion vector.
1380        let r = Request::builder()
1381            .method(Method::GET)
1382            .uri("/b/k")
1383            .header("host", "s3.example.com")
1384            .header("x-amz-content-sha256", "UNSIGNED-PAYLOAD")
1385            .header("x-amz-date", "20260513T120000Z")
1386            .header("x-amz-date", "20260513T130000Z")
1387            .body(())
1388            .expect("dup-header request");
1389        let signed: Vec<String> = ["host", "x-amz-content-sha256", "x-amz-date"]
1390            .iter()
1391            .map(|s| (*s).into())
1392            .collect();
1393        let err = build_canonical_request_bytes(&r, &signed)
1394            .expect_err("duplicate x-amz-date must reject");
1395        match err {
1396            crate::sigv4a::SigV4aError::DuplicateSignedHeader { header } => {
1397                assert_eq!(header, "x-amz-date");
1398            }
1399            other => panic!("expected DuplicateSignedHeader, got {other:?}"),
1400        }
1401    }
1402
1403    /// v0.8.5 #84 H-4: counterpart to the duplicate-reject test —
1404    /// single-occurrence headers on the same path stay accepted.
1405    /// Guards against a regression where the duplicate-detect logic
1406    /// is over-eager and trips on a normally-formed request.
1407    #[test]
1408    fn sigv4a_canonicalization_single_header_passes() {
1409        let r = req(
1410            Method::GET,
1411            "/b/k",
1412            &[
1413                ("host", "s3.example.com"),
1414                ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"),
1415                ("x-amz-date", "20260513T120000Z"),
1416            ],
1417        );
1418        let signed: Vec<String> = ["host", "x-amz-content-sha256", "x-amz-date"]
1419            .iter()
1420            .map(|s| (*s).into())
1421            .collect();
1422        let bytes =
1423            build_canonical_request_bytes(&r, &signed).expect("single-occurrence must accept");
1424        // Body content not asserted in detail (covered by
1425        // canonical_request_bytes_format); just confirm the bytes
1426        // parse as utf-8 and contain the date verbatim.
1427        let s = std::str::from_utf8(&bytes).expect("utf-8");
1428        assert!(
1429            s.contains("x-amz-date:20260513T120000Z"),
1430            "canonical bytes must echo the single x-amz-date verbatim:\n{s}"
1431        );
1432    }
1433
1434    /// v0.8.5 #84 H-4: end-to-end through the
1435    /// [`try_sigv4a_verify_at`] gate — duplicate `x-amz-date` on a
1436    /// SigV4a-shaped request must surface 403 SignatureDoesNotMatch
1437    /// (not silently authenticate against the first value).
1438    #[tokio::test]
1439    async fn sigv4a_pre_route_rejects_duplicate_signed_header() {
1440        let signing = SigningKey::random(&mut OsRng);
1441        let vk = p256::ecdsa::VerifyingKey::from(&signing);
1442        let gate = make_gate_with("AKIAOK", vk);
1443        // Authorization header lists x-amz-date in SignedHeaders —
1444        // signature value itself can be garbage; the duplicate-detect
1445        // path runs strictly before any ECDSA math.
1446        let auth = build_auth_header(
1447            "AKIAOK",
1448            &[
1449                "host",
1450                "x-amz-content-sha256",
1451                "x-amz-date",
1452                REGION_SET_HEADER,
1453            ],
1454            "deadbeef",
1455        );
1456        let r = Request::builder()
1457            .method(Method::GET)
1458            .uri("/bucket/key")
1459            .header("host", "s3.example.com")
1460            .header(
1461                "x-amz-content-sha256",
1462                "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1463            )
1464            .header("x-amz-date", "20260513T120000Z")
1465            .header("x-amz-date", "20260513T130000Z")
1466            .header(REGION_SET_HEADER, "us-east-1")
1467            .header("authorization", auth)
1468            .body(())
1469            .expect("dup-header sigv4a request");
1470        let result = try_sigv4a_verify_at(&r, Some(&gate), "us-east-1", fixture_now())
1471            .expect("must intercept SigV4a request");
1472        let resp = result.expect_err("duplicate signed header must reject at the gate");
1473        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1474        let body = body_to_bytes(resp).await;
1475        let body_str = String::from_utf8(body).expect("xml utf-8");
1476        assert!(
1477            body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1478            "duplicate signed header must surface SignatureDoesNotMatch: {body_str}"
1479        );
1480        assert!(
1481            body_str.contains("duplicate signed header"),
1482            "diagnostic must mention duplicate header: {body_str}"
1483        );
1484    }
1485}