Skip to main content

mcpr_core/proxy/
csp.rs

1//! # CSP — Declarative Content Security Policy for widgets
2//!
3//! This module owns the config types and the merge function that together decide
4//! which domains appear in a widget's CSP when mcpr rewrites an MCP response.
5//!
6//! ## Model
7//!
8//! A widget's CSP has three independent directive arrays:
9//!
10//! - `connectDomains` — allowed targets for `fetch`, `WebSocket`, `EventSource`.
11//! - `resourceDomains` — allowed sources for scripts, styles, images, fonts, media.
12//! - `frameDomains` — allowed sources for nested `<iframe>` content.
13//!
14//! Each directive carries its own [`DirectivePolicy`] — a list of domains and a
15//! [`Mode`] (`extend` or `replace`) that decides how to combine declared domains
16//! with whatever the upstream MCP server already returned.
17//!
18//! A top-level [`CspConfig`] holds one policy per directive plus an optional list
19//! of [`WidgetScoped`] entries. Widget entries match resource URIs with glob
20//! patterns (e.g. `ui://widget/payment*`) and layer on top of the global policy.
21//!
22//! ## Merge
23//!
24//! [`effective_domains`] computes the final domain list for one directive, given
25//! upstream domains, a resource URI, and the config. The rules are:
26//!
27//! 1. If the global directive's mode is `replace`, discard upstream entirely;
28//!    otherwise start from upstream minus localhost and the upstream host itself.
29//! 2. Append the global directive's declared domains.
30//! 3. For each widget entry whose `match` glob matches the resource URI, in
31//!    config order, either extend (append) or replace (overwrite) the working
32//!    list with the widget's domains for this directive.
33//! 4. For `connect` and `resource`, prepend the proxy URL and dedupe. `frame`
34//!    does not receive the proxy URL — widgets don't iframe the proxy back into
35//!    themselves, and prepending it would make every widget look like an
36//!    iframe-embedder to hosts that flag that shape for extra review.
37//!
38//! Replace semantics are scoped: a global replace only ignores upstream; a
39//! widget replace wipes everything accumulated above it.
40//!
41//! ## Example
42//!
43//! ```toml
44//! [csp.connectDomains]
45//! domains = ["api.example.com"]
46//! mode    = "extend"
47//!
48//! [csp.resourceDomains]
49//! domains = ["cdn.example.com"]
50//! mode    = "extend"
51//!
52//! [csp.frameDomains]
53//! domains = []
54//! mode    = "replace"
55//!
56//! [[csp.widget]]
57//! match              = "ui://widget/payment*"
58//! connectDomains     = ["api.stripe.com"]
59//! connectDomainsMode = "extend"
60//! ```
61
62use serde::Deserialize;
63
64/// Merge mode for a single CSP directive.
65#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
66#[serde(rename_all = "lowercase")]
67pub enum Mode {
68    /// Combine the directive's declared domains with upstream.
69    #[default]
70    Extend,
71    /// Ignore upstream for this directive; use only declared domains.
72    Replace,
73}
74
75impl std::fmt::Display for Mode {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        match self {
78            Mode::Extend => write!(f, "extend"),
79            Mode::Replace => write!(f, "replace"),
80        }
81    }
82}
83
84/// Which of the three CSP directive arrays a policy targets.
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86pub enum Directive {
87    Connect,
88    Resource,
89    Frame,
90}
91
92/// A domain list paired with a merge mode.
93///
94/// Empty `domains` combined with `Mode::Extend` is a no-op. Empty `domains`
95/// combined with `Mode::Replace` explicitly clears the accumulated list.
96#[derive(Clone, Debug, Default, Deserialize)]
97#[serde(default)]
98pub struct DirectivePolicy {
99    pub domains: Vec<String>,
100    pub mode: Mode,
101}
102
103impl DirectivePolicy {
104    /// Build a policy that clears the accumulated list — the default for
105    /// `frameDomains`, which fails closed unless the operator opts in.
106    pub fn strict() -> Self {
107        Self {
108            domains: Vec::new(),
109            mode: Mode::Replace,
110        }
111    }
112}
113
114/// Per-widget override matched by glob on resource URI.
115///
116/// Directives are addressed as two paired fields: a domains list and a mode.
117/// Omitting both pairs for a directive leaves that directive untouched by the
118/// widget. Setting `mode = "replace"` with an empty domains list clears the
119/// accumulated domains for that directive.
120#[derive(Clone, Debug, Default, Deserialize)]
121#[serde(default)]
122pub struct WidgetScoped {
123    /// Glob pattern matched against a resource URI. `*` matches any sequence,
124    /// `?` matches one character. Literal everywhere else.
125    #[serde(rename = "match")]
126    pub match_pattern: String,
127
128    #[serde(rename = "connectDomains")]
129    pub connect_domains: Vec<String>,
130    #[serde(rename = "connectDomainsMode")]
131    pub connect_domains_mode: Mode,
132
133    #[serde(rename = "resourceDomains")]
134    pub resource_domains: Vec<String>,
135    #[serde(rename = "resourceDomainsMode")]
136    pub resource_domains_mode: Mode,
137
138    #[serde(rename = "frameDomains")]
139    pub frame_domains: Vec<String>,
140    #[serde(rename = "frameDomainsMode")]
141    pub frame_domains_mode: Mode,
142}
143
144impl WidgetScoped {
145    /// Fetch the (domains, mode) pair for one directive.
146    fn for_directive(&self, d: Directive) -> (&[String], Mode) {
147        match d {
148            Directive::Connect => (&self.connect_domains, self.connect_domains_mode),
149            Directive::Resource => (&self.resource_domains, self.resource_domains_mode),
150            Directive::Frame => (&self.frame_domains, self.frame_domains_mode),
151        }
152    }
153}
154
155/// Complete CSP configuration: three global directives plus widget overrides.
156#[derive(Clone, Debug)]
157pub struct CspConfig {
158    pub connect_domains: DirectivePolicy,
159    pub resource_domains: DirectivePolicy,
160    pub frame_domains: DirectivePolicy,
161    pub widgets: Vec<WidgetScoped>,
162}
163
164impl Default for CspConfig {
165    /// Defaults to permissive extend for connect and resource, and strict
166    /// replace for frame. Frames default strict because nested iframes are
167    /// rare in MCP widgets and the blast radius of an accidental allow is high.
168    fn default() -> Self {
169        Self {
170            connect_domains: DirectivePolicy::default(),
171            resource_domains: DirectivePolicy::default(),
172            frame_domains: DirectivePolicy::strict(),
173            widgets: Vec::new(),
174        }
175    }
176}
177
178impl CspConfig {
179    fn policy(&self, d: Directive) -> &DirectivePolicy {
180        match d {
181            Directive::Connect => &self.connect_domains,
182            Directive::Resource => &self.resource_domains,
183            Directive::Frame => &self.frame_domains,
184        }
185    }
186}
187
188/// Compute the effective domain list for one directive.
189///
190/// - `upstream_domains` are the values the MCP server declared for this
191///   directive; they pass through only when the global mode is `Extend`.
192/// - `resource_uri` selects which `[[csp.widget]]` overrides apply.
193/// - `upstream_host` is the bare host (no scheme) used to strip upstream
194///   self-references that would leak localhost into the proxied CSP.
195/// - `proxy_url` is prepended for `connect` and `resource` so widgets can
196///   reach the proxy for API calls and asset loads. It is NOT prepended for
197///   `frame` — widgets don't iframe the proxy back into themselves, and
198///   including it there makes every widget look like an iframe-embedder.
199pub fn effective_domains(
200    cfg: &CspConfig,
201    directive: Directive,
202    resource_uri: Option<&str>,
203    upstream_domains: &[String],
204    upstream_host: &str,
205    proxy_url: &str,
206) -> Vec<String> {
207    let global = cfg.policy(directive);
208
209    // 1. Seed from upstream unless the global mode replaces it.
210    let mut base: Vec<String> = if global.mode == Mode::Replace {
211        Vec::new()
212    } else {
213        upstream_domains
214            .iter()
215            .filter(|d| !is_self_reference(d, upstream_host))
216            .cloned()
217            .collect()
218    };
219
220    // 2. Always add the globally declared domains.
221    for d in &global.domains {
222        push_unique(&mut base, d);
223    }
224
225    // 3. Walk widget overrides in config order. A widget with empty domains
226    //    and extend mode is a no-op; we skip it to keep the diff clean.
227    if let Some(uri) = resource_uri {
228        for w in &cfg.widgets {
229            if !glob_match(&w.match_pattern, uri) {
230                continue;
231            }
232            let (domains, mode) = w.for_directive(directive);
233            if domains.is_empty() && mode == Mode::Extend {
234                continue;
235            }
236            if mode == Mode::Replace {
237                base = domains.to_vec();
238            } else {
239                for d in domains {
240                    push_unique(&mut base, d);
241                }
242            }
243        }
244    }
245
246    // 4. Prepend the proxy URL for directives that need to reach the proxy
247    //    itself (API calls, asset loads). Frame is intentionally skipped —
248    //    see module docs. Dedupe preserving first-seen order.
249    let mut out = if directive == Directive::Frame {
250        Vec::new()
251    } else {
252        vec![proxy_url.to_string()]
253    };
254    for d in base {
255        push_unique(&mut out, &d);
256    }
257    out
258}
259
260fn push_unique(list: &mut Vec<String>, value: &str) {
261    if !list.iter().any(|s| s == value) {
262        list.push(value.to_string());
263    }
264}
265
266/// Returns true if `domain` points at the upstream MCP server itself or at
267/// a local loopback address. These values only make sense in dev; the proxy
268/// replaces them with its own URL so the widget reaches the proxy instead.
269fn is_self_reference(domain: &str, upstream_host: &str) -> bool {
270    if domain.contains("localhost") || domain.contains("127.0.0.1") {
271        return true;
272    }
273    !upstream_host.is_empty() && domain.contains(upstream_host)
274}
275
276/// Minimal glob matcher over bytes. Supports `*` (any sequence) and `?`
277/// (single character). Everything else matches literally.
278pub fn glob_match(pattern: &str, input: &str) -> bool {
279    glob_rec(pattern.as_bytes(), input.as_bytes())
280}
281
282fn glob_rec(p: &[u8], t: &[u8]) -> bool {
283    if p.is_empty() {
284        return t.is_empty();
285    }
286    if p[0] == b'*' {
287        // `*` matches zero or more characters; try consuming none first, then
288        // consume one from the input and retry. Patterns are short (tens of
289        // bytes) so the recursion depth stays bounded.
290        if glob_rec(&p[1..], t) {
291            return true;
292        }
293        if !t.is_empty() {
294            return glob_rec(p, &t[1..]);
295        }
296        return false;
297    }
298    if !t.is_empty() && (p[0] == b'?' || p[0] == t[0]) {
299        return glob_rec(&p[1..], &t[1..]);
300    }
301    false
302}
303
304#[cfg(test)]
305#[allow(non_snake_case)]
306mod tests {
307    use super::*;
308
309    // ── helpers ────────────────────────────────────────────────────────────
310
311    fn policy(domains: &[&str], mode: Mode) -> DirectivePolicy {
312        DirectivePolicy {
313            domains: domains.iter().map(|s| s.to_string()).collect(),
314            mode,
315        }
316    }
317
318    fn widget(pattern: &str, connect: &[&str], mode: Mode) -> WidgetScoped {
319        WidgetScoped {
320            match_pattern: pattern.to_string(),
321            connect_domains: connect.iter().map(|s| s.to_string()).collect(),
322            connect_domains_mode: mode,
323            ..Default::default()
324        }
325    }
326
327    fn domains(items: &[&str]) -> Vec<String> {
328        items.iter().map(|s| s.to_string()).collect()
329    }
330
331    // ── Mode serde ─────────────────────────────────────────────────────────
332
333    #[test]
334    fn mode__deserialises_extend() {
335        let m: Mode = serde_json::from_str("\"extend\"").unwrap();
336        assert_eq!(m, Mode::Extend);
337    }
338
339    #[test]
340    fn mode__deserialises_replace() {
341        let m: Mode = serde_json::from_str("\"replace\"").unwrap();
342        assert_eq!(m, Mode::Replace);
343    }
344
345    #[test]
346    fn mode__default_is_extend() {
347        assert_eq!(Mode::default(), Mode::Extend);
348    }
349
350    // ── CspConfig defaults ─────────────────────────────────────────────────
351
352    #[test]
353    fn csp_config__default_strict_frames() {
354        let c = CspConfig::default();
355        assert_eq!(c.connect_domains.mode, Mode::Extend);
356        assert_eq!(c.resource_domains.mode, Mode::Extend);
357        assert_eq!(c.frame_domains.mode, Mode::Replace);
358    }
359
360    // ── effective_domains: global extend ───────────────────────────────────
361
362    #[test]
363    fn effective__extend_keeps_external_drops_upstream_host() {
364        let cfg = CspConfig::default();
365        let upstream = domains(&["https://api.external.com", "http://localhost:9000"]);
366
367        let out = effective_domains(
368            &cfg,
369            Directive::Connect,
370            None,
371            &upstream,
372            "localhost:9000",
373            "https://proxy.example.com",
374        );
375
376        assert_eq!(
377            out,
378            domains(&["https://proxy.example.com", "https://api.external.com"])
379        );
380    }
381
382    #[test]
383    fn effective__extend_adds_global_domains() {
384        let cfg = CspConfig {
385            connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
386            ..CspConfig::default()
387        };
388
389        let out = effective_domains(
390            &cfg,
391            Directive::Connect,
392            None,
393            &domains(&["https://api.external.com"]),
394            "upstream.internal",
395            "https://proxy.example.com",
396        );
397
398        assert_eq!(
399            out,
400            domains(&[
401                "https://proxy.example.com",
402                "https://api.external.com",
403                "https://api.mine.com",
404            ])
405        );
406    }
407
408    // ── effective_domains: global replace ──────────────────────────────────
409
410    #[test]
411    fn effective__replace_ignores_upstream() {
412        let cfg = CspConfig {
413            connect_domains: policy(&["https://api.mine.com"], Mode::Replace),
414            ..CspConfig::default()
415        };
416
417        let out = effective_domains(
418            &cfg,
419            Directive::Connect,
420            None,
421            &domains(&["https://api.external.com"]),
422            "upstream.internal",
423            "https://proxy.example.com",
424        );
425
426        assert_eq!(
427            out,
428            domains(&["https://proxy.example.com", "https://api.mine.com"])
429        );
430    }
431
432    #[test]
433    fn effective__replace_with_empty_global_leaves_only_proxy() {
434        let cfg = CspConfig {
435            connect_domains: policy(&[], Mode::Replace),
436            ..CspConfig::default()
437        };
438
439        let out = effective_domains(
440            &cfg,
441            Directive::Connect,
442            None,
443            &domains(&["https://api.external.com"]),
444            "upstream.internal",
445            "https://proxy.example.com",
446        );
447
448        assert_eq!(out, domains(&["https://proxy.example.com"]));
449    }
450
451    // ── effective_domains: frame does not get the proxy URL ───────────────
452
453    #[test]
454    fn effective__frame_directive_default_is_empty_not_proxy() {
455        // Frame defaults to strict replace with empty domains. Unlike connect
456        // and resource, we must NOT prepend the proxy URL — widgets don't
457        // iframe the proxy back into themselves, and including it flags the
458        // widget as an iframe-embedder to hosts like ChatGPT.
459        let cfg = CspConfig::default();
460        let out = effective_domains(
461            &cfg,
462            Directive::Frame,
463            None,
464            &domains(&["https://embed.external.com"]),
465            "upstream.internal",
466            "https://proxy.example.com",
467        );
468
469        assert!(out.is_empty(), "expected empty, got {out:?}");
470    }
471
472    #[test]
473    fn effective__frame_directive_with_declared_domains_omits_proxy() {
474        // When the operator declares frame domains, they pass through verbatim
475        // — still no proxy URL prefix.
476        let cfg = CspConfig {
477            frame_domains: policy(&["https://embed.partner.com"], Mode::Extend),
478            ..CspConfig::default()
479        };
480        let out = effective_domains(
481            &cfg,
482            Directive::Frame,
483            None,
484            &[],
485            "upstream.internal",
486            "https://proxy.example.com",
487        );
488
489        assert_eq!(out, domains(&["https://embed.partner.com"]));
490    }
491
492    #[test]
493    fn effective__connect_and_resource_still_get_proxy_prepend() {
494        // Regression guard: the frame-skip must not affect connect/resource.
495        let cfg = CspConfig::default();
496        for directive in [Directive::Connect, Directive::Resource] {
497            let out = effective_domains(
498                &cfg,
499                directive,
500                None,
501                &[],
502                "upstream.internal",
503                "https://proxy.example.com",
504            );
505            assert_eq!(
506                out,
507                domains(&["https://proxy.example.com"]),
508                "directive {directive:?}"
509            );
510        }
511    }
512
513    // ── effective_domains: widget extend ───────────────────────────────────
514
515    #[test]
516    fn effective__widget_extend_adds_on_top_of_global() {
517        let cfg = CspConfig {
518            connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
519            widgets: vec![widget(
520                "ui://widget/payment*",
521                &["https://api.stripe.com"],
522                Mode::Extend,
523            )],
524            ..CspConfig::default()
525        };
526
527        let out = effective_domains(
528            &cfg,
529            Directive::Connect,
530            Some("ui://widget/payment-form"),
531            &[],
532            "upstream.internal",
533            "https://proxy.example.com",
534        );
535
536        assert_eq!(
537            out,
538            domains(&[
539                "https://proxy.example.com",
540                "https://api.mine.com",
541                "https://api.stripe.com",
542            ])
543        );
544    }
545
546    #[test]
547    fn effective__widget_with_no_matching_uri_is_ignored() {
548        let cfg = CspConfig {
549            widgets: vec![widget(
550                "ui://widget/payment*",
551                &["https://api.stripe.com"],
552                Mode::Extend,
553            )],
554            ..CspConfig::default()
555        };
556
557        let out = effective_domains(
558            &cfg,
559            Directive::Connect,
560            Some("ui://widget/search"),
561            &[],
562            "upstream.internal",
563            "https://proxy.example.com",
564        );
565
566        assert_eq!(out, domains(&["https://proxy.example.com"]));
567    }
568
569    #[test]
570    fn effective__widget_without_uri_context_falls_back_to_global() {
571        let cfg = CspConfig {
572            connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
573            widgets: vec![widget("*", &["https://should.not.apply"], Mode::Extend)],
574            ..CspConfig::default()
575        };
576
577        let out = effective_domains(
578            &cfg,
579            Directive::Connect,
580            None,
581            &[],
582            "upstream.internal",
583            "https://proxy.example.com",
584        );
585
586        assert_eq!(
587            out,
588            domains(&["https://proxy.example.com", "https://api.mine.com"])
589        );
590    }
591
592    // ── effective_domains: widget replace ──────────────────────────────────
593
594    #[test]
595    fn effective__widget_replace_wipes_everything_before_it() {
596        let cfg = CspConfig {
597            connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
598            widgets: vec![widget(
599                "ui://widget/payment*",
600                &["https://api.stripe.com"],
601                Mode::Replace,
602            )],
603            ..CspConfig::default()
604        };
605
606        let out = effective_domains(
607            &cfg,
608            Directive::Connect,
609            Some("ui://widget/payment-form"),
610            &domains(&["https://api.external.com"]),
611            "upstream.internal",
612            "https://proxy.example.com",
613        );
614
615        assert_eq!(
616            out,
617            domains(&["https://proxy.example.com", "https://api.stripe.com"])
618        );
619    }
620
621    #[test]
622    fn effective__widget_replace_with_empty_domains_clears_list() {
623        let cfg = CspConfig {
624            connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
625            widgets: vec![widget("ui://widget/*", &[], Mode::Replace)],
626            ..CspConfig::default()
627        };
628
629        let out = effective_domains(
630            &cfg,
631            Directive::Connect,
632            Some("ui://widget/anything"),
633            &domains(&["https://api.external.com"]),
634            "upstream.internal",
635            "https://proxy.example.com",
636        );
637
638        assert_eq!(out, domains(&["https://proxy.example.com"]));
639    }
640
641    #[test]
642    fn effective__widget_extend_with_empty_domains_is_noop() {
643        // An empty + extend widget entry must not change anything, even when
644        // it matches the URI. Operators use this shape when they set modes
645        // for some directives but not others on the same widget block.
646        let cfg = CspConfig {
647            connect_domains: policy(&["https://api.mine.com"], Mode::Extend),
648            widgets: vec![widget("ui://widget/*", &[], Mode::Extend)],
649            ..CspConfig::default()
650        };
651
652        let out = effective_domains(
653            &cfg,
654            Directive::Connect,
655            Some("ui://widget/anything"),
656            &[],
657            "upstream.internal",
658            "https://proxy.example.com",
659        );
660
661        assert_eq!(
662            out,
663            domains(&["https://proxy.example.com", "https://api.mine.com"])
664        );
665    }
666
667    // ── effective_domains: widget ordering ────────────────────────────────
668
669    #[test]
670    fn effective__multiple_matching_widgets_apply_in_config_order() {
671        let cfg = CspConfig {
672            widgets: vec![
673                widget("ui://widget/*", &["https://a.com"], Mode::Extend),
674                widget("ui://widget/*", &["https://b.com"], Mode::Replace),
675                widget("ui://widget/*", &["https://c.com"], Mode::Extend),
676            ],
677            ..CspConfig::default()
678        };
679
680        let out = effective_domains(
681            &cfg,
682            Directive::Connect,
683            Some("ui://widget/anything"),
684            &[],
685            "upstream.internal",
686            "https://proxy.example.com",
687        );
688
689        // First widget extends with a.com, second wipes and sets b.com,
690        // third extends with c.com. Proxy URL is always prepended last.
691        assert_eq!(
692            out,
693            domains(&[
694                "https://proxy.example.com",
695                "https://b.com",
696                "https://c.com"
697            ])
698        );
699    }
700
701    // ── effective_domains: dedupe ──────────────────────────────────────────
702
703    #[test]
704    fn effective__dedupes_across_sources() {
705        let cfg = CspConfig {
706            connect_domains: policy(&["https://shared.com"], Mode::Extend),
707            widgets: vec![widget(
708                "ui://widget/*",
709                &["https://shared.com"],
710                Mode::Extend,
711            )],
712            ..CspConfig::default()
713        };
714
715        let out = effective_domains(
716            &cfg,
717            Directive::Connect,
718            Some("ui://widget/x"),
719            &domains(&["https://shared.com"]),
720            "upstream.internal",
721            "https://proxy.example.com",
722        );
723
724        assert_eq!(
725            out,
726            domains(&["https://proxy.example.com", "https://shared.com"])
727        );
728    }
729
730    #[test]
731    fn effective__dedupes_proxy_url_already_in_upstream() {
732        let cfg = CspConfig::default();
733        let out = effective_domains(
734            &cfg,
735            Directive::Connect,
736            None,
737            &domains(&["https://proxy.example.com", "https://api.external.com"]),
738            "upstream.internal",
739            "https://proxy.example.com",
740        );
741        let count = out
742            .iter()
743            .filter(|d| *d == "https://proxy.example.com")
744            .count();
745        assert_eq!(count, 1);
746    }
747
748    // ── self-reference stripping ───────────────────────────────────────────
749
750    #[test]
751    fn effective__strips_localhost() {
752        let cfg = CspConfig::default();
753        let out = effective_domains(
754            &cfg,
755            Directive::Connect,
756            None,
757            &domains(&["http://localhost:9000", "http://127.0.0.1:9000"]),
758            "upstream.internal",
759            "https://proxy.example.com",
760        );
761        assert_eq!(out, domains(&["https://proxy.example.com"]));
762    }
763
764    #[test]
765    fn effective__strips_upstream_host() {
766        let cfg = CspConfig::default();
767        let out = effective_domains(
768            &cfg,
769            Directive::Connect,
770            None,
771            &domains(&["https://upstream.internal", "https://api.external.com"]),
772            "upstream.internal",
773            "https://proxy.example.com",
774        );
775        assert_eq!(
776            out,
777            domains(&["https://proxy.example.com", "https://api.external.com"])
778        );
779    }
780
781    #[test]
782    fn effective__empty_upstream_host_disables_self_stripping() {
783        // When the upstream host is unknown (empty), only localhost heuristics
784        // remain. External domains pass through.
785        let cfg = CspConfig::default();
786        let out = effective_domains(
787            &cfg,
788            Directive::Connect,
789            None,
790            &domains(&["https://api.external.com"]),
791            "",
792            "https://proxy.example.com",
793        );
794        assert_eq!(
795            out,
796            domains(&["https://proxy.example.com", "https://api.external.com"])
797        );
798    }
799
800    // ── glob_match ─────────────────────────────────────────────────────────
801
802    #[test]
803    fn glob__literal_match() {
804        assert!(glob_match("ui://widget/payment", "ui://widget/payment"));
805    }
806
807    #[test]
808    fn glob__literal_mismatch() {
809        assert!(!glob_match("ui://widget/payment", "ui://widget/search"));
810    }
811
812    #[test]
813    fn glob__star_matches_suffix() {
814        assert!(glob_match(
815            "ui://widget/payment*",
816            "ui://widget/payment-form"
817        ));
818        assert!(glob_match("ui://widget/payment*", "ui://widget/payment"));
819    }
820
821    #[test]
822    fn glob__star_matches_any_sequence() {
823        assert!(glob_match("ui://*/payment", "ui://widget/payment"));
824        assert!(glob_match("ui://*/payment", "ui://nested/a/b/payment"));
825    }
826
827    #[test]
828    fn glob__double_star_segment() {
829        assert!(glob_match("ui://widget/*", "ui://widget/anything"));
830    }
831
832    #[test]
833    fn glob__question_matches_single_char() {
834        assert!(glob_match("ui://widget/a?c", "ui://widget/abc"));
835        assert!(!glob_match("ui://widget/a?c", "ui://widget/ac"));
836    }
837
838    #[test]
839    fn glob__empty_pattern_matches_empty_string_only() {
840        assert!(glob_match("", ""));
841        assert!(!glob_match("", "anything"));
842    }
843
844    #[test]
845    fn glob__star_only_matches_anything() {
846        assert!(glob_match("*", ""));
847        assert!(glob_match("*", "anything"));
848    }
849
850    // ── Mode Display ───────────────────────────────────────────────────────
851
852    #[test]
853    fn mode__display() {
854        assert_eq!(Mode::Extend.to_string(), "extend");
855        assert_eq!(Mode::Replace.to_string(), "replace");
856    }
857}