diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f42ed6..1a05730 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Install system dependencies - run: sudo apt-get install -y clang lld + run: sudo apt-get install -y clang lld wabt - name: Install kani-verifier run: cargo install --locked kani-verifier && cargo kani setup @@ -40,3 +40,6 @@ jobs: - name: Example (C → Wasm → Rust) run: ./examples/c-to-wasm-to-rust/run.sh + + - name: Example (Inter-Module Lending) + run: ./examples/inter-module-lending/run.sh diff --git a/Cargo.toml b/Cargo.toml index 577e3bd..f44aa1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,8 @@ [workspace] resolver = "2" -members = [ - "crates/herkos-runtime", - "crates/herkos", - "crates/herkos-tests", -] -exclude = [ - "examples/c-to-wasm-to-rust", -] +members = ["crates/herkos-runtime", "crates/herkos", "crates/herkos-tests"] + +exclude = ["examples/c-to-wasm-to-rust", "examples/inter-module-lending"] [workspace.dependencies] anyhow = "1" diff --git a/crates/herkos-tests/tests/inter_module_lending.rs b/crates/herkos-tests/tests/inter_module_lending.rs new file mode 100644 index 0000000..7b41a83 --- /dev/null +++ b/crates/herkos-tests/tests/inter_module_lending.rs @@ -0,0 +1,221 @@ +//! End-to-end tests for inter-module lending. +//! +//! These tests verify the complete inter-module interaction pattern: +//! 1. A host owns memory (simulating a Module's IsolatedMemory) +//! 2. A LibraryModule borrows that memory for read/write operations +//! 3. Import traits enable communication between modules and host +//! 4. Multiple library modules can operate on the same borrowed memory +//! 5. A single host can service import traits from different module types + +use herkos_runtime::{IsolatedMemory, WasmResult}; +use herkos_tests::{import_basic, import_memory, import_multi}; + +/// Host that owns memory and coordinates between multiple library modules. +struct InterModuleHost { + /// Values logged by import_memory's print_i32 + logged_values: Vec, + /// Value returned by import_basic's read_i32 + read_value: i32, + /// Tracks add calls for import_multi + add_call_count: usize, +} + +impl InterModuleHost { + fn new() -> Self { + InterModuleHost { + logged_values: Vec::new(), + read_value: 42, + add_call_count: 0, + } + } +} + +// -- import_memory traits -- + +impl import_memory::EnvImports for InterModuleHost { + fn print_i32(&mut self, value: i32) -> WasmResult<()> { + self.logged_values.push(value); + Ok(()) + } +} + +// -- import_basic traits -- + +impl import_basic::EnvImports for InterModuleHost { + fn print_i32(&mut self, value: i32) -> WasmResult<()> { + self.logged_values.push(value); + Ok(()) + } + + fn read_i32(&mut self) -> WasmResult { + Ok(self.read_value) + } +} + +impl import_basic::WasiSnapshotPreview1Imports for InterModuleHost { + fn fd_write(&mut self, _: i32, _: i32, _: i32, _: i32) -> WasmResult { + Ok(0) + } +} + +// -- import_multi traits -- + +impl import_multi::EnvImports for InterModuleHost { + fn add(&mut self, a: i32, b: i32) -> WasmResult { + self.add_call_count += 1; + Ok(a + b) + } + + fn mul(&mut self, a: i32, b: i32) -> WasmResult { + Ok(a * b) + } + + fn log(&mut self, value: i32) -> WasmResult<()> { + self.logged_values.push(value); + Ok(()) + } +} + +impl import_multi::WasiSnapshotPreview1Imports for InterModuleHost { + fn fd_write(&mut self, _: i32, _: i32, _: i32, _: i32) -> WasmResult { + Ok(0) + } +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +#[test] +fn test_host_writes_library_reads() { + let mut memory = Box::new(IsolatedMemory::<4>::try_new(2).unwrap()); + let mut lib = import_memory::new().unwrap(); + + // Host writes values at several offsets + memory.store_i32(0, 100).unwrap(); + memory.store_i32(4, 200).unwrap(); + memory.store_i32(8, 300).unwrap(); + + // Library borrows memory and reads back + assert_eq!(lib.read_at(0, &mut memory).unwrap(), 100); + assert_eq!(lib.read_at(4, &mut memory).unwrap(), 200); + assert_eq!(lib.read_at(8, &mut memory).unwrap(), 300); +} + +#[test] +fn test_library_writes_host_reads() { + let mut memory = Box::new(IsolatedMemory::<4>::try_new(2).unwrap()); + let mut lib = import_memory::new().unwrap(); + + // Library writes via borrowed memory + lib.write_at(0, 999, &mut memory).unwrap(); + lib.write_at(4, 888, &mut memory).unwrap(); + + // Host reads directly from its own memory + assert_eq!(memory.load_i32(0).unwrap(), 999); + assert_eq!(memory.load_i32(4).unwrap(), 888); +} + +#[test] +fn test_roundtrip_through_library() { + let mut memory = Box::new(IsolatedMemory::<4>::try_new(2).unwrap()); + let mut lib = import_memory::new().unwrap(); + + // Host writes initial data + memory.store_i32(0, 50).unwrap(); + + // Library reads the value + let val = lib.read_at(0, &mut memory).unwrap(); + assert_eq!(val, 50); + + // Library writes a transformed value at a different offset + lib.write_at(4, val * 3, &mut memory).unwrap(); + + // Host reads the transformed value + assert_eq!(memory.load_i32(4).unwrap(), 150); +} + +#[test] +fn test_library_with_imports_and_memory() { + let mut host = InterModuleHost::new(); + let mut memory = Box::new(IsolatedMemory::<4>::try_new(2).unwrap()); + let mut lib = import_memory::new().unwrap(); + + // Host writes a value into memory + memory.store_i32(0, 77).unwrap(); + + // Library's `process` reads from memory at offset 0 (gets 77), + // calls import print_i32(77), then stores 77 at offset 0. + lib.process(0, &mut memory, &mut host).unwrap(); + + // Verify the import was called with the value from memory + assert_eq!(host.logged_values, vec![77]); + + // Verify the value was stored back + assert_eq!(memory.load_i32(0).unwrap(), 77); +} + +#[test] +fn test_two_libraries_same_memory() { + let mut memory = Box::new(IsolatedMemory::<4>::try_new(2).unwrap()); + let mut lib_a = import_memory::new().unwrap(); + let mut lib_b = import_memory::new().unwrap(); + + // Library A writes to the shared memory + lib_a.write_at(0, 111, &mut memory).unwrap(); + lib_a.write_at(4, 222, &mut memory).unwrap(); + + // Library B reads from the same memory — should see A's writes + assert_eq!(lib_b.read_at(0, &mut memory).unwrap(), 111); + assert_eq!(lib_b.read_at(4, &mut memory).unwrap(), 222); + + // Library B overwrites + lib_b.write_at(0, 333, &mut memory).unwrap(); + + // Library A sees B's change + assert_eq!(lib_a.read_at(0, &mut memory).unwrap(), 333); +} + +#[test] +fn test_multiple_module_types_shared_host() { + let mut host = InterModuleHost::new(); + let mut basic_mod = import_basic::new().unwrap(); + let mut multi_mod = import_multi::new().unwrap(); + + // Use import_basic: test_imports calls print_i32 and read_i32 + host.read_value = 10; + let result = basic_mod.test_imports(5, &mut host).unwrap(); + // test_imports: increments counter, calls print_i32(5), returns read_i32() + 10 = 20 + assert_eq!(result, 20); + assert!(host.logged_values.contains(&5)); + + // Use import_multi: mixed_calls uses env.add import + local mul + let result = multi_mod.mixed_calls(3, 4, &mut host).unwrap(); + // mixed_calls(3, 4): add(3,4)=7, local_mul(7,2)=14 + assert_eq!(result, 14); + assert_eq!(host.add_call_count, 1); + + // Use import_multi: call_local_only uses no imports at all + let result = multi_mod.call_local_only(2, 5).unwrap(); + // local_add(2,5)=7, local_mul(7,3)=21 + assert_eq!(result, 21); +} + +#[test] +fn test_memory_grow_visible_across_modules() { + let mut memory = Box::new(IsolatedMemory::<4>::try_new(1).unwrap()); + let mut lib_a = import_memory::new().unwrap(); + let mut lib_b = import_memory::new().unwrap(); + + // Initial size: 1 page + assert_eq!(lib_a.memory_size(&mut memory).unwrap(), 1); + + // Library A grows memory by 1 page + let prev = lib_a.try_grow(1, &mut memory).unwrap(); + assert_eq!(prev, 1, "previous size should be 1"); + + // Library B sees the new size + assert_eq!(lib_b.memory_size(&mut memory).unwrap(), 2); + + // Library B can write to the grown region (page 1 = offset 65536) + lib_b.write_at(65536, 42, &mut memory).unwrap(); + assert_eq!(lib_a.read_at(65536, &mut memory).unwrap(), 42); +} diff --git a/examples/inter-module-lending/.gitignore b/examples/inter-module-lending/.gitignore new file mode 100644 index 0000000..0633bc5 --- /dev/null +++ b/examples/inter-module-lending/.gitignore @@ -0,0 +1,5 @@ +# Generated artifacts (regenerated by run.sh) +fibonacci.wasm +src/math_library_wasm.rs +target/ +Cargo.lock diff --git a/examples/inter-module-lending/Cargo.toml b/examples/inter-module-lending/Cargo.toml new file mode 100644 index 0000000..569acf5 --- /dev/null +++ b/examples/inter-module-lending/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "inter-module-lending" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +herkos-runtime = { path = "../../crates/herkos-runtime" } diff --git a/examples/inter-module-lending/math_library.wat b/examples/inter-module-lending/math_library.wat new file mode 100644 index 0000000..093f643 --- /dev/null +++ b/examples/inter-module-lending/math_library.wat @@ -0,0 +1,74 @@ +;; Math library module — imports memory from host, provides utility functions. +;; Demonstrates the LibraryModule pattern: this module has no owned memory. +;; +;; Pipeline: math_library.wat → math_library.wasm → src/math_library_wasm.rs +(module + ;; Import memory from the host (makes this a LibraryModule) + (import "env" "memory" (memory 1 16)) + + ;; Import a logging function from the host + (import "env" "log_result" (func $log_result (param i32))) + + ;; sum_array(offset, count): sums count i32 values starting at offset + (func (export "sum_array") (param $offset i32) (param $count i32) (result i32) + (local $sum i32) + (local $i i32) + (local.set $sum (i32.const 0)) + (local.set $i (i32.const 0)) + (block $done + (loop $loop + (br_if $done (i32.ge_u (local.get $i) (local.get $count))) + (local.set $sum + (i32.add + (local.get $sum) + (i32.load + (i32.add + (local.get $offset) + (i32.mul (local.get $i) (i32.const 4)))))) + (local.set $i (i32.add (local.get $i) (i32.const 1))) + (br $loop))) + (local.get $sum)) + + ;; double_array(offset, count): doubles each i32 value in place + (func (export "double_array") (param $offset i32) (param $count i32) + (local $i i32) + (local $addr i32) + (local.set $i (i32.const 0)) + (block $done + (loop $loop + (br_if $done (i32.ge_u (local.get $i) (local.get $count))) + (local.set $addr + (i32.add + (local.get $offset) + (i32.mul (local.get $i) (i32.const 4)))) + (i32.store + (local.get $addr) + (i32.mul (i32.load (local.get $addr)) (i32.const 2))) + (local.set $i (i32.add (local.get $i) (i32.const 1))) + (br $loop)))) + + ;; sum_and_log(offset, count): sums values and calls log_result with the sum + (func (export "sum_and_log") (param $offset i32) (param $count i32) (result i32) + (local $sum i32) + (local $i i32) + (local.set $sum (i32.const 0)) + (local.set $i (i32.const 0)) + (block $done + (loop $loop + (br_if $done (i32.ge_u (local.get $i) (local.get $count))) + (local.set $sum + (i32.add + (local.get $sum) + (i32.load + (i32.add + (local.get $offset) + (i32.mul (local.get $i) (i32.const 4)))))) + (local.set $i (i32.add (local.get $i) (i32.const 1))) + (br $loop))) + (call $log_result (local.get $sum)) + (local.get $sum)) + + ;; memory_info: returns current memory size in pages + (func (export "memory_info") (result i32) + memory.size) +) diff --git a/examples/inter-module-lending/run.sh b/examples/inter-module-lending/run.sh new file mode 100755 index 0000000..11b2f3e --- /dev/null +++ b/examples/inter-module-lending/run.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# +# WAT → WebAssembly → Rust inter-module lending example +# +# Prerequisites: +# - wabt (for wat2wasm): apt-get install wabt +# - Rust toolchain (cargo) +# - herkos CLI (cargo install --path ../../crates/herkos) +# +# Usage: +# ./run.sh # build and run +# ./run.sh --clean # remove generated artifacts + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +WAT_FILE="math_library.wat" +WASM_FILE="math_library.wasm" +GENERATED_RS="src/math_library_wasm.rs" + +if [[ "${1:-}" == "--clean" ]]; then + rm -f "$WASM_FILE" "$GENERATED_RS" + cargo clean 2>/dev/null || true + echo "Cleaned generated artifacts." + exit 0 +fi + +# Step 1: Assemble WAT to Wasm +echo "==> Assembling $WAT_FILE to WebAssembly..." +if command -v wat2wasm &>/dev/null; then + wat2wasm "$WAT_FILE" -o "$WASM_FILE" +else + echo "Error: wat2wasm not found. Install with: apt-get install wabt" >&2 + echo "Falling back to pre-compiled $WASM_FILE if present." >&2 + if [[ ! -f "$WASM_FILE" ]]; then + exit 1 + fi +fi +echo " Created $WASM_FILE ($(wc -c < "$WASM_FILE") bytes)" + +# Step 2: Transpile WebAssembly to Rust using herkos +echo "==> Transpiling WebAssembly to Rust..." +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +if command -v herkos &>/dev/null; then + herkos "$WASM_FILE" --output "$GENERATED_RS" +else + cargo run --manifest-path "$REPO_ROOT/Cargo.toml" -p herkos -- \ + "$SCRIPT_DIR/$WASM_FILE" --output "$SCRIPT_DIR/$GENERATED_RS" +fi +echo " Created $GENERATED_RS" + +# Step 3: Build and run +echo "==> Building and running Rust project..." +echo "" +cargo run --release diff --git a/examples/inter-module-lending/src/main.rs b/examples/inter-module-lending/src/main.rs new file mode 100644 index 0000000..3779719 --- /dev/null +++ b/examples/inter-module-lending/src/main.rs @@ -0,0 +1,91 @@ +// Inter-module lending example +// +// This program demonstrates the herkos memory-lending pattern: +// - A host program owns memory (IsolatedMemory) +// - A transpiled WebAssembly "library module" borrows that memory +// - The host writes data, the library processes it, the host reads results +// +// The generated module (src/math_library_wasm.rs) contains no unsafe code. +// Memory isolation is enforced through the type system at compile time. +// +// Run `./run.sh` to regenerate math_library_wasm.rs and execute this program. + +#[allow(dead_code)] +mod math_library_wasm; + +use herkos_runtime::{IsolatedMemory, WasmResult}; + +/// Host that owns memory and provides import functions to the library module. +struct MathHost { + results_log: Vec, +} + +impl MathHost { + fn new() -> Self { + MathHost { + results_log: Vec::new(), + } + } +} + +impl math_library_wasm::EnvImports for MathHost { + fn log_result(&mut self, value: i32) -> WasmResult<()> { + self.results_log.push(value); + println!(" [library logged: {}]", value); + Ok(()) + } +} + +fn main() { + println!("=== Inter-Module Lending Example ===\n"); + + // Step 1: Host creates owned memory (simulates a Module's IsolatedMemory) + let mut memory = Box::new(IsolatedMemory::<4>::try_new(2).unwrap()); + let mut host = MathHost::new(); + let mut library = math_library_wasm::new().expect("library instantiation failed"); + + // Step 2: Host writes an array of integers into memory + let data = [10, 20, 30, 40, 50]; + println!("Host writes data to memory: {:?}", data); + for (i, &val) in data.iter().enumerate() { + memory.store_i32(i * 4, val).unwrap(); + } + + // Step 3: Library borrows memory to compute the sum + let sum = library + .sum_array(0, data.len() as i32, &mut *memory) + .expect("sum_array trapped"); + println!("Library computed sum: {}", sum); + + // Step 4: Library doubles all values in-place + println!("\nLibrary doubles array in-place..."); + library + .double_array(0, data.len() as i32, &mut *memory) + .expect("double_array trapped"); + + // Step 5: Host reads back the modified values + print!("Host reads back: ["); + for i in 0..data.len() { + let val: i32 = memory.load_i32(i * 4).unwrap(); + if i > 0 { + print!(", "); + } + print!("{}", val); + } + println!("]"); + + // Step 6: Library sums and logs (uses both memory and host import) + println!("\nLibrary sums doubled values and logs result:"); + let doubled_sum = library + .sum_and_log(0, data.len() as i32, &mut *memory, &mut host) + .expect("sum_and_log trapped"); + println!("Doubled sum: {}", doubled_sum); + + // Step 7: Show memory info + let pages = library + .memory_info(&mut *memory) + .expect("memory_info trapped"); + println!("\nMemory size: {} pages ({} bytes)", pages, pages * 65536); + + println!("\n=== Done ==="); +}