Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
111 changes: 110 additions & 1 deletion ext/src/ruby_api/wasi_config.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,61 @@
use super::root;
use crate::error;
use crate::helpers::OutputLimitedBuffer;
use crate::ruby_api::convert::ToValType;
use crate::{define_rb_intern, helpers::SymbolEnum};
use lazy_static::lazy_static;
use magnus::{
class, function, gc::Marker, method, typed_data::Obj, value::Opaque, DataTypeFunctions, Error,
Module, Object, RArray, RHash, RString, Ruby, TryConvert, TypedData,
IntoValue, Module, Object, RArray, RHash, RString, Ruby, Symbol, TryConvert, TypedData, Value,
};
use rb_sys::ruby_rarray_flags::RARRAY_EMBED_FLAG;
use std::cell::RefCell;
use std::convert::TryFrom;
use std::fs;
use std::path::Path;
use std::{fs::File, path::PathBuf};
use wasmtime_wasi::p2::pipe::MemoryInputPipe;
use wasmtime_wasi::p2::{OutputFile, WasiCtx, WasiCtxBuilder};
use wasmtime_wasi::preview1::WasiP1Ctx;
use wasmtime_wasi::{DirPerms, FilePerms};

define_rb_intern!(
READ => "read",
WRITE => "write",
MUTATE => "mutate",
ALL => "all",
);

lazy_static! {
static ref FILE_PERMS_MAPPING: SymbolEnum<'static, FilePerms> = {
let mapping = vec![
(*READ, FilePerms::READ),
(*WRITE, FilePerms::WRITE),
(*ALL, FilePerms::all()),
];

SymbolEnum::new(":file_perms", mapping)
};
static ref DIR_PERMS_MAPPING: SymbolEnum<'static, DirPerms> = {
let mapping = vec![
(*READ, DirPerms::READ),
(*MUTATE, DirPerms::MUTATE),
(*ALL, DirPerms::all()),
];

SymbolEnum::new(":dir_perms", mapping)
};
}

struct PermsSymbolEnum(Symbol);

#[derive(Clone)]
struct MappedDirectory {
host_path: String,
guest_path: String,
dir_perms: DirPerms,
file_perms: FilePerms,
}

enum ReadStream {
Inherit,
Expand Down Expand Up @@ -51,6 +96,7 @@ struct WasiConfigInner {
env: Option<Opaque<RHash>>,
args: Option<Opaque<RArray>>,
deterministic: bool,
mapped_directories: Vec<MappedDirectory>,
}

impl WasiConfigInner {
Expand All @@ -73,6 +119,20 @@ impl WasiConfigInner {
}
}

impl TryFrom<PermsSymbolEnum> for DirPerms {
type Error = magnus::Error;
fn try_from(value: PermsSymbolEnum) -> Result<Self, Error> {
DIR_PERMS_MAPPING.get(value.0.into_value())
}
}

impl TryFrom<PermsSymbolEnum> for FilePerms {
type Error = magnus::Error;
fn try_from(value: PermsSymbolEnum) -> Result<Self, Error> {
FILE_PERMS_MAPPING.get(value.0.into_value())
}
}

/// @yard
/// WASI config to be sent as {Store#new}’s +wasi_config+ keyword argument.
///
Expand Down Expand Up @@ -233,6 +293,39 @@ impl WasiConfig {
rb_self
}

/// @yard
/// Set mapped directory for host path and guest path.
/// @param host_path [String]
/// @param guest_path [String]
/// @param dir_perms [Symbol] Directory permissions, one of :read, :mutate, or :all
/// @param file_perms [Symbol] File permissions, one of :read, :write, or :all
/// @def set_mapped_directory(host_path, guest_path, dir_perms, file_perms)
/// @return [WasiConfig] +self+
pub fn set_mapped_directory(
rb_self: RbSelf,
host_path: RString,
guest_path: RString,
dir_perms: Symbol,
file_perms: Symbol,
) -> Result<RbSelf, Error> {
let host_path = host_path.to_string().unwrap();
let guest_path = guest_path.to_string().unwrap();
let dir_perms: DirPerms = PermsSymbolEnum(dir_perms).try_into()?;
let file_perms: FilePerms = PermsSymbolEnum(file_perms).try_into()?;

let mapped_dir = MappedDirectory {
host_path,
guest_path,
dir_perms,
file_perms,
};

let mut inner = rb_self.inner.borrow_mut();
inner.mapped_directories.push(mapped_dir);
Copy link
Member

@saulecabrera saulecabrera Sep 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To:

  • Keep things consistent with the other build options (see e.g., env), could we make MappedDirectory hold the Ruby values rather than eagerly converting them to Rust on each call?
  • Related to point 1, that approach will also result in less overhead in case any of the other values fails to validate and centralizing everything in the build call.


Ok(rb_self)
}

pub fn build_p1(&self, ruby: &Ruby) -> Result<WasiP1Ctx, Error> {
let mut builder = self.build_impl(ruby)?;
let ctx = builder.build_p1();
Expand Down Expand Up @@ -317,6 +410,17 @@ impl WasiConfig {
deterministic_wasi_ctx::add_determinism_to_wasi_ctx_builder(&mut builder);
}

for mapped_dir in &inner.mapped_directories {
builder
.preopened_dir(
Path::new(&mapped_dir.host_path),
&mapped_dir.guest_path,
mapped_dir.dir_perms,
mapped_dir.file_perms,
)
.map_err(|e| error!("{}", e))?;
}

Ok(builder)
}
}
Expand Down Expand Up @@ -355,5 +459,10 @@ pub fn init() -> Result<(), Error> {

class.define_method("set_argv", method!(WasiConfig::set_argv, 1))?;

class.define_method(
"set_mapped_directory",
method!(WasiConfig::set_mapped_directory, 4),
)?;

Ok(())
}
Binary file added spec/fixtures/wasi-fs-p2.wasm
Binary file not shown.
Binary file added spec/fixtures/wasi-fs.wasm
Binary file not shown.
2 changes: 2 additions & 0 deletions spec/fixtures/wasi-fs/.cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[build]
target = "wasm32-wasip1"
10 changes: 10 additions & 0 deletions spec/fixtures/wasi-fs/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[package]
name = "wasi-fs"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[workspace]

