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}