Skip to main content

redevplugin_wasm_abi/
lib.rs

1use std::collections::HashMap;
2use std::fmt;
3use wasmparser::{Encoding, ExternalKind, FuncType, Parser, Payload, RefType, TypeRef, ValType};
4
5pub const WASM_WORKER_ABI_VERSION: &str = "redevplugin-wasm-worker-v2";
6pub const EXPORT_MEMORY: &str = "memory";
7pub const EXPORT_WORKER_ALLOC: &str = "redevplugin_worker_alloc";
8pub const EXPORT_WORKER_DEALLOC: &str = "redevplugin_worker_dealloc";
9pub const EXPORT_WORKER_INVOKE: &str = "redevplugin_worker_invoke";
10pub const REQUIRED_EXPORT_INVOKE: &str = EXPORT_WORKER_INVOKE;
11pub const IMPORT_STORAGE: &str = "redevplugin.storage";
12pub const IMPORT_NETWORK: &str = "redevplugin.network";
13pub const MAX_TABLE_ELEMENTS: u64 = 65_536;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ValueType {
17    I32,
18    I64,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct FunctionContract {
23    pub export_name: String,
24    pub params: Vec<ValueType>,
25    pub results: Vec<ValueType>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct MemoryContract {
30    pub initial_pages: u64,
31    pub maximum_pages: Option<u64>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct HostcallImport {
36    pub module: String,
37    pub name: String,
38    pub params: Vec<ValueType>,
39    pub results: Vec<ValueType>,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ValidatedWorkerModule {
44    pub byte_len: usize,
45    pub memory: MemoryContract,
46    pub alloc_export: FunctionContract,
47    pub dealloc_export: FunctionContract,
48    pub invoke_export: FunctionContract,
49    pub imports: Vec<HostcallImport>,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum ValidationErrorCategory {
54    InvalidModule,
55    UnsupportedImport,
56    InvalidMemory,
57    InvalidTable,
58    MissingExport,
59    InvalidSignature,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ValidationError {
64    category: ValidationErrorCategory,
65    message: String,
66}
67
68impl ValidationError {
69    pub fn category(&self) -> ValidationErrorCategory {
70        self.category
71    }
72
73    fn new(category: ValidationErrorCategory, message: impl Into<String>) -> Self {
74        Self {
75            category,
76            message: message.into(),
77        }
78    }
79}
80
81impl fmt::Display for ValidationError {
82    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
83        formatter.write_str(&self.message)
84    }
85}
86
87impl std::error::Error for ValidationError {}
88
89pub fn validate_worker_module(bytes: &[u8]) -> Result<ValidatedWorkerModule, ValidationError> {
90    wasmparser::Validator::new()
91        .validate_all(bytes)
92        .map_err(|err| invalid_module(format!("wasm validation failed: {err}")))?;
93
94    let mut types = Vec::new();
95    let mut function_type_indices = Vec::new();
96    let mut imported_function_type_indices = Vec::new();
97    let mut imports = Vec::new();
98    let mut memories = Vec::new();
99    let mut table_count = 0_u32;
100    let mut exports = HashMap::new();
101
102    for payload in Parser::new(0).parse_all(bytes) {
103        match payload.map_err(|err| invalid_module(format!("parse wasm module: {err}")))? {
104            Payload::Version { encoding, .. } if encoding != Encoding::Module => {
105                return Err(invalid_module(
106                    "worker artifact must be a core WebAssembly module",
107                ));
108            }
109            Payload::TypeSection(reader) => {
110                for function_type in reader.into_iter_err_on_gc_types() {
111                    types.push(function_type.map_err(|err| {
112                        invalid_module(format!("parse wasm function type: {err}"))
113                    })?);
114                }
115            }
116            Payload::ImportSection(reader) => {
117                for imported in reader.into_imports() {
118                    let imported = imported
119                        .map_err(|err| invalid_module(format!("parse wasm import: {err}")))?;
120                    let TypeRef::Func(type_index) = imported.ty else {
121                        return Err(ValidationError::new(
122                            ValidationErrorCategory::UnsupportedImport,
123                            format!(
124                                "worker import {}/{} must be a function",
125                                imported.module, imported.name
126                            ),
127                        ));
128                    };
129                    let function_type = types.get(type_index as usize).ok_or_else(|| {
130                        invalid_module(format!(
131                            "worker import {}/{} references missing type {type_index}",
132                            imported.module, imported.name
133                        ))
134                    })?;
135                    require_hostcall(imported.module, imported.name, function_type)?;
136                    let (params, results) = function_contract_types(function_type)?;
137                    imports.push(HostcallImport {
138                        module: imported.module.to_string(),
139                        name: imported.name.to_string(),
140                        params,
141                        results,
142                    });
143                    imported_function_type_indices.push(type_index);
144                }
145            }
146            Payload::FunctionSection(reader) => {
147                for type_index in reader {
148                    function_type_indices
149                        .push(type_index.map_err(|err| {
150                            invalid_module(format!("parse wasm function: {err}"))
151                        })?);
152                }
153            }
154            Payload::TableSection(reader) => {
155                for table in reader {
156                    let table =
157                        table.map_err(|err| invalid_module(format!("parse wasm table: {err}")))?;
158                    table_count += 1;
159                    if table_count > 1 {
160                        return Err(ValidationError::new(
161                            ValidationErrorCategory::InvalidTable,
162                            "worker must define at most one table",
163                        ));
164                    }
165                    if table.ty.table64
166                        || table.ty.shared
167                        || !matches!(table.ty.element_type, RefType::FUNCREF | RefType::EXTERNREF)
168                        || table.ty.initial > MAX_TABLE_ELEMENTS
169                        || table
170                            .ty
171                            .maximum
172                            .is_some_and(|maximum| maximum > MAX_TABLE_ELEMENTS)
173                    {
174                        return Err(ValidationError::new(
175                            ValidationErrorCategory::InvalidTable,
176                            "worker table contract is unsupported",
177                        ));
178                    }
179                }
180            }
181            Payload::MemorySection(reader) => {
182                for memory in reader {
183                    let memory = memory
184                        .map_err(|err| invalid_module(format!("parse wasm memory: {err}")))?;
185                    memories.push(memory);
186                }
187            }
188            Payload::ExportSection(reader) => {
189                for exported in reader {
190                    let exported = exported
191                        .map_err(|err| invalid_module(format!("parse wasm export: {err}")))?;
192                    if exports
193                        .insert(exported.name.to_string(), (exported.kind, exported.index))
194                        .is_some()
195                    {
196                        return Err(invalid_module(format!(
197                            "worker export {:?} is duplicated",
198                            exported.name
199                        )));
200                    }
201                }
202            }
203            _ => {}
204        }
205    }
206
207    if memories.len() != 1 {
208        return Err(ValidationError::new(
209            ValidationErrorCategory::InvalidMemory,
210            format!(
211                "worker must define exactly one linear memory, found {}",
212                memories.len()
213            ),
214        ));
215    }
216    let memory = memories[0];
217    if memory.memory64 || memory.shared || memory.page_size_log2.is_some() {
218        return Err(ValidationError::new(
219            ValidationErrorCategory::InvalidMemory,
220            "worker memory must be an unshared 32-bit memory with standard pages",
221        ));
222    }
223    match exports.get(EXPORT_MEMORY) {
224        Some((ExternalKind::Memory, 0)) => {}
225        _ => {
226            return Err(ValidationError::new(
227                ValidationErrorCategory::MissingExport,
228                "worker must export its only linear memory as \"memory\"",
229            ));
230        }
231    }
232
233    let alloc_export = require_function_export(
234        EXPORT_WORKER_ALLOC,
235        &[ValueType::I32],
236        &[ValueType::I32],
237        &exports,
238        &types,
239        &imported_function_type_indices,
240        &function_type_indices,
241    )?;
242    let dealloc_export = require_function_export(
243        EXPORT_WORKER_DEALLOC,
244        &[ValueType::I32, ValueType::I32],
245        &[],
246        &exports,
247        &types,
248        &imported_function_type_indices,
249        &function_type_indices,
250    )?;
251    let invoke_export = require_function_export(
252        EXPORT_WORKER_INVOKE,
253        &[ValueType::I32, ValueType::I32],
254        &[ValueType::I64],
255        &exports,
256        &types,
257        &imported_function_type_indices,
258        &function_type_indices,
259    )?;
260
261    Ok(ValidatedWorkerModule {
262        byte_len: bytes.len(),
263        memory: MemoryContract {
264            initial_pages: memory.initial,
265            maximum_pages: memory.maximum,
266        },
267        alloc_export,
268        dealloc_export,
269        invoke_export,
270        imports,
271    })
272}
273
274fn require_hostcall(
275    module: &str,
276    name: &str,
277    function_type: &FuncType,
278) -> Result<(), ValidationError> {
279    let allowed = matches!(
280        (module, name),
281        (IMPORT_STORAGE, "files" | "kv" | "sqlite") | (IMPORT_NETWORK, "execute")
282    );
283    if !allowed {
284        return Err(ValidationError::new(
285            ValidationErrorCategory::UnsupportedImport,
286            format!("worker import {module}/{name} is unsupported"),
287        ));
288    }
289    let (params, results) = function_contract_types(function_type)?;
290    if params != [ValueType::I32; 4] || results != [ValueType::I32] {
291        return Err(ValidationError::new(
292            ValidationErrorCategory::InvalidSignature,
293            format!("worker import {module}/{name} has an invalid function signature"),
294        ));
295    }
296    Ok(())
297}
298
299fn require_function_export(
300    name: &str,
301    expected_params: &[ValueType],
302    expected_results: &[ValueType],
303    exports: &HashMap<String, (ExternalKind, u32)>,
304    types: &[FuncType],
305    imported_function_type_indices: &[u32],
306    function_type_indices: &[u32],
307) -> Result<FunctionContract, ValidationError> {
308    let Some((ExternalKind::Func, function_index)) = exports.get(name).copied() else {
309        return Err(ValidationError::new(
310            ValidationErrorCategory::MissingExport,
311            format!("required function export {name:?} is missing"),
312        ));
313    };
314    let type_index =
315        if let Some(type_index) = imported_function_type_indices.get(function_index as usize) {
316            *type_index
317        } else {
318            let defined_index = function_index as usize - imported_function_type_indices.len();
319            *function_type_indices.get(defined_index).ok_or_else(|| {
320                invalid_module(format!(
321                    "function export {name:?} references missing function {function_index}"
322                ))
323            })?
324        };
325    let function_type = types.get(type_index as usize).ok_or_else(|| {
326        invalid_module(format!(
327            "function export {name:?} references missing type {type_index}"
328        ))
329    })?;
330    let (params, results) = function_contract_types(function_type)?;
331    if params != expected_params || results != expected_results {
332        return Err(ValidationError::new(
333            ValidationErrorCategory::InvalidSignature,
334            format!("worker export {name:?} has an invalid function signature"),
335        ));
336    }
337    Ok(FunctionContract {
338        export_name: name.to_string(),
339        params,
340        results,
341    })
342}
343
344fn function_contract_types(
345    function_type: &FuncType,
346) -> Result<(Vec<ValueType>, Vec<ValueType>), ValidationError> {
347    let params = function_type
348        .params()
349        .iter()
350        .map(value_type)
351        .collect::<Result<Vec<_>, _>>()?;
352    let results = function_type
353        .results()
354        .iter()
355        .map(value_type)
356        .collect::<Result<Vec<_>, _>>()?;
357    Ok((params, results))
358}
359
360fn value_type(value: &ValType) -> Result<ValueType, ValidationError> {
361    match value {
362        ValType::I32 => Ok(ValueType::I32),
363        ValType::I64 => Ok(ValueType::I64),
364        _ => Err(ValidationError::new(
365            ValidationErrorCategory::InvalidSignature,
366            format!("worker ABI function uses unsupported value type {value}"),
367        )),
368    }
369}
370
371fn invalid_module(message: impl Into<String>) -> ValidationError {
372    ValidationError::new(ValidationErrorCategory::InvalidModule, message)
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn validates_complete_worker_contract() {
381        let module = wat::parse_str(valid_worker_wat()).expect("compile valid worker");
382        let validated = validate_worker_module(&module).expect("validate worker module");
383        assert_eq!(validated.byte_len, module.len());
384        assert_eq!(validated.memory.initial_pages, 1);
385        assert_eq!(validated.alloc_export.export_name, EXPORT_WORKER_ALLOC);
386        assert_eq!(validated.dealloc_export.export_name, EXPORT_WORKER_DEALLOC);
387        assert_eq!(validated.invoke_export.export_name, EXPORT_WORKER_INVOKE);
388        assert_eq!(validated.imports.len(), 2);
389    }
390
391    #[test]
392    fn rejects_missing_memory_export() {
393        let module = wat::parse_str(valid_worker_wat().replace("(export \"memory\")", ""))
394            .expect("compile worker");
395        let error = validate_worker_module(&module).expect_err("missing memory export");
396        assert_eq!(error.category(), ValidationErrorCategory::MissingExport);
397    }
398
399    #[test]
400    fn rejects_invalid_required_export_signatures() {
401        for (name, replacement) in [
402            (
403                "alloc",
404                "(func (export \"redevplugin_worker_alloc\") (param i64) (result i32) i32.const 0)",
405            ),
406            (
407                "dealloc",
408                "(func (export \"redevplugin_worker_dealloc\") (param i32))",
409            ),
410            (
411                "invoke",
412                "(func (export \"redevplugin_worker_invoke\") (param i32 i32) (result i32) i32.const 0)",
413            ),
414        ] {
415            let source = valid_worker_wat();
416            let invalid = match name {
417                "alloc" => source.replace(
418                    "(func (export \"redevplugin_worker_alloc\") (param i32) (result i32) i32.const 0)",
419                    replacement,
420                ),
421                "dealloc" => source.replace(
422                    "(func (export \"redevplugin_worker_dealloc\") (param i32 i32))",
423                    replacement,
424                ),
425                _ => source.replace(
426                    "(func (export \"redevplugin_worker_invoke\") (param i32 i32) (result i64) i64.const 0)",
427                    replacement,
428                ),
429            };
430            let module = wat::parse_str(invalid).expect("compile invalid-signature worker");
431            let error = validate_worker_module(&module).expect_err(name);
432            assert_eq!(error.category(), ValidationErrorCategory::InvalidSignature);
433        }
434    }
435
436    #[test]
437    fn rejects_unsupported_or_mistyped_imports() {
438        for import in [
439            "(import \"wasi_snapshot_preview1\" \"fd_write\" (func (param i32 i32 i32 i32) (result i32)))",
440            "(import \"redevplugin.storage\" \"files\" (func (param i32) (result i32)))",
441            "(import \"redevplugin.storage\" \"memory\" (memory 1))",
442        ] {
443            let source = valid_worker_wat().replace(
444                "(import \"redevplugin.storage\" \"files\" (func (param i32 i32 i32 i32) (result i32)))",
445                import,
446            );
447            let module = wat::parse_str(source).expect("compile worker with invalid import");
448            assert!(
449                validate_worker_module(&module).is_err(),
450                "accepted {import}"
451            );
452        }
453    }
454
455    #[test]
456    fn rejects_invalid_memory_and_table_contracts() {
457        for replacement in [
458            "(memory (export \"memory\") i64 1)",
459            "(memory (export \"memory\") 1 2 shared)",
460            "(memory (export \"memory\") 1) (memory 1)",
461            "(memory (export \"memory\") 1) (table 65537 funcref)",
462            "(memory (export \"memory\") 1) (table 1 funcref) (table 1 funcref)",
463        ] {
464            let source = valid_worker_wat().replace("(memory (export \"memory\") 1)", replacement);
465            let module = wat::parse_str(source).expect("compile worker with invalid resources");
466            assert!(
467                validate_worker_module(&module).is_err(),
468                "accepted {replacement}"
469            );
470        }
471    }
472
473    #[test]
474    fn rejects_shared_invalid_opcode_fixture() {
475        let module = decode_hex(include_str!("../testdata/wasm/invalid-final-opcode.hex"));
476        let error = validate_worker_module(&module).expect_err("invalid opcode");
477        assert_eq!(error.category(), ValidationErrorCategory::InvalidModule);
478    }
479
480    #[test]
481    fn rejects_shared_table_maximum_fixture() {
482        let module = decode_hex(include_str!(
483            "../testdata/wasm/table-maximum-exceeds-limit.hex"
484        ));
485        let error = validate_worker_module(&module).expect_err("table maximum above limit");
486        assert_eq!(error.category(), ValidationErrorCategory::InvalidTable);
487    }
488
489    fn valid_worker_wat() -> &'static str {
490        r#"(module
491            (import "redevplugin.storage" "files" (func (param i32 i32 i32 i32) (result i32)))
492            (import "redevplugin.network" "execute" (func (param i32 i32 i32 i32) (result i32)))
493            (memory (export "memory") 1)
494            (func (export "redevplugin_worker_alloc") (param i32) (result i32) i32.const 0)
495            (func (export "redevplugin_worker_dealloc") (param i32 i32))
496            (func (export "redevplugin_worker_invoke") (param i32 i32) (result i64) i64.const 0)
497        )"#
498    }
499
500    fn decode_hex(input: &str) -> Vec<u8> {
501        let input = input.trim().as_bytes();
502        assert_eq!(input.len() % 2, 0);
503        input
504            .chunks_exact(2)
505            .map(|pair| {
506                let text = std::str::from_utf8(pair).expect("hex fixture is UTF-8");
507                u8::from_str_radix(text, 16).expect("hex fixture byte")
508            })
509            .collect()
510    }
511}