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}