Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/workflows/compatibility-elixir.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
push:
branches:
- main
- '**' # Run on all branches

env:
MIX_ENV: test
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/elixir-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
push:
branches:
- main
- '**' # Run on all branches

env:
ELIXIR_VERSION: 1.17.3
Expand Down Expand Up @@ -120,11 +121,16 @@ jobs:
run: |
rustup target add wasm32-unknown-unknown
rustup target add wasm32-wasip1
rustup target add wasm32-wasip2

- name: "Install cargo-component"
run: cargo install --locked cargo-component
shell: bash

- name: "Install wasm-tools"
run: cargo install --locked wasm-tools
shell: bash

- uses: actions/cache@v4
with:
path: |
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/rust-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ on:
push:
branches:
- main
- '**' # Run on all branches
paths:
- "native/wasmex/**"

defaults:
run:
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ wasmex-*.tar
# Cargo things in the Rust part of this package
priv/native/libwasmex.so
test/**/target/*
test/wasm/
test/component_fixtures/wasi_snapshot_preview1.reactor.wasm

.mix_tasks
**/.DS_Store
Expand Down
138 changes: 138 additions & 0 deletions lib/mix/tasks/wasmex.build_fixtures.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
defmodule Mix.Tasks.Wasmex.BuildFixtures do
@moduledoc """
Builds WebAssembly component fixtures for testing.

This task builds all the Rust-based WebAssembly components in the
test/component_fixtures directory that are required for running tests.

## Usage

mix wasmex.build_fixtures

This task is automatically run before tests via the mix alias.
"""
use Mix.Task

@shortdoc "Build WebAssembly component fixtures for testing"

@fixtures [
"counter-component",
"filesystem-component",
"wasi-test-component"
]

def run(_args) do
ensure_tools_installed()
build_fixtures()
end

defp ensure_tools_installed do
unless System.find_executable("cargo") do
Mix.raise("cargo not found. Please install Rust: https://rustup.rs/")
end

# Check if cargo-component is installed
case System.cmd("cargo", ["component", "--version"], stderr_to_stdout: true) do
{_output, 0} ->
:ok

_ ->
Mix.shell().info("cargo-component not found. Installing...")
{_, 0} = System.cmd("cargo", ["install", "--locked", "cargo-component"])
Mix.shell().info("cargo-component installed successfully.")
end

# Ensure wasm32 targets are installed
ensure_target_installed("wasm32-unknown-unknown")
ensure_target_installed("wasm32-wasip1")
ensure_target_installed("wasm32-wasip2")
end

defp ensure_target_installed(target) do
case System.cmd("rustup", ["target", "list", "--installed"], stderr_to_stdout: true) do
{output, 0} ->
if not String.contains?(output, target) do
Mix.shell().info("Installing Rust target: #{target}")
{_, 0} = System.cmd("rustup", ["target", "add", target])
end

_ ->
Mix.raise("rustup not found. Please ensure Rust is properly installed.")
end
end

defp build_fixtures do
fixtures_dir = Path.join(File.cwd!(), "test/component_fixtures")

Enum.each(@fixtures, fn fixture ->
build_fixture(fixtures_dir, fixture)
end)
end

defp build_fixture(fixtures_dir, fixture) do
fixture_path = Path.join(fixtures_dir, fixture)

wasm_path =
Path.join([
fixture_path,
"target",
"wasm32-wasip1",
"release",
"#{String.replace(fixture, "-", "_")}.wasm"
])

if File.exists?(fixture_path) do
build_if_needed(fixture_path, wasm_path, fixture)
else
Mix.shell().error("Warning: Component fixture not found: #{fixture_path}")
end
end

defp build_if_needed(fixture_path, wasm_path, fixture) do
should_build =
not File.exists?(wasm_path) or source_newer_than_target?(fixture_path, wasm_path)

if should_build do
run_build_command(fixture_path, fixture)
end
end

defp run_build_command(fixture_path, fixture) do
case System.cmd("cargo", ["component", "build", "--release"],
cd: fixture_path,
stderr_to_stdout: true
) do
{_output, 0} ->
:ok

{output, _} ->
Mix.raise("Failed to build #{fixture}:\n#{output}")
end
end

defp source_newer_than_target?(source_dir, target_file) do
case File.stat(target_file) do
{:ok, %{mtime: target_mtime}} ->
any_source_newer?(source_dir, target_mtime)

_ ->
# Target doesn't exist, so we need to build
true
end
end

defp any_source_newer?(source_dir, target_mtime) do
Path.wildcard(Path.join(source_dir, "**/*.{rs,toml,wit}"))
|> Enum.any?(&source_file_newer?(&1, target_mtime))
end

