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