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