diff --git a/ext/src/ruby_api/wasi_config.rs b/ext/src/ruby_api/wasi_config.rs index 7b028dd0..4990045e 100644 --- a/ext/src/ruby_api/wasi_config.rs +++ b/ext/src/ruby_api/wasi_config.rs @@ -1,16 +1,51 @@ 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) + }; +} enum ReadStream { Inherit, @@ -43,6 +78,24 @@ impl WriteStream { } } +struct PermsSymbolEnum(Symbol); + +#[derive(Clone)] +struct MappedDirectory { + host_path: Opaque, + guest_path: Opaque, + dir_perms: Opaque, + file_perms: Opaque, +} +impl MappedDirectory { + pub fn mark(&self, marker: &Marker) { + marker.mark(self.host_path); + marker.mark(self.guest_path); + marker.mark(self.dir_perms); + marker.mark(self.file_perms); + } +} + #[derive(Default)] struct WasiConfigInner { stdin: Option, @@ -51,6 +104,7 @@ struct WasiConfigInner { env: Option>, args: Option>, deterministic: bool, + mapped_directories: Vec, } impl WasiConfigInner { @@ -70,6 +124,23 @@ impl WasiConfigInner { if let Some(v) = self.args.as_ref() { marker.mark(*v); } + for v in &self.mapped_directories { + v.mark(marker); + } + } +} + +impl TryFrom for DirPerms { + type Error = magnus::Error; + fn try_from(value: PermsSymbolEnum) -> Result { + DIR_PERMS_MAPPING.get(value.0.into_value()) + } +} + +impl TryFrom for FilePerms { + type Error = magnus::Error; + fn try_from(value: PermsSymbolEnum) -> Result { + FILE_PERMS_MAPPING.get(value.0.into_value()) } } @@ -233,6 +304,34 @@ 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, + ) -> RbSelf { + let mapped_dir = MappedDirectory { + host_path: host_path.into(), + guest_path: guest_path.into(), + dir_perms: dir_perms.into(), + file_perms: file_perms.into(), + }; + + let mut inner = rb_self.inner.borrow_mut(); + inner.mapped_directories.push(mapped_dir); + + rb_self + } + pub fn build_p1(&self, ruby: &Ruby) -> Result { let mut builder = self.build_impl(ruby)?; let ctx = builder.build_p1(); @@ -317,6 +416,22 @@ impl WasiConfig { deterministic_wasi_ctx::add_determinism_to_wasi_ctx_builder(&mut builder); } + for mapped_dir in &inner.mapped_directories { + let host_path = ruby.get_inner(mapped_dir.host_path).to_string()?; + let guest_path = ruby.get_inner(mapped_dir.guest_path).to_string()?; + let dir_perms = ruby.get_inner(mapped_dir.dir_perms); + let file_perms = ruby.get_inner(mapped_dir.file_perms); + + builder + .preopened_dir( + Path::new(&host_path), + &guest_path, + PermsSymbolEnum(dir_perms).try_into()?, + PermsSymbolEnum(file_perms).try_into()?, + ) + .map_err(|e| error!("{}", e))?; + } + Ok(builder) } } @@ -355,5 +470,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(()) } diff --git a/spec/fixtures/wasi-fs-p2.wasm b/spec/fixtures/wasi-fs-p2.wasm new file mode 100644 index 00000000..6d01a17c Binary files /dev/null and b/spec/fixtures/wasi-fs-p2.wasm differ diff --git a/spec/fixtures/wasi-fs.wasm b/spec/fixtures/wasi-fs.wasm new file mode 100644 index 00000000..9202ce20 Binary files /dev/null and b/spec/fixtures/wasi-fs.wasm differ diff --git a/spec/fixtures/wasi-fs/.cargo/config.toml b/spec/fixtures/wasi-fs/.cargo/config.toml new file mode 100644 index 00000000..6b509f5b --- /dev/null +++ b/spec/fixtures/wasi-fs/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "wasm32-wasip1" diff --git a/spec/fixtures/wasi-fs/Cargo.toml b/spec/fixtures/wasi-fs/Cargo.toml new file mode 100644 index 00000000..92fea674 --- /dev/null +++ b/spec/fixtures/wasi-fs/Cargo.toml @@ -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] diff --git a/spec/fixtures/wasi-fs/README.md b/spec/fixtures/wasi-fs/README.md new file mode 100644 index 00000000..d8131c45 --- /dev/null +++ b/spec/fixtures/wasi-fs/README.md @@ -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 +``` diff --git a/spec/fixtures/wasi-fs/src/main.rs b/spec/fixtures/wasi-fs/src/main.rs new file mode 100644 index 00000000..2e32c565 --- /dev/null +++ b/spec/fixtures/wasi-fs/src/main.rs @@ -0,0 +1,18 @@ +use std::io::{Write, Read}; +use std::fs::File; + +fn main() { + let args: Vec = 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"); +} diff --git a/spec/unit/wasi_spec.rb b/spec/unit/wasi_spec.rb index 2d1a9b3f..de0340fc 100644 --- a/spec/unit/wasi_spec.rb +++ b/spec/unit/wasi_spec.rb @@ -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 @@ -233,6 +235,95 @@ 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 + wasi_config = WasiConfig.new + .set_mapped_directory(tempfile_path("tmp"), "/tmp", :mutate, :invalid_permission) + + expect { run_fs.call(wasi_config) }.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 @@ -240,6 +331,7 @@ module Wasmtime 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 @@ -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 @@ -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") @@ -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