Skip to main content

s4_server/
cors.rs

1//! v0.6 #38: Bucket CORS configuration + preflight matcher.
2//!
3//! S4-server に bucket-level CORS の **own state** を持たせる module。これまで
4//! `get_bucket_cors` / `put_bucket_cors` / `delete_bucket_cors` は backend (s3s
5//! framework) への passthrough だったが、本 module で S4 自身が
6//!
7//! - per-bucket の [`CorsConfig`] (= ordered list of [`CorsRule`])
8//! - rule 評価器 (S3 仕様準拠: 宣言順で先頭マッチ採用)
9//!
10//! を所有する。`crates/s4-server/src/service.rs` の CORS handler が `S4Service`
11//! 経由で [`CorsManager`] を呼び出して、AWS S3 wire-compat な PutBucketCors /
12//! GetBucketCors / DeleteBucketCors の振る舞いを実現する。
13//!
14//! ## scope (v0.6 #38)
15//!
16//! - in-memory only (single instance scope)。multi-instance replication は
17//!   将来 issue で扱う
18//! - `to_json` / `from_json` で snapshot を取る API は提供する。`main.rs` 側で
19//!   `--cors-state-file` flag で起動時に snapshot を load できる
20//! - **OPTIONS preflight routing は本 task の scope 外**。s3s framework は
21//!   OPTIONS verb を専用 handler として持たないため、実際の HTTP-level
22//!   preflight 応答 (Access-Control-Allow-* header の組み立て) は `routing.rs`
23//!   側で hyper-util listener intercept として wire する follow-up が必要。
24//!   本 module は match 評価エンジン (= [`CorsManager::match_preflight`]) と、
25//!   service.rs から呼べる public method ([`crate::S4Service::handle_preflight`])
26//!   を提供するところまで
27//!
28//! ## semantics
29//!
30//! - **rule 評価順序**: AWS S3 は rule を **宣言順** で評価し、最初にマッチ
31//!   した rule を採用する。同一 bucket に対する PutBucketCors は configuration
32//!   全体を **置き換える** (上書き)、partial update は無し
33//! - **wildcard `*`**: origin / method / header いずれも `*` 単独で「任意」を意
34//!   味する (S3 は per-component 部分マッチ wildcard `https://*.example.com` は
35//!   サポートしない — `*` は「すべて」のみ)。ただし AWS docs の最新版では
36//!   `https://*.example.com` 形式も受け付けるとあるので、本実装でも `*` を
37//!   single-segment glob として扱える [`matches_glob`] を提供する
38//! - **origin matching**: case-sensitive (scheme + host + port は RFC 6454 で
39//!   ASCII-lowercase 正規化対象だが、S3 は client が送ってきた string をその
40//!   ままバイト比較する仕様)
41//! - **method matching**: 大文字必須 (HTTP verb は uppercase)、exact match
42//! - **header matching**: case-insensitive (HTTP header name は RFC 7230 で
43//!   case-insensitive)
44
45use std::collections::HashMap;
46use std::sync::RwLock;
47
48use serde::{Deserialize, Serialize};
49
50/// 1つの CORS rule。AWS S3 `CORSRule` element に対応する。
51///
52/// `id` は rule の human-readable label (operator が trace 用に付ける)。
53/// `expose_headers` はレスポンスに含まれる header 名 — preflight ではなく
54/// **actual response** で使われる (`Access-Control-Expose-Headers`)。
55/// `max_age_seconds` は browser 側 preflight cache TTL。
56#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
57pub struct CorsRule {
58    /// `"*"` for any origin, or exact origin string like
59    /// `"https://example.com"`. Multiple values are evaluated as OR within
60    /// this rule.
61    pub allowed_origins: Vec<String>,
62    /// Uppercase HTTP verbs: `"GET"`, `"PUT"`, `"POST"`, `"DELETE"`,
63    /// `"HEAD"`. AWS S3 only allows this set; we don't validate (caller
64    /// is responsible).
65    pub allowed_methods: Vec<String>,
66    /// `"*"` or specific header names. Matched case-insensitively against
67    /// `Access-Control-Request-Headers` from the preflight request.
68    pub allowed_headers: Vec<String>,
69    /// Header names to expose in the actual response via
70    /// `Access-Control-Expose-Headers`. Empty = no header.
71    #[serde(default)]
72    pub expose_headers: Vec<String>,
73    /// `Access-Control-Max-Age` value (browser preflight cache TTL).
74    /// `None` = header omitted.
75    #[serde(default)]
76    pub max_age_seconds: Option<u32>,
77    /// Optional rule identifier (operator-supplied label).
78    #[serde(default)]
79    pub id: Option<String>,
80}
81
82/// Per-bucket CORS configuration (ordered list of rules).
83#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
84pub struct CorsConfig {
85    /// Rules in declaration order — first match wins (S3 spec).
86    pub rules: Vec<CorsRule>,
87}
88
89/// snapshot のシリアライズ format。`to_json` / `from_json` 用。
90#[derive(Debug, Default, Serialize, Deserialize)]
91struct CorsSnapshot {
92    by_bucket: HashMap<String, CorsConfig>,
93}
94
95/// per-bucket CORS configuration を一元管理する manager。
96///
97/// すべての書き込み (`put` / `delete`) は `RwLock` write 経由で atomic、
98/// すべての読み出し (`get` / `match_preflight`) は read 経由で `CorsConfig`
99/// の clone (or 派生 `CorsRule` の clone) を返す。
100#[derive(Debug, Default)]
101pub struct CorsManager {
102    by_bucket: RwLock<HashMap<String, CorsConfig>>,
103}
104
105impl CorsManager {
106    /// 空 manager。
107    #[must_use]
108    pub fn new() -> Self {
109        Self::default()
110    }
111
112    /// `put_bucket_cors` handler から呼ぶ。bucket の既存 configuration は
113    /// **完全に置き換える** (S3 spec: PutBucketCors は upsert ではなく replace)。
114    pub fn put(&self, bucket: &str, config: CorsConfig) {
115        crate::lock_recovery::recover_write(&self.by_bucket, "cors.by_bucket")
116            .insert(bucket.to_owned(), config);
117    }
118
119    /// `get_bucket_cors` handler から呼ぶ。configuration が無ければ `None`
120    /// (handler 側で `NoSuchCORSConfiguration` 404 を返す材料)。
121    #[must_use]
122    pub fn get(&self, bucket: &str) -> Option<CorsConfig> {
123        crate::lock_recovery::recover_read(&self.by_bucket, "cors.by_bucket")
124            .get(bucket)
125            .cloned()
126    }
127
128    /// `delete_bucket_cors` handler から呼ぶ。bucket が無くても idempotent。
129    pub fn delete(&self, bucket: &str) {
130        crate::lock_recovery::recover_write(&self.by_bucket, "cors.by_bucket").remove(bucket);
131    }
132
133    /// snapshot を JSON 文字列にして返す。`--cors-state-file` 経路で
134    /// 起動時 dump-load を将来 wire するための hook。
135    pub fn to_json(&self) -> Result<String, serde_json::Error> {
136        let snap = CorsSnapshot {
137            by_bucket: crate::lock_recovery::recover_read(&self.by_bucket, "cors.by_bucket")
138                .clone(),
139        };
140        serde_json::to_string(&snap)
141    }
142
143    /// snapshot JSON から restore。起動時に `--cors-state-file` を読み込む
144    /// 経路で使える。
145    pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
146        let snap: CorsSnapshot = serde_json::from_str(s)?;
147        Ok(Self {
148            by_bucket: RwLock::new(snap.by_bucket),
149        })
150    }
151
152    /// CORS preflight (OPTIONS) request を bucket の rule list に対して評価
153    /// する。S3 仕様通り、宣言順で **最初にマッチした rule** を返す。マッチ
154    /// しない / bucket に config が無い場合は `None`。
155    ///
156    /// rule マッチ条件 (AND):
157    /// 1. `origin` が `rule.allowed_origins` のどれか 1 つに [`matches_glob`] でマッチ
158    /// 2. `method` (uppercase) が `rule.allowed_methods` の exact-match 1 つに含まれる
159    /// 3. `request_headers` の **全要素** が `rule.allowed_headers` のいずれかに [`matches_glob`] (case-insensitive) でマッチ
160    #[must_use]
161    pub fn match_preflight(
162        &self,
163        bucket: &str,
164        origin: &str,
165        method: &str,
166        request_headers: &[String],
167    ) -> Option<CorsRule> {
168        let map = crate::lock_recovery::recover_read(&self.by_bucket, "cors.by_bucket");
169        let cfg = map.get(bucket)?;
170        for rule in &cfg.rules {
171            if !rule_matches_origin(rule, origin) {
172                continue;
173            }
174            if !rule_matches_method(rule, method) {
175                continue;
176            }
177            if !rule_matches_headers(rule, request_headers) {
178                continue;
179            }
180            return Some(rule.clone());
181        }
182        None
183    }
184}
185
186fn rule_matches_origin(rule: &CorsRule, origin: &str) -> bool {
187    rule.allowed_origins
188        .iter()
189        .any(|pat| matches_glob(pat, origin))
190}
191
192fn rule_matches_method(rule: &CorsRule, method: &str) -> bool {
193    // HTTP verbs are case-sensitive uppercase; we still tolerate the
194    // wildcard pattern but otherwise require exact match.
195    rule.allowed_methods
196        .iter()
197        .any(|pat| pat == "*" || pat == method)
198}
199
200fn rule_matches_headers(rule: &CorsRule, request_headers: &[String]) -> bool {
201    if request_headers.is_empty() {
202        return true;
203    }
204    request_headers.iter().all(|h| {
205        rule.allowed_headers
206            .iter()
207            .any(|pat| matches_glob_ci(pat, h))
208    })
209}
210
211/// AWS S3 CORS の `*` matching。
212///
213/// - `pattern == "*"` → 任意の `candidate` にマッチ (true)
214/// - それ以外は **exact byte equality** で比較
215///
216/// AWS docs は `https://*.example.com` 形式も受け付けるとあるが、`*` は
217/// segment 単位ではなく「全体のいずれか 1 つ」として S3 上で動くケースが
218/// 大半なので、本実装は wildcard を `*` 単独 token に限定する。case
219/// sensitivity は呼び出し側で制御 (origin は case-sensitive、header は
220/// [`matches_glob_ci`] 経由で case-insensitive)。
221#[must_use]
222pub fn matches_glob(pattern: &str, candidate: &str) -> bool {
223    if pattern == "*" {
224        return true;
225    }
226    pattern == candidate
227}
228
229/// case-insensitive 版の [`matches_glob`]。HTTP header name 用。
230#[must_use]
231pub fn matches_glob_ci(pattern: &str, candidate: &str) -> bool {
232    if pattern == "*" {
233        return true;
234    }
235    pattern.eq_ignore_ascii_case(candidate)
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    fn rule(origins: &[&str], methods: &[&str], headers: &[&str]) -> CorsRule {
243        CorsRule {
244            allowed_origins: origins.iter().map(|s| (*s).to_owned()).collect(),
245            allowed_methods: methods.iter().map(|s| (*s).to_owned()).collect(),
246            allowed_headers: headers.iter().map(|s| (*s).to_owned()).collect(),
247            expose_headers: Vec::new(),
248            max_age_seconds: Some(3600),
249            id: None,
250        }
251    }
252
253    #[test]
254    fn matches_glob_wildcard_matches_anything() {
255        assert!(matches_glob("*", "https://example.com"));
256        assert!(matches_glob("*", ""));
257        assert!(matches_glob("*", "GET"));
258    }
259
260    #[test]
261    fn matches_glob_exact_match() {
262        assert!(matches_glob("https://example.com", "https://example.com"));
263        assert!(matches_glob("GET", "GET"));
264    }
265
266    #[test]
267    fn matches_glob_no_match() {
268        assert!(!matches_glob("https://example.com", "https://evil.com"));
269        assert!(!matches_glob("GET", "PUT"));
270    }
271
272    #[test]
273    fn matches_glob_origin_is_case_sensitive() {
274        // S3 origin matching is case-sensitive byte equality.
275        assert!(!matches_glob("https://Example.com", "https://example.com"));
276    }
277
278    #[test]
279    fn matches_glob_ci_header_is_case_insensitive() {
280        assert!(matches_glob_ci("Content-Type", "content-type"));
281        assert!(matches_glob_ci("X-Amz-Date", "x-amz-date"));
282        assert!(!matches_glob_ci("X-Other", "X-Different"));
283    }
284
285    #[test]
286    fn match_preflight_happy_path() {
287        let mgr = CorsManager::new();
288        mgr.put(
289            "b",
290            CorsConfig {
291                rules: vec![rule(
292                    &["https://app.example.com"],
293                    &["GET", "PUT"],
294                    &["Content-Type"],
295                )],
296            },
297        );
298        let m = mgr.match_preflight(
299            "b",
300            "https://app.example.com",
301            "PUT",
302            &["Content-Type".to_owned()],
303        );
304        assert!(m.is_some());
305        let rule = m.unwrap();
306        assert_eq!(rule.max_age_seconds, Some(3600));
307    }
308
309    #[test]
310    fn match_preflight_no_rule_for_bucket() {
311        let mgr = CorsManager::new();
312        let m = mgr.match_preflight("ghost", "https://anything", "GET", &[]);
313        assert!(m.is_none());
314    }
315
316    #[test]
317    fn match_preflight_method_not_allowed() {
318        let mgr = CorsManager::new();
319        mgr.put(
320            "b",
321            CorsConfig {
322                rules: vec![rule(&["*"], &["GET"], &["*"])],
323            },
324        );
325        // Rule allows GET only — DELETE preflight must miss.
326        assert!(
327            mgr.match_preflight("b", "https://x", "DELETE", &[])
328                .is_none()
329        );
330        // Sanity: GET still matches.
331        assert!(mgr.match_preflight("b", "https://x", "GET", &[]).is_some());
332    }
333
334    #[test]
335    fn match_preflight_origin_not_allowed() {
336        let mgr = CorsManager::new();
337        mgr.put(
338            "b",
339            CorsConfig {
340                rules: vec![rule(&["https://good.example.com"], &["GET"], &["*"])],
341            },
342        );
343        assert!(
344            mgr.match_preflight("b", "https://evil.example.com", "GET", &[])
345                .is_none()
346        );
347    }
348
349    #[test]
350    fn match_preflight_wildcard_origin() {
351        let mgr = CorsManager::new();
352        mgr.put(
353            "b",
354            CorsConfig {
355                rules: vec![rule(&["*"], &["GET"], &[])],
356            },
357        );
358        let m = mgr.match_preflight("b", "https://anywhere", "GET", &[]);
359        assert!(m.is_some());
360    }
361
362    #[test]
363    fn match_preflight_wildcard_header() {
364        let mgr = CorsManager::new();
365        mgr.put(
366            "b",
367            CorsConfig {
368                rules: vec![rule(&["*"], &["PUT"], &["*"])],
369            },
370        );
371        let m = mgr.match_preflight(
372            "b",
373            "https://x",
374            "PUT",
375            &["X-Custom-Header".to_owned(), "Content-Type".to_owned()],
376        );
377        assert!(m.is_some());
378    }
379
380    #[test]
381    fn match_preflight_first_matching_rule_wins() {
382        let mgr = CorsManager::new();
383        mgr.put(
384            "b",
385            CorsConfig {
386                rules: vec![
387                    CorsRule {
388                        allowed_origins: vec!["*".into()],
389                        allowed_methods: vec!["GET".into()],
390                        allowed_headers: vec!["*".into()],
391                        expose_headers: Vec::new(),
392                        max_age_seconds: Some(60),
393                        id: Some("first".into()),
394                    },
395                    CorsRule {
396                        allowed_origins: vec!["*".into()],
397                        allowed_methods: vec!["GET".into()],
398                        allowed_headers: vec!["*".into()],
399                        expose_headers: Vec::new(),
400                        max_age_seconds: Some(7200),
401                        id: Some("second".into()),
402                    },
403                ],
404            },
405        );
406        let m = mgr
407            .match_preflight("b", "https://x", "GET", &[])
408            .expect("should match");
409        // First-match-wins: shorter max_age_seconds, id="first".
410        assert_eq!(m.id.as_deref(), Some("first"));
411        assert_eq!(m.max_age_seconds, Some(60));
412    }
413
414    #[test]
415    fn match_preflight_header_case_insensitive() {
416        let mgr = CorsManager::new();
417        mgr.put(
418            "b",
419            CorsConfig {
420                rules: vec![rule(&["*"], &["PUT"], &["Content-Type"])],
421            },
422        );
423        // request header sent in lowercase — must still match the
424        // CamelCase pattern (HTTP header names are case-insensitive).
425        let m = mgr.match_preflight("b", "https://x", "PUT", &["content-type".to_owned()]);
426        assert!(m.is_some());
427    }
428
429    #[test]
430    fn put_replaces_previous_config() {
431        let mgr = CorsManager::new();
432        mgr.put(
433            "b",
434            CorsConfig {
435                rules: vec![rule(&["https://a"], &["GET"], &["*"])],
436            },
437        );
438        mgr.put(
439            "b",
440            CorsConfig {
441                rules: vec![rule(&["https://b"], &["PUT"], &["*"])],
442            },
443        );
444        let cfg = mgr.get("b").expect("config present");
445        assert_eq!(cfg.rules.len(), 1);
446        assert_eq!(cfg.rules[0].allowed_origins, vec!["https://b".to_string()]);
447    }
448
449    #[test]
450    fn delete_is_idempotent() {
451        let mgr = CorsManager::new();
452        mgr.delete("never-existed"); // must not panic
453        mgr.put(
454            "b",
455            CorsConfig {
456                rules: vec![rule(&["*"], &["GET"], &[])],
457            },
458        );
459        mgr.delete("b");
460        assert!(mgr.get("b").is_none());
461    }
462
463    #[test]
464    fn json_round_trip() {
465        let mgr = CorsManager::new();
466        mgr.put(
467            "b",
468            CorsConfig {
469                rules: vec![CorsRule {
470                    allowed_origins: vec!["https://example.com".into()],
471                    allowed_methods: vec!["GET".into(), "PUT".into()],
472                    allowed_headers: vec!["Content-Type".into()],
473                    expose_headers: vec!["ETag".into()],
474                    max_age_seconds: Some(3600),
475                    id: Some("rule-1".into()),
476                }],
477            },
478        );
479        let json = mgr.to_json().expect("to_json");
480        let mgr2 = CorsManager::from_json(&json).expect("from_json");
481        assert_eq!(mgr.get("b"), mgr2.get("b"));
482    }
483
484    /// v0.8.4 #77 (audit H-8): a panic inside the `by_bucket` write
485    /// guard poisons the lock. `to_json` must recover via
486    /// [`crate::lock_recovery::recover_read`] and surface the data
487    /// instead of re-panicking on the SIGUSR1 dump-back path.
488    #[test]
489    fn cors_to_json_after_panic_recovers_via_poison() {
490        let mgr = std::sync::Arc::new(CorsManager::new());
491        mgr.put(
492            "b",
493            CorsConfig {
494                rules: vec![rule(&["*"], &["GET"], &[])],
495            },
496        );
497        let mgr_cl = std::sync::Arc::clone(&mgr);
498        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
499            let mut g = mgr_cl.by_bucket.write().expect("clean lock");
500            g.entry("b2".into()).or_default();
501            panic!("force-poison");
502        }));
503        assert!(
504            mgr.by_bucket.is_poisoned(),
505            "write panic must poison by_bucket lock"
506        );
507        let json = mgr.to_json().expect("to_json after poison must succeed");
508        let mgr2 = CorsManager::from_json(&json).expect("from_json");
509        assert!(
510            mgr2.get("b").is_some(),
511            "recovered snapshot keeps original config"
512        );
513    }
514}