[dependencies]
14 changes: 14 additions & 0 deletions spec/fixtures/wasi-fs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
Example WASI program used to test WASI preopened directories

To update:

```shell
cargo build --release && \
wasm-opt -O \
--enable-bulk-memory \
target/wasm32-wasip1/release/wasi-fs.wasm \
-o ../wasi-fs.wasm && \
cargo build --target=wasm32-wasip2 --release && \
cp target/wasm32-wasip2/release/wasi-fs.wasm \
../wasi-fs-p2.wasm
```
18 changes: 18 additions & 0 deletions spec/fixtures/wasi-fs/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
use std::io::{Write, Read};
use std::fs::File;

fn main() {
let args: Vec<String> = std::env::args().collect();

let counter_file = File::open(&args[1]).expect("failed to open counter file");

let mut counter_str = String::new();
counter_file.take(100).read_to_string(&mut counter_str)
.expect("failed to read counter file");
let mut counter: u32 = counter_str.trim().parse().expect("failed to parse counter");

counter += 1;

let mut counter_file = File::create(&args[1]).expect("failed to create counter file");
write!(counter_file, "{}", counter).expect("failed to write counter file");
}
111 changes: 111 additions & 0 deletions spec/unit/wasi_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ module Wasmtime
# Compile module only once for speed
@compiled_wasi_module = @engine.precompile_module(IO.binread("spec/fixtures/wasi-debug.wasm"))
@compiled_wasi_deterministic_module = @engine.precompile_module(IO.binread("spec/fixtures/wasi-deterministic.wasm"))
@compiled_wasi_fs_module = @engine.precompile_module(IO.binread("spec/fixtures/wasi-fs.wasm"))

@compiled_wasi_component = @engine.precompile_component(IO.binread("spec/fixtures/wasi-debug-p2.wasm"))
@compiled_wasi_deterministic_component = @engine.precompile_component(IO.binread("spec/fixtures/wasi-deterministic-p2.wasm"))
@compiled_wasi_fs_component = @engine.precompile_component(IO.binread("spec/fixtures/wasi-fs-p2.wasm"))
end

describe "Linker.new" do
Expand Down Expand Up @@ -233,13 +235,103 @@ module Wasmtime
end
end
end

it "writes to mapped directory" do
Dir.mkdir(tempfile_path("tmp"))
File.write(tempfile_path(File.join("tmp", "counter")), "0")

wasi_config = WasiConfig.new
.set_argv(["wasi-fs", "/tmp/counter"])
.set_mapped_directory(tempfile_path("tmp"), "/tmp", :all, :all)

expect { run_fs.call(wasi_config) }.not_to raise_error

expect(File.read(tempfile_path(File.join("tmp", "counter")))).to eq("1")
end

