Skip to main content

uni_plugin_extism/
host_fns.rs

1//! Host function registration surface for Extism plugins.
2//!
3//! Unlike the Component Model loader โ€” where capability-gated imports
4//! live in per-major `wasmtime::Linker`s and are added or omitted based
5//! on the granted capability set โ€” Extism plugins import host fns by
6//! name and the host registers them imperatively at plugin construction
7//! time. This module owns that registration surface.
8//!
9//! Per proposal ยง10.2, the Extism path collapses to one enforcement
10//! layer: host-fn-body capability checks. The [`HostFnRegistry`] is
11//! the bookkeeping that makes those checks consistent across host fns
12//! (every gated fn checks its capability against the calling plugin's
13//! grants via the same helper, not ad-hoc).
14
15use std::collections::BTreeMap;
16
17/// Registry of host functions available to Extism plugins.
18///
19/// The host populates this once per uni-db instance with all gateable
20/// imports (`host_fs_read`, `host_net_http_get`, `host_query_run`,
21/// `host_kms_sign`, `host_secrets_acquire`, etc). At plugin load time,
22/// the loader filters the registry through the plugin's granted
23/// capability set and registers only the matching host fns into the
24/// Extism plugin's import table.
25///
26/// The capability check at call time is mechanical: each gated host fn
27/// looks up its `HostFnSpec` in the registry and verifies the plugin's
28/// grants. Plugins that bypass the capability check (because the host
29/// author forgot) cannot exist โ€” every host fn invocation routes
30/// through the registry's check helper.
31///
32/// # Status
33///
34/// M6a scaffolding: the public API surface is in place; the actual
35/// `Function` registration into Extism's runtime arrives in the cutover.
36#[derive(Debug, Default)]
37pub struct HostFnRegistry {
38    specs: BTreeMap<String, HostFnSpec>,
39}
40
41/// Metadata describing a single host function exposed to Extism plugins.
42#[derive(Debug, Clone)]
43pub struct HostFnSpec {
44    /// Name plugins use to import this fn (e.g., `"host_fs_read"`).
45    pub name: String,
46    /// Capability required for this fn to be visible to a plugin. `None`
47    /// means always-available (e.g., `host_log`). Matched by *variant* against
48    /// the plugin's effective set (attenuation patterns are enforced in the
49    /// host-fn body, not at the visibility gate).
50    pub required_capability: Option<uni_plugin::Capability>,
51    /// Human-readable description; surfaced via `uni plugin info`.
52    pub docs: String,
53}
54
55impl HostFnRegistry {
56    /// Construct a fresh, empty registry.
57    #[must_use]
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    /// Register a host fn spec.
63    pub fn register(&mut self, spec: HostFnSpec) {
64        self.specs.insert(spec.name.clone(), spec);
65    }
66
67    /// Look up a registered spec by name.
68    #[must_use]
69    pub fn get(&self, name: &str) -> Option<&HostFnSpec> {
70        self.specs.get(name)
71    }
72
73    /// Iterate all registered specs.
74    pub fn iter(&self) -> impl Iterator<Item = &HostFnSpec> {
75        self.specs.values()
76    }
77
78    /// Number of registered host fns.
79    #[must_use]
80    pub fn len(&self) -> usize {
81        self.specs.len()
82    }
83
84    /// Returns true if no host fns are registered.
85    #[must_use]
86    pub fn is_empty(&self) -> bool {
87        self.specs.is_empty()
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn registry_starts_empty() {
97        let r = HostFnRegistry::new();
98        assert!(r.is_empty());
99        assert_eq!(r.len(), 0);
100    }
101
102    #[test]
103    fn register_and_lookup_round_trip() {
104        let mut r = HostFnRegistry::new();
105        r.register(HostFnSpec {
106            name: "host_fs_read".to_owned(),
107            required_capability: Some(uni_plugin::Capability::Filesystem {
108                read: vec![],
109                write: vec![],
110            }),
111            docs: "Read a file from the host filesystem.".to_owned(),
112        });
113        let spec = r.get("host_fs_read").expect("registered");
114        assert!(matches!(
115            spec.required_capability,
116            Some(uni_plugin::Capability::Filesystem { .. })
117        ));
118        assert_eq!(r.len(), 1);
119    }
120
121    #[test]
122    fn always_available_fns_have_no_required_capability() {
123        let mut r = HostFnRegistry::new();
124        r.register(HostFnSpec {
125            name: "host_log".to_owned(),
126            required_capability: None,
127            docs: "Emit a tracing event.".to_owned(),
128        });
129        let spec = r.get("host_log").expect("registered");
130        assert!(spec.required_capability.is_none());
131    }
132
133    #[test]
134    fn iter_yields_registered_specs() {
135        let mut r = HostFnRegistry::new();
136        r.register(HostFnSpec {
137            name: "a".to_owned(),
138            required_capability: None,
139            docs: String::new(),
140        });
141        r.register(HostFnSpec {
142            name: "b".to_owned(),
143            required_capability: Some(uni_plugin::Capability::Network { allow: vec![] }),
144            docs: String::new(),
145        });
146        let names: Vec<&str> = r.iter().map(|s| s.name.as_str()).collect();
147        assert_eq!(names, vec!["a", "b"]);
148    }
149}