defp source_file_newer?(source_file, target_mtime) do
case File.stat(source_file) do
{:ok, %{mtime: source_mtime}} ->
source_mtime > target_mtime

_ ->
false
end
end
end
135 changes: 131 additions & 4 deletions lib/wasmex/components.ex
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,139 @@ defmodule Wasmex.Components do
{:error, 404} # error case
```

### Currently Unsupported Types
- `resource` (stateful objects with methods)
```wit
resource counter {
constructor(initial: u32);
increment: func() -> u32;
get-value: func() -> u32;
}
```
Resources are created using constructors and map to Elixir references.
See the "Working with Resources" section below for details.

## Working with Resources

Resources are stateful objects in the Component Model with constructors and methods.
They provide an object-oriented interface for WebAssembly components.

### Creating Resources (Constructors)

The idiomatic way to create resources is using their constructors:

```elixir
# Setup
{:ok, store} = Wasmex.Components.Store.new()
{:ok, component} = Wasmex.Components.Component.new(store, component_bytes)
{:ok, instance} = Wasmex.Components.Instance.new(store, component, %{})

# Create a resource using its constructor
{:ok, counter} = Wasmex.Components.Instance.new_resource(
instance,
["component:counter/types", "counter"],
[42] # constructor arguments
)
```

### Calling Resource Methods

Once you have a resource, you can call its methods:

```elixir
# Clean and simple API
{:ok, new_value} = Wasmex.Components.Instance.call(instance, counter, "increment")
IO.puts("Counter incremented to: " <> Integer.to_string(new_value))

# Get the current value
{:ok, value} = Wasmex.Components.Instance.call(instance, counter, "get-value")

# Reset with a parameter
:ok = Wasmex.Components.Instance.call(instance, counter, "reset", [100])

# With explicit interface
{:ok, result} = Wasmex.Components.Instance.call(
instance,
resource,
"process",
[42, "hello"],
interface: ["my:interface"]
)
```

### Resource Lifecycle

- Resources are tied to the store that created them
- Resources cannot be used across different stores
- Resources are automatically cleaned up when the store is dropped
- Resources can be passed as arguments to functions and returned from functions

The following WIT type is not yet supported:
- Resources
Support for the Component Model, including resources, should be considered beta quality.

Support for the Component Model should be considered beta quality.
## WASI Filesystem Resources

Wasmex supports real WASI filesystem operations through preopened directories.
This allows WebAssembly components to perform actual file I/O operations on the host filesystem
in a controlled and secure manner.

### Setup Requirements

1. Create a store with preopened directories:
```elixir
{:ok, store} = Wasmex.Components.Store.new_wasi(%WasiP2Options{
preopen_dirs: [
"/host/path/input", # Component can access this as a preopened directory
"/host/path/output" # Component can write files here
]
})
```

2. WASM component can only access preopened paths
3. All paths in WASM are relative to preopens

### Security Model

- Components CANNOT access arbitrary filesystem paths
- Only explicitly preopened directories are accessible
- Path traversal (../) is blocked by WASI runtime
- Consider using temporary directories for isolation

### Example

```elixir
# Create a sandboxed directory for testing
sandbox_dir = Path.join(System.tmp_dir!(), "wasmex_#{:rand.uniform(10000)}")
File.mkdir_p!(sandbox_dir)

# Create subdirectories
File.mkdir_p!(Path.join(sandbox_dir, "input"))
File.mkdir_p!(Path.join(sandbox_dir, "output"))

# Setup WASI with filesystem access
{:ok, store} = Wasmex.Components.Store.new_wasi(%WasiP2Options{
preopen_dirs: [
Path.join(sandbox_dir, "input"),
Path.join(sandbox_dir, "output")
],
inherit_stdout: true,
inherit_stderr: true
})

# Load component and create instance
component_bytes = File.read!("filesystem_component.wasm")
{:ok, component} = Wasmex.Components.Component.new(store, component_bytes)
{:ok, instance} = Wasmex.Components.Instance.new(store, component, %{})

# Component can now perform real file operations
{:ok, dir} = Instance.call_function(instance, ["types", "open-directory"], ["/output"])
{:ok, file} = Instance.call_function(instance, ["types", "[method]directory.create-file"], [dir, "test.txt"])
{:ok, _} = Instance.call_function(instance, ["types", "[method]file-handle.write"], [file, "Hello WASI!"])

# File now exists on host filesystem
File.read!(Path.join(sandbox_dir, "output/test.txt")) # => "Hello WASI!"

# Clean up
File.rm_rf!(sandbox_dir)
```

## Options

Expand Down
Loading