Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions crates/component-abi/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions crates/component-abi/README.md
Original file line number Diff line number Diff line change
@@ -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<u8>`, and `list<u32>`. 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.
171 changes: 171 additions & 0 deletions crates/component-abi/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<u8>`, and `list<u32>` 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<tinywasm::Error> for AbiError {
fn from(error: tinywasm::Error) -> Self {
Self::Runtime(error)
}
}

impl From<AbiError> 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<Self, AbiError> {
if memory.ty(store)?.arch() != MemoryArch::I32 {
return Err(AbiError::Memory64);
}
Ok(Self { memory, limits })
}

/// Borrows a canonical `list<u8>` 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<u32>` 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<U32List<'a>, 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<u8>` 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<core::ops::Range<usize>, 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<u32>` 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::Item> {
self.chunks.next().map(|bytes| u32::from_le_bytes(*bytes))
}

fn size_hint(&self) -> (usize, Option<usize>) {
let count = self.chunks.len();
(count, Some(count))
}
}

impl ExactSizeIterator for U32List<'_> {}
103 changes: 103 additions & 0 deletions crates/component-abi/tests/memory.rs
Original file line number Diff line number Diff line change
@@ -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::<Vec<_>>(), [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<i32> {
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(())
}