Skip to main content

confium_sandbox_wasm/
imports.rs

1//! Host imports callable from a sandboxed WASM plugin.
2//!
3//! Every import is gated by [`Capability`]: the plugin must hold a
4//! matching capability before the host side executes the real work.
5//! A denied call traps the guest (returns an error to the host
6//! caller of [`SandboxInstance::call`](crate::SandboxInstance::call)).
7//!
8//! The convention follows the design doc: imports are named
9//! `cfm_<interface>_<verb>` and `InterfaceAccess { name: "<interface>" }`
10//! gates the whole family.
11//!
12//! NOTE: the real I/O implementations (hash, net, key) live in
13//! confium-core / confium-net / confium-store. This crate only owns
14//! the capability-gating dispatch surface; the per-import handlers
15//! are stubs for now, wired up as the host side matures.
16
17use std::collections::HashSet;
18use std::sync::Mutex;
19
20use crate::sandbox::Capability;
21
22/// Per-instance capability state. Held behind a `Mutex` so host
23/// imports (which run on the wasmtime scheduler) and the host-side
24/// `grant_capability` / `revoke_capability` calls agree on a single
25/// view.
26#[derive(Debug, Default)]
27pub(crate) struct CapabilitySet {
28    caps: Mutex<HashSet<Capability>>,
29}
30
31impl CapabilitySet {
32    pub(crate) fn new() -> Self {
33        Self::default()
34    }
35
36    pub(crate) fn grant(&self, cap: Capability) {
37        let mut guard = self.caps.lock().expect("capability mutex poisoned");
38        guard.insert(cap);
39    }
40
41    pub(crate) fn revoke(&self, cap: &Capability) {
42        let mut guard = self.caps.lock().expect("capability mutex poisoned");
43        guard.remove(cap);
44    }
45
46    /// True iff `cap` is currently granted. Kept on the public API
47    /// for future capability introspection (e.g. printing the
48    /// instance's envelope to a debug log).
49    #[allow(dead_code)]
50    pub(crate) fn has(&self, cap: &Capability) -> bool {
51        let guard = self.caps.lock().expect("capability mutex poisoned");
52        guard.contains(cap)
53    }
54
55    /// True iff the plugin holds an `InterfaceAccess { name }` cap
56    /// for the given interface.
57    pub(crate) fn has_interface(&self, name: &str) -> bool {
58        let guard = self.caps.lock().expect("capability mutex poisoned");
59        guard.iter().any(|c| match c {
60            Capability::InterfaceAccess { name: n } => n == name,
61            _ => false,
62        })
63    }
64}
65
66/// Outcome of a host-import dispatch.
67#[derive(Debug)]
68pub(crate) enum ImportOutcome {
69    /// Import executed; result value to return to the guest.
70    Done(i64),
71    /// Capability missing; the guest call should be reported as
72    /// denied.
73    Denied,
74}
75
76/// The dispatch table the WASM runtime calls into.
77///
78/// Each method takes the per-instance capability set plus the
79/// call arguments, checks the capability, and (when wired up) runs
80/// the real handler. Until the real handlers exist, the methods
81/// return a deterministic stub value so the sandbox pipeline can be
82/// exercised end-to-end (see the integration tests).
83pub(crate) struct HostImports;
84
85impl HostImports {
86    /// `cfm_hash_update(len: i32) -> i64` — gated by
87    /// `InterfaceAccess { name: "hash" }`.
88    ///
89    /// Returns the length it claims to have processed (stub).
90    pub(crate) fn cfm_hash_update(caps: &CapabilitySet, len: i32) -> ImportOutcome {
91        if !Self::has_interface(caps, "hash") {
92            return ImportOutcome::Denied;
93        }
94        // Stub: report the input length as bytes-hashed.
95        ImportOutcome::Done(i64::from(len))
96    }
97
98    /// `cfm_net_send(url_id: i64) -> i64` — gated by
99    /// `NetworkEndpoint { url }` for the url identified by `url_id`.
100    ///
101    /// Until a url-table is wired up, `url_id` is treated as an
102    /// opaque index the host resolves; the capability check uses
103    /// the plugin's first granted `NetworkEndpoint` for the smoke
104    /// test path. Real implementation will pass the url through
105    /// guest linear memory.
106    pub(crate) fn cfm_net_send(caps: &CapabilitySet, url_id: i64) -> ImportOutcome {
107        if !Self::has_any_network(caps) {
108            return ImportOutcome::Denied;
109        }
110        // Stub: report the url_id echoed back.
111        ImportOutcome::Done(url_id)
112    }
113
114    /// `cfm_key_get_secret(key_id: i64) -> i64` — gated by
115    /// `KeyAccess { key_id }`. Returns the secret length (stub),
116    /// never the bytes themselves.
117    pub(crate) fn cfm_key_get_secret(caps: &CapabilitySet, key_id: i64) -> ImportOutcome {
118        if !Self::has_any_key(caps) {
119            return ImportOutcome::Denied;
120        }
121        // Stub: report a 32-byte secret size.
122        let _ = key_id;
123        ImportOutcome::Done(32)
124    }
125
126    // -- helpers ---------------------------------------------------------
127
128    /// True iff the plugin holds `InterfaceAccess { name }`.
129    fn has_interface(caps: &CapabilitySet, name: &str) -> bool {
130        caps.has_interface(name)
131    }
132
133    fn has_any_network(caps: &CapabilitySet) -> bool {
134        let guard = caps.caps.lock().expect("capability mutex poisoned");
135        guard
136            .iter()
137            .any(|c| matches!(c, Capability::NetworkEndpoint { .. }))
138    }
139
140    fn has_any_key(caps: &CapabilitySet) -> bool {
141        let guard = caps.caps.lock().expect("capability mutex poisoned");
142        guard
143            .iter()
144            .any(|c| matches!(c, Capability::KeyAccess { .. }))
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn cfm_hash_update_denied_without_capability() {
154        let caps = CapabilitySet::new();
155        let out = HostImports::cfm_hash_update(&caps, 7);
156        match out {
157            ImportOutcome::Denied => {}
158            other => panic!("expected Denied, got {:?}", other),
159        }
160    }
161
162    #[test]
163    fn cfm_hash_update_permitted_with_capability() {
164        let caps = CapabilitySet::new();
165        caps.grant(Capability::InterfaceAccess {
166            name: "hash".into(),
167        });
168        let out = HostImports::cfm_hash_update(&caps, 7);
169        assert!(matches!(out, ImportOutcome::Done(7)));
170    }
171
172    #[test]
173    fn cfm_key_get_secret_denied_without_key_capability() {
174        let caps = CapabilitySet::new();
175        let out = HostImports::cfm_key_get_secret(&caps, 0);
176        assert!(matches!(out, ImportOutcome::Denied));
177    }
178
179    #[test]
180    fn cfm_key_get_secret_permitted_with_key_capability() {
181        let caps = CapabilitySet::new();
182        caps.grant(Capability::KeyAccess {
183            key_id: "k1".into(),
184        });
185        let out = HostImports::cfm_key_get_secret(&caps, 0);
186        assert!(matches!(out, ImportOutcome::Done(32)));
187    }
188
189    #[test]
190    fn revoke_takes_effect() {
191        let caps = CapabilitySet::new();
192        caps.grant(Capability::InterfaceAccess {
193            name: "hash".into(),
194        });
195        assert!(matches!(
196            HostImports::cfm_hash_update(&caps, 1),
197            ImportOutcome::Done(1)
198        ));
199        caps.revoke(&Capability::InterfaceAccess {
200            name: "hash".into(),
201        });
202        assert!(matches!(
203            HostImports::cfm_hash_update(&caps, 1),
204            ImportOutcome::Denied
205        ));
206    }
207}