Skip to main content

greentic_ext_runtime/
loaded.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use greentic_extension_sdk_contract::{DescribeJson, ExtensionKind};
5use wasmtime::Store;
6use wasmtime::component::{Component, HasSelf, Instance, Linker};
7
8use crate::health::ExtensionHealth;
9use crate::host_state::HostState;
10use crate::pool::InstancePool;
11
12#[derive(Clone, Debug, PartialEq, Eq, Hash)]
13pub struct ExtensionId(pub String);
14
15impl ExtensionId {
16    #[must_use]
17    pub fn from_describe(describe: &DescribeJson) -> Self {
18        Self(describe.metadata.id.clone())
19    }
20
21    #[must_use]
22    pub fn as_str(&self) -> &str {
23        &self.0
24    }
25}
26
27impl From<&str> for ExtensionId {
28    fn from(s: &str) -> Self {
29        Self(s.to_string())
30    }
31}
32
33impl From<String> for ExtensionId {
34    fn from(s: String) -> Self {
35        Self(s)
36    }
37}
38
39pub struct LoadedExtension {
40    pub id: ExtensionId,
41    pub describe: Arc<DescribeJson>,
42    pub kind: ExtensionKind,
43    pub source_dir: PathBuf,
44    pub component: Component,
45    pub pool: InstancePool,
46    pub health: ExtensionHealth,
47}
48
49impl LoadedExtension {
50    pub fn load_from_dir(engine: &wasmtime::Engine, source_dir: &Path) -> anyhow::Result<Self> {
51        let describe_path = source_dir.join("describe.json");
52        let describe_bytes = std::fs::read(&describe_path)?;
53        let describe_value: serde_json::Value = serde_json::from_slice(&describe_bytes)?;
54        greentic_extension_sdk_contract::schema::validate_describe_json(&describe_value)
55            .map_err(|e| anyhow::anyhow!("invalid describe.json: {e}"))?;
56        let describe: DescribeJson = serde_json::from_value(describe_value)?;
57        let id = ExtensionId::from_describe(&describe);
58        let wasm_path = wasm_component_path(&describe, source_dir)?;
59        let component = Component::from_file(engine, &wasm_path)?;
60        let pool = InstancePool::new(2);
61        let kind = describe.kind;
62        Ok(Self {
63            id,
64            describe: Arc::new(describe),
65            kind,
66            source_dir: source_dir.to_path_buf(),
67            component,
68            pool,
69            health: ExtensionHealth::Healthy,
70        })
71    }
72}
73
74impl LoadedExtension {
75    /// Build a fresh wasmtime Store with [`HostState`] and instantiate the component.
76    /// Each call creates a new instance (no pooling yet — pooling is future work).
77    pub fn build_store_and_instance(
78        &self,
79        engine: &wasmtime::Engine,
80        host_overrides: HostOverrides,
81        ctx: &crate::host_ports::HostCallContext,
82    ) -> anyhow::Result<(Store<HostState>, Instance)> {
83        use crate::host_bindings::greentic::extension_host::{
84            broker, http, i18n, llm, logging, secrets,
85        };
86
87        let mut linker: Linker<HostState> = Linker::new(engine);
88
89        // Wire WASI host functions. cargo-component always adds WASI imports to
90        // its output even when the Rust source never calls them directly.
91        wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?;
92
93        // HasSelf<T> wraps T and implements HasData — required for wasmtime 43 bindgen add_to_linker.
94        logging::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
95        i18n::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
96        secrets::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
97        broker::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
98        http::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
99        llm::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
100        crate::host_bindings::design_v04::greentic::oauth_broker::broker_v1::add_to_linker::<
101            HostState,
102            HasSelf<HostState>,
103        >(&mut linker, |s| s)?;
104
105        // Per-extension network allow-list: when the extension declares
106        // `runtime.permissions.network` patterns, those patterns become the
107        // authoritative allow-list for this extension (replace semantics —
108        // the host-level override is NOT added). When no patterns are
109        // declared the host-level override is used unchanged (deny-all by
110        // default). See `effective_url_matcher` for the loopback-http rule.
111        let url_matcher = effective_url_matcher(
112            &self.describe.runtime.permissions.network,
113            host_overrides.url_matcher,
114        );
115
116        let state = HostState::builder(
117            self.id.as_str().to_string(),
118            self.describe.runtime.permissions.clone(),
119        )
120        .translator(host_overrides.translator)
121        .secrets_backend(host_overrides.secrets_backend)
122        .http_client(host_overrides.http_client)
123        .llm_port(host_overrides.llm_port)
124        .call_ctx(ctx.clone())
125        .url_matcher(url_matcher)
126        .runtime_weak(host_overrides.runtime_weak)
127        .call_depth_start(host_overrides.call_depth_start)
128        .oauth_config(host_overrides.oauth_config.clone())
129        .build();
130
131        let mut store = Store::new(engine, state);
132        let instance = linker.instantiate(&mut store, &self.component)?;
133        Ok((store, instance))
134    }
135}
136
137/// Resolve the WASM component path for an extension's runtime component.
138///
139/// Extensions that use the dual-component layout ship:
140/// - Root `extension.wasm` — design-side WebAssembly with metadata (channel
141///   name, icon, i18n, schemas). This is what the designer loads.
142/// - A runtime gtpack (e.g. `runtime/provider.gtpack`) — either a placeholder
143///   text file or a real .gtpack ZIP. The real runner-host WASM lives downstream
144///   and is fetched lazily there; the designer must never try to parse it.
145///
146/// Multiple extension kinds follow this dual-component layout:
147/// - `ProviderExtension` (e.g. `greentic.provider.telegram-1.3.1-research`)
148/// - `DesignExtension` (e.g. `greentic.llm-openai-1.3.1-research`) — has a
149///   real 80–900 KB `extension.wasm`; `describe.json` points at
150///   `runtime/component-llm-openai.gtpack` (a 929 KB .gtpack ZIP that wasmtime
151///   cannot parse as a raw component).
152/// - `BundleExtension` (e.g. `greentic.bundle-standard-1.3.0-research`) — has
153///   a 938 KB `extension.wasm`; `describe.json` points at a `.gtxpack` that
154///   may not even exist in the installed directory.
155///
156/// Strategy: if `<source_dir>/extension.wasm` exists, prefer it unconditionally
157/// regardless of kind. Only the runner-host — which has its own separate loader
158/// path — needs the runtime gtpack declared in `describe.runtime.components`.
159/// Designer's boot loader only consumes design-side metadata and UI assets.
160///
161/// Older single-component extensions that ship no `extension.wasm` at root fall
162/// back to `describe.runtime.components[X].gtpack.file` resolved relative to
163/// `source_dir`, exactly as before.
164///
165/// v2's `runtime.components` is a map keyed by component id. ext-runtime today
166/// loads a single WASM component per extension, so we require exactly one entry.
167/// Multi-component dispatch (driven by `runtime_ref` on nodeTypes/tools) is a
168/// follow-up — when it lands, callers will pick the component by id and this
169/// helper goes away.
170fn wasm_component_path(describe: &DescribeJson, source_dir: &Path) -> anyhow::Result<PathBuf> {
171    // Dual-component layout: extensions that ship a design-side `extension.wasm`
172    // at the source-dir root use it for designer-side loading regardless of kind.
173    // The runtime gtpack declared in `describe.runtime.components` stays meaningful
174    // for runner-host (flow-execution time), which has its own separate loader path.
175    //
176    // Provider, llm-openai (DesignExtension), and bundle-standard (BundleExtension)
177    // all follow this layout. Older single-component extensions that don't ship
178    // `extension.wasm` fall back to the describe.json declared path below.
179    let design_wasm = source_dir.join("extension.wasm");
180    if design_wasm.exists() {
181        return Ok(design_wasm);
182    }
183
184    // Fallback for older single-component extensions: read
185    // `describe.runtime.components[X].gtpack.file` and resolve it relative to
186    // `source_dir`. These kinds already point at real WASM at that path.
187    let mut iter = describe.runtime.components.iter();
188    let Some((id, component)) = iter.next() else {
189        anyhow::bail!("describe.runtime.components must declare at least one entry");
190    };
191    if iter.next().is_some() {
192        anyhow::bail!(
193            "describe.runtime.components has more than one entry; multi-component dispatch is not yet implemented"
194        );
195    }
196    let gtpack = component.gtpack.as_ref().ok_or_else(|| {
197        anyhow::anyhow!(
198            "describe.runtime.components[{id:?}].gtpack must be set for source-dir loads (OCI-only deploy is not yet supported)",
199        )
200    })?;
201    Ok(source_dir.join(gtpack.file.as_str()))
202}
203
204/// Select the URL matcher for a single extension instantiation.
205///
206/// **Replace semantics:** when the extension's `describe.json` declares one
207/// or more patterns under `runtime.permissions.network`, those patterns are
208/// the authoritative allow-list for that extension and a fresh
209/// [`UrlMatcher`] is built from them (with the loopback-http rule applied —
210/// see below). The host-level `override_matcher` is **ignored** in this
211/// path — it is the host-wide default that applies only to extensions that
212/// make no network declaration.
213///
214/// When the declaration is empty the host-level override is returned
215/// unchanged, which is the deny-all default in most deployments. This
216/// preserves existing behavior for extensions that do not need outbound HTTP.
217///
218/// # Loopback-http rule
219///
220/// [`UrlMatcher`] rejects non-`https` URLs by default (scheme-downgrade
221/// defence) and only honours plain `http` when `with_allow_http(true)` is
222/// set. That toggle is **matcher-wide** — it cannot be scoped to a single
223/// pattern. To let an extension talk to a local dev service over
224/// `http://127.0.0.1` / `http://localhost` WITHOUT also opening plain http
225/// to public hosts, we:
226///
227/// 1. drop any declared `http://` pattern whose host is NOT loopback (it
228///    could never be safely honoured — a public-host plain-http downgrade
229///    is exactly the attack the matcher defends against), and
230/// 2. enable `with_allow_http(true)` only when at least one *loopback*
231///    `http://` pattern survives.
232///
233/// Because the matcher matches scheme exactly per declared pattern, a
234/// co-declared `https://host/*` pattern still requires `https` even when
235/// the toggle is on — the toggle only decides whether `http` patterns are
236/// consulted at all, and after step 1 the only surviving `http` patterns
237/// are loopback.
238///
239/// # Arguments
240///
241/// * `declared_patterns` — the `runtime.permissions.network` slice from
242///   the extension's parsed `describe.json`.
243/// * `override_matcher` — the host-level matcher supplied via
244///   [`HostOverrides`]. Used only when `declared_patterns` is empty.
245///
246/// # Returns
247///
248/// A [`UrlMatcher`] that enforces the correct allow-list for this extension.
249pub(crate) fn effective_url_matcher(
250    declared_patterns: &[String],
251    override_matcher: crate::url_matcher::UrlMatcher,
252) -> crate::url_matcher::UrlMatcher {
253    if declared_patterns.is_empty() {
254        return override_matcher;
255    }
256
257    // Replace path: build the effective matcher exclusively from the
258    // extension's declared patterns (the host override does NOT apply).
259    let mut patterns: Vec<String> = declared_patterns.to_vec();
260
261    // Loopback-http handling: keep loopback http patterns, drop public-host
262    // http patterns (they can never be honoured safely), and record whether
263    // any loopback http pattern remains so we can flip the matcher-wide
264    // allow_http toggle.
265    let mut allow_loopback_http = false;
266    patterns.retain(|p| {
267        if let Some(host) = http_pattern_host(p) {
268            if is_loopback_host(host) {
269                allow_loopback_http = true;
270                true
271            } else {
272                tracing::warn!(
273                    pattern = %p,
274                    "dropping non-loopback http url pattern; plain http is only honoured for loopback hosts"
275                );
276                false
277            }
278        } else {
279            // https (or any non-http) pattern — kept verbatim; UrlMatcher
280            // validates it on construction.
281            true
282        }
283    });
284
285    crate::url_matcher::UrlMatcher::from_patterns(patterns).with_allow_http(allow_loopback_http)
286}
287
288/// Return the host portion of a `http://` pattern, or `None` when the
289/// pattern is not plain http. The leading `*.` wildcard label (e.g.
290/// `http://*.example.com/*`) is stripped so the remaining host can be
291/// classified; a bare wildcard host is treated as non-loopback.
292///
293/// Bracketed IPv6 literals (e.g. `[::1]` in `http://[::1]:8787/*`) are
294/// returned with their brackets intact so that `is_loopback_host` can strip
295/// them: splitting on the first `:` would otherwise yield the bare `"["`
296/// opener and misclassify `[::1]` as non-loopback.
297fn http_pattern_host(pattern: &str) -> Option<&str> {
298    let rest = pattern.strip_prefix("http://")?;
299    let host_and_port = rest.split('/').next().unwrap_or(rest);
300    // Strip the userinfo (`user@host`) if present.
301    let host_and_port = host_and_port.rsplit('@').next().unwrap_or(host_and_port);
302    // Bracketed IPv6 literal: `[::1]` or `[::1]:8787`.
303    // Return the bracketed token (including the `]`) so is_loopback_host can
304    // strip the brackets and compare against `::1`.
305    let host = if let Some(bracket_end) = host_and_port.find(']') {
306        &host_and_port[..=bracket_end]
307    } else {
308        // Plain hostname or IPv4: split on first `:` to drop optional port.
309        host_and_port.split(':').next().unwrap_or(host_and_port)
310    };
311    Some(host.trim_start_matches("*."))
312}
313
314/// Loopback hosts for which plain http is acceptable: `localhost`,
315/// `127.0.0.1` (any IPv4 loopback in `127.0.0.0/8` would also qualify, but
316/// the only spellings extensions declare in practice are these two and
317/// `[::1]`), and the IPv6 loopback.
318fn is_loopback_host(host: &str) -> bool {
319    let host = host.trim_start_matches('[').trim_end_matches(']');
320    host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
321}
322
323pub type LoadedExtensionRef = Arc<LoadedExtension>;
324
325/// Bundle of overrides every dispatch caller must supply when building a
326/// `HostState`. Production code (designer) constructs adapters around
327/// `greentic-i18n` + `greentic-secrets`; tests use [`HostOverrides::defaults_for_tests`].
328///
329/// `http_client` is `Option` because `reqwest::blocking::Client` spawns an
330/// internal tokio runtime, and dropping that runtime from inside an
331/// outer async context panics with "Cannot drop a runtime in a context
332/// where blocking is not allowed". Tests instantiate `ExtensionRuntime`
333/// inside `#[tokio::test]` bodies but never call `http::fetch`, so
334/// they leave the client `None` — `host_state` will surface a clean
335/// "http client not configured" error if a test ever does invoke fetch.
336/// Production callers pass `Some(client)` once at startup.
337#[derive(Clone)]
338pub struct HostOverrides {
339    pub translator: std::sync::Arc<dyn crate::host_ports::Translator>,
340    pub secrets_backend: std::sync::Arc<dyn crate::host_ports::SecretsBackend>,
341    pub http_client: Option<reqwest::blocking::Client>,
342    pub llm_port: Option<std::sync::Arc<dyn crate::host_ports::LlmPort>>,
343    pub url_matcher: crate::url_matcher::UrlMatcher,
344    pub runtime_weak: std::sync::Weak<crate::runtime::ExtensionRuntime>,
345    pub call_depth_start: u32,
346    pub oauth_config: Option<crate::oauth::OAuthBrokerConfig>,
347}
348
349impl std::fmt::Debug for HostOverrides {
350    /// Opaque debug representation: trait-object fields cannot provide
351    /// structural debug output, and `reqwest::blocking::Client` does not
352    /// implement `Debug`. We show field presence rather than field values.
353    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
354        f.debug_struct("HostOverrides")
355            .field("translator", &"<dyn Translator>")
356            .field("secrets_backend", &"<dyn SecretsBackend>")
357            .field(
358                "http_client",
359                &self.http_client.as_ref().map(|_| "<Client>"),
360            )
361            .field("llm_port", &self.llm_port.as_ref().map(|_| "<dyn LlmPort>"))
362            .field("url_matcher", &self.url_matcher)
363            .field(
364                "runtime_weak",
365                &self
366                    .runtime_weak
367                    .upgrade()
368                    .map(|_| "<Arc<ExtensionRuntime>>"),
369            )
370            .field("call_depth_start", &self.call_depth_start)
371            .field(
372                "oauth_config",
373                &self.oauth_config.as_ref().map(|_| "<OAuthBrokerConfig>"),
374            )
375            .finish()
376    }
377}
378
379impl HostOverrides {
380    /// Fakes-everywhere helper. `http_client` is `None` so dropping the
381    /// runtime inside an outer async context never panics; the test never
382    /// hits the path that uses it. Runtime weak is left unset (`Weak::new`),
383    /// so broker dispatch returns "no runtime context available" until
384    /// the cross-extension dispatch cascade lands.
385    #[must_use]
386    pub fn defaults_for_tests() -> Self {
387        Self::default()
388    }
389}
390
391impl Default for HostOverrides {
392    /// Production-safe defaults: key-pass-through translator (i18n key
393    /// returned verbatim), empty in-memory secrets, no HTTP client (callers
394    /// that need HTTP must supply `Some(client)` via
395    /// `RuntimeConfig::with_host_overrides` or
396    /// `ExtensionRuntime::with_host_overrides`), empty URL allow-list, and
397    /// no broker-runtime weak reference (cross-extension dispatch returns
398    /// "no runtime context available" until the cascade cascade lands).
399    ///
400    /// `http_client` is intentionally `None` rather than eagerly constructed
401    /// because `reqwest::blocking::Client` spawns its own internal tokio
402    /// runtime; dropping that runtime from inside an outer `#[tokio::test]`
403    /// body panics with "Cannot drop a runtime in a context where blocking is
404    /// not allowed". Tests leave it `None`; production callers pass
405    /// `Some(client)` once at startup.
406    fn default() -> Self {
407        Self {
408            translator: std::sync::Arc::new(crate::host_ports::KeyTranslator),
409            secrets_backend: std::sync::Arc::new(crate::host_ports::InMemorySecrets::new()),
410            http_client: None,
411            llm_port: None,
412            url_matcher: crate::url_matcher::UrlMatcher::default(),
413            runtime_weak: std::sync::Weak::new(),
414            call_depth_start: 0,
415            oauth_config: None,
416        }
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use crate::url_matcher::UrlMatcher;
424
425    fn empty_override() -> UrlMatcher {
426        UrlMatcher::default()
427    }
428
429    fn override_with_pattern(pattern: &str) -> UrlMatcher {
430        UrlMatcher::from_patterns(vec![pattern.to_string()])
431    }
432
433    /// Extensions that declare network patterns must have exactly those
434    /// patterns enforced — the host-level override must NOT apply.
435    #[test]
436    fn declared_patterns_allow_declared_host_and_deny_undeclared() {
437        let declared = vec!["https://api.github.com/*".to_string()];
438        let matcher = effective_url_matcher(&declared, empty_override());
439
440        assert!(
441            matcher.is_allowed("https://api.github.com/repos/org/repo"),
442            "declared host must be allowed"
443        );
444        assert!(
445            !matcher.is_allowed("https://evil.com/"),
446            "undeclared host must be denied even though host override is empty"
447        );
448    }
449
450    /// When no network patterns are declared the host-level override is
451    /// returned verbatim — behavior is unchanged for legacy extensions.
452    #[test]
453    fn empty_declaration_falls_back_to_host_override() {
454        let override_matcher = override_with_pattern("https://allowed.com/*");
455        let matcher = effective_url_matcher(&[], override_matcher);
456
457        assert!(
458            matcher.is_allowed("https://allowed.com/path"),
459            "host-override host must be reachable when declare is empty"
460        );
461        assert!(
462            !matcher.is_allowed("https://other.com/path"),
463            "host-override deny must still apply"
464        );
465    }
466
467    /// Non-empty declaration REPLACES (not unions) the host-level
468    /// override. A broader operator override must not bleed through to
469    /// an extension that declared its own narrower allow-list.
470    #[test]
471    fn declared_patterns_replace_not_union_host_override() {
472        let declared = vec!["https://api.github.com/*".to_string()];
473        let override_matcher = override_with_pattern("https://operator-allowed.com/*");
474        let matcher = effective_url_matcher(&declared, override_matcher);
475
476        assert!(
477            matcher.is_allowed("https://api.github.com/repos/org/repo"),
478            "declared host must be allowed"
479        );
480        assert!(
481            !matcher.is_allowed("https://operator-allowed.com/anything"),
482            "operator override must NOT bleed through when declaration is non-empty"
483        );
484    }
485
486    /// Empty declaration + empty host override must deny every URL —
487    /// this is the default deny-all posture for extensions that never
488    /// call the network.
489    #[test]
490    fn empty_declaration_and_empty_override_denies_everything() {
491        let matcher = effective_url_matcher(&[], empty_override());
492
493        assert!(
494            !matcher.is_allowed("https://api.github.com/anything"),
495            "empty declaration + empty override must produce deny-all matcher"
496        );
497    }
498
499    /// A declared loopback `http://127.0.0.1` pattern must be reachable
500    /// over plain http. The matcher rejects non-https by default, so the
501    /// effective matcher has to opt http in — but ONLY because the
502    /// declared pattern is loopback.
503    #[test]
504    fn declared_http_loopback_127_allows_plain_http() {
505        let declared = vec!["http://127.0.0.1:8787/*".to_string()];
506        let matcher = effective_url_matcher(&declared, empty_override());
507
508        assert!(
509            matcher.is_allowed("http://127.0.0.1:8787/execute"),
510            "declared http loopback pattern must permit plain http to that loopback"
511        );
512    }
513
514    /// `http://localhost` is the other loopback spelling and must behave
515    /// the same as `127.0.0.1`.
516    #[test]
517    fn declared_http_loopback_localhost_allows_plain_http() {
518        let declared = vec!["http://localhost:8787/*".to_string()];
519        let matcher = effective_url_matcher(&declared, empty_override());
520
521        assert!(
522            matcher.is_allowed("http://localhost:8787/execute"),
523            "declared http localhost pattern must permit plain http to localhost"
524        );
525    }
526
527    /// The loopback-http opt-in must NOT leak to non-loopback http: a
528    /// declared `http://evil.com` pattern must stay denied (no plain-http
529    /// downgrade for a public host) even though the pattern technically
530    /// targets http.
531    #[test]
532    fn declared_http_non_loopback_stays_denied() {
533        let declared = vec!["http://evil.com/*".to_string()];
534        let matcher = effective_url_matcher(&declared, empty_override());
535
536        assert!(
537            !matcher.is_allowed("http://evil.com/anything"),
538            "plain http must stay denied for a non-loopback declared host"
539        );
540    }
541
542    /// A mixed declaration (loopback http + a normal https host) must keep
543    /// https reachable AND the loopback http reachable, while still
544    /// refusing plain http to the https host (the global `allow_http` toggle
545    /// must not downgrade the https-only host because no http pattern for
546    /// it exists, and `is_allowed` matches scheme exactly per pattern).
547    #[test]
548    fn mixed_loopback_http_and_https_host() {
549        let declared = vec![
550            "http://127.0.0.1:8787/*".to_string(),
551            "https://api.example.com/*".to_string(),
552        ];
553        let matcher = effective_url_matcher(&declared, empty_override());
554
555        assert!(
556            matcher.is_allowed("http://127.0.0.1:8787/execute"),
557            "loopback http must be allowed in a mixed declaration"
558        );
559        assert!(
560            matcher.is_allowed("https://api.example.com/v1/foo"),
561            "declared https host must stay reachable"
562        );
563        assert!(
564            !matcher.is_allowed("http://api.example.com/v1/foo"),
565            "plain http to the https-only host must stay denied even with loopback http enabled"
566        );
567    }
568
569    /// A bracketed IPv6 loopback `http://[::1]:8787/*` must survive the
570    /// loopback filter and allow plain http to `http://[::1]:8787/x`.
571    ///
572    /// The url crate's `host_str()` returns the bracketed form `"[::1]"` for
573    /// both the pattern and the request URL, so the Exact host rule matches.
574    /// The bug this test guards against: `http_pattern_host` previously split
575    /// on the first `:`, yielding `"["` as the host, which was classified as
576    /// non-loopback and dropped.
577    #[test]
578    fn declared_http_ipv6_loopback_allows_plain_http() {
579        let declared = vec!["http://[::1]:8787/*".to_string()];
580        let matcher = effective_url_matcher(&declared, empty_override());
581
582        assert!(
583            matcher.is_allowed("http://[::1]:8787/x"),
584            "declared http IPv6 loopback pattern must permit plain http to [::1]"
585        );
586        // Must not bleed to arbitrary non-loopback hosts.
587        assert!(
588            !matcher.is_allowed("http://evil.com/x"),
589            "IPv6 loopback opt-in must not permit plain http to non-loopback hosts"
590        );
591    }
592
593    /// An adversarial pattern `http://[::1].evil.com/*` that tries to smuggle
594    /// a non-loopback host inside brackets must be rejected. The url crate
595    /// refuses to parse this (it is not a valid bracketed IPv6 literal), so
596    /// the pattern is either unparseable (dropped by `UrlMatcher`) or the
597    /// resulting host does not match `[::1]` in `is_loopback_host`.
598    ///
599    /// Either way the request to `http://[::1].evil.com/x` must be denied.
600    #[test]
601    fn adversarial_fake_ipv6_bracket_host_is_denied() {
602        let declared = vec!["http://[::1].evil.com/*".to_string()];
603        let matcher = effective_url_matcher(&declared, empty_override());
604
605        // The pattern is malformed: url::Url::parse rejects `[::1].evil.com`
606        // as a host, so the pattern is silently dropped and the matcher
607        // remains deny-all for this declaration.
608        assert!(
609            !matcher.is_allowed("http://[::1].evil.com/x"),
610            "malformed bracketed host must not be allowed"
611        );
612        // Real IPv6 loopback must also NOT be granted by a bad pattern.
613        assert!(
614            !matcher.is_allowed("http://[::1]/x"),
615            "bad pattern must not accidentally allow real IPv6 loopback"
616        );
617    }
618}