From cef98efe3204a707d6d0197fe5d1b91fb5bf56b5 Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Sat, 17 Oct 2026 13:50:27 -0400 Subject: [PATCH] Add tests for the readonly component Signed-off-by: Scott Andrews --- Cargo.lock | 5 + components/readonly/Cargo.toml | 7 + components/readonly/README.md | 6 +- components/readonly/build.rs | 7 + components/readonly/src/lib.rs | 34 +- components/readonly/tests/readonly.rs | 821 ++++++++++++++++++++++++++ crates/test-harness/src/lib.rs | 14 +- 7 files changed, 875 insertions(+), 19 deletions(-) create mode 100644 components/readonly/build.rs create mode 100644 components/readonly/tests/readonly.rs diff --git a/Cargo.lock b/Cargo.lock index bbd8e6d..abf803b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1207,6 +1207,11 @@ dependencies = [ name = "readonly" version = "0.1.0" dependencies = [ + "componentized-rt", + "tempfile", + "test-harness", + "tokio", + "wasmtime", "wit-bindgen", ] diff --git a/components/readonly/Cargo.toml b/components/readonly/Cargo.toml index f985e10..3e5511f 100644 --- a/components/readonly/Cargo.toml +++ b/components/readonly/Cargo.toml @@ -8,4 +8,11 @@ license = "Apache-2.0" crate-type = ["cdylib"] [dependencies] +componentized-rt = { workspace = true, features = ["executor"] } wit-bindgen = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/readonly/README.md b/components/readonly/README.md index 7a82d1d..fd6a97e 100644 --- a/components/readonly/README.md +++ b/components/readonly/README.md @@ -1,3 +1,7 @@ # `readonly` -Virtualizes the wasi:filesystem interfaces making the exported file system read-only. +Virtualizes the `wasi:filesystem` interfaces making the exported file system read-only. + +Every operation that reads the filesystem is passed through, every operation that would change it fails with `read-only`, including opening a descriptor with flags that would allow a change. `sync` and `sync-data` are denied as well. + +`write-via-stream` and `append-via-stream` are synchronous and resolve their result on a background thread, so the host must support component model threads, e.g. `wasmtime -W component-model-threading`. diff --git a/components/readonly/build.rs b/components/readonly/build.rs new file mode 100644 index 0000000..cd4e5a1 --- /dev/null +++ b/components/readonly/build.rs @@ -0,0 +1,7 @@ +fn main() { + // componentized_rt::executor's thread is started with thread.new-indirect, which calls through + // the exported function table + if std::env::var("CARGO_CFG_TARGET_FAMILY").as_deref() == Ok("wasm") { + println!("cargo:rustc-link-arg-cdylib=--export-table"); + } +} diff --git a/components/readonly/src/lib.rs b/components/readonly/src/lib.rs index 231c4b5..3a12d07 100644 --- a/components/readonly/src/lib.rs +++ b/components/readonly/src/lib.rs @@ -1,5 +1,3 @@ -use std::path::PathBuf; - use exports::wasi::filesystem::preopens::Guest as Preopens; use exports::wasi::filesystem::types::{ Advice, Descriptor, DescriptorBorrow, DescriptorFlags, DescriptorStat, DescriptorType, @@ -17,7 +15,7 @@ impl Preopens for FilesystemReadOnly { preopens::get_directories() .into_iter() .map(|(fd, path)| { - let fd = Descriptor::new(ReadOnlyDescriptor::new(fd, path.clone().into())); + let fd = Descriptor::new(ReadOnlyDescriptor::new(fd)); (fd, path) }) .collect() @@ -30,12 +28,11 @@ impl Types for FilesystemReadOnly { struct ReadOnlyDescriptor { fd: types::Descriptor, - path: PathBuf, } impl ReadOnlyDescriptor { - fn new(fd: types::Descriptor, path: PathBuf) -> Self { - Self { fd, path } + fn new(fd: types::Descriptor) -> Self { + Self { fd } } } @@ -55,18 +52,14 @@ impl exports::wasi::filesystem::types::GuestDescriptor for ReadOnlyDescriptor { _data: wit_bindgen::StreamReader, _offset: Filesize, ) -> wit_bindgen::FutureReader> { - let (tx, rx) = wit_future::new(|| Err(ErrorCode::ReadOnly)); - tx.write(Err(ErrorCode::ReadOnly)); - rx + read_only() } fn append_via_stream( &self, _data: wit_bindgen::StreamReader, ) -> wit_bindgen::FutureReader> { - let (tx, rx) = wit_future::new(|| Err(ErrorCode::ReadOnly)); - tx.write(Err(ErrorCode::ReadOnly)); - rx + read_only() } async fn advise( @@ -171,6 +164,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for ReadOnlyDescriptor { || flags.contains(DescriptorFlags::FILE_INTEGRITY_SYNC) || flags.contains(DescriptorFlags::DATA_INTEGRITY_SYNC) || flags.contains(DescriptorFlags::REQUESTED_WRITE_SYNC) + || flags.contains(DescriptorFlags::MUTATE_DIRECTORY) { return Err(ErrorCode::ReadOnly); } @@ -180,10 +174,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for ReadOnlyDescriptor { .open_at(path_flags, path.clone(), open_flags, flags) .await { - Ok(fd) => Ok(Descriptor::new(ReadOnlyDescriptor::new( - fd, - self.path.clone().join(path), - ))), + Ok(fd) => Ok(Descriptor::new(ReadOnlyDescriptor::new(fd))), Err(error_code) => Err(error_code), } } @@ -231,6 +222,17 @@ impl exports::wasi::filesystem::types::GuestDescriptor for ReadOnlyDescriptor { } } +/// A future resolved to `read-only`, for the sync exports that return a future. +fn read_only() -> wit_bindgen::FutureReader> { + let (tx, rx) = wit_future::new(|| Err(ErrorCode::ReadOnly)); + // a sync export can't wait for the write, the reader has not been returned yet, so the write + // completes in the background + componentized_rt::executor::spawn(async move { + let _ = tx.write(Err(ErrorCode::ReadOnly)).await; + }); + rx +} + wit_bindgen::generate!({ path: "../wit", world: "filesystem", diff --git a/components/readonly/tests/readonly.rs b/components/readonly/tests/readonly.rs new file mode 100644 index 0000000..da1938d --- /dev/null +++ b/components/readonly/tests/readonly.rs @@ -0,0 +1,821 @@ +//! The readonly component allows every operation that reads the filesystem and denies every +//! operation that would change it with `read-only`. +//! +//! Each test preopens a fresh copy of this tree read-write, so any denial comes from the component +//! rather than the host: +//! +//! ```text +//! file.txt "hello world" +//! link -> file.txt +//! empty/ +//! dir/nested.txt "nested" +//! ``` +//! +//! Tests of mutations compare a snapshot of the tree before and after, nothing may change. + +#![cfg(unix)] + +use std::collections::BTreeMap; +use std::fs; +use std::os::unix::fs::symlink; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use tempfile::TempDir; +use test_harness::bindings::exports::wasi::filesystem::types::{ + Advice, GuestDescriptor, NewTimestamp, +}; +use test_harness::{ + Ctx, DescriptorFlags, DescriptorType, ErrorCode, Exports, Harness, OpenFlags, PathFlags, Perms, + TestSubject, +}; +use wasmtime::component::{Accessor, ResourceAny}; + +const FILE: &str = "hello world"; +const NESTED: &str = "nested"; + +struct Fixture { + dir: TempDir, +} + +/// What a test can observe about one entry of the tree. +#[derive(Debug, PartialEq, Eq)] +enum Entry { + // not the access time, reads may change it, as does taking the snapshot + File { + content: Vec, + modified: SystemTime, + }, + Dir { + modified: SystemTime, + }, + Link { + target: PathBuf, + }, +} + +impl Fixture { + fn new() -> Self { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + fs::write(root.join("file.txt"), FILE).unwrap(); + symlink("file.txt", root.join("link")).unwrap(); + fs::create_dir(root.join("empty")).unwrap(); + fs::create_dir(root.join("dir")).unwrap(); + fs::write(root.join("dir/nested.txt"), NESTED).unwrap(); + Self { dir } + } + + fn path(&self) -> &Path { + self.dir.path() + } + + /// Every entry of the tree, by its path relative to the root. + fn snapshot(&self) -> BTreeMap { + fn walk(root: &Path, dir: &Path, entries: &mut BTreeMap) { + for entry in fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + let meta = fs::symlink_metadata(&path).unwrap(); + let relative = path.strip_prefix(root).unwrap().to_path_buf(); + if meta.is_symlink() { + let target = fs::read_link(&path).unwrap(); + entries.insert(relative, Entry::Link { target }); + } else if meta.is_dir() { + let modified = meta.modified().unwrap(); + entries.insert(relative, Entry::Dir { modified }); + walk(root, &path, entries); + } else { + let content = fs::read(&path).unwrap(); + let modified = meta.modified().unwrap(); + entries.insert(relative, Entry::File { content, modified }); + } + } + } + let mut entries = BTreeMap::new(); + walk(self.path(), self.path(), &mut entries); + entries + } + + async fn readonly(&self) -> TestSubject { + Harness::new("readonly") + .preopen(self.path(), "/", Perms::ReadWrite) + .build() + .await + .expect("readonly") + } +} + +/// Descriptors opened through the readonly component. +#[derive(Clone, Copy)] +struct Handles { + /// The preopen + root: ResourceAny, + /// `dir`, opened from the root + dir: ResourceAny, + /// `file.txt`, opened from the root + file: ResourceAny, + /// `dir/nested.txt`, opened from `dir` + nested: ResourceAny, +} + +fn descriptor(exports: &Exports) -> GuestDescriptor<'_> { + exports.wasi_filesystem_types().descriptor() +} + +async fn open( + accessor: &Accessor, + exports: &Exports, + fd: ResourceAny, + path: &str, + open_flags: OpenFlags, + flags: DescriptorFlags, +) -> wasmtime::Result> { + descriptor(exports) + .call_open_at( + accessor, + fd, + PathFlags::empty(), + path.into(), + open_flags, + flags, + ) + .await +} + +async fn handles(accessor: &Accessor, exports: &Exports) -> wasmtime::Result { + let dirs = exports + .wasi_filesystem_preopens() + .call_get_directories(accessor) + .await?; + let (root, _) = dirs.into_iter().next().expect("a preopen"); + let read = DescriptorFlags::READ; + let dir = open(accessor, exports, root, "dir", OpenFlags::DIRECTORY, read) + .await? + .expect("open dir"); + let file = open( + accessor, + exports, + root, + "file.txt", + OpenFlags::empty(), + read, + ) + .await? + .expect("open file.txt"); + let nested = open( + accessor, + exports, + dir, + "nested.txt", + OpenFlags::empty(), + read, + ) + .await? + .expect("open dir/nested.txt"); + Ok(Handles { + root, + dir, + file, + nested, + }) +} + +/// Run the operations, each must be denied with `read-only` and leave the tree unchanged. +async fn assert_denied( + f: impl AsyncFnOnce( + &Accessor, + &Exports, + Handles, + ) -> wasmtime::Result>> + + Send, +) { + let fixture = Fixture::new(); + let before = fixture.snapshot(); + let mut readonly = fixture.readonly().await; + let results = readonly + .run(async |accessor, exports| { + let handles = handles(accessor, exports).await?; + f(accessor, exports, handles).await + }) + .await + .expect("run"); + assert!(!results.is_empty()); + for (index, result) in results.into_iter().enumerate() { + assert_eq!(result, Err(ErrorCode::ReadOnly), "operation {index}"); + } + assert_eq!(fixture.snapshot(), before); +} + +/// Run reads against the readonly component, the tree must be unchanged after. +async fn read( + f: impl AsyncFnOnce(&Accessor, &Exports, Handles) -> wasmtime::Result + Send, +) -> R { + let fixture = Fixture::new(); + let before = fixture.snapshot(); + let mut readonly = fixture.readonly().await; + let result = readonly + .run(async |accessor, exports| { + let handles = handles(accessor, exports).await?; + f(accessor, exports, handles).await + }) + .await + .expect("run"); + assert_eq!(fixture.snapshot(), before); + result +} + +fn ok(result: Result) -> Result<(), E> { + result.map(|_| ()) +} + +// --- reads are allowed --- + +#[tokio::test(flavor = "multi_thread")] +async fn preopens_are_passed_through() { + let fixture = Fixture::new(); + let mut readonly = Harness::new("readonly") + .preopen(fixture.path(), "/", Perms::ReadWrite) + .preopen(fixture.path().join("dir"), "/data", Perms::ReadWrite) + .build() + .await + .unwrap(); + let names = readonly + .run(async |accessor, exports| { + let dirs = exports + .wasi_filesystem_preopens() + .call_get_directories(accessor) + .await?; + Ok(dirs.into_iter().map(|(_, name)| name).collect::>()) + }) + .await + .unwrap(); + assert_eq!(names, ["/", "/data"]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn files_and_directories_are_opened_for_reading() { + let results = read(async |accessor, exports, h| { + let read = DescriptorFlags::READ; + let mut results = vec![]; + for (fd, path, open_flags) in [ + (h.root, "file.txt", OpenFlags::empty()), + (h.root, ".", OpenFlags::DIRECTORY), + (h.root, "empty", OpenFlags::DIRECTORY), + (h.root, "dir/nested.txt", OpenFlags::empty()), + (h.dir, "nested.txt", OpenFlags::empty()), + (h.dir, ".", OpenFlags::DIRECTORY), + ] { + results.push(ok( + open(accessor, exports, fd, path, open_flags, read).await? + )); + } + // following a symlink is a read + results.push(ok(descriptor(exports) + .call_open_at( + accessor, + h.root, + PathFlags::SYMLINK_FOLLOW, + "link".into(), + OpenFlags::empty(), + read, + ) + .await?)); + Ok(results) + }) + .await; + for (index, result) in results.into_iter().enumerate() { + assert_eq!(result, Ok(()), "open {index}"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn opening_a_missing_file_is_the_host_error() { + let result = read(async |accessor, exports, h| { + Ok(ok(open( + accessor, + exports, + h.root, + "missing.txt", + OpenFlags::empty(), + DescriptorFlags::READ, + ) + .await?)) + }) + .await; + assert_eq!(result, Err(ErrorCode::NoEntry)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn files_are_read() { + let (whole, offset, nested) = read(async |accessor, exports, h| { + async fn read_all( + accessor: &Accessor, + exports: &Exports, + fd: ResourceAny, + offset: u64, + ) -> wasmtime::Result { + let (data, _) = descriptor(exports) + .call_read_via_stream(accessor, fd, offset) + .await?; + let mut data = test_harness::collect(accessor, data)?; + let mut bytes = vec![]; + while let Some(byte) = data.recv().await { + bytes.push(byte); + } + Ok(String::from_utf8(bytes)?) + } + Ok(( + read_all(accessor, exports, h.file, 0).await?, + read_all(accessor, exports, h.file, 6).await?, + read_all(accessor, exports, h.nested, 0).await?, + )) + }) + .await; + assert_eq!(whole, FILE); + assert_eq!(offset, "world"); + assert_eq!(nested, NESTED); +} + +#[tokio::test(flavor = "multi_thread")] +async fn directories_are_listed() { + // the entries and result are passed through from the host, which the harness can't read, see + // `test_harness::resolve`, the entries are listed from the opened directories instead + let opened = read(async |accessor, exports, h| { + let d = descriptor(exports); + for fd in [h.root, h.dir] { + d.call_read_directory(accessor, fd).await?; + } + let read = DescriptorFlags::READ; + let mut opened = vec![]; + for path in ["dir", "empty", "file.txt", "dir/nested.txt"] { + let open_flags = match path { + "dir" | "empty" => OpenFlags::DIRECTORY, + _ => OpenFlags::empty(), + }; + opened.push(ok( + open(accessor, exports, h.root, path, open_flags, read).await? + )); + } + Ok(opened) + }) + .await; + assert!(opened.iter().all(Result::is_ok), "{opened:?}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn metadata_is_read() { + let results = read(async |accessor, exports, h| { + let d = descriptor(exports); + let file = d.call_stat(accessor, h.file).await?.expect("stat"); + let root = d.call_stat(accessor, h.root).await?.expect("stat root"); + let file_at = d + .call_stat_at(accessor, h.root, PathFlags::empty(), "file.txt".into()) + .await? + .expect("stat-at"); + let link = d + .call_stat_at(accessor, h.root, PathFlags::empty(), "link".into()) + .await? + .expect("stat-at link"); + let followed = d + .call_stat_at(accessor, h.root, PathFlags::SYMLINK_FOLLOW, "link".into()) + .await? + .expect("stat-at followed"); + let types = ( + d.call_get_type(accessor, h.file).await?, + d.call_get_type(accessor, h.dir).await?, + ); + Ok((file, root, file_at, link, followed, types)) + }) + .await; + let (file, root, file_at, link, followed, types) = results; + assert_eq!(file.type_, DescriptorType::RegularFile); + assert_eq!(file.size, FILE.len() as u64); + assert_eq!(root.type_, DescriptorType::Directory); + assert_eq!(file_at, file); + assert_eq!(link.type_, DescriptorType::SymbolicLink); + assert_eq!(followed, file); + assert_eq!( + types, + ( + Ok(DescriptorType::RegularFile), + Ok(DescriptorType::Directory) + ) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn metadata_hashes_are_read() { + let (file, file_at, followed, other) = read(async |accessor, exports, h| { + let d = descriptor(exports); + Ok(( + d.call_metadata_hash(accessor, h.file).await?, + d.call_metadata_hash_at(accessor, h.root, PathFlags::empty(), "file.txt".into()) + .await?, + d.call_metadata_hash_at(accessor, h.root, PathFlags::SYMLINK_FOLLOW, "link".into()) + .await?, + d.call_metadata_hash(accessor, h.nested).await?, + )) + }) + .await; + assert!(file.is_ok(), "{file:?}"); + assert_eq!(file_at, file); + assert_eq!(followed, file); + assert_ne!(other, file); +} + +#[tokio::test(flavor = "multi_thread")] +async fn links_are_read() { + let target = read(async |accessor, exports, h| { + Ok(descriptor(exports) + .call_readlink_at(accessor, h.root, "link".into()) + .await?) + }) + .await; + assert_eq!(target, Ok("file.txt".into())); +} + +#[tokio::test(flavor = "multi_thread")] +async fn descriptors_are_compared() { + let (same, different) = read(async |accessor, exports, h| { + let again = open( + accessor, + exports, + h.root, + "dir", + OpenFlags::DIRECTORY, + DescriptorFlags::READ, + ) + .await? + .expect("open dir"); + let d = descriptor(exports); + Ok(( + d.call_is_same_object(accessor, h.dir, again).await?, + d.call_is_same_object(accessor, h.dir, h.root).await?, + )) + }) + .await; + assert!(same); + assert!(!different); +} + +#[tokio::test(flavor = "multi_thread")] +async fn advice_is_given() { + let result = read(async |accessor, exports, h| { + Ok(descriptor(exports) + .call_advise(accessor, h.file, 0, 0, Advice::Sequential) + .await?) + }) + .await; + assert_eq!(result, Ok(())); +} + +#[tokio::test(flavor = "multi_thread")] +async fn flags_are_read_only() { + let flags = read(async |accessor, exports, h| { + let d = descriptor(exports); + Ok(vec![ + d.call_get_flags(accessor, h.root).await?, + d.call_get_flags(accessor, h.dir).await?, + d.call_get_flags(accessor, h.file).await?, + d.call_get_flags(accessor, h.nested).await?, + ]) + }) + .await; + // the host preopen is writable, the readonly descriptors are not + for (index, flags) in flags.into_iter().enumerate() { + assert_eq!(flags, Ok(DescriptorFlags::READ), "descriptor {index}"); + } +} + +// --- mutations are denied --- + +/// Every combination of open flags and descriptor flags that would let a descriptor change the +/// filesystem. +fn mutating_opens() -> Vec<(OpenFlags, DescriptorFlags)> { + let read = DescriptorFlags::READ; + vec![ + (OpenFlags::CREATE, read), + (OpenFlags::CREATE | OpenFlags::EXCLUSIVE, read), + (OpenFlags::EXCLUSIVE, read), + (OpenFlags::TRUNCATE, read), + (OpenFlags::empty(), DescriptorFlags::WRITE), + (OpenFlags::empty(), read | DescriptorFlags::WRITE), + ( + OpenFlags::empty(), + read | DescriptorFlags::FILE_INTEGRITY_SYNC, + ), + ( + OpenFlags::empty(), + read | DescriptorFlags::DATA_INTEGRITY_SYNC, + ), + ( + OpenFlags::empty(), + read | DescriptorFlags::REQUESTED_WRITE_SYNC, + ), + (OpenFlags::empty(), read | DescriptorFlags::MUTATE_DIRECTORY), + ] +} + +#[tokio::test(flavor = "multi_thread")] +async fn opening_existing_files_for_mutation_is_denied() { + assert_denied(async |accessor, exports, h| { + let mut results = vec![]; + for (open_flags, flags) in mutating_opens() { + for (fd, path) in [ + (h.root, "file.txt"), + (h.root, "link"), + (h.dir, "nested.txt"), + ] { + results.push(ok( + open(accessor, exports, fd, path, open_flags, flags).await? + )); + } + } + Ok(results) + }) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn opening_new_files_is_denied() { + assert_denied(async |accessor, exports, h| { + let mut results = vec![]; + for (open_flags, flags) in mutating_opens() { + for (fd, path) in [ + (h.root, "new.txt"), + (h.dir, "new.txt"), + (h.root, "empty/new.txt"), + ] { + results.push(ok( + open(accessor, exports, fd, path, open_flags, flags).await? + )); + } + } + Ok(results) + }) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn opening_directories_for_mutation_is_denied() { + assert_denied(async |accessor, exports, h| { + let read = DescriptorFlags::READ; + let mut results = vec![]; + for flags in [ + read | DescriptorFlags::MUTATE_DIRECTORY, + read | DescriptorFlags::WRITE, + ] { + for (fd, path) in [(h.root, "."), (h.root, "empty"), (h.dir, ".")] { + results.push(ok(open( + accessor, + exports, + fd, + path, + OpenFlags::DIRECTORY, + flags, + ) + .await?)); + } + } + Ok(results) + }) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn writing_is_denied() { + assert_denied(async |accessor, exports, h| { + let d = descriptor(exports); + let mut results = vec![]; + for (fd, offset) in [(h.file, 0), (h.file, 6), (h.nested, 0)] { + let data = test_harness::stream(accessor, b"changed".to_vec())?; + let result = d.call_write_via_stream(accessor, fd, data, offset).await?; + results.push(test_harness::resolve(accessor, result).await?); + } + Ok(results) + }) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn appending_is_denied() { + assert_denied(async |accessor, exports, h| { + let d = descriptor(exports); + let mut results = vec![]; + for fd in [h.file, h.nested] { + let data = test_harness::stream(accessor, b"changed".to_vec())?; + let result = d.call_append_via_stream(accessor, fd, data).await?; + results.push(test_harness::resolve(accessor, result).await?); + } + Ok(results) + }) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn resizing_is_denied() { + assert_denied(async |accessor, exports, h| { + let d = descriptor(exports); + Ok(vec![ + d.call_set_size(accessor, h.file, 0).await?, + d.call_set_size(accessor, h.file, 1024).await?, + d.call_set_size(accessor, h.nested, 0).await?, + ]) + }) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn setting_times_is_denied() { + assert_denied(async |accessor, exports, h| { + let d = descriptor(exports); + let now = || NewTimestamp::Now; + let mut results = vec![]; + for fd in [h.file, h.root, h.dir, h.nested] { + results.push(d.call_set_times(accessor, fd, now(), now()).await?); + results.push( + d.call_set_times(accessor, fd, NewTimestamp::NoChange, NewTimestamp::Now) + .await?, + ); + } + for (fd, path_flags, path) in [ + (h.root, PathFlags::empty(), "file.txt"), + (h.root, PathFlags::SYMLINK_FOLLOW, "link"), + (h.root, PathFlags::empty(), "link"), + (h.root, PathFlags::empty(), "empty"), + (h.dir, PathFlags::empty(), "nested.txt"), + ] { + results.push( + d.call_set_times_at(accessor, fd, path_flags, path.into(), now(), now()) + .await?, + ); + } + Ok(results) + }) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn syncing_is_denied() { + assert_denied(async |accessor, exports, h| { + let d = descriptor(exports); + let mut results = vec![]; + for fd in [h.file, h.root, h.nested] { + results.push(d.call_sync(accessor, fd).await?); + results.push(d.call_sync_data(accessor, fd).await?); + } + Ok(results) + }) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn creating_directories_is_denied() { + assert_denied(async |accessor, exports, h| { + let d = descriptor(exports); + Ok(vec![ + d.call_create_directory_at(accessor, h.root, "new".into()) + .await?, + d.call_create_directory_at(accessor, h.root, "empty/new".into()) + .await?, + d.call_create_directory_at(accessor, h.dir, "new".into()) + .await?, + // already exists, denied before the host is asked + d.call_create_directory_at(accessor, h.root, "empty".into()) + .await?, + ]) + }) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn removing_directories_is_denied() { + assert_denied(async |accessor, exports, h| { + let d = descriptor(exports); + Ok(vec![ + d.call_remove_directory_at(accessor, h.root, "empty".into()) + .await?, + d.call_remove_directory_at(accessor, h.root, "dir".into()) + .await?, + d.call_remove_directory_at(accessor, h.root, "missing".into()) + .await?, + ]) + }) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn unlinking_is_denied() { + assert_denied(async |accessor, exports, h| { + let d = descriptor(exports); + Ok(vec![ + d.call_unlink_file_at(accessor, h.root, "file.txt".into()) + .await?, + d.call_unlink_file_at(accessor, h.root, "link".into()) + .await?, + d.call_unlink_file_at(accessor, h.dir, "nested.txt".into()) + .await?, + d.call_unlink_file_at(accessor, h.root, "missing.txt".into()) + .await?, + ]) + }) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn renaming_is_denied() { + assert_denied(async |accessor, exports, h| { + let d = descriptor(exports); + Ok(vec![ + d.call_rename_at( + accessor, + h.root, + "file.txt".into(), + h.root, + "renamed.txt".into(), + ) + .await?, + d.call_rename_at( + accessor, + h.root, + "file.txt".into(), + h.dir, + "moved.txt".into(), + ) + .await?, + d.call_rename_at( + accessor, + h.dir, + "nested.txt".into(), + h.root, + "file.txt".into(), + ) + .await?, + d.call_rename_at(accessor, h.root, "empty".into(), h.root, "renamed".into()) + .await?, + d.call_rename_at( + accessor, + h.root, + "link".into(), + h.root, + "renamed-link".into(), + ) + .await?, + ]) + }) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn hard_linking_is_denied() { + assert_denied(async |accessor, exports, h| { + let d = descriptor(exports); + Ok(vec![ + d.call_link_at( + accessor, + h.root, + PathFlags::empty(), + "file.txt".into(), + h.root, + "hard.txt".into(), + ) + .await?, + d.call_link_at( + accessor, + h.root, + PathFlags::SYMLINK_FOLLOW, + "link".into(), + h.dir, + "hard.txt".into(), + ) + .await?, + d.call_link_at( + accessor, + h.dir, + PathFlags::empty(), + "nested.txt".into(), + h.root, + "hard.txt".into(), + ) + .await?, + ]) + }) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn symlinking_is_denied() { + assert_denied(async |accessor, exports, h| { + let d = descriptor(exports); + Ok(vec![ + d.call_symlink_at(accessor, h.root, "file.txt".into(), "new-link".into()) + .await?, + d.call_symlink_at(accessor, h.dir, "nested.txt".into(), "new-link".into()) + .await?, + // already exists, denied before the host is asked + d.call_symlink_at(accessor, h.root, "file.txt".into(), "link".into()) + .await?, + ]) + }) + .await; +} diff --git a/crates/test-harness/src/lib.rs b/crates/test-harness/src/lib.rs index 35ff044..73ed088 100644 --- a/crates/test-harness/src/lib.rs +++ b/crates/test-harness/src/lib.rs @@ -51,7 +51,7 @@ pub use wasmtime_wasi::FsPerms as Perms; /// Receive the items written to a guest stream on a channel. /// -/// Only for streams the guest writes itself, see [`resolve`]. +/// Only for streams the guest writes itself, or byte streams, see [`resolve`]. pub fn collect( accessor: &Accessor, stream: StreamReader, @@ -61,11 +61,21 @@ pub fn collect( Ok(rx) } +/// Create a stream for the guest that yields the items, then closes. +pub fn stream( + accessor: &Accessor, + items: Vec, +) -> Result> { + accessor.with(|store| StreamReader::new(store, items)) +} + /// Wait for the value of a guest future. /// /// Only for futures the guest writes itself. A future the guest passes through from a wasmtime-wasi /// host import is transferred host to host, which requires the same Rust type on both ends, and -/// the harness' exported types are generated separately from wasmtime-wasi's. +/// the harness' exported types are generated separately from wasmtime-wasi's. Bindgen's `with` +/// doesn't apply to exports, and wasmtime won't convert a host stream or future to another Rust +/// type. A byte stream has the same Rust type on both ends. pub async fn resolve( accessor: &Accessor, future: FutureReader,