Skip to main content

rich_ext/plugin_loading/
wasm.rs

1//! Sandboxed WASM plugins, run by `wasmi` (a pure-Rust interpreter).
2//!
3//! The host gives a module nothing: it may not import a single function, so
4//! it has no WASI, no file system, no network, no clock and no randomness. It
5//! sees only the text passed in, through its own linear memory. Every call
6//! runs in a fresh instance with a fuel budget (roughly, an instruction
7//! count) and a memory cap, so a plugin that loops forever or allocates
8//! without bound is stopped, and no state leaks from one call to the next.
9//!
10//! The exports it must provide are listed in [`rich_plugin_api::abi::wasm`].
11
12use std::path::Path;
13use std::sync::Arc;
14
15use rich_plugin_api::abi::{PluginAbi, wasm as names};
16use wasmi::{
17    Config, EnforcedLimits, Engine, ExternType, Instance, Linker, Memory, Module, Store,
18    StoreLimits, StoreLimitsBuilder, TrapCode,
19};
20
21use super::{AbiBackend, LoadError, MAX_OUTPUT_BYTES, RuntimeKind, RuntimePlugin, WasmLimits};
22
23/// Bytes in a WASM page.
24const PAGE: u64 = 64 * 1024;
25
26struct WasmBackend {
27    engine: Engine,
28    module: Module,
29    limits: WasmLimits,
30}
31
32/// Why a call failed, before it is turned into text.
33enum CallError {
34    OutOfFuel,
35    Memory(String),
36    Other(String),
37}
38
39impl CallError {
40    fn message(self, limits: &WasmLimits) -> String {
41        match self {
42            CallError::OutOfFuel => format!(
43                "the plugin ran out of fuel (the limit is {} units per call)",
44                limits.fuel
45            ),
46            CallError::Memory(message) | CallError::Other(message) => message,
47        }
48    }
49}
50
51fn classify(error: wasmi::Error) -> CallError {
52    match error.as_trap_code() {
53        Some(TrapCode::OutOfFuel) => CallError::OutOfFuel,
54        Some(TrapCode::GrowthOperationLimited) => CallError::Memory(
55            "the plugin tried to use more memory (or table elements) than the limit allows"
56                .to_string(),
57        ),
58        _ => CallError::Other(format!("the plugin failed: {error}")),
59    }
60}
61
62impl WasmBackend {
63    /// A fresh store and instance with this call's fuel and memory limits.
64    fn instantiate(&self) -> Result<(Store<StoreLimits>, Instance, Memory), CallError> {
65        let limits = StoreLimitsBuilder::new()
66            .memory_size(self.limits.memory_bytes)
67            .memories(1)
68            .tables(1)
69            .table_elements(self.limits.table_elements)
70            .instances(1)
71            .trap_on_grow_failure(true)
72            .build();
73        let mut store = Store::new(&self.engine, limits);
74        store.limiter(|limits| limits);
75        store
76            .set_fuel(self.limits.fuel)
77            .map_err(|e| CallError::Other(e.to_string()))?;
78        let linker = Linker::<StoreLimits>::new(&self.engine);
79        let instance = linker
80            .instantiate_and_start(&mut store, &self.module)
81            .map_err(classify)?;
82        let memory = instance
83            .get_memory(&store, names::MEMORY)
84            .ok_or_else(|| CallError::Other(format!("it does not export `{}`", names::MEMORY)))?;
85        Ok((store, instance, memory))
86    }
87
88    /// Read `len` bytes at `ptr` from a packed `ptr << 32 | len` result.
89    fn read_packed(
90        store: &Store<StoreLimits>,
91        memory: &Memory,
92        packed: u64,
93    ) -> Result<String, CallError> {
94        let ptr = ((packed >> 32) & 0x7fff_ffff) as usize;
95        let len = (packed & 0xffff_ffff) as usize;
96        if len > MAX_OUTPUT_BYTES {
97            return Err(CallError::Other(format!(
98                "the plugin returned {len} bytes; at most {MAX_OUTPUT_BYTES} are accepted"
99            )));
100        }
101        let bytes = memory
102            .data(store)
103            .get(ptr..ptr.saturating_add(len))
104            .ok_or_else(|| {
105                CallError::Other("the plugin returned a range outside its memory".into())
106            })?;
107        String::from_utf8(bytes.to_vec())
108            .map_err(|_| CallError::Other("the plugin returned non-UTF-8".into()))
109    }
110
111    fn manifest(&self) -> Result<String, CallError> {
112        let (mut store, instance, memory) = self.instantiate()?;
113        let manifest = instance
114            .get_typed_func::<(), i64>(&store, names::MANIFEST)
115            .map_err(|_| missing(names::MANIFEST, "() -> i64"))?;
116        let packed = manifest.call(&mut store, ()).map_err(classify)? as u64;
117        Self::read_packed(&store, &memory, packed & !names::ERROR_BIT)
118    }
119
120    fn run(&self, capability: usize, input: &str, width: u32) -> Result<String, CallError> {
121        let (mut store, instance, memory) = self.instantiate()?;
122        let alloc = instance
123            .get_typed_func::<i32, i32>(&store, names::ALLOC)
124            .map_err(|_| missing(names::ALLOC, "(i32) -> i32"))?;
125        let call = instance
126            .get_typed_func::<(i32, i32, i32, i32), i64>(&store, names::CALL)
127            .map_err(|_| missing(names::CALL, "(i32, i32, i32, i32) -> i64"))?;
128        let too_big = || CallError::Memory("the input is larger than the plugin can accept".into());
129        let len = i32::try_from(input.len()).map_err(|_| too_big())?;
130        let ptr = alloc.call(&mut store, len).map_err(classify)?;
131        let start = usize::try_from(ptr).map_err(|_| too_big())?;
132        memory
133            .write(&mut store, start, input.as_bytes())
134            .map_err(|_| {
135                CallError::Other("the plugin's allocation is outside its memory".into())
136            })?;
137        let capability = i32::try_from(capability).unwrap_or(i32::MAX);
138        let width = i32::try_from(width).unwrap_or(i32::MAX);
139        let packed = call
140            .call(&mut store, (capability, ptr, len, width))
141            .map_err(classify)? as u64;
142        let text = Self::read_packed(&store, &memory, packed & !names::ERROR_BIT)?;
143        if packed & names::ERROR_BIT != 0 {
144            Err(CallError::Other(text))
145        } else {
146            Ok(text)
147        }
148    }
149}
150
151fn missing(name: &str, signature: &str) -> CallError {
152    CallError::Other(format!("it does not export `{name}` as {signature}"))
153}
154
155impl AbiBackend for WasmBackend {
156    fn call(&self, capability: usize, input: &str, width: u32) -> Result<String, String> {
157        self.run(capability, input, width)
158            .map_err(|error| error.message(&self.limits))
159    }
160}
161
162/// Load a WASM plugin with these limits.
163///
164/// Refused when the file is too large or not valid WASM, when the module
165/// imports anything, when its memory starts above the cap, and when its
166/// manifest cannot be read within the limits or is not a valid
167/// [`PluginAbi`].
168pub fn load_wasm(path: &Path, limits: &WasmLimits) -> Result<RuntimePlugin, LoadError> {
169    // Absolute, as a native plugin's path is, so `rich plugins info` shows
170    // which file it is whatever directory it was named from.
171    let path = &std::fs::canonicalize(path).map_err(|error| LoadError::Io {
172        path: path.to_path_buf(),
173        message: error.to_string(),
174    })?;
175    let io = |message: String| LoadError::Io {
176        path: path.to_path_buf(),
177        message,
178    };
179    let limit = |message: String| LoadError::Limit {
180        path: path.to_path_buf(),
181        message,
182    };
183    let size = std::fs::metadata(path)
184        .map_err(|e| io(e.to_string()))?
185        .len();
186    if size > limits.module_bytes {
187        return Err(limit(format!(
188            "the module is {size} bytes; at most {} are accepted",
189            limits.module_bytes
190        )));
191    }
192    let bytes = std::fs::read(path).map_err(|e| io(e.to_string()))?;
193    load_wasm_bytes(&bytes, path, limits)
194}
195
196/// [`load_wasm`] for a module already in memory: `path` is only where the
197/// plugin says it came from (its [`RuntimePlugin::path`] and the path in a
198/// [`LoadError`]). A host that checked the bytes first (against a lockfile,
199/// say) loads those bytes, not the file again.
200pub fn load_wasm_bytes(
201    bytes: &[u8],
202    path: &Path,
203    limits: &WasmLimits,
204) -> Result<RuntimePlugin, LoadError> {
205    let not_a_plugin = |message: String| LoadError::NotAPlugin {
206        path: path.to_path_buf(),
207        message,
208    };
209    let limit = |message: String| LoadError::Limit {
210        path: path.to_path_buf(),
211        message,
212    };
213    if bytes.len() as u64 > limits.module_bytes {
214        return Err(limit(format!(
215            "the module is {} bytes; at most {} are accepted",
216            bytes.len(),
217            limits.module_bytes
218        )));
219    }
220
221    // wasmparser's validation first: wasmi 2.0.0 panics (it divides by
222    // zero) on a code section that declares no functions but holds bytes,
223    // before it reads that section through. The validator refuses it.
224    wasmparser::Validator::new()
225        .validate_all(bytes)
226        .map_err(|e| not_a_plugin(format!("invalid WASM: {e}")))?;
227
228    let mut config = Config::default();
229    config
230        .consume_fuel(true)
231        .enforced_limits(EnforcedLimits::strict());
232    let engine = Engine::new(&config);
233    let module =
234        Module::new(&engine, bytes).map_err(|e| not_a_plugin(format!("invalid WASM: {e}")))?;
235    if let Some(import) = module.imports().next() {
236        return Err(not_a_plugin(format!(
237            "it imports `{}.{}`, but the host provides nothing to import (no WASI, no host \
238             functions)",
239            import.module(),
240            import.name()
241        )));
242    }
243    for export in module.exports() {
244        if let ExternType::Memory(memory) = export.ty() {
245            let bytes = memory.minimum().saturating_mul(PAGE);
246            if bytes > limits.memory_bytes as u64 {
247                return Err(limit(format!(
248                    "its memory starts at {bytes} bytes, over the limit of {} bytes",
249                    limits.memory_bytes
250                )));
251            }
252        }
253    }
254
255    let backend = WasmBackend {
256        engine,
257        module,
258        limits: *limits,
259    };
260    let manifest = backend.manifest().map_err(|error| match error {
261        CallError::OutOfFuel | CallError::Memory(_) => limit(error.message(limits)),
262        CallError::Other(message) => not_a_plugin(message),
263    })?;
264    let abi = PluginAbi::parse_manifest(&manifest).map_err(|error| LoadError::Abi {
265        path: path.to_path_buf(),
266        error,
267    })?;
268    RuntimePlugin::new(abi, RuntimeKind::Wasm, path, Arc::new(backend))
269}