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    let gate = gate?;
284    if !crate::sigv4a::detect(req) {
285        // Not a SigV4a request — caller forwards to the SigV4 path.
286        return None;
287    }
288    // Pre-parse the Authorization header so we know which signed-headers
289    // list to canonicalise in. If the header is malformed, fail fast
290    // with 403 rather than building canonical bytes that can never
291    // verify.
292    let auth_hdr = req
293        .headers()
294        .get(http::header::AUTHORIZATION)
295        .and_then(|v| v.to_str().ok());
296    let signed_headers: Vec<String> = match auth_hdr
297        .and_then(crate::sigv4a::parse_authorization_header)
298    {
299        Some(parsed) => parsed.signed_headers,
300        None => {
301            // No / unparseable Authorization header but `detect` flagged
302            // it as SigV4a-shaped (e.g. only the region-set header is
303            // present) — surface as SignatureDoesNotMatch directly.
304            return Some(Err(build_sigv4a_error_response(
305                "SignatureDoesNotMatch",
306                "missing or malformed Authorization header for SigV4a request",
307            )));
308        }
309    };
310    let canonical = build_canonical_request_bytes(req, &signed_headers);
311    match gate.pre_route(req, requested_region, &canonical) {
312        Ok(()) => Some(Ok(())),
313        Err(err) => {
314            tracing::warn!(error = %err, "SigV4a verify rejected request");
315            Some(Err(build_sigv4a_error_response(
316                err.s3_error_code(),
317                &err.to_string(),
318            )))
319        }
320    }
321}
322
323/// v0.7 #47: build a SigV4-shaped canonical request from the HTTP
324/// surface alone (no body access). Returns the bytes that the
325/// SigV4a gate will check the ECDSA signature against.
326///
327/// Format (one element per line, joined with `\n`):
328/// 1. HTTP method (uppercase)
329/// 2. canonical URI (path; we leave it untouched since AWS SDKs
330///    pre-encode it the same way s3s receives it)
331/// 3. canonical query string (sorted by name, name=value pairs joined
332///    by `&`; empty when no query string)
333/// 4. canonical headers (one `name:trimmed-value\n` per signed header,
334///    in the **order** they appear in `SignedHeaders=`)
335/// 5. signed headers list (lowercase names joined by `;`)
336/// 6. payload hash (value of `x-amz-content-sha256`, or `UNSIGNED-PAYLOAD`
337///    if absent)
338fn build_canonical_request_bytes<B>(
339    req: &Request<B>,
340    signed_headers: &[String],
341) -> Vec<u8> {
342    let mut buf = String::with_capacity(512);
343    buf.push_str(req.method().as_str());
344    buf.push('\n');
345    buf.push_str(req.uri().path());
346    buf.push('\n');
347    buf.push_str(&canonical_query_string(req.uri().query().unwrap_or("")));
348    buf.push('\n');
349    for name in signed_headers {
350        let value = req
351            .headers()
352            .get(name.as_str())
353            .and_then(|v| v.to_str().ok())
354            .unwrap_or("");
355        buf.push_str(name);
356        buf.push(':');
357        // Trim whitespace and collapse repeated inner whitespace per
358        // SigV4 canonicalisation rules. This is the same trimming AWS
359        // SDKs do when they sign.
360        buf.push_str(&trim_collapse_ws(value));
361        buf.push('\n');
362    }
363    buf.push('\n');
364    buf.push_str(&signed_headers.join(";"));
365    buf.push('\n');
366    let payload_hash = req
367        .headers()
368        .get("x-amz-content-sha256")
369        .and_then(|v| v.to_str().ok())
370        .unwrap_or("UNSIGNED-PAYLOAD");
371    buf.push_str(payload_hash);
372    buf.into_bytes()
373}
374
375/// SigV4 canonical query string: split on `&`, parse each `k=v` (or
376/// `k`), sort lexicographically by name (then by value), re-join with
377/// `&`. Empty input → empty string. We do **not** re-encode the values
378/// — they already arrived URL-encoded over the wire, and AWS SDKs
379/// expect the server to compare the bytes verbatim.
380fn canonical_query_string(query: &str) -> String {
381    if query.is_empty() {
382        return String::new();
383    }
384    let mut pairs: Vec<(&str, &str)> = query
385        .split('&')
386        .filter(|s| !s.is_empty())
387        .map(|kv| match kv.split_once('=') {
388            Some((k, v)) => (k, v),
389            None => (kv, ""),
390        })
391        .collect();
392    pairs.sort_by(|a, b| a.0.cmp(b.0).then_with(|| a.1.cmp(b.1)));
393    let mut out = String::with_capacity(query.len());
394    for (i, (k, v)) in pairs.iter().enumerate() {
395        if i > 0 {
396            out.push('&');
397        }
398        out.push_str(k);
399        out.push('=');
400        out.push_str(v);
401    }
402    out
403}
404
405/// SigV4 header-value canonicalisation: trim leading + trailing
406/// whitespace and collapse runs of internal whitespace to a single
407/// space. This mirrors what AWS SDKs do client-side when computing the
408/// canonical request — without it, a header value with extra spaces
409/// would canonicalise differently on each side.
410fn trim_collapse_ws(s: &str) -> String {
411    let trimmed = s.trim();
412    let mut out = String::with_capacity(trimmed.len());
413    let mut prev_ws = false;
414    for c in trimmed.chars() {
415        if c.is_whitespace() {
416            if !prev_ws {
417                out.push(' ');
418            }
419            prev_ws = true;
420        } else {
421            out.push(c);
422            prev_ws = false;
423        }
424    }
425    out
426}
427
428/// v0.7 #47: build an AWS-shaped 403 XML response for a SigV4a verify
429/// failure. The response body matches the wire format AWS S3 emits for
430/// the same conditions so SDKs surface the right exception class to the
431/// caller.
432fn build_sigv4a_error_response(code: &str, message: &str) -> Response<s3s::Body> {
433    let body_str = format!(
434        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
435         <Error>\n  <Code>{code}</Code>\n  <Message>{message}</Message>\n</Error>"
436    );
437    let bytes = Bytes::from(body_str.into_bytes());
438    Response::builder()
439        .status(StatusCode::FORBIDDEN)
440        .header("content-type", "application/xml")
441        .header("content-length", bytes.len().to_string())
442        .body(s3s::Body::http_body(
443            Full::new(bytes).map_err(|never| match never {}),
444        ))
445        .expect("sigv4a error response builder")
446}
447
448
449/// `/health` と `/ready` のレスポンス Body。
450/// inner S3Service の Body と互換する形にするために `s3s::Body` でラップ可能な
451/// `Full<Bytes>` を `s3s::Body::http_body` 経由で構築する。
452type RespBody = s3s::Body;
453
454fn make_text_response(status: StatusCode, body: &'static str) -> Response<RespBody> {
455    let bytes = Bytes::from_static(body.as_bytes());
456    Response::builder()
457        .status(status)
458        .header("content-type", "text/plain; charset=utf-8")
459        .header("content-length", bytes.len().to_string())
460        .body(s3s::Body::http_body(
461            Full::new(bytes).map_err(|never| match never {}),
462        ))
463        .expect("static response")
464}
465
466fn make_owned_text_response(
467    status: StatusCode,
468    content_type: &'static str,
469    body: String,
470) -> Response<RespBody> {
471    let bytes = Bytes::from(body.into_bytes());
472    Response::builder()
473        .status(status)
474        .header("content-type", content_type)
475        .header("content-length", bytes.len().to_string())
476        .body(s3s::Body::http_body(
477            Full::new(bytes).map_err(|never| match never {}),
478        ))
479        .expect("owned response")
480}
481
482impl<S> Service<Request<Incoming>> for HealthRouter<S>
483where
484    S: Service<Request<Incoming>, Response = Response<s3s::Body>, Error = s3s::HttpError>
485        + Clone
486        + Send
487        + 'static,
488    S::Future: Send + 'static,
489{
490    type Response = Response<RespBody>;
491    type Error = s3s::HttpError;
492    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
493
494    fn call(&self, req: Request<Incoming>) -> Self::Future {
495        // v0.7 #44: short-circuit CORS OPTIONS preflight at the HTTP layer
496        // before health/metrics dispatch. Preflight must run only for
497        // OPTIONS requests, and only when a CORS manager is attached and
498        // a config exists for the requested bucket; otherwise fall
499        // through to the existing routing logic.
500        if let Some(resp) = try_handle_preflight(&req, self.cors_manager.as_ref()) {
501            return Box::pin(async move { Ok(resp) });
502        }
503        // v0.7 #47: SigV4a verify gate. When the request is signed with
504        // `AWS4-ECDSA-P256-SHA256` and a credential store is configured,
505        // verify here at the HTTP layer (s3s' SigV4 verifier would
506        // otherwise reject the request as "unknown algorithm" before
507        // any handler ran). Plain SigV4 (HMAC) requests return `None`
508        // and fall through to the inner service untouched.
509        if let Some(result) =
510            try_sigv4a_verify(&req, self.sigv4a_gate.as_ref(), &self.region)
511        {
512            match result {
513                Ok(()) => {
514                    // verified — fall through to the path-routing logic
515                    // below (the health/metrics/inner-service dispatch).
516                }
517                Err(resp) => return Box::pin(async move { Ok(resp) }),
518            }
519        }
520        let path = req.uri().path();
521        match (req.method(), path) {
522            (&hyper::Method::GET, "/health") | (&hyper::Method::HEAD, "/health") => {
523                Box::pin(async { Ok(make_text_response(StatusCode::OK, "ok\n")) })
524            }
525            (&hyper::Method::GET, "/metrics") | (&hyper::Method::HEAD, "/metrics") => {
526                let handle = self.metrics_handle.clone();
527                Box::pin(async move {
528                    match handle {
529                        Some(h) => {
530                            let body = h.render();
531                            Ok(make_owned_text_response(
532                                StatusCode::OK,
533                                "text/plain; version=0.0.4; charset=utf-8",
534                                body,
535                            ))
536                        }
537                        None => Ok(make_text_response(
538                            StatusCode::SERVICE_UNAVAILABLE,
539                            "metrics not configured\n",
540                        )),
541                    }
542                })
543            }
544            (&hyper::Method::GET, "/ready") | (&hyper::Method::HEAD, "/ready") => {
545                let check = self.ready_check.clone();
546                Box::pin(async move {
547                    match check {
548                        Some(f) => match f().await {
549                            Ok(()) => Ok(make_text_response(StatusCode::OK, "ready\n")),
550                            Err(reason) => {
551                                tracing::warn!(%reason, "readiness check failed");
552                                Ok(make_text_response(
553                                    StatusCode::SERVICE_UNAVAILABLE,
554                                    "not ready\n",
555                                ))
556                            }
557                        },
558                        None => Ok(make_text_response(StatusCode::OK, "ready (no check)\n")),
559                    }
560                })
561            }
562            _ => {
563                let inner = self.inner.clone();
564                Box::pin(async move { inner.call(req).await })
565            }
566        }
567    }
568}
569
570/// `Infallible` を anything に変換するためのトリック (`Full::map_err` 用)
571trait FullExt<B> {
572    fn map_err<E, F: FnMut(Infallible) -> E>(
573        self,
574        f: F,
575    ) -> http_body_util::combinators::MapErr<Self, F>
576    where
577        Self: Sized;
578}
579impl<B> FullExt<B> for Full<B>
580where
581    B: bytes::Buf,
582{
583    fn map_err<E, F: FnMut(Infallible) -> E>(
584        self,
585        f: F,
586    ) -> http_body_util::combinators::MapErr<Self, F>
587    where
588        Self: Sized,
589    {
590        http_body_util::BodyExt::map_err(self, f)
591    }
592}
593
594#[cfg(test)]
595mod preflight_tests {
596    //! v0.7 #44: unit tests for the OPTIONS preflight interceptor.
597    //!
598    //! These exercise [`try_handle_preflight`] directly — no hyper
599    //! `Incoming` body is needed because the function is generic over
600    //! the body type. Behavioural matrix:
601    //!
602    //! 1. matching preflight → 200 + Allow-* headers
603    //! 2. no matching rule (config exists, but origin/method/headers fail)
604    //!    → 403
605    //! 3. missing `Origin` header → `None` (not a CORS preflight)
606    //! 4. non-OPTIONS verb → `None`
607    //! 5. no CORS config registered for the bucket → `None`
608    //! 6. no manager attached → `None`
609
610    use super::*;
611    use crate::cors::{CorsConfig, CorsManager, CorsRule};
612
613    fn rule(origins: &[&str], methods: &[&str], headers: &[&str]) -> CorsRule {
614        CorsRule {
615            allowed_origins: origins.iter().map(|s| (*s).to_owned()).collect(),
616            allowed_methods: methods.iter().map(|s| (*s).to_owned()).collect(),
617            allowed_headers: headers.iter().map(|s| (*s).to_owned()).collect(),
618            expose_headers: vec!["ETag".into()],
619            max_age_seconds: Some(600),
620            id: Some("test".into()),
621        }
622    }
623
624    /// Helper: build a `Request<()>` with the given method, path, and
625    /// headers — body is ignored by the matcher.
626    fn req(
627        method: Method,
628        path: &str,
629        headers: &[(&str, &str)],
630    ) -> Request<()> {
631        let mut b = Request::builder().method(method).uri(path);
632        for (k, v) in headers {
633            b = b.header(*k, *v);
634        }
635        b.body(()).expect("request builder")
636    }
637
638    fn manager_with_rule() -> Arc<CorsManager> {
639        let mgr = CorsManager::new();
640        mgr.put(
641            "b",
642            CorsConfig {
643                rules: vec![rule(
644                    &["https://app.example.com"],
645                    &["GET", "PUT", "DELETE"],
646                    &["Content-Type", "X-Amz-Date"],
647                )],
648            },
649        );
650        Arc::new(mgr)
651    }
652
653    #[test]
654    fn preflight_match_returns_allow_response() {
655        let mgr = manager_with_rule();
656        let r = req(
657            Method::OPTIONS,
658            "/b/key.txt",
659            &[
660                ("origin", "https://app.example.com"),
661                ("access-control-request-method", "PUT"),
662                ("access-control-request-headers", "content-type, x-amz-date"),
663            ],
664        );
665        let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
666        assert_eq!(resp.status(), StatusCode::OK);
667        let h = resp.headers();
668        assert_eq!(
669            h.get("access-control-allow-origin")
670                .and_then(|v| v.to_str().ok()),
671            Some("https://app.example.com")
672        );
673        assert_eq!(
674            h.get("access-control-allow-methods")
675                .and_then(|v| v.to_str().ok()),
676            Some("GET, PUT, DELETE")
677        );
678        assert_eq!(
679            h.get("access-control-allow-headers")
680                .and_then(|v| v.to_str().ok()),
681            Some("Content-Type, X-Amz-Date")
682        );
683        assert_eq!(
684            h.get("access-control-max-age")
685                .and_then(|v| v.to_str().ok()),
686            Some("600")
687        );
688        assert_eq!(
689            h.get("access-control-expose-headers")
690                .and_then(|v| v.to_str().ok()),
691            Some("ETag")
692        );
693    }
694
695    #[test]
696    fn preflight_no_match_returns_403() {
697        let mgr = manager_with_rule();
698        // Origin not in allow-list → no rule matches but bucket has CORS
699        // config, so we must answer 403 directly (not fall through to
700        // s3s, which would otherwise leak the bucket existence via 405).
701        let r = req(
702            Method::OPTIONS,
703            "/b/key.txt",
704            &[
705                ("origin", "https://evil.example.com"),
706                ("access-control-request-method", "PUT"),
707            ],
708        );
709        let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
710        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
711        // 403 deny response must NOT carry Allow-Origin (RFC 7234 + S3 wire compat).
712        assert!(resp.headers().get("access-control-allow-origin").is_none());
713    }
714
715    #[test]
716    fn preflight_no_origin_falls_through() {
717        // OPTIONS without Origin is a generic OPTIONS (e.g. `OPTIONS *`)
718        // — not a CORS preflight, must not be intercepted.
719        let mgr = manager_with_rule();
720        let r = req(
721            Method::OPTIONS,
722            "/b/key.txt",
723            &[("access-control-request-method", "PUT")],
724        );
725        assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
726    }
727
728    #[test]
729    fn non_options_falls_through() {
730        let mgr = manager_with_rule();
731        // Even with Origin + ACRM headers, GET is not a preflight.
732        let r = req(
733            Method::GET,
734            "/b/key.txt",
735            &[
736                ("origin", "https://app.example.com"),
737                ("access-control-request-method", "PUT"),
738            ],
739        );
740        assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
741    }
742
743    #[test]
744    fn no_cors_config_for_bucket_falls_through() {
745        // Manager attached but no rule registered for "ghost" → fall
746        // through to inner service so backend can respond naturally.
747        let mgr = manager_with_rule();
748        let r = req(
749            Method::OPTIONS,
750            "/ghost/key.txt",
751            &[
752                ("origin", "https://app.example.com"),
753                ("access-control-request-method", "PUT"),
754            ],
755        );
756        assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
757    }
758
759    #[test]
760    fn no_manager_attached_falls_through() {
761        let r = req(
762            Method::OPTIONS,
763            "/b/key.txt",
764            &[
765                ("origin", "https://app.example.com"),
766                ("access-control-request-method", "PUT"),
767            ],
768        );
769        assert!(try_handle_preflight(&r, None).is_none());
770    }
771
772    #[test]
773    fn preflight_wildcard_origin_echoes_star() {
774        // Rule with `*` origin → response echoes literal "*" (S3 spec).
775        let mgr = CorsManager::new();
776        mgr.put(
777            "b",
778            CorsConfig {
779                rules: vec![rule(&["*"], &["GET", "PUT"], &["*"])],
780            },
781        );
782        let mgr = Arc::new(mgr);
783        let r = req(
784            Method::OPTIONS,
785            "/b/key",
786            &[
787                ("origin", "https://anywhere.example"),
788                ("access-control-request-method", "PUT"),
789                ("access-control-request-headers", "x-custom-header"),
790            ],
791        );
792        let resp = try_handle_preflight(&r, Some(&mgr)).expect("must intercept");
793        assert_eq!(resp.status(), StatusCode::OK);
794        assert_eq!(
795            resp.headers()
796                .get("access-control-allow-origin")
797                .and_then(|v| v.to_str().ok()),
798            Some("*"),
799            "wildcard rule must echo literal '*' instead of requesting origin"
800        );
801    }
802
803    #[test]
804    fn preflight_empty_path_falls_through() {
805        let mgr = manager_with_rule();
806        let r = req(
807            Method::OPTIONS,
808            "/",
809            &[
810                ("origin", "https://app.example.com"),
811                ("access-control-request-method", "PUT"),
812            ],
813        );
814        assert!(try_handle_preflight(&r, Some(&mgr)).is_none());
815    }
816}
817
818#[cfg(test)]
819mod sigv4a_gate_tests {
820    //! v0.7 #47: unit tests for the SigV4a verify gate middleware.
821    //!
822    //! These exercise [`try_sigv4a_verify`] directly — no hyper
823    //! `Incoming` body is needed because the function is generic over
824    //! the body type. The canonical-request bytes computed by the
825    //! middleware are the same bytes the test signs over (we use the
826    //! `build_canonical_request_bytes` helper for both sides), so the
827    //! happy-path verify is end-to-end byte-exact.
828    //!
829    //! Behavioural matrix:
830    //!
831    //! 1. no `AWS4-ECDSA-P256-SHA256` prefix and no region-set header
832    //!    → `None` (caller forwards to s3s SigV4 path)
833    //! 2. SigV4a Authorization + valid signature → `Some(Ok(()))`
834    //! 3. SigV4a Authorization + tampered signature → `Some(Err(403))`
835    //!    with `SignatureDoesNotMatch` body
836    //! 4. SigV4a Authorization + region-set mismatch → `Some(Err(403))`
837    //! 5. gate is `None` (no credential store) → `None` even when the
838    //!    request looks SigV4a-shaped (caller forwards, and s3s will
839    //!    surface its own "unknown algorithm" error — operator sees the
840    //!    misconfiguration rather than a silent pass)
841    //! 6. unknown access-key-id → `Some(Err(403))` with
842    //!    `InvalidAccessKeyId` body
843    //! 7. SigV4a-shaped (region-set header only, no SigV4a auth header)
844    //!    → `Some(Err(403))` (we cannot verify without a parseable
845    //!    Authorization, fail closed)
846
847    use super::*;
848
849    use std::collections::HashMap;
850
851    use http_body_util::BodyExt;
852    use p256::ecdsa::SigningKey;
853    use p256::ecdsa::signature::Signer;
854    use rand::rngs::OsRng;
855
856    use crate::service::SigV4aGate;
857    use crate::sigv4a::{REGION_SET_HEADER, SigV4aCredentialStore};
858
859    fn lower_hex(bytes: &[u8]) -> String {
860        let mut s = String::with_capacity(bytes.len() * 2);
861        for b in bytes {
862            s.push_str(&format!("{b:02x}"));
863        }
864        s
865    }
866
867    /// Build a `Request<()>` with the given method, path, and headers.
868    fn req(method: Method, path: &str, headers: &[(&str, &str)]) -> Request<()> {
869        let mut b = Request::builder().method(method).uri(path);
870        for (k, v) in headers {
871            b = b.header(*k, *v);
872        }
873        b.body(()).expect("request builder")
874    }
875
876    /// Build the SigV4a Authorization header for the given access-key,
877    /// signed-headers list, and signature (lowercase hex DER).
878    fn build_auth_header(access_key: &str, signed_headers: &[&str], sig_hex: &str) -> String {
879        format!(
880            "AWS4-ECDSA-P256-SHA256 \
881             Credential={access_key}/20260513/s3/aws4_request, \
882             SignedHeaders={}, \
883             Signature={sig_hex}",
884            signed_headers.join(";")
885        )
886    }
887
888    /// Build a fully-signed SigV4a `Request<()>` ready for the gate to
889    /// verify. Returns the request and the verifying key it should be
890    /// loaded against.
891    fn make_signed_request(
892        access_key: &str,
893        method: Method,
894        path: &str,
895        region_set: &str,
896    ) -> (Request<()>, p256::ecdsa::VerifyingKey) {
897        let signing = SigningKey::random(&mut OsRng);
898        let verifying = p256::ecdsa::VerifyingKey::from(&signing);
899        let signed_headers_list = ["host", "x-amz-content-sha256", "x-amz-date", REGION_SET_HEADER];
900        // Build the request first WITHOUT the Authorization header so we
901        // can compute canonical bytes and sign them; then re-build the
902        // request with the Authorization header attached.
903        let pre = Request::builder()
904            .method(method.clone())
905            .uri(path)
906            .header("host", "s3.example.com")
907            .header(
908                "x-amz-content-sha256",
909                "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
910            )
911            .header("x-amz-date", "20260513T120000Z")
912            .header(REGION_SET_HEADER, region_set)
913            .body(())
914            .expect("pre-request");
915        let signed_headers: Vec<String> =
916            signed_headers_list.iter().map(|s| (*s).to_string()).collect();
917        let canonical = build_canonical_request_bytes(&pre, &signed_headers);
918        let sig: p256::ecdsa::Signature = signing.sign(&canonical);
919        let sig_hex = lower_hex(sig.to_der().as_bytes());
920        let auth = build_auth_header(access_key, &signed_headers_list, &sig_hex);
921
922        // Rebuild with the Authorization header — every other header
923        // value is identical so the canonical bytes the gate computes
924        // match what we signed.
925        let r = Request::builder()
926            .method(method)
927            .uri(path)
928            .header("host", "s3.example.com")
929            .header(
930                "x-amz-content-sha256",
931                "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
932            )
933            .header("x-amz-date", "20260513T120000Z")
934            .header(REGION_SET_HEADER, region_set)
935            .header("authorization", auth)
936            .body(())
937            .expect("signed request");
938        (r, verifying)
939    }
940
941    fn make_gate_with(access_key: &str, vk: p256::ecdsa::VerifyingKey) -> Arc<SigV4aGate> {
942        let mut m = HashMap::new();
943        m.insert(access_key.to_string(), vk);
944        let store = Arc::new(SigV4aCredentialStore::from_map(m));
945        Arc::new(SigV4aGate::new(store))
946    }
947
948    /// Drain a `s3s::Body` into bytes for body-content assertions.
949    async fn body_to_bytes(resp: Response<s3s::Body>) -> Vec<u8> {
950        resp.into_body()
951            .collect()
952            .await
953            .expect("body collect")
954            .to_bytes()
955            .to_vec()
956    }
957
958    #[test]
959    fn no_sigv4a_prefix_returns_none() {
960        // Plain SigV4 (HMAC-SHA256) request — gate must defer to s3s.
961        let (_, vk) = (
962            (),
963            p256::ecdsa::VerifyingKey::from(&SigningKey::random(&mut OsRng)),
964        );
965        let gate = make_gate_with("AKIAOK", vk);
966        let r = req(
967            Method::GET,
968            "/bucket/key",
969            &[(
970                "authorization",
971                "AWS4-HMAC-SHA256 Credential=AKIA/20260513/us-east-1/s3/aws4_request, \
972                 SignedHeaders=host, Signature=deadbeef",
973            )],
974        );
975        assert!(
976            try_sigv4a_verify(&r, Some(&gate), "us-east-1").is_none(),
977            "plain SigV4 request must fall through to the inner service"
978        );
979    }
980
981    #[test]
982    fn sigv4a_valid_signature_returns_ok() {
983        let (r, vk) =
984            make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1,us-west-2");
985        let gate = make_gate_with("AKIAOK", vk);
986        let result = try_sigv4a_verify(&r, Some(&gate), "us-east-1")
987            .expect("must intercept SigV4a request");
988        assert!(
989            result.is_ok(),
990            "valid SigV4a signature must verify: {result:?}"
991        );
992    }
993
994    #[tokio::test]
995    async fn sigv4a_tampered_signature_returns_403() {
996        let (r, vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
997        let gate = make_gate_with("AKIAOK", vk);
998
999        // Tamper one byte of the signature hex inside the Authorization
1000        // header — the DER decode may still succeed, but ECDSA verify
1001        // will fail (or the DER decode itself will fail; both surface
1002        // as `SignatureDoesNotMatch`).
1003        let auth = r
1004            .headers()
1005            .get("authorization")
1006            .and_then(|v| v.to_str().ok())
1007            .expect("auth header")
1008            .to_string();
1009        // Flip the last hex char to corrupt the signature.
1010        let mut chars: Vec<char> = auth.chars().collect();
1011        let last = chars.len() - 1;
1012        chars[last] = if chars[last] == '0' { '1' } else { '0' };
1013        let tampered_auth: String = chars.into_iter().collect();
1014        let tampered = req(
1015            Method::GET,
1016            "/bucket/key",
1017            &[
1018                ("host", "s3.example.com"),
1019                (
1020                    "x-amz-content-sha256",
1021                    "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1022                ),
1023                ("x-amz-date", "20260513T120000Z"),
1024                (REGION_SET_HEADER, "us-east-1"),
1025                ("authorization", &tampered_auth),
1026            ],
1027        );
1028        let result = try_sigv4a_verify(&tampered, Some(&gate), "us-east-1")
1029            .expect("must intercept SigV4a request");
1030        let resp = result.expect_err("tampered signature must surface a 403 response");
1031        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1032        let body = body_to_bytes(resp).await;
1033        let body_str = String::from_utf8(body).expect("xml utf-8");
1034        assert!(
1035            body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1036            "403 body must surface SignatureDoesNotMatch: {body_str}"
1037        );
1038    }
1039
1040    #[tokio::test]
1041    async fn sigv4a_region_set_mismatch_returns_403() {
1042        // Sign for `us-east-1` only, then verify with the listener
1043        // region claiming `eu-west-1` — must fail with
1044        // SignatureDoesNotMatch (the region-set check sits inside the
1045        // gate's verify path, and any failure there folds to
1046        // SignatureDoesNotMatch).
1047        let (r, vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1048        let gate = make_gate_with("AKIAOK", vk);
1049        let result = try_sigv4a_verify(&r, Some(&gate), "eu-west-1")
1050            .expect("must intercept SigV4a request");
1051        let resp = result.expect_err("region mismatch must produce 403");
1052        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1053        let body = body_to_bytes(resp).await;
1054        let body_str = String::from_utf8(body).expect("xml utf-8");
1055        assert!(
1056            body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1057            "region-set mismatch must surface SignatureDoesNotMatch: {body_str}"
1058        );
1059    }
1060
1061    #[test]
1062    fn no_gate_attached_returns_none() {
1063        // Even a SigV4a-shaped request returns None when no gate is
1064        // installed — the listener will hand it to s3s, which surfaces
1065        // its own "unknown algorithm" error so the misconfiguration is
1066        // visible to the operator.
1067        let (r, _vk) = make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1068        assert!(
1069            try_sigv4a_verify(&r, None, "us-east-1").is_none(),
1070            "missing gate must defer to inner service"
1071        );
1072    }
1073
1074    #[tokio::test]
1075    async fn unknown_access_key_returns_403_invalid_access_key_id() {
1076        // Sign with one key but load the credential store with a
1077        // different access-key-id → InvalidAccessKeyId.
1078        let (r, _vk_unused) =
1079            make_signed_request("AKIAOK", Method::GET, "/bucket/key", "us-east-1");
1080        let other_signing = SigningKey::random(&mut OsRng);
1081        let other_vk = p256::ecdsa::VerifyingKey::from(&other_signing);
1082        let gate = make_gate_with("AKIASOMEONEELSE", other_vk);
1083        let result = try_sigv4a_verify(&r, Some(&gate), "us-east-1")
1084            .expect("must intercept SigV4a request");
1085        let resp = result.expect_err("unknown key must produce 403");
1086        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1087        let body = body_to_bytes(resp).await;
1088        let body_str = String::from_utf8(body).expect("xml utf-8");
1089        assert!(
1090            body_str.contains("<Code>InvalidAccessKeyId</Code>"),
1091            "unknown access-key must surface InvalidAccessKeyId: {body_str}"
1092        );
1093    }
1094
1095    #[tokio::test]
1096    async fn region_set_header_only_without_sigv4a_auth_returns_403() {
1097        // Some legacy clients stamp the `X-Amz-Region-Set` header
1098        // before swapping the algorithm string. `detect` flags this as
1099        // SigV4a-shaped but we cannot verify without a parseable
1100        // Authorization → fail closed (SignatureDoesNotMatch).
1101        let signing = SigningKey::random(&mut OsRng);
1102        let vk = p256::ecdsa::VerifyingKey::from(&signing);
1103        let gate = make_gate_with("AKIAOK", vk);
1104        let r = req(
1105            Method::GET,
1106            "/bucket/key",
1107            &[
1108                // SigV4 algorithm + region-set header → detected, but
1109                // the Authorization is plain SigV4 so `parse_authorization_header`
1110                // returns None.
1111                (
1112                    "authorization",
1113                    "AWS4-HMAC-SHA256 Credential=AKIA/20260513/us-east-1/s3/aws4_request, \
1114                     SignedHeaders=host, Signature=deadbeef",
1115                ),
1116                (REGION_SET_HEADER, "us-east-1"),
1117            ],
1118        );
1119        let result = try_sigv4a_verify(&r, Some(&gate), "us-east-1")
1120            .expect("must intercept SigV4a-shaped request");
1121        let resp = result.expect_err("region-set without sigv4a auth must produce 403");
1122        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1123        let body = body_to_bytes(resp).await;
1124        let body_str = String::from_utf8(body).expect("xml utf-8");
1125        assert!(
1126            body_str.contains("<Code>SignatureDoesNotMatch</Code>"),
1127            "missing/malformed Authorization for SigV4a-shaped request must fail closed: {body_str}"
1128        );
1129    }
1130
1131    /// Cover the canonical-request builder directly: empty query
1132    /// string, sorted multi-pair query, and header value collapsed
1133    /// whitespace all hit the right code paths.
1134    #[test]
1135    fn canonical_request_bytes_format() {
1136        let r = req(
1137            Method::PUT,
1138            "/bucket/key?z=1&a=2",
1139            &[
1140                ("host", "s3.example.com"),
1141                ("x-amz-content-sha256", "UNSIGNED-PAYLOAD"),
1142                ("x-amz-date", "  20260513T120000Z  "),
1143            ],
1144        );
1145        let signed: Vec<String> =
1146            ["host", "x-amz-content-sha256", "x-amz-date"].iter().map(|s| (*s).into()).collect();
1147        let bytes = build_canonical_request_bytes(&r, &signed);
1148        let s = std::str::from_utf8(&bytes).expect("utf-8");
1149        let expected = "PUT\n\
1150                        /bucket/key\n\
1151                        a=2&z=1\n\
1152                        host:s3.example.com\n\
1153                        x-amz-content-sha256:UNSIGNED-PAYLOAD\n\
1154                        x-amz-date:20260513T120000Z\n\
1155                        \n\
1156                        host;x-amz-content-sha256;x-amz-date\n\
1157                        UNSIGNED-PAYLOAD";
1158        assert_eq!(s, expected, "canonical request bytes mismatch:\n{s}");
1159    }
1160}