it "fails to write to mapped directory if not permitted" do
Dir.mkdir(tempfile_path("tmp"))
File.write(tempfile_path(File.join("tmp", "counter")), "0")

stderr_str = ""
wasi_config = WasiConfig.new
.set_argv(["wasi-fs", "/tmp/counter"])
.set_stderr_buffer(stderr_str, 40000)
.set_mapped_directory(tempfile_path("tmp"), "/tmp", :read, :read)

expect { run_fs.call(wasi_config) }.to raise_error do |error|
expect(error).to be_a(Wasmtime::Error)
end

expect(stderr_str).to match(/failed to create counter file/)

expect(File.read(tempfile_path(File.join("tmp", "counter")))).to eq("0")
end

it "fails to read from mapped directory if not permitted" do
Dir.mkdir(tempfile_path("tmp"))
File.write(tempfile_path(File.join("tmp", "counter")), "0")

stderr_str = ""
wasi_config = WasiConfig.new
.set_argv(["wasi-fs", "/tmp/counter"])
.set_stderr_buffer(stderr_str, 40000)
.set_mapped_directory(tempfile_path("tmp"), "/tmp", :mutate, :write)

expect { run_fs.call(wasi_config) }.to raise_error do |error|
expect(error).to be_a(Wasmtime::Error)
end

expect(stderr_str).to match(/failed to open counter file/)

expect(File.read(tempfile_path(File.join("tmp", "counter")))).to eq("0")
end

it "fails to access non-mapped directories" do
Dir.mkdir(tempfile_path("tmp"))
File.write(tempfile_path(File.join("tmp", "counter")), "0")

stderr_str = ""
wasi_config = WasiConfig.new
.set_argv(["wasi-fs", File.join(tempfile_path("tmp"), "counter")])
.set_stderr_buffer(stderr_str, 40000)

expect { run_fs.call(wasi_config) }.to raise_error do |error|
expect(error).to be_a(Wasmtime::Error)
end

expect(stderr_str).to match(/failed to find a pre-opened file descriptor/)

expect(File.read(tempfile_path(File.join("tmp", "counter")))).to eq("0")
end

it "does not accept an invalid host path" do
wasi_config = WasiConfig.new
.set_mapped_directory(tempfile_path("tmp"), "/tmp", :all, :all)

expect { run_fs.call(wasi_config) }.to raise_error do |error|
expect(error).to be_a(Wasmtime::Error)
# error message is os-specific
end
end

it "does not accept invalid permissions" do
expect {
WasiConfig.new
.set_mapped_directory(tempfile_path("tmp"), "/tmp", :mutate, :invalid_permission)
}.to raise_error do |error|
expect(error).to be_a(ArgumentError)
expect(error.message).to match(/invalid :file_perms, expected one of \[:read, :write, :all\], got :invalid_permission/)
end
end
end

describe "WasiConfig preview 1" do
it_behaves_like WasiConfig do
let(:run) { method(:run_wasi_module) }
let(:wasi_env) { method(:wasi_module_env) }
let(:run_deterministic) { method(:run_wasi_module_deterministic) }
let(:run_fs) { method(:run_wasi_module_fs) }
end
end

Expand All @@ -248,6 +340,7 @@ module Wasmtime
let(:run) { method(:run_wasi_component) }
let(:wasi_env) { method(:wasi_component_env) }
let(:run_deterministic) { method(:run_wasi_component_deterministic) }
let(:run_fs) { method(:run_wasi_component_fs) }
end
end

Expand All @@ -272,6 +365,13 @@ def run_wasi_module_deterministic(wasi_config)
.invoke("_start")
end

def run_wasi_module_fs(wasi_config)
linker = Linker.new(@engine)
WASI::P1.add_to_linker_sync(linker)
store = Store.new(@engine, wasi_p1_config: wasi_config)
linker.instantiate(store, Module.deserialize(@engine, @compiled_wasi_fs_module)).invoke("_start")
end

def wasi_module_env
stdout_file = tempfile_path("stdout")

Expand Down Expand Up @@ -318,6 +418,17 @@ def run_wasi_component_deterministic(wasi_config)
).call_run(store)
end

def run_wasi_component_fs(wasi_config)
linker = Component::Linker.new(@engine)
WASI::P2.add_to_linker_sync(linker)
store = Store.new(@engine, wasi_config: wasi_config)
Component::WasiCommand.new(
store,
Component::Component.deserialize(@engine, @compiled_wasi_fs_component),
linker
).call_run(store)
end

def tempfile_path(name)
File.join(tmpdir, name)
end
Expand Down
Loading