diff --git a/Cargo.lock b/Cargo.lock index 68d2d2b..4359f68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1022,6 +1022,14 @@ dependencies = [ "wat", ] +[[package]] +name = "tinywasm-component-abi" +version = "0.11.0" +dependencies = [ + "tinywasm", + "wat", +] + [[package]] name = "tinywasm-parser" version = "0.11.0" diff --git a/crates/component-abi/Cargo.toml b/crates/component-abi/Cargo.toml new file mode 100644 index 0000000..e4607b9 --- /dev/null +++ b/crates/component-abi/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "tinywasm-component-abi" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "Opt-in canonical ABI memory helpers for TinyWasm hosts" +homepage.workspace = true +repository.workspace = true +license.workspace = true +keywords = ["tinywasm", "component-model", "wasm", "wit"] +categories = ["wasm", "no-std"] + +[dependencies] +tinywasm = { workspace = true, default-features = false } + +[dev-dependencies] +tinywasm = { workspace = true, features = ["parser"] } +wat.workspace = true + +[lints] +workspace = true diff --git a/crates/component-abi/README.md b/crates/component-abi/README.md new file mode 100644 index 0000000..6d14784 --- /dev/null +++ b/crates/component-abi/README.md @@ -0,0 +1,16 @@ +# tinywasm-component-abi + +This optional `no_std + alloc` crate starts with checked, borrowed wasm32 +canonical-ABI access for UTF-8 strings, `list`, and `list`. Hosts +provide the memory handle and a per-transfer byte limit; reads do not copy or +allocate. The crate has no dependency on `tinywasm-wasi`, and neither the core +interpreter nor the WASI Preview 1 calling path depends on it. + +This is **not** a component runtime. It does not parse or instantiate +components, implement WASI Preview 2/3 modules, call `realloc`, manage resource +handles, or bridge async calls. It is a small foundation for custom WIT host +modules; full components and cross-language async can be built incrementally +without making Preview 1 users pay for them. + +Hosts still need store fuel/time limits, memory/resource quotas, and their own +total-work budgets. The byte limit here applies to each transfer only. diff --git a/crates/component-abi/src/lib.rs b/crates/component-abi/src/lib.rs new file mode 100644 index 0000000..c431ae7 --- /dev/null +++ b/crates/component-abi/src/lib.rs @@ -0,0 +1,171 @@ +#![no_std] +#![forbid(unsafe_code)] +#![warn(missing_docs, rust_2018_idioms)] + +//! Opt-in, allocation-free access to a small subset of the component canonical ABI. +//! +//! This crate does not parse, validate, instantiate, or link components. It helps +//! hosts implementing WIT-shaped imports for core Wasm modules read and write +//! canonical-ABI-shaped values in a selected wasm32 linear memory. The memory +//! is supplied explicitly: canonical ABI memories need not be named `memory`. +//! +//! Only UTF-8 strings, `list`, and `list` are covered here. Full +//! component values, resource handles, `realloc`, post-return, and async calls +//! require further work. Per-transfer bounds do not replace store fuel, memory +//! limits, or per-instance resource quotas. + +extern crate alloc; + +use alloc::string::ToString; +use core::fmt; +use tinywasm::types::MemoryArch; +use tinywasm::{Memory, Store}; + +/// The canonical ABI's maximum string/list byte length. +pub const CANONICAL_MAX_BYTES: usize = (1 << 28) - 1; + +/// A host-selected bound on each string or list transfer. +/// +/// The effective bound is also capped by [`CANONICAL_MAX_BYTES`]. A host +/// should use a smaller value appropriate to its device and interface. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Limits { + /// Maximum bytes read or written in one operation. + pub max_transfer_bytes: usize, +} + +/// A failed canonical ABI memory operation. +#[derive(Debug)] +pub enum AbiError { + /// The memory handle is invalid for this store or memory access failed. + Runtime(tinywasm::Error), + /// This initial helper only handles wasm32 memory. + Memory64, + /// A string or list exceeds the host or canonical ABI bound. + TooLarge, + /// Pointer arithmetic overflowed or the region is outside linear memory. + OutOfBounds, + /// A list pointer does not satisfy its element alignment. + Misaligned, + /// The canonical UTF-8 string has invalid encoding. + InvalidUtf8, +} + +impl fmt::Display for AbiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Runtime(error) => write!(f, "{error}"), + Self::Memory64 => f.write_str("canonical ABI helper requires wasm32 memory"), + Self::TooLarge => f.write_str("canonical ABI transfer exceeds the configured limit"), + Self::OutOfBounds => f.write_str("canonical ABI memory range is out of bounds"), + Self::Misaligned => f.write_str("canonical ABI list pointer is misaligned"), + Self::InvalidUtf8 => f.write_str("canonical ABI string is not valid UTF-8"), + } + } +} + +impl From for AbiError { + fn from(error: tinywasm::Error) -> Self { + Self::Runtime(error) + } +} + +impl From for tinywasm::Error { + fn from(error: AbiError) -> Self { + match error { + AbiError::Runtime(error) => error, + error => Self::Other(error.to_string()), + } + } +} + +/// Checked access to one explicitly selected wasm32 linear memory. +/// +/// Reads borrow the store's memory; they neither allocate nor copy. The borrow +/// must end before the guest can execute or grow that memory again. +pub struct CanonicalMemory { + memory: Memory, + limits: Limits, +} + +impl CanonicalMemory { + /// Binds a memory handle and an explicit per-transfer limit. + pub fn new(memory: Memory, store: &Store, limits: Limits) -> Result { + if memory.ty(store)?.arch() != MemoryArch::I32 { + return Err(AbiError::Memory64); + } + Ok(Self { memory, limits }) + } + + /// Borrows a canonical `list` from guest memory. + pub fn bytes<'a>(&self, store: &'a Store, ptr: u32, len: u32) -> Result<&'a [u8], AbiError> { + let memory = self.memory.data(store)?; + self.range(memory.len(), ptr, len as usize, 1).map(|range| &memory[range]) + } + + /// Borrows a canonical UTF-8 string from guest memory. + pub fn utf8<'a>(&self, store: &'a Store, ptr: u32, len: u32) -> Result<&'a str, AbiError> { + core::str::from_utf8(self.bytes(store, ptr, len)?).map_err(|_| AbiError::InvalidUtf8) + } + + /// Borrows a canonical `list` without allocating or copying. + /// + /// Values are decoded from little-endian bytes by the returned iterator. + pub fn u32_list<'a>(&self, store: &'a Store, ptr: u32, len: u32) -> Result, AbiError> { + let bytes = (len as usize).checked_mul(4).ok_or(AbiError::TooLarge)?; + let memory = self.memory.data(store)?; + let range = self.range(memory.len(), ptr, bytes, 4)?; + Ok(U32List { chunks: memory[range].as_chunks::<4>().0.iter() }) + } + + /// Writes into an already allocated canonical `list` or UTF-8 region. + /// + /// Allocation and `realloc` are deliberately left to the caller. + pub fn write_bytes(&self, store: &mut Store, ptr: u32, bytes: &[u8]) -> Result<(), AbiError> { + let memory = self.memory.data_mut(store)?; + let range = self.range(memory.len(), ptr, bytes.len(), 1)?; + memory[range].copy_from_slice(bytes); + Ok(()) + } + + fn range( + &self, + memory_len: usize, + ptr: u32, + bytes: usize, + align: usize, + ) -> Result, AbiError> { + if bytes > self.limits.max_transfer_bytes.min(CANONICAL_MAX_BYTES) { + return Err(AbiError::TooLarge); + } + let start = ptr as usize; + if !start.is_multiple_of(align) { + return Err(AbiError::Misaligned); + } + let end = start.checked_add(bytes).ok_or(AbiError::OutOfBounds)?; + if end > memory_len { + return Err(AbiError::OutOfBounds); + } + Ok(start..end) + } +} + +/// A borrowed iterator over canonical little-endian `list` elements. +pub struct U32List<'a> { + chunks: core::slice::Iter<'a, [u8; 4]>, +} + +impl Iterator for U32List<'_> { + type Item = u32; + + fn next(&mut self) -> Option { + self.chunks.next().map(|bytes| u32::from_le_bytes(*bytes)) + } + + fn size_hint(&self) -> (usize, Option) { + let count = self.chunks.len(); + (count, Some(count)) + } +} + +impl ExactSizeIterator for U32List<'_> {} diff --git a/crates/component-abi/tests/memory.rs b/crates/component-abi/tests/memory.rs new file mode 100644 index 0000000..0e8fd15 --- /dev/null +++ b/crates/component-abi/tests/memory.rs @@ -0,0 +1,103 @@ +use tinywasm::types::{MemoryArch, MemoryType}; +use tinywasm::{FuncContext, HostFunction, Imports, Memory, ModuleInstance, Store}; +use tinywasm_component_abi::{AbiError, CANONICAL_MAX_BYTES, CanonicalMemory, Limits}; + +const LIMITS: Limits = Limits { max_transfer_bytes: 64 }; + +fn memory() -> (Store, CanonicalMemory) { + let mut store = Store::default(); + let handle = Memory::try_new(&mut store, MemoryType::default().with_page_count_initial(1)).unwrap(); + let abi = CanonicalMemory::new(handle, &store, LIMITS).unwrap(); + (store, abi) +} + +#[test] +fn borrows_and_writes_without_allocating() { + let (mut store, abi) = memory(); + abi.write_bytes(&mut store, 7, b"hello").unwrap(); + assert_eq!(abi.bytes(&store, 7, 5).unwrap(), b"hello"); + assert_eq!(abi.utf8(&store, 7, 5).unwrap(), "hello"); + abi.write_bytes(&mut store, 16, &[1, 0, 0, 0, 0xfe, 0xff, 0xff, 0xff]).unwrap(); + assert_eq!(abi.u32_list(&store, 16, 2).unwrap().collect::>(), [1, 0xffff_fffe]); + assert_eq!(abi.u32_list(&store, 16, 0).unwrap().len(), 0); +} + +#[test] +fn rejects_pathological_lengths_pointers_alignment_and_encoding() { + let (mut store, abi) = memory(); + assert!(matches!(abi.bytes(&store, 0, 65), Err(AbiError::TooLarge))); + assert!(matches!(abi.bytes(&store, 0, (CANONICAL_MAX_BYTES + 1) as u32), Err(AbiError::TooLarge))); + assert!(matches!(abi.u32_list(&store, 0, 17), Err(AbiError::TooLarge))); + assert!(matches!(abi.bytes(&store, u32::MAX, u32::MAX), Err(AbiError::TooLarge))); + assert!(matches!(abi.bytes(&store, u32::MAX, 4), Err(AbiError::OutOfBounds))); + assert!(matches!(abi.bytes(&store, 65536, 1), Err(AbiError::OutOfBounds))); + assert!(abi.bytes(&store, 65536, 0).unwrap().is_empty()); + assert!(matches!(abi.u32_list(&store, 1, 1), Err(AbiError::Misaligned))); + assert!(matches!(abi.u32_list(&store, 65536, 1), Err(AbiError::OutOfBounds))); + assert!(matches!(abi.write_bytes(&mut store, 65535, b"xx"), Err(AbiError::OutOfBounds))); + assert!(matches!(abi.write_bytes(&mut store, 0, &[0; 65]), Err(AbiError::TooLarge))); + abi.write_bytes(&mut store, 0, &[0xff]).unwrap(); + assert!(matches!(abi.utf8(&store, 0, 1), Err(AbiError::InvalidUtf8))); +} + +#[test] +fn arbitrary_guest_ranges_never_escape_memory() { + let (store, abi) = memory(); + let mut random = 0x9e37_79b9_u32; + for i in 0..20_000 { + random ^= random << 13; + random ^= random >> 17; + random ^= random << 5; + let ptr = if i % 2 == 0 { random % 65_540 } else { random }; + random ^= random << 13; + random ^= random >> 17; + random ^= random << 5; + let len = if i % 2 == 0 { random % 70 } else { random }; + + let expected = usize::try_from(len).unwrap() <= LIMITS.max_transfer_bytes + && (ptr as usize).checked_add(len as usize).is_some_and(|end| end <= 65536); + assert_eq!(abi.bytes(&store, ptr, len).is_ok(), expected, "ptr={ptr} len={len}"); + } +} + +#[test] +fn rejects_memory64_and_foreign_store_handles() { + let mut store = Store::default(); + let memory64 = + Memory::try_new(&mut store, MemoryType::default().with_arch(MemoryArch::I64).with_page_count_initial(1)) + .unwrap(); + assert!(matches!(CanonicalMemory::new(memory64, &store, LIMITS), Err(AbiError::Memory64))); + + let (other_store, _) = memory(); + assert!(matches!(CanonicalMemory::new(memory64, &other_store, LIMITS), Err(AbiError::Runtime(_)))); +} + +#[test] +fn custom_wit_shaped_import_uses_borrowed_guest_data() -> tinywasm::Result<()> { + let wasm = wat::parse_str( + r#"(module + (import "snqr:example/bytes@0.1.0" "checksum" (func $checksum (param i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 8) "\01\02\03\04") + (func (export "run") (result i32) + i32.const 8 + i32.const 4 + call $checksum))"#, + ) + .unwrap(); + let module = tinywasm::parse_bytes(&wasm)?; + let mut imports = Imports::new(); + imports.define( + "snqr:example/bytes@0.1.0", + "checksum", + HostFunction::from(|ctx: FuncContext<'_>, (ptr, len): (i32, i32)| -> tinywasm::Result { + let abi = CanonicalMemory::new(ctx.memory("memory")?, ctx.store(), LIMITS)?; + let bytes = abi.bytes(ctx.store(), ptr as u32, len as u32)?; + Ok(bytes.iter().map(|&byte| i32::from(byte)).sum()) + }), + ); + let mut store = Store::default(); + let instance = ModuleInstance::instantiate(&mut store, &module, Some(&imports))?; + assert_eq!(instance.func::<(), i32>(&store, "run")?.call(&mut store, ())?, 10); + Ok(()) +}