1use crate::config::{WasmOnError, WasmTransformConfig};
5use crate::engine::WasmEngine;
6use crate::instance::Outcome;
7use crate::metrics;
8use faucet_core::FaucetError;
9use faucet_core::stage::TransformStage;
10use serde_json::Value;
11use std::sync::{Arc, Mutex};
12use std::time::Instant;
13
14pub struct WasmTransform {
17 engine: Arc<Mutex<WasmEngine>>,
18 on_error: WasmOnError,
19 module_label: String,
20}
21
22impl std::fmt::Debug for WasmTransform {
23 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24 f.debug_struct("WasmTransform")
25 .field("module", &self.module_label)
26 .field("on_error", &self.on_error)
27 .finish_non_exhaustive()
28 }
29}
30
31impl WasmTransform {
32 pub fn compile(cfg: &WasmTransformConfig) -> Result<Self, FaucetError> {
35 let engine = WasmEngine::compile(cfg)?;
36 Ok(Self {
37 module_label: engine.module_label.clone(),
38 engine: Arc::new(Mutex::new(engine)),
39 on_error: cfg.on_error,
40 })
41 }
42
43 pub fn into_page_stage(self) -> TransformStage {
45 let engine = self.engine;
46 let on_error = self.on_error;
47 let module_label = self.module_label;
48 TransformStage::PageFn(Arc::new(move |records: Vec<Value>| {
49 execute_page(&engine, on_error, &module_label, records)
50 }))
51 }
52
53 #[cfg(test)]
54 fn run_page(&self, records: Vec<Value>) -> Result<Vec<Value>, FaucetError> {
55 execute_page(&self.engine, self.on_error, &self.module_label, records)
56 }
57}
58
59fn execute_page(
60 engine: &Arc<Mutex<WasmEngine>>,
61 on_error: WasmOnError,
62 module_label: &str,
63 records: Vec<Value>,
64) -> Result<Vec<Value>, FaucetError> {
65 if records.is_empty() {
66 return Ok(Vec::new());
67 }
68 let mut eng = engine.lock().unwrap_or_else(|e| e.into_inner());
69 eng.reload_if_changed();
70 let mut inst = eng.new_page_instance()?;
71
72 let mut out = Vec::with_capacity(records.len());
73 for rec in records {
74 let input = serde_json::to_vec(&rec).map_err(|e| {
75 FaucetError::Transform(format!("wasm transform: serialize record: {e}"))
76 })?;
77 let start = Instant::now();
78 let result = inst.run(&input)?;
79 metrics::invocation_duration(module_label, start.elapsed().as_secs_f64());
80 metrics::fuel_consumed(module_label, result.fuel_consumed);
81
82 let err_msg: String = match result.outcome {
83 Outcome::Emit(bytes) => match serde_json::from_slice::<Value>(&bytes) {
84 Ok(v) => {
85 metrics::invocation(module_label, "ok");
86 out.push(v);
87 continue;
88 }
89 Err(e) => format!("wasm transform: module returned invalid JSON: {e}"),
90 },
91 Outcome::Drop => {
92 metrics::invocation(module_label, "filter");
93 continue;
94 }
95 Outcome::Error(msg) => msg,
96 };
97
98 metrics::invocation(module_label, "error");
100 match on_error {
101 WasmOnError::Fail => return Err(FaucetError::Transform(err_msg)),
102 WasmOnError::Skip => tracing::warn!(
103 target: "faucet::transform::wasm",
104 module = %module_label,
105 error = %err_msg,
106 "dropping record after wasm error (on_error: skip)"
107 ),
108 WasmOnError::Passthrough => {
109 tracing::warn!(
110 target: "faucet::transform::wasm",
111 module = %module_label,
112 error = %err_msg,
113 "passing record through unchanged after wasm error (on_error: passthrough)"
114 );
115 out.push(rec);
116 }
117 }
118 }
119 metrics::memory_bytes(module_label, inst.peak_memory());
120 Ok(out)
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use serde_json::json;
127 use std::io::Write;
128 use tempfile::NamedTempFile;
129
130 const PREAMBLE: &str = r#"
134 (memory (export "memory") 1)
135 (global $bump (mut i32) (i32.const 1024))
136 (func (export "alloc") (param $len i32) (result i32)
137 (local $p i32)
138 (local.set $p (global.get $bump))
139 (global.set $bump (i32.add (global.get $bump) (local.get $len)))
140 (local.get $p))
141 "#;
142
143 fn identity_wat() -> String {
145 format!(
146 r#"(module {PREAMBLE}
147 (func (export "transform") (param $ptr i32) (param $len i32) (result i64)
148 (local $out i32)
149 (local.set $out (global.get $bump))
150 (global.set $bump (i32.add (global.get $bump) (local.get $len)))
151 (memory.copy (local.get $out) (local.get $ptr) (local.get $len))
152 (i64.or
153 (i64.shl (i64.extend_i32_u (local.get $out)) (i64.const 32))
154 (i64.extend_i32_u (local.get $len)))))"#
155 )
156 }
157
158 fn drop_wat() -> String {
160 format!(
161 r#"(module {PREAMBLE}
162 (func (export "transform") (param i32) (param i32) (result i64)
163 (i64.const 0)))"#
164 )
165 }
166
167 fn error_wat() -> String {
169 format!(
170 r#"(module {PREAMBLE}
171 (data (i32.const 100) "boom in module")
172 (func (export "transform") (param i32) (param i32) (result i64)
173 (i64.const -1))
174 (func (export "error_ptr") (result i32) (i32.const 100))
175 (func (export "error_len") (result i32) (i32.const 14)))"#
176 )
177 }
178
179 fn error_no_msg_wat() -> String {
181 format!(
182 r#"(module {PREAMBLE}
183 (func (export "transform") (param i32) (param i32) (result i64)
184 (i64.const -1)))"#
185 )
186 }
187
188 fn fuel_bomb_wat() -> String {
190 format!(
191 r#"(module {PREAMBLE}
192 (func (export "transform") (param i32) (param i32) (result i64)
193 (loop $l (br $l))
194 (i64.const 0)))"#
195 )
196 }
197
198 fn mem_bomb_wat() -> String {
200 format!(
201 r#"(module {PREAMBLE}
202 (func (export "transform") (param i32) (param i32) (result i64)
203 (drop (memory.grow (i32.const 100000)))
204 (i64.const 0)))"#
205 )
206 }
207
208 fn host_wat() -> String {
210 format!(
211 r#"(module
212 (import "faucet_v1" "log" (func $log (param i32 i32 i32)))
213 (import "faucet_v1" "now_ns" (func $now (result i64)))
214 {PREAMBLE}
215 (func (export "transform") (param $ptr i32) (param $len i32) (result i64)
216 (local $out i32)
217 (drop (call $now))
218 (call $log (i32.const 3) (local.get $ptr) (local.get $len))
219 (local.set $out (global.get $bump))
220 (global.set $bump (i32.add (global.get $bump) (local.get $len)))
221 (memory.copy (local.get $out) (local.get $ptr) (local.get $len))
222 (i64.or
223 (i64.shl (i64.extend_i32_u (local.get $out)) (i64.const 32))
224 (i64.extend_i32_u (local.get $len)))))"#
225 )
226 }
227
228 fn bad_ptr_wat() -> String {
230 format!(
231 r#"(module {PREAMBLE}
232 (func (export "transform") (param i32) (param i32) (result i64)
233 (i64.or (i64.shl (i64.const 1000000) (i64.const 32)) (i64.const 10))))"#
234 )
235 }
236
237 fn invalid_json_wat() -> String {
239 format!(
240 r#"(module {PREAMBLE}
241 (data (i32.const 200) "notjson")
242 (func (export "transform") (param i32) (param i32) (result i64)
243 (i64.or (i64.shl (i64.const 200) (i64.const 32)) (i64.const 7))))"#
244 )
245 }
246
247 fn identity_with_free_wat() -> String {
249 format!(
250 r#"(module {PREAMBLE}
251 (func (export "free") (param i32) (param i32))
252 (func (export "transform") (param $ptr i32) (param $len i32) (result i64)
253 (local $out i32)
254 (local.set $out (global.get $bump))
255 (global.set $bump (i32.add (global.get $bump) (local.get $len)))
256 (memory.copy (local.get $out) (local.get $ptr) (local.get $len))
257 (i64.or
258 (i64.shl (i64.extend_i32_u (local.get $out)) (i64.const 32))
259 (i64.extend_i32_u (local.get $len)))))"#
260 )
261 }
262
263 fn log_levels_wat() -> String {
265 format!(
266 r#"(module
267 (import "faucet_v1" "log" (func $log (param i32 i32 i32)))
268 {PREAMBLE}
269 (func (export "transform") (param $ptr i32) (param $len i32) (result i64)
270 (local $out i32)
271 (call $log (i32.const 0) (local.get $ptr) (local.get $len))
272 (call $log (i32.const 1) (local.get $ptr) (local.get $len))
273 (call $log (i32.const 2) (local.get $ptr) (local.get $len))
274 (call $log (i32.const 4) (i32.const 5000000) (i32.const 4))
275 (local.set $out (global.get $bump))
276 (global.set $bump (i32.add (global.get $bump) (local.get $len)))
277 (memory.copy (local.get $out) (local.get $ptr) (local.get $len))
278 (i64.or
279 (i64.shl (i64.extend_i32_u (local.get $out)) (i64.const 32))
280 (i64.extend_i32_u (local.get $len)))))"#
281 )
282 }
283
284 fn alloc_trap_wat() -> &'static str {
286 r#"(module
287 (memory (export "memory") 1)
288 (func (export "alloc") (param i32) (result i32) unreachable)
289 (func (export "transform") (param i32) (param i32) (result i64) (i64.const 0)))"#
290 }
291
292 fn bad_alloc_ptr_wat() -> &'static str {
294 r#"(module
295 (memory (export "memory") 1)
296 (func (export "alloc") (param i32) (result i32) (i32.const 5000000))
297 (func (export "transform") (param i32) (param i32) (result i64) (i64.const 0)))"#
298 }
299
300 fn error_oob_wat() -> String {
302 format!(
303 r#"(module {PREAMBLE}
304 (func (export "transform") (param i32) (param i32) (result i64) (i64.const -1))
305 (func (export "error_ptr") (result i32) (i32.const 5000000))
306 (func (export "error_len") (result i32) (i32.const 10)))"#
307 )
308 }
309
310 fn write_wasm(wat: &str) -> NamedTempFile {
313 let wasm = wat::parse_str(wat).expect("valid wat");
314 let mut f = NamedTempFile::new().unwrap();
315 f.write_all(&wasm).unwrap();
316 f.flush().unwrap();
317 f
318 }
319
320 fn cfg(path: &std::path::Path) -> WasmTransformConfig {
321 WasmTransformConfig {
322 module: path.to_string_lossy().into_owned(),
323 function: "transform".into(),
324 memory_limit_mb: 16,
325 fuel_limit: 1_000_000,
326 on_error: WasmOnError::Fail,
327 reload_on_change: false,
328 }
329 }
330
331 fn build(
334 wat: &str,
335 tweak: impl FnOnce(&mut WasmTransformConfig),
336 ) -> (WasmTransform, NamedTempFile) {
337 let f = write_wasm(wat);
338 let mut c = cfg(f.path());
339 tweak(&mut c);
340 let t = WasmTransform::compile(&c).expect("compile");
341 (t, f)
342 }
343
344 #[test]
347 fn identity_round_trips_records() {
348 let (t, _f) = build(&identity_wat(), |_| {});
349 let out = t
350 .run_page(vec![json!({"id": 1, "name": "a"}), json!({"id": 2})])
351 .unwrap();
352 assert_eq!(out, vec![json!({"id": 1, "name": "a"}), json!({"id": 2})]);
353 }
354
355 #[test]
356 fn identity_with_free_round_trips() {
357 let (t, _f) = build(&identity_with_free_wat(), |_| {});
358 let out = t.run_page(vec![json!({"x": 42})]).unwrap();
359 assert_eq!(out, vec![json!({"x": 42})]);
360 }
361
362 #[test]
363 fn empty_page_is_identity() {
364 let (t, _f) = build(&identity_wat(), |_| {});
365 assert_eq!(t.run_page(vec![]).unwrap(), Vec::<Value>::new());
366 }
367
368 #[test]
369 fn drop_filters_every_record() {
370 let (t, _f) = build(&drop_wat(), |_| {});
371 let out = t.run_page(vec![json!({"a": 1}), json!({"a": 2})]).unwrap();
372 assert_eq!(out, Vec::<Value>::new());
373 }
374
375 #[test]
376 fn error_with_message_fails_by_default() {
377 let (t, _f) = build(&error_wat(), |_| {});
378 let err = t.run_page(vec![json!({"a": 1})]).unwrap_err();
379 let msg = format!("{err}");
380 assert!(msg.contains("boom in module"), "{msg}");
381 }
382
383 #[test]
384 fn error_without_message_still_reports() {
385 let (t, _f) = build(&error_no_msg_wat(), |_| {});
386 let err = t.run_page(vec![json!({"a": 1})]).unwrap_err();
387 assert!(format!("{err}").contains("no error_ptr"), "{err}");
388 }
389
390 #[test]
391 fn on_error_skip_drops_failing_record() {
392 let (t, _f) = build(&error_wat(), |c| c.on_error = WasmOnError::Skip);
393 let out = t.run_page(vec![json!({"a": 1})]).unwrap();
394 assert_eq!(out, Vec::<Value>::new());
395 }
396
397 #[test]
398 fn on_error_passthrough_keeps_original() {
399 let (t, _f) = build(&error_wat(), |c| c.on_error = WasmOnError::Passthrough);
400 let out = t.run_page(vec![json!({"a": 1})]).unwrap();
401 assert_eq!(out, vec![json!({"a": 1})]);
402 }
403
404 #[test]
405 fn fuel_exhaustion_is_an_error() {
406 let (t, _f) = build(&fuel_bomb_wat(), |c| {
407 c.fuel_limit = 10_000;
408 c.on_error = WasmOnError::Skip;
409 });
410 let out = t.run_page(vec![json!({"a": 1})]).unwrap();
412 assert_eq!(out, Vec::<Value>::new());
413 }
414
415 #[test]
416 fn fuel_exhaustion_message_mentions_fuel() {
417 let (t, _f) = build(&fuel_bomb_wat(), |c| c.fuel_limit = 10_000);
418 let err = t.run_page(vec![json!({"a": 1})]).unwrap_err();
419 assert!(format!("{err}").to_lowercase().contains("fuel"), "{err}");
420 }
421
422 #[test]
423 fn memory_cap_is_an_error() {
424 let (t, _f) = build(&mem_bomb_wat(), |c| {
425 c.memory_limit_mb = 1;
426 c.on_error = WasmOnError::Passthrough;
427 });
428 let out = t.run_page(vec![json!({"a": 1})]).unwrap();
429 assert_eq!(out, vec![json!({"a": 1})]);
431 }
432
433 #[test]
434 fn host_imports_work() {
435 let (t, _f) = build(&host_wat(), |_| {});
436 let out = t.run_page(vec![json!({"hello": "world"})]).unwrap();
437 assert_eq!(out, vec![json!({"hello": "world"})]);
438 }
439
440 #[test]
441 fn out_of_bounds_output_is_an_error() {
442 let (t, _f) = build(&bad_ptr_wat(), |c| c.on_error = WasmOnError::Skip);
443 assert_eq!(
444 t.run_page(vec![json!({"a": 1})]).unwrap(),
445 Vec::<Value>::new()
446 );
447 }
448
449 #[test]
450 fn invalid_json_output_is_an_error() {
451 let (t, _f) = build(&invalid_json_wat(), |_| {});
452 let err = t.run_page(vec![json!({"a": 1})]).unwrap_err();
453 assert!(format!("{err}").contains("invalid JSON"), "{err}");
454 }
455
456 #[test]
457 fn missing_module_file_fails_to_compile() {
458 let c = WasmTransformConfig {
459 module: "/no/such/module.wasm".into(),
460 function: "transform".into(),
461 memory_limit_mb: 16,
462 fuel_limit: 1000,
463 on_error: WasmOnError::Fail,
464 reload_on_change: false,
465 };
466 let err = WasmTransform::compile(&c).unwrap_err();
467 assert!(format!("{err}").contains("cannot read module"), "{err}");
468 }
469
470 #[test]
471 fn malformed_module_fails_to_compile() {
472 let mut f = NamedTempFile::new().unwrap();
473 f.write_all(b"not a wasm module").unwrap();
474 f.flush().unwrap();
475 let err = WasmTransform::compile(&cfg(f.path())).unwrap_err();
476 assert!(format!("{err}").contains("compile"), "{err}");
477 }
478
479 #[test]
480 fn missing_alloc_export_fails_to_compile() {
481 let wat = r#"(module
482 (memory (export "memory") 1)
483 (func (export "transform") (param i32) (param i32) (result i64) (i64.const 0)))"#;
484 let f = write_wasm(wat);
485 let err = WasmTransform::compile(&cfg(f.path())).unwrap_err();
486 assert!(format!("{err}").contains("alloc"), "{err}");
487 }
488
489 #[test]
490 fn missing_transform_function_fails_to_compile() {
491 let f = write_wasm(&drop_wat());
492 let mut c = cfg(f.path());
493 c.function = "does_not_exist".into();
494 let err = WasmTransform::compile(&c).unwrap_err();
495 assert!(format!("{err}").contains("does_not_exist"), "{err}");
496 }
497
498 #[test]
499 fn missing_memory_export_fails_to_compile() {
500 let wat = r#"(module
501 (func (export "alloc") (param i32) (result i32) (i32.const 0))
502 (func (export "transform") (param i32) (param i32) (result i64) (i64.const 0)))"#;
503 let f = write_wasm(wat);
504 let err = WasmTransform::compile(&cfg(f.path())).unwrap_err();
505 assert!(format!("{err}").contains("memory"), "{err}");
506 }
507
508 #[test]
509 fn into_page_stage_wires_through_core() {
510 let (t, _f) = build(&identity_wat(), |_| {});
512 let stage = t.into_page_stage();
513 let compiled = faucet_core::compile_stage(&stage).unwrap();
514 let out =
515 faucet_core::stage::apply_stages_to_page(vec![json!({"k": "v"})], &[compiled]).unwrap();
516 assert_eq!(out, vec![json!({"k": "v"})]);
517 }
518
519 #[test]
520 fn debug_impl_names_module() {
521 let (t, _f) = build(&identity_wat(), |_| {});
522 assert!(format!("{t:?}").contains("WasmTransform"));
523 }
524
525 #[test]
526 fn reload_on_change_swaps_module() {
527 let f = write_wasm(&identity_wat());
530 let mut c = cfg(f.path());
531 c.reload_on_change = true;
532 let t = WasmTransform::compile(&c).unwrap();
533 assert_eq!(
534 t.run_page(vec![json!({"a": 1})]).unwrap(),
535 vec![json!({"a": 1})]
536 );
537
538 std::thread::sleep(std::time::Duration::from_millis(20));
540 std::fs::write(f.path(), wat::parse_str(drop_wat()).unwrap()).unwrap();
541
542 assert_eq!(
543 t.run_page(vec![json!({"a": 1})]).unwrap(),
544 Vec::<Value>::new()
545 );
546 }
547
548 #[test]
549 fn log_at_all_levels_including_bad_pointer() {
550 let (t, _f) = build(&log_levels_wat(), |_| {});
551 let out = t.run_page(vec![json!({"msg": "hi"})]).unwrap();
552 assert_eq!(out, vec![json!({"msg": "hi"})]);
553 }
554
555 #[test]
556 fn alloc_trap_is_an_error() {
557 let (t, _f) = build(alloc_trap_wat(), |c| c.on_error = WasmOnError::Skip);
558 assert_eq!(
559 t.run_page(vec![json!({"a": 1})]).unwrap(),
560 Vec::<Value>::new()
561 );
562 }
563
564 #[test]
565 fn bad_alloc_pointer_fails_input_write() {
566 let (t, _f) = build(bad_alloc_ptr_wat(), |c| c.on_error = WasmOnError::Skip);
567 assert_eq!(
568 t.run_page(vec![json!({"a": 1})]).unwrap(),
569 Vec::<Value>::new()
570 );
571 }
572
573 #[test]
574 fn error_pointer_out_of_bounds_still_reports() {
575 let (t, _f) = build(&error_oob_wat(), |_| {});
576 let err = t.run_page(vec![json!({"a": 1})]).unwrap_err();
577 assert!(format!("{err}").contains("out of bounds"), "{err}");
578 }
579
580 #[test]
581 fn reload_keeps_last_good_when_file_deleted() {
582 let f = write_wasm(&identity_wat());
583 let path = f.path().to_path_buf();
584 let mut c = cfg(&path);
585 c.reload_on_change = true;
586 let t = WasmTransform::compile(&c).unwrap();
587
588 std::thread::sleep(std::time::Duration::from_millis(20));
590 std::fs::remove_file(&path).unwrap();
591
592 assert_eq!(
593 t.run_page(vec![json!({"stay": 1})]).unwrap(),
594 vec![json!({"stay": 1})]
595 );
596 }
597
598 #[test]
599 fn reload_keeps_last_good_on_bad_module() {
600 let f = write_wasm(&identity_wat());
601 let mut c = cfg(f.path());
602 c.reload_on_change = true;
603 let t = WasmTransform::compile(&c).unwrap();
604
605 std::thread::sleep(std::time::Duration::from_millis(20));
607 std::fs::write(f.path(), b"garbage not wasm").unwrap();
608
609 assert_eq!(
610 t.run_page(vec![json!({"keep": true})]).unwrap(),
611 vec![json!({"keep": true})]
612 );
613 }
614}