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