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}