From f48e9a3abdace9cfbf3799e0faa1252750168419 Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Fri, 15 May 2026 00:22:52 -0400 Subject: [PATCH 01/21] Filesystem gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intercept wasi:filesystem calls allowing per call access control decisions. The 'gate' is a component that virtualizes the wasi:filesystem interfaces and also import a latch interface. The latch defines the access control check and returns a decision. For any call, a latch can either allow, deny or abstain. Denials include an error-code as the reason. Latches can be composed together to define more advanced behavior. The latch-N components compose N latches together. An allow or deny decision is returned immediately while an abstain decision allows the next latch to have an opinion. For example, if we want to make a component's access to the filesystem be read-only while other parts of the runtime need read-write, we can compose the ro-consume with a gate, while the rw-consumer has full access to the filesystem. The latch is itself a composition of two other latches, the first is the read-only latch which denies calls that would modify the filesystem. The allow latch approves calls that have otherwise not been denied. The composition enabled additional behavior be added, such as denying access to certain directories, or any other. behavior a use chooses to implement. Replace the latch2 component with latch3 when there is a third latch to orchestrate. ``` host ↗ ↖ ↗ latch-readonly rw-consumer gate → latch2 ↑ ↘ latch-allow ro-consumer ``` Signed-off-by: Scott Andrews --- Cargo.lock | 65 ++ Cargo.toml | 10 + README.md | 9 +- components/gate/Cargo.toml | 13 + components/gate/README.md | 3 + components/gate/src/lib.rs | 1134 +++++++++++++++++++++++++ components/gate/src/path.rs | 218 +++++ components/latch-defer-all/Cargo.toml | 11 + components/latch-defer-all/README.md | 3 + components/latch-defer-all/src/lib.rs | 29 + components/latch-deny-all/Cargo.toml | 11 + components/latch-deny-all/README.md | 3 + components/latch-deny-all/src/lib.rs | 29 + components/latch-n2/Cargo.toml | 11 + components/latch-n2/README.md | 15 + components/latch-n2/src/lib.rs | 22 + components/latch-n3/Cargo.toml | 11 + components/latch-n3/README.md | 16 + components/latch-n3/src/lib.rs | 26 + components/latch-n4/Cargo.toml | 11 + components/latch-n4/README.md | 17 + components/latch-n4/src/lib.rs | 32 + components/latch-n5/Cargo.toml | 11 + components/latch-n5/README.md | 18 + components/latch-n5/src/lib.rs | 34 + components/latch-readonly/Cargo.toml | 11 + components/latch-readonly/README.md | 3 + components/latch-readonly/src/lib.rs | 85 ++ components/wit/worlds.wit | 17 + crates/filesystem-latch-n/Cargo.toml | 8 + crates/filesystem-latch-n/src/lib.rs | 54 ++ wit/latch.wit | 189 +++++ wit/worlds.wit | 3 + wkg.lock | 18 + 34 files changed, 2149 insertions(+), 1 deletion(-) create mode 100644 components/gate/Cargo.toml create mode 100644 components/gate/README.md create mode 100644 components/gate/src/lib.rs create mode 100644 components/gate/src/path.rs create mode 100644 components/latch-defer-all/Cargo.toml create mode 100644 components/latch-defer-all/README.md create mode 100644 components/latch-defer-all/src/lib.rs create mode 100644 components/latch-deny-all/Cargo.toml create mode 100644 components/latch-deny-all/README.md create mode 100644 components/latch-deny-all/src/lib.rs create mode 100644 components/latch-n2/Cargo.toml create mode 100644 components/latch-n2/README.md create mode 100644 components/latch-n2/src/lib.rs create mode 100644 components/latch-n3/Cargo.toml create mode 100644 components/latch-n3/README.md create mode 100644 components/latch-n3/src/lib.rs create mode 100644 components/latch-n4/Cargo.toml create mode 100644 components/latch-n4/README.md create mode 100644 components/latch-n4/src/lib.rs create mode 100644 components/latch-n5/Cargo.toml create mode 100644 components/latch-n5/README.md create mode 100644 components/latch-n5/src/lib.rs create mode 100644 components/latch-readonly/Cargo.toml create mode 100644 components/latch-readonly/README.md create mode 100644 components/latch-readonly/src/lib.rs create mode 100644 crates/filesystem-latch-n/Cargo.toml create mode 100644 crates/filesystem-latch-n/src/lib.rs create mode 100644 wit/latch.wit diff --git a/Cargo.lock b/Cargo.lock index 6af5986..c9b9ea1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -516,6 +516,13 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "filesystem-latch-n" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -638,6 +645,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "gate" +version = "0.1.0" +dependencies = [ + "chrono", + "heck", + "wit-bindgen", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -936,6 +952,55 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "latch-defer-all" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "latch-deny-all" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "latch-n2" +version = "0.1.0" +dependencies = [ + "filesystem-latch-n", +] + +[[package]] +name = "latch-n3" +version = "0.1.0" +dependencies = [ + "filesystem-latch-n", +] + +[[package]] +name = "latch-n4" +version = "0.1.0" +dependencies = [ + "filesystem-latch-n", +] + +[[package]] +name = "latch-n5" +version = "0.1.0" +dependencies = [ + "filesystem-latch-n", +] + +[[package]] +name = "latch-readonly" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "leb128" version = "0.2.7" diff --git a/Cargo.toml b/Cargo.toml index 2589883..463db6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,14 +2,24 @@ resolver = "2" members = [ "components/chroot", + "components/gate", + "components/latch-n2", + "components/latch-n3", + "components/latch-n4", + "components/latch-n5", + "components/latch-defer-all", + "components/latch-deny-all", + "components/latch-readonly", "components/readonly", "components/trace", + "crates/filesystem-latch-n", "crates/test-harness", ] [workspace.dependencies] chrono = { git = "https://github.com/chronotope/chrono.git", branch = "0.5.x" } componentized-rt = { path = "./crates/componentized-rt" } +filesystem-latch-n = { path = "./crates/filesystem-latch-n" } heck = "0.5" tempfile = "3" test-harness = { path = "./crates/test-harness" } diff --git a/README.md b/README.md index 86f9f8f..68eab4c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,14 @@ A collection of utility components that remix wasi:filesystem types and interfac ## Components - [`chroot`](./components/chroot/) -- [`readonly`](./components/readonly/) +- [`gate`](./components/gate/) +- [`latch-2`](./components/latch-2/) +- [`latch-3`](./components/latch-3/) +- [`latch-4`](./components/latch-4/) +- [`latch-allow`](./components/latch-allow/) +- [`latch-deny`](./components/latch-deny/) +- [`latch-readonly`](./components/latch-readonly/) +- ~~[`readonly`](./components/readonly/)~~ (deprecated, favor gate with readonly latch) - [`trace`](./components/trace/) ## Build diff --git a/components/gate/Cargo.toml b/components/gate/Cargo.toml new file mode 100644 index 0000000..fe14ecf --- /dev/null +++ b/components/gate/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "gate" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +chrono = { workspace = true } +heck = { workspace = true } +wit-bindgen = { workspace = true } diff --git a/components/gate/README.md b/components/gate/README.md new file mode 100644 index 0000000..2a982e1 --- /dev/null +++ b/components/gate/README.md @@ -0,0 +1,3 @@ +# `gate` + +Filesystem gate access control. diff --git a/components/gate/src/lib.rs b/components/gate/src/lib.rs new file mode 100644 index 0000000..d32fe3b --- /dev/null +++ b/components/gate/src/lib.rs @@ -0,0 +1,1134 @@ +use std::fmt::Display; + +use chrono::DateTime; +use heck::ToKebabCase; + +use exports::wasi::filesystem::preopens::Guest as Preopens; +use exports::wasi::filesystem::types::{ + Advice, Descriptor, DescriptorBorrow, DescriptorFlags, DescriptorStat, DescriptorType, + DirectoryEntry, ErrorCode, Filesize, Guest as Types, MetadataHashValue, NewTimestamp, + OpenFlags, PathFlags, +}; +use wasi::filesystem::preopens; +use wasi::filesystem::types; +use wasi::logging::logging::{log, Level}; + +mod path; + +use path::Location; + +use crate::componentized::filesystem::latch::{ + self, Decision, DescriptorAdviseArgs, DescriptorCreateDirectoryAtArgs, DescriptorLinkAtArgs, + DescriptorMetadataHashAtArgs, DescriptorOpenAtArgs, DescriptorOperation, + DescriptorReadlinkAtArgs, DescriptorRemoveDirectoryAtArgs, DescriptorRenameAtArgs, + DescriptorSetSizeArgs, DescriptorSetTimesArgs, DescriptorSetTimesAtArgs, DescriptorStatAtArgs, + DescriptorSymlinkAtArgs, DescriptorUnlinkFileAtArgs, PreopensOperation, +}; +use crate::componentized::filesystem::latch::{ + Decision::{Deferred, Denied}, + DescriptorReadViaStreamArgs, ErrorCode as LatchErrorCode, Operation, +}; + +macro_rules! error { + ($dst:expr, $($arg:tt)*) => { + log(Level::Error, "componentized-gate", &format!($dst, $($arg)*)); + }; + ($dst:expr) => { + log(Level::Error, "componentized-gate", &format!($dst)); + }; +} + +macro_rules! warn { + ($dst:expr, $($arg:tt)*) => { + log(Level::Warn, "componentized-gate", &format!($dst, $($arg)*)); + }; + ($dst:expr) => { + log(Level::Warn, "componentized-gate", &format!($dst)); + }; +} + +macro_rules! trace { + ($dst:expr, $($arg:tt)*) => { + log(Level::Trace, "componentized-gate", &format!($dst, $($arg)*)); + }; + ($dst:expr) => { + log(Level::Trace, "componentized-gate", &format!($dst)); + }; +} + +/// Authorize the operation with the latch, then report the decision back to the latch. Latches +/// act on the final decision in `observe-decision`, never while authorizing. Failing to observe +/// the decision fails the operation, the same as a latch error. +fn authorize(operation: &Operation) -> Result { + let decision = latch::authorize(operation)?; + latch::observe_decision(&decision, operation)?; + Ok(decision) +} + +struct GatedFilesystem {} + +impl Preopens for GatedFilesystem { + #[doc = "/ Return the set of preopened directories, and their paths."] + #[allow(async_fn_in_trait)] + fn get_directories() -> Vec<(Descriptor, String)> { + let call_summary = || format!("OPERATION=wasi:filesystem/preopens#get-directories"); + match authorize(&Operation::Preopens(PreopensOperation::GetDirectories)) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + vec![] + } + Ok(Deferred) => preopens::get_directories() + .into_iter() + .filter(|(fd, path)| { + match authorize(&Operation::Preopens(PreopensOperation::GetDirectoriesItem( + (fd, path.clone()), + ))) { + Ok(Denied(reason)) => { + trace!("Denied REASON={reason} {} PATH={path}", call_summary()); + false + } + _ => true, + } + }) + .map(|(fd, path)| { + ( + Descriptor::new(GatedDescriptor::new(fd, Location::preopen(&path))), + path, + ) + }) + .collect(), + Err(code) => { + panic!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ) + } + } + } +} + +impl Types for GatedFilesystem { + type Descriptor = GatedDescriptor; +} + +struct GatedDescriptor { + fd: types::Descriptor, + /// What the descriptor is, the base `PATH` arguments are resolved against. + location: Location, +} + +impl GatedDescriptor { + fn new(fd: types::Descriptor, location: Location) -> Self { + Self { fd, location } + } +} + +impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { + #[doc = "/ Return a stream for reading from a file."] + #[doc = "/"] + #[doc = "/ Multiple read, write, and append streams may be active on the same open"] + #[doc = "/ file and they do not interfere with each other."] + #[doc = "/"] + #[doc = "/ This function returns a `stream` which provides the data received from the"] + #[doc = "/ file, and a `future` providing additional error information in case an"] + #[doc = "/ error is encountered."] + #[doc = "/"] + #[doc = "/ If no error is encountered, `stream.read` on the `stream` will return"] + #[doc = "/ `read-status::closed` with no `error-context` and the future resolves to"] + #[doc = "/ the value `ok`. If an error is encountered, `stream.read` on the"] + #[doc = "/ `stream` returns `read-status::closed` with an `error-context` and the future"] + #[doc = "/ resolves to `err` with an `error-code`."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `pread` in POSIX."] + #[allow(async_fn_in_trait)] + fn read_via_stream( + &self, + offset: Filesize, + ) -> ( + wit_bindgen::StreamReader, + wit_bindgen::FutureReader>, + ) { + let call_summary = || { + format!( + "OPERATION=wasi:filesystem/types#descriptor.read-via-stream FD={self} OFFSET={offset}" + ) + }; + match authorize(&Operation::Descriptor(DescriptorOperation::ReadViaStream( + (&self.fd, DescriptorReadViaStreamArgs { offset }), + ))) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason)? + } + Ok(Deferred) => self.fd.read_via_stream(offset), + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Return a stream for writing to a file, if available."] + #[doc = "/"] + #[doc = "/ May fail with an error-code describing why the file cannot be written."] + #[doc = "/"] + #[doc = "/ It is valid to write past the end of a file; the file is extended to the"] + #[doc = "/ extent of the write, with bytes between the previous end and the start of"] + #[doc = "/ the write set to zero."] + #[doc = "/"] + #[doc = "/ This function returns once either full contents of the stream are"] + #[doc = "/ written or an error is encountered."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `pwrite` in POSIX."] + #[allow(async_fn_in_trait)] + fn write_via_stream( + &self, + data: wit_bindgen::StreamReader, + offset: Filesize, + ) -> wit_bindgen::FutureReader> { + let call_summary = || { + format!( + "OPERATION=wasi:filesystem/types#descriptor.write-via-stream FD={self} OFFSET={offset}" + ) + }; + match authorize(&Operation::Descriptor(DescriptorOperation::WriteViaStream( + (&self.fd, DescriptorWriteViaStreamArgs { offset }), + ))) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason)? + } + Ok(Deferred) => self.fd.write_via_stream(data, offset), + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Return a stream for appending to a file, if available."] + #[doc = "/"] + #[doc = "/ May fail with an error-code describing why the file cannot be appended."] + #[doc = "/"] + #[doc = "/ This function returns once either full contents of the stream are"] + #[doc = "/ written or an error is encountered."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `write` with `O_APPEND` in POSIX."] + #[allow(async_fn_in_trait)] + fn append_via_stream( + &self, + data: wit_bindgen::StreamReader, + ) -> wit_bindgen::FutureReader> { + let call_summary = + || format!("OPERATION=wasi:filesystem/types#descriptor.append-via-stream FD={self}"); + match authorize(&Operation::Descriptor( + DescriptorOperation::AppendViaStream((&self.fd,)), + )) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason)? + } + Ok(Deferred) => self.fd.append_via_stream(data), + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Provide file advisory information on a descriptor."] + #[doc = "/"] + #[doc = "/ This is similar to `posix_fadvise` in POSIX."] + #[allow(async_fn_in_trait)] + async fn advise( + &self, + offset: Filesize, + length: Filesize, + advice: Advice, + ) -> Result<(), ErrorCode> { + let call_summary = || { + format!( + "OPERATION=wasi:filesystem/types#descriptor.advise FD={self} OFFSET={offset} LENGTH={length} ADVICE={advice}" + ) + }; + match authorize(&Operation::Descriptor(DescriptorOperation::Advise(( + &self.fd, + DescriptorAdviseArgs { + offset, + length, + advice, + }, + )))) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason)? + } + Ok(Deferred) => self.fd.advise(offset, length, advice).await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Synchronize the data of a file to disk."] + #[doc = "/"] + #[doc = "/ This function succeeds with no effect if the file descriptor is not"] + #[doc = "/ opened for writing."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `fdatasync` in POSIX."] + #[allow(async_fn_in_trait)] + async fn sync_data(&self) -> Result<(), ErrorCode> { + let call_summary = + || format!("OPERATION=wasi:filesystem/types#descriptor.sync-data FD={self}"); + match authorize(&Operation::Descriptor(DescriptorOperation::SyncData(( + &self.fd, + )))) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason)? + } + Ok(Deferred) => self.fd.sync_data().await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Get flags associated with a descriptor."] + #[doc = "/"] + #[doc = "/ Note: This returns similar flags to `fcntl(fd, F_GETFL)` in POSIX."] + #[doc = "/"] + #[doc = "/ Note: This returns the value that was the `fs_flags` value returned"] + #[doc = "/ from `fdstat_get` in earlier versions of WASI."] + #[allow(async_fn_in_trait)] + async fn get_flags(&self) -> Result { + let call_summary = + || format!("OPERATION=wasi:filesystem/types#descriptor.get-flags FD={self}"); + match authorize(&Operation::Descriptor(DescriptorOperation::GetFlags(( + &self.fd, + )))) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason) + } + Ok(Deferred) => self.fd.get_flags().await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Get the dynamic type of a descriptor."] + #[doc = "/"] + #[doc = "/ Note: This returns the same value as the `type` field of the `fd-stat`"] + #[doc = "/ returned by `stat`, `stat-at` and similar."] + #[doc = "/"] + #[doc = "/ Note: This returns similar flags to the `st_mode & S_IFMT` value provided"] + #[doc = "/ by `fstat` in POSIX."] + #[doc = "/"] + #[doc = "/ Note: This returns the value that was the `fs_filetype` value returned"] + #[doc = "/ from `fdstat_get` in earlier versions of WASI."] + #[allow(async_fn_in_trait)] + async fn get_type(&self) -> Result { + let call_summary = + || format!("OPERATION=wasi:filesystem/types#descriptor.get-type FD={self}"); + match authorize(&Operation::Descriptor(DescriptorOperation::GetType(( + &self.fd, + )))) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason) + } + Ok(Deferred) => self.fd.get_type().await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Adjust the size of an open file. If this increases the file\'s size, the"] + #[doc = "/ extra bytes are filled with zeros."] + #[doc = "/"] + #[doc = "/ Note: This was called `fd_filestat_set_size` in earlier versions of WASI."] + #[allow(async_fn_in_trait)] + async fn set_size(&self, size: Filesize) -> Result<(), ErrorCode> { + let call_summary = + || format!("OPERATION=wasi:filesystem/types#descriptor.set-size FD={self} SIZE={size}"); + match authorize(&Operation::Descriptor(DescriptorOperation::SetSize(( + &self.fd, + DescriptorSetSizeArgs { size }, + )))) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason) + } + Ok(Deferred) => self.fd.set_size(size).await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Adjust the timestamps of an open file or directory."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `futimens` in POSIX."] + #[doc = "/"] + #[doc = "/ Note: This was called `fd_filestat_set_times` in earlier versions of WASI."] + #[allow(async_fn_in_trait)] + async fn set_times( + &self, + data_access_timestamp: NewTimestamp, + data_modification_timestamp: NewTimestamp, + ) -> Result<(), ErrorCode> { + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.set-times FD={self} ACCESS-TIMESTAMP={data_access_timestamp} MODIFICATION-TIMESTAMP={data_modification_timestamp}") + }; + match authorize(&Operation::Descriptor(DescriptorOperation::SetTimes(( + &self.fd, + DescriptorSetTimesArgs { + data_access_timestamp, + data_modification_timestamp, + }, + )))) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason) + } + Ok(Deferred) => { + self.fd + .set_times(data_access_timestamp, data_modification_timestamp) + .await + } + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Read directory entries from a directory."] + #[doc = "/"] + #[doc = "/ On filesystems where directories contain entries referring to themselves"] + #[doc = "/ and their parents, often named `.` and `..` respectively, these entries"] + #[doc = "/ are omitted."] + #[doc = "/"] + #[doc = "/ This always returns a new stream which starts at the beginning of the"] + #[doc = "/ directory. Multiple streams may be active on the same directory, and they"] + #[doc = "/ do not interfere with each other."] + #[doc = "/"] + #[doc = "/ This function returns a future, which will resolve to an error code if"] + #[doc = "/ reading full contents of the directory fails."] + #[allow(async_fn_in_trait)] + fn read_directory( + &self, + ) -> ( + wit_bindgen::StreamReader, + wit_bindgen::FutureReader>, + ) { + let call_summary = + || format!("OPERATION=wasi:filesystem/types#descriptor.read-directory FD={self}"); + match authorize(&Operation::Descriptor(DescriptorOperation::ReadDirectory( + (&self.fd,), + ))) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason) + } + Ok(Deferred) => self.fd.read_directory(), + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Synchronize the data and metadata of a file to disk."] + #[doc = "/"] + #[doc = "/ This function succeeds with no effect if the file descriptor is not"] + #[doc = "/ opened for writing."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `fsync` in POSIX."] + #[allow(async_fn_in_trait)] + async fn sync(&self) -> Result<(), ErrorCode> { + let call_summary = || format!("OPERATION=wasi:filesystem/types#descriptor.sync FD={self}"); + match authorize(&Operation::Descriptor(DescriptorOperation::Sync(( + &self.fd, + )))) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason) + } + Ok(Deferred) => self.fd.sync().await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Create a directory."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `mkdirat` in POSIX."] + #[allow(async_fn_in_trait)] + async fn create_directory_at(&self, path: String) -> Result<(), ErrorCode> { + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.create-directory-at FD={self} PATH={path}") + }; + match authorize(&Operation::Descriptor( + DescriptorOperation::CreateDirectoryAt(( + &self.fd, + DescriptorCreateDirectoryAtArgs { path }, + )), + )) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason) + } + Ok(Deferred) => self.fd.create_directory_at(path).await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Return the attributes of an open file or directory."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `fstat` in POSIX, except that it does not return"] + #[doc = "/ device and inode information. For testing whether two descriptors refer to"] + #[doc = "/ the same underlying filesystem object, use `is-same-object`. To obtain"] + #[doc = "/ additional data that can be used do determine whether a file has been"] + #[doc = "/ modified, use `metadata-hash`."] + #[doc = "/"] + #[doc = "/ Note: This was called `fd_filestat_get` in earlier versions of WASI."] + #[allow(async_fn_in_trait)] + async fn stat(&self) -> Result { + let call_summary = || format!("OPERATION=wasi:filesystem/types#descriptor.stat FD={self}"); + match authorize(&Operation::Descriptor(DescriptorOperation::Stat(( + &self.fd, + )))) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason) + } + Ok(Deferred) => self.fd.stat().await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Return the attributes of a file or directory."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `fstatat` in POSIX, except that it does not"] + #[doc = "/ return device and inode information. See the `stat` description for a"] + #[doc = "/ discussion of alternatives."] + #[doc = "/"] + #[doc = "/ Note: This was called `path_filestat_get` in earlier versions of WASI."] + #[allow(async_fn_in_trait)] + async fn stat_at( + &self, + path_flags: PathFlags, + path: String, + ) -> Result { + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.stat-at FD={self} PATH-FLAGS={path_flags} PATH={path}") + }; + match authorize(&Operation::Descriptor(DescriptorOperation::StatAt(( + &self.fd, + DescriptorStatAtArgs { + path_flags, + path: path.clone(), + }, + )))) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason) + } + Ok(Deferred) => self.fd.stat_at(path_flags, path).await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Adjust the timestamps of a file or directory."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `utimensat` in POSIX."] + #[doc = "/"] + #[doc = "/ Note: This was called `path_filestat_set_times` in earlier versions of"] + #[doc = "/ WASI."] + #[allow(async_fn_in_trait)] + async fn set_times_at( + &self, + path_flags: PathFlags, + path: String, + data_access_timestamp: NewTimestamp, + data_modification_timestamp: NewTimestamp, + ) -> Result<(), ErrorCode> { + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.set-times-at FD={self} PATH-FLAGS={path_flags} PATH={path} ACCESS-TIMESTAMP={data_access_timestamp} MODIFICATION-TIMESTAMP={data_modification_timestamp}") + }; + match authorize(&Operation::Descriptor(DescriptorOperation::SetTimesAt(( + &self.fd, + DescriptorSetTimesAtArgs { + path_flags, + path: path.clone(), + data_access_timestamp, + data_modification_timestamp, + }, + )))) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason) + } + Ok(Deferred) => { + self.fd + .set_times_at( + path_flags, + path, + data_access_timestamp, + data_modification_timestamp, + ) + .await + } + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Create a hard link."] + #[doc = "/"] + #[doc = "/ Fails with `error-code::no-entry` if the old path does not exist,"] + #[doc = "/ with `error-code::exist` if the new path already exists, and"] + #[doc = "/ `error-code::not-permitted` if the old path is not a file."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `linkat` in POSIX."] + #[allow(async_fn_in_trait)] + async fn link_at( + &self, + old_path_flags: PathFlags, + old_path: String, + new_descriptor: DescriptorBorrow<'_>, + new_path: String, + ) -> Result<(), ErrorCode> { + let new_descriptor: &Self = new_descriptor.get(); + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.link-at FD={self} OLD-PATH-FLAGS={old_path_flags} OLD-PATH={old_path} NEW-DESCRIPTOR={new_descriptor} NEW-PATH={new_path}") + }; + match authorize(&Operation::Descriptor(DescriptorOperation::LinkAt(( + &self.fd, + DescriptorLinkAtArgs { + old_path_flags, + old_path: old_path.clone(), + new_descriptor: &new_descriptor.fd, + new_path: new_path.clone(), + }, + )))) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason) + } + Ok(Deferred) => { + self.fd + .link_at(old_path_flags, old_path, &new_descriptor.fd, new_path) + .await + } + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Open a file or directory."] + #[doc = "/"] + #[doc = "/ If `flags` contains `descriptor-flags::mutate-directory`, and the base"] + #[doc = "/ descriptor doesn\'t have `descriptor-flags::mutate-directory` set,"] + #[doc = "/ `open-at` fails with `error-code::read-only`."] + #[doc = "/"] + #[doc = "/ If `flags` contains `write` or `mutate-directory`, or `open-flags`"] + #[doc = "/ contains `truncate` or `create`, and the base descriptor doesn\'t have"] + #[doc = "/ `descriptor-flags::mutate-directory` set, `open-at` fails with"] + #[doc = "/ `error-code::read-only`."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `openat` in POSIX."] + #[allow(async_fn_in_trait)] + async fn open_at( + &self, + path_flags: PathFlags, + path: String, + open_flags: OpenFlags, + flags: DescriptorFlags, + ) -> Result { + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.open-at FD={self} PATH-FLAGS={path_flags} PATH={path} OPEN-FLAGS={open_flags} FLAGS={flags}") + }; + match authorize(&Operation::Descriptor(DescriptorOperation::OpenAt(( + &self.fd, + DescriptorOpenAtArgs { + path_flags, + path: path.clone(), + open_flags, + flags, + }, + )))) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason) + } + Ok(Deferred) => { + let fd = self + .fd + .open_at(path_flags, path.clone(), open_flags, flags) + .await?; + // the host opened the path, so it is resolved against this descriptor + let follow = path_flags.contains(PathFlags::SYMLINK_FOLLOW); + let location = self + .location + .open(&path, follow, async |path: &str| { + self.fd.readlink_at(path.to_string()).await.ok() + }) + .await; + Ok(Descriptor::new(GatedDescriptor::new(fd, location))) + } + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Read the contents of a symbolic link."] + #[doc = "/"] + #[doc = "/ If the contents contain an absolute or rooted path in the underlying"] + #[doc = "/ filesystem, this function fails with `error-code::not-permitted`."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `readlinkat` in POSIX."] + #[allow(async_fn_in_trait)] + async fn readlink_at(&self, path: String) -> Result { + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.readlink-at FD={self} PATH={path}") + }; + match authorize(&Operation::Descriptor(DescriptorOperation::ReadlinkAt(( + &self.fd, + DescriptorReadlinkAtArgs { path: path.clone() }, + )))) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason) + } + Ok(Deferred) => self.fd.readlink_at(path).await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Remove a directory."] + #[doc = "/"] + #[doc = "/ Return `error-code::not-empty` if the directory is not empty."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `unlinkat(fd, path, AT_REMOVEDIR)` in POSIX."] + #[allow(async_fn_in_trait)] + async fn remove_directory_at(&self, path: String) -> Result<(), ErrorCode> { + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.remove-directory-at FD={self} PATH={path}") + }; + match authorize(&Operation::Descriptor( + DescriptorOperation::RemoveDirectoryAt(( + &self.fd, + DescriptorRemoveDirectoryAtArgs { path: path.clone() }, + )), + )) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason) + } + Ok(Deferred) => self.fd.remove_directory_at(path).await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Rename a filesystem object."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `renameat` in POSIX."] + #[allow(async_fn_in_trait)] + async fn rename_at( + &self, + old_path: String, + new_descriptor: DescriptorBorrow<'_>, + new_path: String, + ) -> Result<(), ErrorCode> { + let new_descriptor: &Self = new_descriptor.get(); + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.rename-at FD={self} OLD-PATH={old_path} NEW-DESCRIPTOR={new_descriptor} NEW-PATH={new_path}") + }; + match authorize(&Operation::Descriptor(DescriptorOperation::RenameAt(( + &self.fd, + DescriptorRenameAtArgs { + old_path: old_path.clone(), + new_descriptor: &new_descriptor.fd, + new_path: new_path.clone(), + }, + )))) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason) + } + Ok(Deferred) => { + self.fd + .rename_at(old_path, &new_descriptor.fd, new_path) + .await + } + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Create a symbolic link (also known as a \"symlink\")."] + #[doc = "/"] + #[doc = "/ If `old-path` starts with `/`, the function fails with"] + #[doc = "/ `error-code::not-permitted`."] + #[doc = "/"] + #[doc = "/ Note: This is similar to `symlinkat` in POSIX."] + #[allow(async_fn_in_trait)] + async fn symlink_at(&self, old_path: String, new_path: String) -> Result<(), ErrorCode> { + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.symlink-at FD={self} OLD-PATH={old_path} NEW-PATH={new_path}") + }; + match authorize(&Operation::Descriptor(DescriptorOperation::SymlinkAt(( + &self.fd, + DescriptorSymlinkAtArgs { + old_path: old_path.clone(), + new_path: new_path.clone(), + }, + )))) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason) + } + Ok(Deferred) => self.fd.symlink_at(old_path, new_path).await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Unlink a filesystem object that is not a directory."] + #[doc = "/"] + #[doc = "/ This is similar to `unlinkat(fd, path, 0)` in POSIX."] + #[doc = "/"] + #[doc = "/ Error returns are as specified by POSIX."] + #[doc = "/"] + #[doc = "/ If the filesystem object is a directory, `error-code::access` or"] + #[doc = "/ `error-code::is-directory` may be returned instead of the"] + #[doc = "/ POSIX-specified `error-code::not-permitted`."] + #[allow(async_fn_in_trait)] + async fn unlink_file_at(&self, path: String) -> Result<(), ErrorCode> { + let call_summary = || { + format!( + "OPERATION=wasi:filesystem/types#descriptor.unlink-file-at FD={self} PATH={path}" + ) + }; + match authorize(&Operation::Descriptor(DescriptorOperation::UnlinkFileAt(( + &self.fd, + DescriptorUnlinkFileAtArgs { path: path.clone() }, + )))) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason) + } + Ok(Deferred) => self.fd.unlink_file_at(path).await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Test whether two descriptors refer to the same filesystem object."] + #[doc = "/"] + #[doc = "/ In POSIX, this corresponds to testing whether the two descriptors have the"] + #[doc = "/ same device (`st_dev`) and inode (`st_ino` or `d_ino`) numbers."] + #[doc = "/ wasi-filesystem does not expose device and inode numbers, so this function"] + #[doc = "/ may be used instead."] + #[allow(async_fn_in_trait)] + async fn is_same_object(&self, other: DescriptorBorrow<'_>) -> bool { + let other: &Self = other.get(); + self.fd.is_same_object(&other.fd).await + } + + #[doc = "/ Return a hash of the metadata associated with a filesystem object referred"] + #[doc = "/ to by a descriptor."] + #[doc = "/"] + #[doc = "/ This returns a hash of the last-modification timestamp and file size, and"] + #[doc = "/ may also include the inode number, device number, birth timestamp, and"] + #[doc = "/ other metadata fields that may change when the file is modified or"] + #[doc = "/ replaced. It may also include a secret value chosen by the"] + #[doc = "/ implementation and not otherwise exposed."] + #[doc = "/"] + #[doc = "/ Implementations are encouraged to provide the following properties:"] + #[doc = "/"] + #[doc = "/ - If the file is not modified or replaced, the computed hash value should"] + #[doc = "/ usually not change."] + #[doc = "/ - If the object is modified or replaced, the computed hash value should"] + #[doc = "/ usually change."] + #[doc = "/ - The inputs to the hash should not be easily computable from the"] + #[doc = "/ computed hash."] + #[doc = "/"] + #[doc = "/ However, none of these is required."] + #[allow(async_fn_in_trait)] + async fn metadata_hash(&self) -> Result { + let call_summary = + || format!("OPERATION=wasi:filesystem/types#descriptor.metadata-hash FD={self}"); + match authorize(&Operation::Descriptor(DescriptorOperation::MetadataHash(( + &self.fd, + )))) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason) + } + Ok(Deferred) => self.fd.metadata_hash().await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } + + #[doc = "/ Return a hash of the metadata associated with a filesystem object referred"] + #[doc = "/ to by a directory descriptor and a relative path."] + #[doc = "/"] + #[doc = "/ This performs the same hash computation as `metadata-hash`."] + #[allow(async_fn_in_trait)] + async fn metadata_hash_at( + &self, + path_flags: PathFlags, + path: String, + ) -> Result { + let call_summary = || { + format!("OPERATION=wasi:filesystem/types#descriptor.metadata-hash-at FD={self} PATH-FLAGS={path_flags} PATH={path}") + }; + match authorize(&Operation::Descriptor(DescriptorOperation::MetadataHashAt( + ( + &self.fd, + DescriptorMetadataHashAtArgs { + path_flags, + path: path.clone(), + }, + ), + ))) { + Ok(Denied(reason)) => { + warn!("Denied REASON={reason} {}", call_summary()); + Err(reason) + } + Ok(Deferred) => self.fd.metadata_hash_at(path_flags, path).await, + Err(code) => { + error!( + "Latch error CODE={code} {summary}", + summary = call_summary() + ); + Err(code)? + } + } + } +} + +impl From for ErrorCode { + fn from(value: LatchErrorCode) -> Self { + match value { + LatchErrorCode::InvalidConfig(latch) => { + Self::Other(Some(format!("latch-error: invalid-config<{latch}>"))) + } + LatchErrorCode::ObservationFailed(latch) => { + Self::Other(Some(format!("latch-error: observation-failed<{latch}>"))) + } + LatchErrorCode::Other(Some(message)) => { + Self::Other(Some(format!("latch-error: {message}"))) + } + LatchErrorCode::Other(None) => Self::Other(Some("latch-error".to_string())), + } + } +} + +struct DisplayLatchError<'a>(&'a LatchErrorCode); +impl std::fmt::Display for DisplayLatchError<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self(LatchErrorCode::InvalidConfig(latch)) => { + write!(f, "invalid-config<{latch}>") + } + Self(LatchErrorCode::ObservationFailed(latch)) => { + write!(f, "observation-failed<{latch}>") + } + Self(LatchErrorCode::Other(Some(message))) => f.write_str(message), + Self(LatchErrorCode::Other(None)) => f.write_str("other"), + } + } +} + +impl Display for GatedDescriptor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.location.fmt(f) + } +} + +impl Display for types::Advice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + types::Advice::Normal => "normal", + types::Advice::Sequential => "sequential", + types::Advice::Random => "random", + types::Advice::WillNeed => "will-need", + types::Advice::DontNeed => "dont-need", + types::Advice::NoReuse => "no-reuse", + }) + } +} + +impl Display for types::DescriptorFlags { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let names: Vec = self + .iter_names() + .map(|(name, _flags)| name.to_kebab_case()) + .collect(); + f.write_fmt(format_args!("({})", &names.join("|"))) + } +} + +impl Display for types::OpenFlags { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let names: Vec = self + .iter_names() + .map(|(name, _flags)| name.to_kebab_case()) + .collect(); + f.write_fmt(format_args!("({})", &names.join("|"))) + } +} + +impl Display for types::PathFlags { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let names: Vec = self + .iter_names() + .map(|(name, _flags)| name.to_kebab_case()) + .collect(); + f.write_fmt(format_args!("({})", &names.join("|"))) + } +} + +impl Display for types::NewTimestamp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + types::NewTimestamp::NoChange => f.write_str("no-change"), + types::NewTimestamp::Now => f.write_str("now"), + types::NewTimestamp::Timestamp(instant) => { + f.write_fmt(format_args!("timestamp<{instant}>")) + } + } + } +} + +impl Display for types::Instant { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let date = DateTime::from_timestamp(self.seconds as i64, self.nanoseconds) + .unwrap() + .format("%Y-%m-%d %H:%M:%S.%3fZ"); + + f.write_fmt(format_args!("{date}")) + } +} + +wit_bindgen::generate!({ + path: "../wit", + world: "filesystem", + merge_structurally_equal_types: true, + generate_all +}); + +export!(GatedFilesystem); diff --git a/components/gate/src/path.rs b/components/gate/src/path.rs new file mode 100644 index 0000000..6fed128 --- /dev/null +++ b/components/gate/src/path.rs @@ -0,0 +1,218 @@ +//! The physical path of a descriptor, for its label in the trace. +//! +//! A descriptor opened with `open-at` is labeled with the path of the directory or file it is, the +//! path `PATH` arguments of later calls are resolved against: `.` and `..` are applied, and +//! symlinks are followed where the host follows them, every component but the last, and the last +//! with `symlink-follow`. + +/// The most symlinks followed resolving one path, more is a loop, see `ELOOP` in POSIX. +const MAX_SYMLINKS: usize = 40; + +/// Where a descriptor is, the preopen it was opened from and the components of its path within it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Location { + preopen: String, + components: Vec, +} + +impl Location { + /// The location of a preopen, labeled with its name, e.g. `/` or `.`. + pub fn preopen(name: &str) -> Self { + Self { + preopen: name.to_string(), + components: vec![], + } + } + + /// Resolve a path opened from this location. + /// + /// `readlink` reads a symlink at a path relative to this location, `None` when the path is not + /// a symlink. The host already opened the path, so it names a location below this one; a + /// symlink with an absolute target, which the host doesn't follow, is left unresolved. + pub async fn open( + &self, + path: &str, + follow: bool, + mut readlink: impl AsyncFnMut(&str) -> Option, + ) -> Self { + // components relative to this location, still to be resolved, and resolved + let mut pending: Vec = split(path).rev().map(String::from).collect(); + let mut resolved: Vec = vec![]; + let mut symlinks = 0; + while let Some(component) = pending.pop() { + match component.as_str() { + "." => {} + ".." => { + resolved.pop(); + } + _ => { + resolved.push(component); + // the last component is only followed with `symlink-follow` + if (!pending.is_empty() || follow) && symlinks < MAX_SYMLINKS { + if let Some(target) = readlink(&resolved.join("/")).await { + if !target.starts_with('/') { + symlinks += 1; + // the target is relative to the directory containing the link + resolved.pop(); + pending.extend(split(&target).rev().map(String::from)); + } + } + } + } + } + } + let mut components = self.components.clone(); + components.extend(resolved); + Self { + preopen: self.preopen.clone(), + components, + } + } +} + +/// The components of a path, without empty components from repeated or trailing separators. +fn split(path: &str) -> impl DoubleEndedIterator { + path.split('/').filter(|component| !component.is_empty()) +} + +impl std::fmt::Display for Location { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.preopen)?; + for (index, component) in self.components.iter().enumerate() { + if index > 0 || !self.preopen.ends_with('/') { + f.write_str("/")?; + } + f.write_str(component)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::future::Future; + use std::pin::pin; + use std::task::{Context, Poll, Waker}; + + use super::*; + + /// Run a future that never waits, as the fake readlink never does. + fn now(future: impl Future) -> T { + match pin!(future).poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(value) => value, + Poll::Pending => panic!("the future waited"), + } + } + + /// Open `path` from `from`, with the symlinks of a filesystem, by their path relative to `from`. + fn open(from: &Location, path: &str, follow: bool, links: &[(&str, &str)]) -> String { + let links: HashMap<&str, &str> = links.iter().copied().collect(); + let location = now(from.open(path, follow, async |path: &str| { + links.get(path).map(|target| target.to_string()) + })); + location.to_string() + } + + fn root() -> Location { + Location::preopen("/") + } + + #[test] + fn preopens_are_labeled_with_their_name() { + assert_eq!(Location::preopen("/").to_string(), "/"); + assert_eq!(Location::preopen(".").to_string(), "."); + assert_eq!(Location::preopen("/data").to_string(), "/data"); + } + + #[test] + fn relative_paths_are_joined() { + assert_eq!(open(&root(), "dir", true, &[]), "/dir"); + assert_eq!(open(&root(), "dir/sub", true, &[]), "/dir/sub"); + let data = Location::preopen("/data"); + assert_eq!(open(&data, "dir", true, &[]), "/data/dir"); + let dot = Location::preopen("."); + assert_eq!(open(&dot, "dir/sub", true, &[]), "./dir/sub"); + } + + #[test] + fn dots_and_separators_are_resolved() { + assert_eq!(open(&root(), ".", true, &[]), "/"); + assert_eq!(open(&root(), "./dir/./sub/.", true, &[]), "/dir/sub"); + assert_eq!(open(&root(), "dir//sub/", true, &[]), "/dir/sub"); + assert_eq!(open(&root(), "dir/sub/..", true, &[]), "/dir"); + assert_eq!(open(&root(), "dir/../other", true, &[]), "/other"); + assert_eq!(open(&root(), "dir/sub/../..", true, &[]), "/"); + } + + #[test] + fn paths_resolve_from_the_descriptor() { + let dir = now(root().open("dir", true, async |_: &str| None)); + assert_eq!(open(&dir, "sub", true, &[]), "/dir/sub"); + assert_eq!(open(&dir, ".", true, &[]), "/dir"); + assert_eq!(open(&dir, "sub/..", true, &[]), "/dir"); + } + + #[test] + fn symlinks_are_followed() { + let links = [("link", "dir")]; + assert_eq!(open(&root(), "link", true, &links), "/dir"); + assert_eq!(open(&root(), "link/sub", true, &links), "/dir/sub"); + // `..` of the link's target, not of the directory containing the link + let links = [("link", "dir/sub")]; + assert_eq!(open(&root(), "link/..", true, &links), "/dir"); + } + + #[test] + fn the_last_symlink_is_only_followed_with_the_flag() { + let links = [("link", "dir")]; + assert_eq!(open(&root(), "link", false, &links), "/link"); + // intermediate symlinks are always followed + assert_eq!(open(&root(), "link/sub", false, &links), "/dir/sub"); + } + + #[test] + fn symlink_targets_are_relative_to_the_link() { + let links = [("dir/up", "../other")]; + assert_eq!(open(&root(), "dir/up", true, &links), "/other"); + let links = [("dir/self", ".")]; + assert_eq!(open(&root(), "dir/self/sub", true, &links), "/dir/sub"); + let links = [("dir/sub-link", "sub/")]; + assert_eq!(open(&root(), "dir/sub-link", true, &links), "/dir/sub"); + } + + #[test] + fn chained_symlinks_are_followed() { + let links = [ + ("first", "second"), + ("second", "dir/third"), + ("dir/third", "../target"), + ]; + assert_eq!(open(&root(), "first", true, &links), "/target"); + assert_eq!( + open(&root(), "first/file.txt", true, &links), + "/target/file.txt" + ); + } + + #[test] + fn symlinks_resolve_from_the_descriptor() { + let dir = now(root().open("dir", true, async |_: &str| None)); + // readlink is given paths relative to the descriptor + let links = [("link", "sub")]; + assert_eq!(open(&dir, "link", true, &links), "/dir/sub"); + } + + #[test] + fn absolute_symlinks_are_not_followed() { + let links = [("link", "/etc")]; + assert_eq!(open(&root(), "link", true, &links), "/link"); + } + + #[test] + fn symlink_loops_end() { + let links = [("a", "b"), ("b", "a")]; + // the host fails to open a loop, but resolution must still end + let _ = open(&root(), "a", true, &links); + } +} diff --git a/components/latch-defer-all/Cargo.toml b/components/latch-defer-all/Cargo.toml new file mode 100644 index 0000000..c438068 --- /dev/null +++ b/components/latch-defer-all/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "latch-defer-all" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = { workspace = true } diff --git a/components/latch-defer-all/README.md b/components/latch-defer-all/README.md new file mode 100644 index 0000000..dbd7286 --- /dev/null +++ b/components/latch-defer-all/README.md @@ -0,0 +1,3 @@ +# `latch-defer-all` + +Filesystem latch that implicitly defers all operations. diff --git a/components/latch-defer-all/src/lib.rs b/components/latch-defer-all/src/lib.rs new file mode 100644 index 0000000..f607a78 --- /dev/null +++ b/components/latch-defer-all/src/lib.rs @@ -0,0 +1,29 @@ +use crate::{ + exports::componentized::filesystem::latch::{Decision, Guest as Latch, Operation}, + wasi::filesystem::types::ErrorCode, +}; + +struct LatchDenyAll {} + +impl Latch for LatchDenyAll { + #[allow(async_fn_in_trait)] + fn authorize(_operation: Operation) -> Result { + Ok(Decision::Deferred) + } + + #[allow(async_fn_in_trait)] + fn observe_decision( + _final_decision: Decision, + _operation: Operation<'_>, + ) -> Result<(), ErrorCode> { + Ok(()) + } +} + +wit_bindgen::generate!({ + path: "../wit", + world: "latch", + generate_all +}); + +export!(LatchDenyAll); diff --git a/components/latch-deny-all/Cargo.toml b/components/latch-deny-all/Cargo.toml new file mode 100644 index 0000000..741f659 --- /dev/null +++ b/components/latch-deny-all/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "latch-deny-all" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = { workspace = true } diff --git a/components/latch-deny-all/README.md b/components/latch-deny-all/README.md new file mode 100644 index 0000000..c619098 --- /dev/null +++ b/components/latch-deny-all/README.md @@ -0,0 +1,3 @@ +# `latch-deny-all` + +Filesystem latch that implicitly denies all operations. diff --git a/components/latch-deny-all/src/lib.rs b/components/latch-deny-all/src/lib.rs new file mode 100644 index 0000000..953fcfb --- /dev/null +++ b/components/latch-deny-all/src/lib.rs @@ -0,0 +1,29 @@ +use crate::{ + exports::componentized::filesystem::latch::{Decision, Guest as Latch, Operation}, + wasi::filesystem::types::ErrorCode, +}; + +struct LatchDenyAll {} + +impl Latch for LatchDenyAll { + #[allow(async_fn_in_trait)] + fn authorize(_operation: Operation) -> Result { + Ok(Decision::Denied(ErrorCode::NotPermitted)) + } + + #[allow(async_fn_in_trait)] + fn observe_decision( + _final_decision: Decision, + _operation: Operation<'_>, + ) -> Result<(), ErrorCode> { + Ok(()) + } +} + +wit_bindgen::generate!({ + path: "../wit", + world: "latch", + generate_all +}); + +export!(LatchDenyAll); diff --git a/components/latch-n2/Cargo.toml b/components/latch-n2/Cargo.toml new file mode 100644 index 0000000..692aa71 --- /dev/null +++ b/components/latch-n2/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "latch-n2" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +filesystem-latch-n = { workspace = true } diff --git a/components/latch-n2/README.md b/components/latch-n2/README.md new file mode 100644 index 0000000..471bb99 --- /dev/null +++ b/components/latch-n2/README.md @@ -0,0 +1,15 @@ +# `latch-n2` + +Filesystem latch that aggregates two other filesystem latches. + +## Interfaces + +Imports: + +- `wasi:filesystem/types@0.3.0`: the socket types of the operations being authorized +- `latch0: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order +- `latch1: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order + +Exports: + +- `componentized:filesystem/latch@0.3.0-dev`: the latch, aggregating the imported latches diff --git a/components/latch-n2/src/lib.rs b/components/latch-n2/src/lib.rs new file mode 100644 index 0000000..9cf89d7 --- /dev/null +++ b/components/latch-n2/src/lib.rs @@ -0,0 +1,22 @@ +use filesystem_latch_n::{latch0, latch1}; +use filesystem_latch_n::{Decision, ErrorCode, Latch, Operation}; + +struct LatchN2 {} + +impl Latch for LatchN2 { + #[allow(async_fn_in_trait)] + fn authorize(operation: Operation<'_>) -> Result { + let authorizers = vec![latch0::authorize, latch1::authorize]; + filesystem_latch_n::authorize(operation, authorizers) + } + + fn observe_decision( + final_decision: Decision, + operation: Operation<'_>, + ) -> Result<(), ErrorCode> { + let observers = vec![latch0::observe_decision, latch1::observe_decision]; + filesystem_latch_n::observe_decision(final_decision, operation, observers) + } +} + +filesystem_latch_n::export!(LatchN2 with_types_in filesystem_latch_n::bindings); diff --git a/components/latch-n3/Cargo.toml b/components/latch-n3/Cargo.toml new file mode 100644 index 0000000..b9a62d2 --- /dev/null +++ b/components/latch-n3/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "latch-n3" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +filesystem-latch-n = { workspace = true } diff --git a/components/latch-n3/README.md b/components/latch-n3/README.md new file mode 100644 index 0000000..b22ab4e --- /dev/null +++ b/components/latch-n3/README.md @@ -0,0 +1,16 @@ +# `latch-n3` + +Filesystem latch that aggregates three other filesystem latches. + +## Interfaces + +Imports: + +- `wasi:filesystem/types@0.3.0`: the socket types of the operations being authorized +- `latch0: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order +- `latch1: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order +- `latch2: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order + +Exports: + +- `componentized:filesystem/latch@0.3.0-dev`: the latch, aggregating the imported latches diff --git a/components/latch-n3/src/lib.rs b/components/latch-n3/src/lib.rs new file mode 100644 index 0000000..b105445 --- /dev/null +++ b/components/latch-n3/src/lib.rs @@ -0,0 +1,26 @@ +use filesystem_latch_n::{Decision, ErrorCode, Latch, Operation}; +use filesystem_latch_n::{latch0, latch1, latch2}; + +struct LatchN3 {} + +impl Latch for LatchN3 { + #[allow(async_fn_in_trait)] + fn authorize(operation: Operation<'_>) -> Result { + let authorizers = vec![latch0::authorize, latch1::authorize, latch2::authorize]; + filesystem_latch_n::authorize(operation, authorizers) + } + + fn observe_decision( + final_decision: Decision, + operation: Operation<'_>, + ) -> Result<(), ErrorCode> { + let observers = vec![ + latch0::observe_decision, + latch1::observe_decision, + latch2::observe_decision, + ]; + filesystem_latch_n::observe_decision(final_decision, operation, observers) + } +} + +filesystem_latch_n::export!(LatchN3 with_types_in filesystem_latch_n::bindings); diff --git a/components/latch-n4/Cargo.toml b/components/latch-n4/Cargo.toml new file mode 100644 index 0000000..e18bd53 --- /dev/null +++ b/components/latch-n4/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "latch-n4" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +filesystem-latch-n = { workspace = true } diff --git a/components/latch-n4/README.md b/components/latch-n4/README.md new file mode 100644 index 0000000..3475784 --- /dev/null +++ b/components/latch-n4/README.md @@ -0,0 +1,17 @@ +# `latch-n4` + +Fileystem latch that aggregates four other filesystem latches. + +## Interfaces + +Imports: + +- `wasi:filesystem/types@0.3.0`: the socket types of the operations being authorized +- `latch0: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order +- `latch1: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order +- `latch2: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order +- `latch3: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order + +Exports: + +- `componentized:filesystem/latch@0.3.0-dev`: the latch, aggregating the imported latches diff --git a/components/latch-n4/src/lib.rs b/components/latch-n4/src/lib.rs new file mode 100644 index 0000000..b65e5f3 --- /dev/null +++ b/components/latch-n4/src/lib.rs @@ -0,0 +1,32 @@ +use filesystem_latch_n::{latch0, latch1, latch2, latch3}; +use filesystem_latch_n::{Decision, ErrorCode, Latch, Operation}; + +struct LatchN4 {} + +impl Latch for LatchN4 { + #[allow(async_fn_in_trait)] + fn authorize(operation: Operation<'_>) -> Result { + let authorizers = vec![ + latch0::authorize, + latch1::authorize, + latch2::authorize, + latch3::authorize, + ]; + filesystem_latch_n::authorize(operation, authorizers) + } + + fn observe_decision( + final_decision: Decision, + operation: Operation<'_>, + ) -> Result<(), ErrorCode> { + let observers = vec![ + latch0::observe_decision, + latch1::observe_decision, + latch2::observe_decision, + latch3::observe_decision, + ]; + filesystem_latch_n::observe_decision(final_decision, operation, observers) + } +} + +filesystem_latch_n::export!(LatchN4 with_types_in filesystem_latch_n::bindings); diff --git a/components/latch-n5/Cargo.toml b/components/latch-n5/Cargo.toml new file mode 100644 index 0000000..f467e35 --- /dev/null +++ b/components/latch-n5/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "latch-n5" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +filesystem-latch-n = { workspace = true } diff --git a/components/latch-n5/README.md b/components/latch-n5/README.md new file mode 100644 index 0000000..08d7d08 --- /dev/null +++ b/components/latch-n5/README.md @@ -0,0 +1,18 @@ +# `latch-n5` + +Filesystem latch that aggregates five other filesystem latches. + +## Interfaces + +Imports: + +- `wasi:filesystem/types@0.3.0`: the socket types of the operations being authorized +- `latch0: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order +- `latch1: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order +- `latch2: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order +- `latch3: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order +- `latch4: componentized:filesystem/latch@0.3.0-dev`: an aggregated latch, consulted in order + +Exports: + +- `componentized:filesystem/latch@0.1.1-dev`: the latch, aggregating the imported latches diff --git a/components/latch-n5/src/lib.rs b/components/latch-n5/src/lib.rs new file mode 100644 index 0000000..77d2c28 --- /dev/null +++ b/components/latch-n5/src/lib.rs @@ -0,0 +1,34 @@ +use filesystem_latch_n::{Decision, ErrorCode, Latch, Operation}; +use filesystem_latch_n::{latch0, latch1, latch2, latch3, latch4}; + +struct LatchN5 {} + +impl Latch for LatchN5 { + #[allow(async_fn_in_trait)] + fn authorize(operation: Operation<'_>) -> Result { + let authorizers = vec![ + latch0::authorize, + latch1::authorize, + latch2::authorize, + latch3::authorize, + latch4::authorize, + ]; + filesystem_latch_n::authorize(operation, authorizers) + } + + fn observe_decision( + final_decision: Decision, + operation: Operation<'_>, + ) -> Result<(), ErrorCode> { + let observers = vec![ + latch0::observe_decision, + latch1::observe_decision, + latch2::observe_decision, + latch3::observe_decision, + latch4::observe_decision, + ]; + filesystem_latch_n::observe_decision(final_decision, operation, observers) + } +} + +filesystem_latch_n::export!(LatchN5 with_types_in filesystem_latch_n::bindings); diff --git a/components/latch-readonly/Cargo.toml b/components/latch-readonly/Cargo.toml new file mode 100644 index 0000000..8e3f98b --- /dev/null +++ b/components/latch-readonly/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "latch-readonly" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = { workspace = true } diff --git a/components/latch-readonly/README.md b/components/latch-readonly/README.md new file mode 100644 index 0000000..79e5e96 --- /dev/null +++ b/components/latch-readonly/README.md @@ -0,0 +1,3 @@ +# `latch-readonly` + +Filesystem latch that denies operations which would mutate the filesystem. diff --git a/components/latch-readonly/src/lib.rs b/components/latch-readonly/src/lib.rs new file mode 100644 index 0000000..4d0780a --- /dev/null +++ b/components/latch-readonly/src/lib.rs @@ -0,0 +1,85 @@ +use crate::{ + exports::componentized::filesystem::latch::{ + Decision, DescriptorOpenAtArgs, DescriptorOperation, Guest as Latch, Operation, + }, + wasi::filesystem::types::{DescriptorFlags, ErrorCode, OpenFlags}, +}; + +struct ReadOnlyLatch {} + +impl Latch for ReadOnlyLatch { + #[allow(async_fn_in_trait)] + fn authorize(operation: Operation) -> Result { + match operation { + Operation::Preopens(_preopens_operation) => Ok(Decision::Deferred), + Operation::Descriptor(descriptor_operation) => match descriptor_operation { + DescriptorOperation::ReadViaStream(_) => Ok(Decision::Deferred), + DescriptorOperation::WriteViaStream(_) => Ok(Decision::Denied(ErrorCode::ReadOnly)), + DescriptorOperation::AppendViaStream(_) => { + Ok(Decision::Denied(ErrorCode::ReadOnly)) + } + DescriptorOperation::Advise(_) => Ok(Decision::Deferred), + DescriptorOperation::SyncData(_) => Ok(Decision::Denied(ErrorCode::ReadOnly)), + DescriptorOperation::GetFlags(_) => Ok(Decision::Deferred), + DescriptorOperation::GetType(_) => Ok(Decision::Deferred), + DescriptorOperation::SetSize(_) => Ok(Decision::Denied(ErrorCode::ReadOnly)), + DescriptorOperation::SetTimes(_) => Ok(Decision::Denied(ErrorCode::ReadOnly)), + DescriptorOperation::ReadDirectory(_) => Ok(Decision::Deferred), + DescriptorOperation::ReadDirectoryEntry(_) => Ok(Decision::Deferred), + DescriptorOperation::Sync(_) => Ok(Decision::Denied(ErrorCode::ReadOnly)), + DescriptorOperation::CreateDirectoryAt(_) => { + Ok(Decision::Denied(ErrorCode::ReadOnly)) + } + DescriptorOperation::Stat(_) => Ok(Decision::Deferred), + DescriptorOperation::StatAt(_) => Ok(Decision::Deferred), + DescriptorOperation::SetTimesAt(_) => Ok(Decision::Denied(ErrorCode::ReadOnly)), + DescriptorOperation::LinkAt(_) => Ok(Decision::Denied(ErrorCode::ReadOnly)), + DescriptorOperation::OpenAt(( + _, + DescriptorOpenAtArgs { + open_flags, flags, .. + }, + )) => { + if open_flags.contains(OpenFlags::CREATE) + || open_flags.contains(OpenFlags::EXCLUSIVE) + || open_flags.contains(OpenFlags::TRUNCATE) + || flags.contains(DescriptorFlags::WRITE) + || flags.contains(DescriptorFlags::FILE_INTEGRITY_SYNC) + || flags.contains(DescriptorFlags::DATA_INTEGRITY_SYNC) + || flags.contains(DescriptorFlags::REQUESTED_WRITE_SYNC) + || flags.contains(DescriptorFlags::MUTATE_DIRECTORY) + { + Ok(Decision::Denied(ErrorCode::ReadOnly)) + } else { + Ok(Decision::Deferred) + } + } + DescriptorOperation::ReadlinkAt(_) => Ok(Decision::Deferred), + DescriptorOperation::RemoveDirectoryAt(_) => { + Ok(Decision::Denied(ErrorCode::ReadOnly)) + } + DescriptorOperation::RenameAt(_) => Ok(Decision::Denied(ErrorCode::ReadOnly)), + DescriptorOperation::SymlinkAt(_) => Ok(Decision::Denied(ErrorCode::ReadOnly)), + DescriptorOperation::UnlinkFileAt(_) => Ok(Decision::Denied(ErrorCode::ReadOnly)), + DescriptorOperation::MetadataHash(_) => Ok(Decision::Deferred), + DescriptorOperation::MetadataHashAt(_) => Ok(Decision::Deferred), + }, + } + } + + #[allow(async_fn_in_trait)] + fn observe_decision( + _final_decision: Decision, + _operation: Operation<'_>, + ) -> Result<(), ErrorCode> { + Ok(()) + } +} + +wit_bindgen::generate!({ + path: "../wit", + world: "latch", + generate_all +}); + +export!(ReadOnlyLatch); diff --git a/components/wit/worlds.wit b/components/wit/worlds.wit index e85c334..d21998f 100644 --- a/components/wit/worlds.wit +++ b/components/wit/worlds.wit @@ -3,8 +3,25 @@ package componentized:filesystem-components; world filesystem { import wasi:config/store@0.2.0-rc.1; import wasi:logging/logging@0.1.0-draft; + import componentized:filesystem/latch@0.3.0-dev; import wasi:filesystem/preopens@0.3.0; export wasi:filesystem/preopens@0.3.0; import wasi:filesystem/types@0.3.0; export wasi:filesystem/types@0.3.0; } + +world latch { + import wasi:logging/logging@0.1.0-draft; + import wasi:config/store@0.2.0-rc.1; + import componentized:filesystem/latch@0.3.0-dev; + export componentized:filesystem/latch@0.3.0-dev; +} + +world latch-n { + export componentized:filesystem/latch@0.3.0-dev; + import latch0: componentized:filesystem/latch@0.3.0-dev; + import latch1: componentized:filesystem/latch@0.3.0-dev; + import latch2: componentized:filesystem/latch@0.3.0-dev; + import latch3: componentized:filesystem/latch@0.3.0-dev; + import latch4: componentized:filesystem/latch@0.3.0-dev; +} diff --git a/crates/filesystem-latch-n/Cargo.toml b/crates/filesystem-latch-n/Cargo.toml new file mode 100644 index 0000000..a5ac91b --- /dev/null +++ b/crates/filesystem-latch-n/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "filesystem-latch-n" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[dependencies] +wit-bindgen = { workspace = true } diff --git a/crates/filesystem-latch-n/src/lib.rs b/crates/filesystem-latch-n/src/lib.rs new file mode 100644 index 0000000..8577a4e --- /dev/null +++ b/crates/filesystem-latch-n/src/lib.rs @@ -0,0 +1,54 @@ +pub use crate::bindings::exports::componentized::filesystem::latch::{ + Decision, ErrorCode, Guest as Latch, Operation, +}; +pub use crate::bindings::{latch0, latch1, latch2, latch3, latch4}; + +pub fn authorize( + operation: Operation, + authorizers: Vec) -> Result>, +) -> Result { + for authorize in authorizers { + match authorize(&operation)? { + Decision::Deferred => {} + Decision::Denied(error_code) => return Ok(Decision::Denied(error_code)), + } + } + Ok(Decision::Deferred) +} + +/// Pass the final decision to every nested latch, including latches that were not asked to +/// authorize the operation because an earlier latch denied it. +/// +/// Every observer is called even if an earlier one fails, the result is the first error. +pub fn observe_decision( + final_decision: Decision, + operation: Operation, + observers: Vec) -> Result<(), ErrorCode>>, +) -> Result<(), ErrorCode> { + let mut result = Ok(()); + for observe in observers { + if let Err(err) = observe(&final_decision, &operation) { + if result.is_ok() { + result = Err(err); + } + } + } + result +} + +pub mod bindings { + wit_bindgen::generate!({ + path: "../../components/wit", + world: "latch-n", + pub_export_macro: true, + merge_structurally_equal_types: true, + generate_all + }); +} + +#[macro_export] +macro_rules! export { + ($($t:tt)*) => { + $crate::bindings::export!($($t)*); + }; +} diff --git a/wit/latch.wit b/wit/latch.wit new file mode 100644 index 0000000..d60073a --- /dev/null +++ b/wit/latch.wit @@ -0,0 +1,189 @@ + +interface latch { + use wasi:filesystem/types@0.3.0.{advice, descriptor, descriptor-flags, directory-entry, error-code as filesystem-error-code, filesize, open-flags, new-timestamp, path-flags}; + + record descriptor-get-directory-args { + path: string, + } + + record descriptor-read-via-stream-args { + offset: filesize, + } + + record descriptor-write-via-stream-args { + offset: filesize, + } + + record descriptor-advise-args { + offset: filesize, + length: filesize, + advice: advice, + } + + record descriptor-set-size-args { + size: filesize, + } + + record descriptor-set-times-args { + data-access-timestamp: new-timestamp, + data-modification-timestamp: new-timestamp, + } + + record descriptor-read-args { + length: filesize, + offset: filesize, + } + + record descriptor-write-args { + buffer-length: u64, + offset: filesize, + } + + record descriptor-create-directory-at-args { + path: string, + } + + record descriptor-stat-at-args { + path-flags: path-flags, + path: string, + } + + record descriptor-set-times-at-args { + path-flags: path-flags, + path: string, + data-access-timestamp: new-timestamp, + data-modification-timestamp: new-timestamp, + } + + record descriptor-link-at-args { + old-path-flags: path-flags, + old-path: string, + new-descriptor: borrow, + new-path: string, + } + + record descriptor-open-at-args { + path-flags: path-flags, + path: string, + open-flags: open-flags, + %flags: descriptor-flags, + } + + record descriptor-readlink-at-args { + path: string, + } + + record descriptor-remove-directory-at-args { + path: string, + } + + record descriptor-rename-at-args { + old-path: string, + new-descriptor: borrow, + new-path: string, + } + + record descriptor-symlink-at-args { + old-path: string, + new-path: string, + } + + record descriptor-unlink-file-at-args { + path: string, + } + + + record descriptor-metadata-hash-at-args { + path-flags: path-flags, + path: string, + } + + variant preopens-operation { + get-directories, + get-directories-item(tuple, string>), + } + + variant descriptor-operation { + read-via-stream(tuple, descriptor-read-via-stream-args>), + write-via-stream(tuple, descriptor-write-via-stream-args>), + append-via-stream(tuple>), + advise(tuple, descriptor-advise-args>), + sync-data(tuple>), + get-flags(tuple>), + get-type(tuple>), + set-size(tuple, descriptor-set-size-args>), + set-times(tuple, descriptor-set-times-args>), + read-directory(tuple>), + read-directory-entry(tuple, directory-entry>), + sync(tuple>), + create-directory-at(tuple, descriptor-create-directory-at-args>), + stat(tuple>), + stat-at(tuple, descriptor-stat-at-args>), + set-times-at(tuple, descriptor-set-times-at-args>), + link-at(tuple, descriptor-link-at-args>), + open-at(tuple, descriptor-open-at-args>), + readlink-at(tuple, descriptor-readlink-at-args>), + remove-directory-at(tuple, descriptor-remove-directory-at-args>), + rename-at(tuple, descriptor-rename-at-args>), + symlink-at(tuple, descriptor-symlink-at-args>), + unlink-file-at(tuple, descriptor-unlink-file-at-args>), + metadata-hash(tuple>), + metadata-hash-at(tuple, descriptor-metadata-hash-at-args>), + } + + /// Operations for the wasi:sockets package. + @since(version = 0.3.0-dev) + variant operation { + preopens(preopens-operation), + descriptor(descriptor-operation), + } + + /// Whether the operation is allowed to proceed. + @since(version = 0.3.0-dev) + variant decision { + /// No objection to the operation. + deferred, + /// The operation must not proceed. The error code is returned to the caller for + /// operations that fail. For traffic that is dropped instead, like an accepted connection + /// or a received datagram, it is only logged. + denied(filesystem-error-code), + } + + /// The name of a latch component, identifying which latch reported an error. + @since(version = 0.3.0-dev) + type latch-name = string; + + /// A latch was unable to decide or observe. The gate fails the operation, except for an + /// accepted connection, which is closed as if it were denied. + @since(version = 0.1.0) + variant error-code { + /// The latch's configuration is invalid, the cause is logged by the latch. + invalid-config(latch-name), + /// The latch was unable to act on the final decision. + observation-failed(latch-name), + /// Any other error. + other(option), + } + + /// Decide whether the operation may proceed. + /// + /// Must not have side effects. When latches are aggregated, a latch is not asked about an + /// operation another latch already denied, so state changed here could disagree with what + /// actually happened. Act on decisions in `observe-decision` instead. + @since(version = 0.3.0-dev) + authorize: func(operation: operation) -> result; + + /// Observe the final decision for an operation, after `authorize` returned a decision. + /// + /// Called for both deferred and denied operations, including operations this latch was not + /// asked to authorize because an aggregated latch denied them first. The final decision is + /// what the gate enforces, so this is where a latch updates any state it keeps, for example + /// remembering peers the caller is allowed to exchange traffic with. Not called when + /// `authorize` returned an error. + /// + /// An error fails the operation, since the latch could not act on the decision. + @since(version = 0.3.0-dev) + observe-decision: func(final-decision: decision, operation: operation) -> result<_, error-code>; +} + + diff --git a/wit/worlds.wit b/wit/worlds.wit index b4f47f1..567c63c 100644 --- a/wit/worlds.wit +++ b/wit/worlds.wit @@ -1,6 +1,9 @@ package componentized:filesystem@0.3.0-dev; world imports { + import latch; + import wasi:config/store@0.2.0-rc.1; + import wasi:logging/logging@0.1.0-draft; import wasi:filesystem/preopens@0.3.0; import wasi:filesystem/types@0.3.0; } diff --git a/wkg.lock b/wkg.lock index a35e14e..d82b52c 100644 --- a/wkg.lock +++ b/wkg.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 1 +[[packages]] +name = "wasi:config" +registry = "wasi.dev" + +[[packages.versions]] +requirement = "=0.2.0-rc.1" +version = "0.2.0-rc.1" +digest = "sha256:1b7f1b0fd07bb4cede16c6a6ec8852815dfb924639a78735fc7bdffdc164485d" + [[packages]] name = "wasi:filesystem" registry = "wasi.dev" @@ -10,3 +19,12 @@ registry = "wasi.dev" requirement = "=0.3.0" version = "0.3.0" digest = "sha256:42921410cddcdce2e009fd65c9a763bfb3fb61e2c0a64ff71647cc4405470378" + +[[packages]] +name = "wasi:logging" +registry = "wasi.dev" + +[[packages.versions]] +requirement = "=0.1.0-draft" +version = "0.1.0-draft" +digest = "sha256:09621a45b12b0a9cddc798517f778aac0e5ae4bd234077b3d70758d6cf625580" From 020b2d6b4e259b418a3f556a2a1d6b60abc4a4a9 Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Mon, 18 May 2026 13:51:16 -0400 Subject: [PATCH 02/21] centralize logic for latch-n components Signed-off-by: Scott Andrews --- Cargo.lock | 158 +++++++++++++------------- components/latch-defer-all/Cargo.toml | 1 + components/latch-defer-all/src/lib.rs | 5 +- components/latch-deny-all/src/lib.rs | 7 +- components/latch-n2/src/lib.rs | 2 +- components/latch-n4/src/lib.rs | 2 +- crates/latch-n/Cargo.toml | 8 ++ wit/worlds.wit | 4 - wkg.lock | 18 --- 9 files changed, 96 insertions(+), 109 deletions(-) create mode 100644 crates/latch-n/Cargo.toml diff --git a/Cargo.lock b/Cargo.lock index c9b9ea1..78efccc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,9 +31,9 @@ checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -58,7 +58,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -69,9 +69,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -117,9 +117,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ "find-msvc-tools", "jobserver", @@ -129,9 +129,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "chacha20" @@ -276,7 +276,7 @@ dependencies = [ "cranelift-entity", "cranelift-isle", "gimli", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "libm", "log", "postcard", @@ -338,7 +338,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "366ff7cb014bd08752210eba55a650969f8db9b52670d69d2ea0b23e1e9e3edf" dependencies = [ "cranelift-codegen", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "log", "smallvec", "target-lexicon", @@ -459,7 +459,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -496,9 +496,9 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -525,9 +525,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "fixedbitset" @@ -707,9 +707,9 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "foldhash", "serde", @@ -858,12 +858,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -907,9 +907,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.14" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "ittapi" @@ -943,9 +943,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.100" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -956,6 +956,7 @@ dependencies = [ name = "latch-defer-all" version = "0.1.0" dependencies = [ + "filesystem-latch-n", "wit-bindgen", ] @@ -1015,9 +1016,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libm" @@ -1066,7 +1067,7 @@ checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -1077,9 +1078,9 @@ checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memfd" @@ -1114,7 +1115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c" dependencies = [ "crc32fast", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "indexmap", "memchr", ] @@ -1181,7 +1182,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" dependencies = [ "proc-macro2", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -1299,7 +1300,7 @@ checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85" dependencies = [ "allocator-api2", "bumpalo", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "log", "rustc-hash", "serde", @@ -1343,15 +1344,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.19" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "scopeguard" @@ -1371,9 +1366,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1381,34 +1376,35 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.138" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -1487,9 +1483,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.5" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -1504,7 +1500,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -1581,7 +1577,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -1617,7 +1613,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -1711,9 +1707,9 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-width" @@ -1763,9 +1759,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasm-bindgen" -version = "0.2.123" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -1776,9 +1772,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.123" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1786,22 +1782,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.123" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.123" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -1884,7 +1880,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe9269dd817e0e7021bf534ccd9761bc2d64778b6708e41228b8439d75eaa0b1" dependencies = [ "bitflags", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "indexmap", "semver", "serde", @@ -1897,7 +1893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f7c12eac7bb587801590f6a67ff0bc84d0748513864b71108e4b311cc3df694" dependencies = [ "bitflags", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "indexmap", "semver", ] @@ -1989,7 +1985,7 @@ dependencies = [ "cranelift-bitset", "cranelift-entity", "gimli", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "indexmap", "log", "object", @@ -2056,7 +2052,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba00c5db77adb0749ee059068e5a417bfac3d8969f5a3479d9bd1ca8c1ba248d" dependencies = [ "anyhow", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "libm", "serde", ] @@ -2564,7 +2560,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn 3.0.5", + "syn 3.0.9", "wasm-metadata 0.259.0", "wit-bindgen-core", "wit-component 0.259.0", @@ -2581,7 +2577,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -2631,7 +2627,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72a2127663dcf3ee16614fb0e3c33d5c6c66fec9dad317a8b0fdd3869487a533" dependencies = [ "anyhow", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "id-arena", "indexmap", "log", @@ -2650,7 +2646,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6390f4f02493ce678d509c859cf1698e38929fb75c8012a6ee2f7d6b6cb66cd7" dependencies = [ "anyhow", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "id-arena", "indexmap", "log", @@ -2699,7 +2695,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", "synstructure", ] @@ -2720,7 +2716,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", "synstructure", ] @@ -2754,9 +2750,15 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + [[package]] name = "zstd" version = "0.13.3" diff --git a/components/latch-defer-all/Cargo.toml b/components/latch-defer-all/Cargo.toml index c438068..dfb1edf 100644 --- a/components/latch-defer-all/Cargo.toml +++ b/components/latch-defer-all/Cargo.toml @@ -8,4 +8,5 @@ license = "Apache-2.0" crate-type = ["cdylib"] [dependencies] +filesystem-latch-n = { workspace = true } wit-bindgen = { workspace = true } diff --git a/components/latch-defer-all/src/lib.rs b/components/latch-defer-all/src/lib.rs index f607a78..4750b4e 100644 --- a/components/latch-defer-all/src/lib.rs +++ b/components/latch-defer-all/src/lib.rs @@ -1,6 +1,5 @@ -use crate::{ - exports::componentized::filesystem::latch::{Decision, Guest as Latch, Operation}, - wasi::filesystem::types::ErrorCode, +use crate::exports::componentized::filesystem::latch::{ + Decision, ErrorCode, Guest as Latch, Operation, }; struct LatchDenyAll {} diff --git a/components/latch-deny-all/src/lib.rs b/components/latch-deny-all/src/lib.rs index 953fcfb..823fbf3 100644 --- a/components/latch-deny-all/src/lib.rs +++ b/components/latch-deny-all/src/lib.rs @@ -1,6 +1,5 @@ -use crate::{ - exports::componentized::filesystem::latch::{Decision, Guest as Latch, Operation}, - wasi::filesystem::types::ErrorCode, +use crate::exports::componentized::filesystem::latch::{ + Decision, ErrorCode, FilesystemErrorCode, Guest as Latch, Operation, }; struct LatchDenyAll {} @@ -8,7 +7,7 @@ struct LatchDenyAll {} impl Latch for LatchDenyAll { #[allow(async_fn_in_trait)] fn authorize(_operation: Operation) -> Result { - Ok(Decision::Denied(ErrorCode::NotPermitted)) + Ok(Decision::Denied(FilesystemErrorCode::NotPermitted)) } #[allow(async_fn_in_trait)] diff --git a/components/latch-n2/src/lib.rs b/components/latch-n2/src/lib.rs index 9cf89d7..b791b15 100644 --- a/components/latch-n2/src/lib.rs +++ b/components/latch-n2/src/lib.rs @@ -1,5 +1,5 @@ -use filesystem_latch_n::{latch0, latch1}; use filesystem_latch_n::{Decision, ErrorCode, Latch, Operation}; +use filesystem_latch_n::{latch0, latch1}; struct LatchN2 {} diff --git a/components/latch-n4/src/lib.rs b/components/latch-n4/src/lib.rs index b65e5f3..6c29d1f 100644 --- a/components/latch-n4/src/lib.rs +++ b/components/latch-n4/src/lib.rs @@ -1,5 +1,5 @@ -use filesystem_latch_n::{latch0, latch1, latch2, latch3}; use filesystem_latch_n::{Decision, ErrorCode, Latch, Operation}; +use filesystem_latch_n::{latch0, latch1, latch2, latch3}; struct LatchN4 {} diff --git a/crates/latch-n/Cargo.toml b/crates/latch-n/Cargo.toml new file mode 100644 index 0000000..89c2b9e --- /dev/null +++ b/crates/latch-n/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "latch-n" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" + +[dependencies] +wit-bindgen = { workspace = true } diff --git a/wit/worlds.wit b/wit/worlds.wit index 567c63c..5840cad 100644 --- a/wit/worlds.wit +++ b/wit/worlds.wit @@ -2,8 +2,4 @@ package componentized:filesystem@0.3.0-dev; world imports { import latch; - import wasi:config/store@0.2.0-rc.1; - import wasi:logging/logging@0.1.0-draft; - import wasi:filesystem/preopens@0.3.0; - import wasi:filesystem/types@0.3.0; } diff --git a/wkg.lock b/wkg.lock index d82b52c..a35e14e 100644 --- a/wkg.lock +++ b/wkg.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 1 -[[packages]] -name = "wasi:config" -registry = "wasi.dev" - -[[packages.versions]] -requirement = "=0.2.0-rc.1" -version = "0.2.0-rc.1" -digest = "sha256:1b7f1b0fd07bb4cede16c6a6ec8852815dfb924639a78735fc7bdffdc164485d" - [[packages]] name = "wasi:filesystem" registry = "wasi.dev" @@ -19,12 +10,3 @@ registry = "wasi.dev" requirement = "=0.3.0" version = "0.3.0" digest = "sha256:42921410cddcdce2e009fd65c9a763bfb3fb61e2c0a64ff71647cc4405470378" - -[[packages]] -name = "wasi:logging" -registry = "wasi.dev" - -[[packages.versions]] -requirement = "=0.1.0-draft" -version = "0.1.0-draft" -digest = "sha256:09621a45b12b0a9cddc798517f778aac0e5ae4bd234077b3d70758d6cf625580" From f91c8532971fafb7cc6adbd00c412879b841a60c Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Mon, 18 May 2026 15:51:09 -0400 Subject: [PATCH 03/21] Gate check preopens and directory-entry-streams The preopens interface and directory-entry-stream resource are a bit different as we allow the call and check if the returned value should be passed back to the caller. This allows the gate to filter the preopened filesystems, or files in a directory. Signed-off-by: Scott Andrews --- components/gate/src/lib.rs | 9 +++ components/latch-readonly/src/lib.rs | 1 + crates/latch-n/Cargo.toml | 8 -- wit/latch.wit | 110 +++++++++++++++------------ 4 files changed, 72 insertions(+), 56 deletions(-) delete mode 100644 crates/latch-n/Cargo.toml diff --git a/components/gate/src/lib.rs b/components/gate/src/lib.rs index d32fe3b..04c6a91 100644 --- a/components/gate/src/lib.rs +++ b/components/gate/src/lib.rs @@ -56,6 +56,15 @@ macro_rules! trace { }; } +macro_rules! trace { + ($dst:expr, $($arg:tt)*) => { + log(Level::Trace, "componentized-gate", &format!($dst, $($arg)*)); + }; + ($dst:expr) => { + log(Level::Trace, "componentized-gate", &format!($dst)); + }; +} + /// Authorize the operation with the latch, then report the decision back to the latch. Latches /// act on the final decision in `observe-decision`, never while authorizing. Failing to observe /// the decision fails the operation, the same as a latch error. diff --git a/components/latch-readonly/src/lib.rs b/components/latch-readonly/src/lib.rs index 4d0780a..517613f 100644 --- a/components/latch-readonly/src/lib.rs +++ b/components/latch-readonly/src/lib.rs @@ -64,6 +64,7 @@ impl Latch for ReadOnlyLatch { DescriptorOperation::MetadataHash(_) => Ok(Decision::Deferred), DescriptorOperation::MetadataHashAt(_) => Ok(Decision::Deferred), }, + Operation::DirectoryEntryStream(_) => Abstain, } } diff --git a/crates/latch-n/Cargo.toml b/crates/latch-n/Cargo.toml deleted file mode 100644 index 89c2b9e..0000000 --- a/crates/latch-n/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "latch-n" -version = "0.1.0" -edition = "2021" -license = "Apache-2.0" - -[dependencies] -wit-bindgen = { workspace = true } diff --git a/wit/latch.wit b/wit/latch.wit index d60073a..a85d2ea 100644 --- a/wit/latch.wit +++ b/wit/latch.wit @@ -99,7 +99,10 @@ interface latch { } variant preopens-operation { + <<<<<<< HEAD get-directories, + ======= + >>>>>>> 32aee1a (Gate check preopens and directory-entry-streams) get-directories-item(tuple, string>), } @@ -131,59 +134,70 @@ interface latch { metadata-hash-at(tuple, descriptor-metadata-hash-at-args>), } + <<<<<<< HEAD /// Operations for the wasi:sockets package. @since(version = 0.3.0-dev) variant operation { preopens(preopens-operation), descriptor(descriptor-operation), + ======= + variant directory-entry-stream-operation { + read-directory-entry(directory-entry), + } + + variant operation { + preopens(preopens-operation), + descriptor(tuple, descriptor-operation>), + directory-entry-stream(tuple, directory-entry-stream-operation>), + >>>>>>> 32aee1a (Gate check preopens and directory-entry-streams) + } + + /// Whether the operation is allowed to proceed. + @since(version = 0.3.0-dev) + variant decision { + /// No objection to the operation. + deferred, + /// The operation must not proceed. The error code is returned to the caller for + /// operations that fail. For traffic that is dropped instead, like an accepted connection + /// or a received datagram, it is only logged. + denied(filesystem-error-code), + } + + /// The name of a latch component, identifying which latch reported an error. + @since(version = 0.3.0-dev) + type latch-name = string; + + /// A latch was unable to decide or observe. The gate fails the operation, except for an + /// accepted connection, which is closed as if it were denied. + @since(version = 0.1.0) + variant error-code { + /// The latch's configuration is invalid, the cause is logged by the latch. + invalid-config(latch-name), + /// The latch was unable to act on the final decision. + observation-failed(latch-name), + /// Any other error. + other(option), + } + + /// Decide whether the operation may proceed. + /// + /// Must not have side effects. When latches are aggregated, a latch is not asked about an + /// operation another latch already denied, so state changed here could disagree with what + /// actually happened. Act on decisions in `observe-decision` instead. + @since(version = 0.3.0-dev) + authorize: func(operation: operation) -> result; + + /// Observe the final decision for an operation, after `authorize` returned a decision. + /// + /// Called for both deferred and denied operations, including operations this latch was not + /// asked to authorize because an aggregated latch denied them first. The final decision is + /// what the gate enforces, so this is where a latch updates any state it keeps, for example + /// remembering peers the caller is allowed to exchange traffic with. Not called when + /// `authorize` returned an error. + /// + /// An error fails the operation, since the latch could not act on the decision. + @since(version = 0.3.0-dev) + observe-decision: func(final-decision: decision, operation: operation) -> result<_, error-code>; } - /// Whether the operation is allowed to proceed. - @since(version = 0.3.0-dev) - variant decision { - /// No objection to the operation. - deferred, - /// The operation must not proceed. The error code is returned to the caller for - /// operations that fail. For traffic that is dropped instead, like an accepted connection - /// or a received datagram, it is only logged. - denied(filesystem-error-code), - } - - /// The name of a latch component, identifying which latch reported an error. - @since(version = 0.3.0-dev) - type latch-name = string; - - /// A latch was unable to decide or observe. The gate fails the operation, except for an - /// accepted connection, which is closed as if it were denied. - @since(version = 0.1.0) - variant error-code { - /// The latch's configuration is invalid, the cause is logged by the latch. - invalid-config(latch-name), - /// The latch was unable to act on the final decision. - observation-failed(latch-name), - /// Any other error. - other(option), - } - - /// Decide whether the operation may proceed. - /// - /// Must not have side effects. When latches are aggregated, a latch is not asked about an - /// operation another latch already denied, so state changed here could disagree with what - /// actually happened. Act on decisions in `observe-decision` instead. - @since(version = 0.3.0-dev) - authorize: func(operation: operation) -> result; - - /// Observe the final decision for an operation, after `authorize` returned a decision. - /// - /// Called for both deferred and denied operations, including operations this latch was not - /// asked to authorize because an aggregated latch denied them first. The final decision is - /// what the gate enforces, so this is where a latch updates any state it keeps, for example - /// remembering peers the caller is allowed to exchange traffic with. Not called when - /// `authorize` returned an error. - /// - /// An error fails the operation, since the latch could not act on the decision. - @since(version = 0.3.0-dev) - observe-decision: func(final-decision: decision, operation: operation) -> result<_, error-code>; -} - From 30f5e6b40ec3d3ab94df763a412d0b07fb29e046 Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Sun, 31 May 2026 23:54:16 -0400 Subject: [PATCH 04/21] Add components that can be used to test th gate Signed-off-by: Scott Andrews --- Makefile | 61 +++++++++++++++++ components/gate/src/lib.rs | 36 ++++++++++ lib/tests/.gitignore | 2 + tests/filesystem-cli/Cargo.toml | 8 +++ tests/filesystem-cli/src/main.rs | 100 ++++++++++++++++++++++++++++ tests/readonly.wac | 12 ++++ wit/latch.wit | 110 ++++++++++++++----------------- 7 files changed, 267 insertions(+), 62 deletions(-) create mode 100644 lib/tests/.gitignore create mode 100644 tests/filesystem-cli/Cargo.toml create mode 100644 tests/filesystem-cli/src/main.rs create mode 100644 tests/readonly.wac diff --git a/Makefile b/Makefile index 9e26af7..a466b9b 100644 --- a/Makefile +++ b/Makefile @@ -25,6 +25,67 @@ clean: clean-components clean-wit clean-all: clean-components clean-tools clean-wit cargo clean +define TEST_COMPONENT + +lib/tests/$1.wasm: Cargo.toml Cargo.lock wit/deps $(shell find tests/$1 -type f) + cargo component build -p $1 --target wasm32-wasip2 --release + cp target/wasm32-wasip2/release/$1.wasm lib/tests/$1.wasm + +lib/tests/$1.debug.wasm: Cargo.toml Cargo.lock wit/deps $(shell find tests/$1 -type f) + cargo component build -p $1 --target wasm32-wasip2 + cp target/wasm32-wasip2/debug/$1.wasm lib/tests/$1.debug.wasm + +endef + +$(foreach component,$(TEST_COMPONENTS),$(eval $(call TEST_COMPONENT,$(component)))) + +lib/tests/logging-to-stdout.wasm: + wkg oci pull ghcr.io/componentized/logging/to-stdout:v0.2.1 -o "lib/tests/logging-to-stdout.wasm" + +lib/tests/allow.wasm: lib/gate.wasm lib/latch-allow.wasm + wac plug lib/gate.wasm \ + --plug lib/latch-allow.wasm \ + -o lib/tests/allow.wasm + +lib/tests/filesystem-cli-allow.wasm: lib/tests/filesystem-cli.wasm lib/tests/allow.wasm lib/tests/logging-to-stdout.wasm + wac plug lib/tests/filesystem-cli.wasm \ + --plug <( \ + wac plug lib/tests/allow.wasm \ + --plug lib/tests/logging-to-stdout.wasm \ + ) \ + -o lib/tests/filesystem-cli-allow.wasm + +lib/tests/deny.wasm: lib/gate.wasm lib/latch-deny.wasm + wac plug lib/gate.wasm \ + --plug lib/latch-deny.wasm \ + -o lib/tests/deny.wasm + +lib/tests/filesystem-cli-deny.wasm: lib/tests/filesystem-cli.wasm lib/tests/deny.wasm lib/tests/logging-to-stdout.wasm + wac plug lib/tests/filesystem-cli.wasm \ + --plug <( \ + wac plug lib/tests/deny.wasm \ + --plug lib/tests/logging-to-stdout.wasm \ + ) \ + -o lib/tests/filesystem-cli-deny.wasm + +lib/tests/readonly.wasm: tests/readonly.wac lib/gate.wasm lib/latch-n2.wasm lib/latch-readonly.wasm lib/latch-allow.wasm + wac compose -o lib/tests/readonly.wasm \ + -d componentized:gate="lib/gate.wasm" \ + -d componentized:latch-n2="lib/latch-n2.wasm" \ + -d componentized:latch-readonly="lib/latch-readonly.wasm" \ + -d componentized:latch-allow="lib/latch-allow.wasm" \ + tests/readonly.wac + +lib/tests/filesystem-cli-readonly.wasm: lib/tests/filesystem-cli.wasm lib/tests/readonly.wasm lib/tests/logging-to-stdout.wasm + wac plug lib/tests/filesystem-cli.wasm \ + --plug <( \ + wac plug lib/tests/readonly.wasm \ + --plug lib/tests/logging-to-stdout.wasm \ + ) \ + -o lib/tests/filesystem-cli-readonly.wasm + +.PHONY: tests +tests: $(foreach component,$(TEST_COMPONENTS),lib/tests/$(component).wasm) lib/tests/filesystem-cli-allow.wasm lib/tests/filesystem-cli-deny.wasm lib/tests/filesystem-cli-readonly.wasm .PHONY: test test: components diff --git a/components/gate/src/lib.rs b/components/gate/src/lib.rs index 04c6a91..2c6bd16 100644 --- a/components/gate/src/lib.rs +++ b/components/gate/src/lib.rs @@ -1133,6 +1133,42 @@ impl Display for types::Instant { } } +impl Display for types::Advice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.to_string().to_kebab_case()) + } +} + +impl Display for types::DescriptorFlags { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let names: Vec = self + .iter_names() + .map(|(name, _flags)| name.to_kebab_case()) + .collect(); + f.write_fmt(format_args!("({})", &names.join("|"))) + } +} + +impl Display for types::OpenFlags { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let names: Vec = self + .iter_names() + .map(|(name, _flags)| name.to_kebab_case()) + .collect(); + f.write_fmt(format_args!("({})", &names.join("|"))) + } +} + +impl Display for types::PathFlags { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let names: Vec = self + .iter_names() + .map(|(name, _flags)| name.to_kebab_case()) + .collect(); + f.write_fmt(format_args!("({})", &names.join("|"))) + } +} + wit_bindgen::generate!({ path: "../wit", world: "filesystem", diff --git a/lib/tests/.gitignore b/lib/tests/.gitignore new file mode 100644 index 0000000..902f891 --- /dev/null +++ b/lib/tests/.gitignore @@ -0,0 +1,2 @@ +*.md +*.wasm diff --git a/tests/filesystem-cli/Cargo.toml b/tests/filesystem-cli/Cargo.toml new file mode 100644 index 0000000..6b2ecad --- /dev/null +++ b/tests/filesystem-cli/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "filesystem-cli" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" + +[dependencies] +clap = { version = "4.6.1", features = ["derive"] } diff --git a/tests/filesystem-cli/src/main.rs b/tests/filesystem-cli/src/main.rs new file mode 100644 index 0000000..1eda915 --- /dev/null +++ b/tests/filesystem-cli/src/main.rs @@ -0,0 +1,100 @@ +use std::{ + fs::{self, File, OpenOptions}, + io, + path::PathBuf, +}; + +use clap::{Parser, Subcommand}; + +/// componentized filesystem CLI +#[derive(Debug, Parser)] // requires `derive` feature +#[command(name = "filesystem")] +#[command(about = "componentized filesystem CLI", long_about = None)] +struct FilesystemCli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Debug, Subcommand, Clone)] +enum Commands { + /// List items in a directory + List { + #[arg()] + path: PathBuf, + }, + + /// Read a file + #[command(arg_required_else_help = true)] + Read { + #[arg()] + path: PathBuf, + }, + + /// Write a file + #[command(arg_required_else_help = true)] + Write { + #[arg()] + path: PathBuf, + }, + + /// Append to a file + #[command(arg_required_else_help = true)] + Append { + #[arg()] + path: PathBuf, + }, + + /// Move a file + #[command(arg_required_else_help = true)] + Move { + #[arg()] + from: PathBuf, + + #[arg()] + to: PathBuf, + }, + + /// Remove a file + #[command(arg_required_else_help = true)] + Remove { + #[arg()] + path: PathBuf, + }, +} + +fn main() -> Result<(), std::io::Error> { + match FilesystemCli::parse().command { + Commands::List { path } => { + for entry in fs::read_dir(path)? { + let entry = entry?; + println!("{:?}", entry.file_name()); + } + Ok(()) + } + Commands::Read { path } => { + let mut from = OpenOptions::new().read(true).open(path)?; + let mut to = io::stdout(); + io::copy(&mut from, &mut to)?; + Ok(()) + } + Commands::Write { path } => { + let mut from = io::stdin(); + let mut to = File::create(path)?; + io::copy(&mut from, &mut to)?; + Ok(()) + } + Commands::Append { path } => { + let mut from = io::stdin(); + let mut to = OpenOptions::new().create(true).append(true).open(path)?; + io::copy(&mut from, &mut to)?; + Ok(()) + } + Commands::Move { from, to } => fs::rename(from, to), + Commands::Remove { path } => { + if path.is_dir() { + return fs::remove_dir_all(path); + } + fs::remove_file(path) + } + } +} diff --git a/tests/readonly.wac b/tests/readonly.wac new file mode 100644 index 0000000..df73c7c --- /dev/null +++ b/tests/readonly.wac @@ -0,0 +1,12 @@ +package componentized:filesystem; + +let readonly = new componentized:gate { + latch: new componentized:latch-n2 { + latch: new componentized:latch-readonly { ... }.latch, + latch1: new componentized:latch-allow { ... }.latch, + ... + }.latch, + ... +}; + +export readonly...; diff --git a/wit/latch.wit b/wit/latch.wit index a85d2ea..d60073a 100644 --- a/wit/latch.wit +++ b/wit/latch.wit @@ -99,10 +99,7 @@ interface latch { } variant preopens-operation { - <<<<<<< HEAD get-directories, - ======= - >>>>>>> 32aee1a (Gate check preopens and directory-entry-streams) get-directories-item(tuple, string>), } @@ -134,70 +131,59 @@ interface latch { metadata-hash-at(tuple, descriptor-metadata-hash-at-args>), } - <<<<<<< HEAD /// Operations for the wasi:sockets package. @since(version = 0.3.0-dev) variant operation { preopens(preopens-operation), descriptor(descriptor-operation), - ======= - variant directory-entry-stream-operation { - read-directory-entry(directory-entry), - } - - variant operation { - preopens(preopens-operation), - descriptor(tuple, descriptor-operation>), - directory-entry-stream(tuple, directory-entry-stream-operation>), - >>>>>>> 32aee1a (Gate check preopens and directory-entry-streams) - } - - /// Whether the operation is allowed to proceed. - @since(version = 0.3.0-dev) - variant decision { - /// No objection to the operation. - deferred, - /// The operation must not proceed. The error code is returned to the caller for - /// operations that fail. For traffic that is dropped instead, like an accepted connection - /// or a received datagram, it is only logged. - denied(filesystem-error-code), - } - - /// The name of a latch component, identifying which latch reported an error. - @since(version = 0.3.0-dev) - type latch-name = string; - - /// A latch was unable to decide or observe. The gate fails the operation, except for an - /// accepted connection, which is closed as if it were denied. - @since(version = 0.1.0) - variant error-code { - /// The latch's configuration is invalid, the cause is logged by the latch. - invalid-config(latch-name), - /// The latch was unable to act on the final decision. - observation-failed(latch-name), - /// Any other error. - other(option), - } - - /// Decide whether the operation may proceed. - /// - /// Must not have side effects. When latches are aggregated, a latch is not asked about an - /// operation another latch already denied, so state changed here could disagree with what - /// actually happened. Act on decisions in `observe-decision` instead. - @since(version = 0.3.0-dev) - authorize: func(operation: operation) -> result; - - /// Observe the final decision for an operation, after `authorize` returned a decision. - /// - /// Called for both deferred and denied operations, including operations this latch was not - /// asked to authorize because an aggregated latch denied them first. The final decision is - /// what the gate enforces, so this is where a latch updates any state it keeps, for example - /// remembering peers the caller is allowed to exchange traffic with. Not called when - /// `authorize` returned an error. - /// - /// An error fails the operation, since the latch could not act on the decision. - @since(version = 0.3.0-dev) - observe-decision: func(final-decision: decision, operation: operation) -> result<_, error-code>; } + /// Whether the operation is allowed to proceed. + @since(version = 0.3.0-dev) + variant decision { + /// No objection to the operation. + deferred, + /// The operation must not proceed. The error code is returned to the caller for + /// operations that fail. For traffic that is dropped instead, like an accepted connection + /// or a received datagram, it is only logged. + denied(filesystem-error-code), + } + + /// The name of a latch component, identifying which latch reported an error. + @since(version = 0.3.0-dev) + type latch-name = string; + + /// A latch was unable to decide or observe. The gate fails the operation, except for an + /// accepted connection, which is closed as if it were denied. + @since(version = 0.1.0) + variant error-code { + /// The latch's configuration is invalid, the cause is logged by the latch. + invalid-config(latch-name), + /// The latch was unable to act on the final decision. + observation-failed(latch-name), + /// Any other error. + other(option), + } + + /// Decide whether the operation may proceed. + /// + /// Must not have side effects. When latches are aggregated, a latch is not asked about an + /// operation another latch already denied, so state changed here could disagree with what + /// actually happened. Act on decisions in `observe-decision` instead. + @since(version = 0.3.0-dev) + authorize: func(operation: operation) -> result; + + /// Observe the final decision for an operation, after `authorize` returned a decision. + /// + /// Called for both deferred and denied operations, including operations this latch was not + /// asked to authorize because an aggregated latch denied them first. The final decision is + /// what the gate enforces, so this is where a latch updates any state it keeps, for example + /// remembering peers the caller is allowed to exchange traffic with. Not called when + /// `authorize` returned an error. + /// + /// An error fails the operation, since the latch could not act on the decision. + @since(version = 0.3.0-dev) + observe-decision: func(final-decision: decision, operation: operation) -> result<_, error-code>; +} + From caad499da319c94eb403ef47646d1e4b9a951280 Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Mon, 1 Jun 2026 09:52:45 -0400 Subject: [PATCH 05/21] Polish names - allow -> some(permitted) - deny -> some(denied) - abstain -> none - check -> authorize - latch-allow -> latch-permit-all - latch-deny -> latch-deny-all Signed-off-by: Scott Andrews --- Makefile | 22 +++++++++++----------- README.md | 10 +++++----- components/latch-readonly/src/lib.rs | 2 +- tests/readonly.wac | 2 +- wit/latch.wit | 2 +- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/Makefile b/Makefile index a466b9b..cbb18d1 100644 --- a/Makefile +++ b/Makefile @@ -42,22 +42,22 @@ $(foreach component,$(TEST_COMPONENTS),$(eval $(call TEST_COMPONENT,$(component) lib/tests/logging-to-stdout.wasm: wkg oci pull ghcr.io/componentized/logging/to-stdout:v0.2.1 -o "lib/tests/logging-to-stdout.wasm" -lib/tests/allow.wasm: lib/gate.wasm lib/latch-allow.wasm +lib/tests/permit.wasm: lib/gate.wasm lib/latch-permit-all.wasm wac plug lib/gate.wasm \ - --plug lib/latch-allow.wasm \ - -o lib/tests/allow.wasm + --plug lib/latch-permit-all.wasm \ + -o lib/tests/permit.wasm -lib/tests/filesystem-cli-allow.wasm: lib/tests/filesystem-cli.wasm lib/tests/allow.wasm lib/tests/logging-to-stdout.wasm +lib/tests/filesystem-cli-permit.wasm: lib/tests/filesystem-cli.wasm lib/tests/permit.wasm lib/tests/logging-to-stdout.wasm wac plug lib/tests/filesystem-cli.wasm \ --plug <( \ - wac plug lib/tests/allow.wasm \ + wac plug lib/tests/permit.wasm \ --plug lib/tests/logging-to-stdout.wasm \ ) \ - -o lib/tests/filesystem-cli-allow.wasm + -o lib/tests/filesystem-cli-permit.wasm -lib/tests/deny.wasm: lib/gate.wasm lib/latch-deny.wasm +lib/tests/deny.wasm: lib/gate.wasm lib/latch-deny-all.wasm wac plug lib/gate.wasm \ - --plug lib/latch-deny.wasm \ + --plug lib/latch-deny-all.wasm \ -o lib/tests/deny.wasm lib/tests/filesystem-cli-deny.wasm: lib/tests/filesystem-cli.wasm lib/tests/deny.wasm lib/tests/logging-to-stdout.wasm @@ -68,12 +68,12 @@ lib/tests/filesystem-cli-deny.wasm: lib/tests/filesystem-cli.wasm lib/tests/deny ) \ -o lib/tests/filesystem-cli-deny.wasm -lib/tests/readonly.wasm: tests/readonly.wac lib/gate.wasm lib/latch-n2.wasm lib/latch-readonly.wasm lib/latch-allow.wasm +lib/tests/readonly.wasm: tests/readonly.wac lib/gate.wasm lib/latch-n2.wasm lib/latch-readonly.wasm lib/latch-permit-all.wasm wac compose -o lib/tests/readonly.wasm \ -d componentized:gate="lib/gate.wasm" \ -d componentized:latch-n2="lib/latch-n2.wasm" \ -d componentized:latch-readonly="lib/latch-readonly.wasm" \ - -d componentized:latch-allow="lib/latch-allow.wasm" \ + -d componentized:latch-permit="lib/latch-permit-all.wasm" \ tests/readonly.wac lib/tests/filesystem-cli-readonly.wasm: lib/tests/filesystem-cli.wasm lib/tests/readonly.wasm lib/tests/logging-to-stdout.wasm @@ -85,7 +85,7 @@ lib/tests/filesystem-cli-readonly.wasm: lib/tests/filesystem-cli.wasm lib/tests/ -o lib/tests/filesystem-cli-readonly.wasm .PHONY: tests -tests: $(foreach component,$(TEST_COMPONENTS),lib/tests/$(component).wasm) lib/tests/filesystem-cli-allow.wasm lib/tests/filesystem-cli-deny.wasm lib/tests/filesystem-cli-readonly.wasm +tests: $(foreach component,$(TEST_COMPONENTS),lib/tests/$(component).wasm) lib/tests/filesystem-cli-permit.wasm lib/tests/filesystem-cli-deny.wasm lib/tests/filesystem-cli-readonly.wasm .PHONY: test test: components diff --git a/README.md b/README.md index 68eab4c..6c0560f 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,11 @@ A collection of utility components that remix wasi:filesystem types and interfac - [`chroot`](./components/chroot/) - [`gate`](./components/gate/) -- [`latch-2`](./components/latch-2/) -- [`latch-3`](./components/latch-3/) -- [`latch-4`](./components/latch-4/) -- [`latch-allow`](./components/latch-allow/) -- [`latch-deny`](./components/latch-deny/) +- [`latch-n2`](./components/latch-n2/) +- [`latch-n3`](./components/latch-n3/) +- [`latch-n4`](./components/latch-n4/) +- [`latch-deny-all`](./components/latch-deny-all/) +- [`latch-permit-all`](./components/latch-permit-all/) - [`latch-readonly`](./components/latch-readonly/) - ~~[`readonly`](./components/readonly/)~~ (deprecated, favor gate with readonly latch) - [`trace`](./components/trace/) diff --git a/components/latch-readonly/src/lib.rs b/components/latch-readonly/src/lib.rs index 517613f..2a47eeb 100644 --- a/components/latch-readonly/src/lib.rs +++ b/components/latch-readonly/src/lib.rs @@ -64,7 +64,7 @@ impl Latch for ReadOnlyLatch { DescriptorOperation::MetadataHash(_) => Ok(Decision::Deferred), DescriptorOperation::MetadataHashAt(_) => Ok(Decision::Deferred), }, - Operation::DirectoryEntryStream(_) => Abstain, + Operation::DirectoryEntryStream(_) => None, } } diff --git a/tests/readonly.wac b/tests/readonly.wac index df73c7c..908403a 100644 --- a/tests/readonly.wac +++ b/tests/readonly.wac @@ -3,7 +3,7 @@ package componentized:filesystem; let readonly = new componentized:gate { latch: new componentized:latch-n2 { latch: new componentized:latch-readonly { ... }.latch, - latch1: new componentized:latch-allow { ... }.latch, + latch1: new componentized:latch-permit { ... }.latch, ... }.latch, ... diff --git a/wit/latch.wit b/wit/latch.wit index d60073a..52adddd 100644 --- a/wit/latch.wit +++ b/wit/latch.wit @@ -35,7 +35,7 @@ interface latch { } record descriptor-write-args { - buffer-length: u64, + buffer-length: u64, // derived from buffer: list offset: filesize, } From ee1d0eb62c6ef9860f3e6e7acfcfab6655497e1c Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Mon, 1 Jun 2026 12:48:55 -0400 Subject: [PATCH 06/21] Run operation unless denied The gate now treats `Some(Permitted)` and `None` authorization decisions as allowing the operation. This dramatically simplifies use of a single latch as it no longer needs to be aggregated with allow-all. Not needing an aggregation also means `wac plug` can manage the composition, while an aggregation requires a wac script or custom embedding to manage the interface names. Signed-off-by: Scott Andrews --- Makefile | 11 ++++------- tests/readonly.wac | 12 ------------ 2 files changed, 4 insertions(+), 19 deletions(-) delete mode 100644 tests/readonly.wac diff --git a/Makefile b/Makefile index cbb18d1..5626e03 100644 --- a/Makefile +++ b/Makefile @@ -68,13 +68,10 @@ lib/tests/filesystem-cli-deny.wasm: lib/tests/filesystem-cli.wasm lib/tests/deny ) \ -o lib/tests/filesystem-cli-deny.wasm -lib/tests/readonly.wasm: tests/readonly.wac lib/gate.wasm lib/latch-n2.wasm lib/latch-readonly.wasm lib/latch-permit-all.wasm - wac compose -o lib/tests/readonly.wasm \ - -d componentized:gate="lib/gate.wasm" \ - -d componentized:latch-n2="lib/latch-n2.wasm" \ - -d componentized:latch-readonly="lib/latch-readonly.wasm" \ - -d componentized:latch-permit="lib/latch-permit-all.wasm" \ - tests/readonly.wac +lib/tests/readonly.wasm: lib/gate.wasm lib/latch-n2.wasm lib/latch-readonly.wasm lib/latch-permit-all.wasm + wac plug lib/gate.wasm \ + --plug lib/latch-readonly.wasm \ + -o lib/tests/readonly.wasm lib/tests/filesystem-cli-readonly.wasm: lib/tests/filesystem-cli.wasm lib/tests/readonly.wasm lib/tests/logging-to-stdout.wasm wac plug lib/tests/filesystem-cli.wasm \ diff --git a/tests/readonly.wac b/tests/readonly.wac deleted file mode 100644 index 908403a..0000000 --- a/tests/readonly.wac +++ /dev/null @@ -1,12 +0,0 @@ -package componentized:filesystem; - -let readonly = new componentized:gate { - latch: new componentized:latch-n2 { - latch: new componentized:latch-readonly { ... }.latch, - latch1: new componentized:latch-permit { ... }.latch, - ... - }.latch, - ... -}; - -export readonly...; From 169e61b018357209398dfbefe6416d71b35defb0 Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Mon, 1 Jun 2026 16:31:24 -0400 Subject: [PATCH 07/21] Include path as part of the operation The effective path is now passed as part of the operation. This enables a latch to consider the path of an operation. `latch-glob` uses glob pattern matching on the path to permit or deny an operation. Signed-off-by: Scott Andrews --- README.md | 1 + components/latch-glob/Cargo.toml | 12 ++ components/latch-glob/README.md | 9 ++ components/latch-glob/src/lib.rs | 209 +++++++++++++++++++++++++++++++ 4 files changed, 231 insertions(+) create mode 100644 components/latch-glob/Cargo.toml create mode 100644 components/latch-glob/README.md create mode 100644 components/latch-glob/src/lib.rs diff --git a/README.md b/README.md index 6c0560f..773efec 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ A collection of utility components that remix wasi:filesystem types and interfac - [`latch-n4`](./components/latch-n4/) - [`latch-deny-all`](./components/latch-deny-all/) - [`latch-permit-all`](./components/latch-permit-all/) +- [`latch-glob`](./components/latch-glob/) - [`latch-readonly`](./components/latch-readonly/) - ~~[`readonly`](./components/readonly/)~~ (deprecated, favor gate with readonly latch) - [`trace`](./components/trace/) diff --git a/components/latch-glob/Cargo.toml b/components/latch-glob/Cargo.toml new file mode 100644 index 0000000..862df7b --- /dev/null +++ b/components/latch-glob/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "latch-glob" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = { workspace = true } +path-matchers = "1.0" diff --git a/components/latch-glob/README.md b/components/latch-glob/README.md new file mode 100644 index 0000000..e033cc2 --- /dev/null +++ b/components/latch-glob/README.md @@ -0,0 +1,9 @@ +# `latch-glob` + +Filesystem latch that uses glob patterns to permit or deny operations on a path. + +The patterns are defined in a wasi:config/store. Keys starting with `deny` are parsed as globs with matching path operations being denied. Multiple patterns are allowed by defining unique config keys (e.g. `deny-1`, `deny-2`, etc). Keys starting with `permit` are parsed as globs with matching path operations being permitted. + +Operations are evaluated against the pattern with the base path for the operation joined with a relative path, if any. For example, the file descriptor open-at operation will join the descriptor's path with the argument's path, the resulting path is matched to the patterns. + +For operations with multiple paths, each path is evaluated individually. Any path matching a deny pattern will result in a denial. diff --git a/components/latch-glob/src/lib.rs b/components/latch-glob/src/lib.rs new file mode 100644 index 0000000..a5313bd --- /dev/null +++ b/components/latch-glob/src/lib.rs @@ -0,0 +1,209 @@ +#![no_main] + +use std::path::Path; + +use path_matchers::{glob, PathMatcher}; + +use crate::{ + exports::componentized::filesystem::latch::{ + Decision, DescriptorOperation, DirectoryEntryStreamOperation, Guest as Latch, Operation, + PreopensOperation, + }, + wasi::filesystem::types::ErrorCode, +}; + +struct GlobLatch {} + +struct Patterns { + initialized: bool, + denies: Option>>, + permits: Option>>, + deny_reason: Option, +} + +impl Patterns { + fn initialize() { + if unsafe { STATE.initialized } { + return; + } + + let mut denies: Vec> = vec![]; + let mut permits: Vec> = vec![]; + let mut reason = ErrorCode::NotPermitted; + + for (key, value) in wasi::config::store::get_all().expect("config must be available") { + if key.starts_with("deny") { + denies.push(Box::new( + glob(&value).expect("config value must parse as a glob"), + )); + } else if key.starts_with("permit") { + permits.push(Box::new( + glob(&value).expect("config value must parse as a glob"), + )); + } else if key == "reason" { + reason = get_error_code(value).unwrap_or(ErrorCode::NotPermitted); + } + } + + unsafe { + STATE.denies = Some(denies); + STATE.permits = Some(permits); + STATE.deny_reason = Some(reason); + STATE.initialized = true; + }; + } + + #[allow(static_mut_refs)] + fn authorize(path: String, paths: Vec) -> Option { + if paths.len() == 0 { + let path = Path::new(&path); + return unsafe { STATE.authorize_path(path) }; + } + let mut decision = None; + for p in paths { + let path = Path::new(&path).join(p); + match unsafe { STATE.authorize_path(&path) } { + // return denies immediately, buffer permits + Some(Decision::Denied(reason)) => return Some(Decision::Denied(reason)), + Some(Decision::Permitted) => decision = Some(Decision::Permitted), + None => {} + } + } + decision + } + + fn authorize_path(&self, path: &Path) -> Option { + for deny in self.denies.as_ref().unwrap() { + if deny.matches(path) { + return Some(Decision::Denied(self.deny_reason.unwrap())); + } + } + for permit in self.permits.as_ref().unwrap() { + if permit.matches(path) { + return Some(Decision::Permitted); + } + } + None + } +} + +static mut STATE: Patterns = Patterns { + initialized: false, + denies: None, + permits: None, + deny_reason: None, +}; + +impl Latch for GlobLatch { + fn authorize(operation: Operation) -> Option { + Patterns::initialize(); + + match operation { + Operation::Preopens(preopens_operation) => match preopens_operation { + PreopensOperation::GetDirectoriesItem((_, path)) => { + Patterns::authorize(path, vec![]) + } + }, + Operation::Descriptor((_, path, descriptor_operation)) => match descriptor_operation { + DescriptorOperation::ReadViaStream(_) => Patterns::authorize(path, vec![]), + DescriptorOperation::WriteViaStream(_) => Patterns::authorize(path, vec![]), + DescriptorOperation::AppendViaStream => Patterns::authorize(path, vec![]), + DescriptorOperation::Advise(_) => Patterns::authorize(path, vec![]), + DescriptorOperation::SyncData => Patterns::authorize(path, vec![]), + DescriptorOperation::GetFlags => Patterns::authorize(path, vec![]), + DescriptorOperation::GetType => Patterns::authorize(path, vec![]), + DescriptorOperation::SetSize(_) => Patterns::authorize(path, vec![]), + DescriptorOperation::SetTimes(_) => Patterns::authorize(path, vec![]), + DescriptorOperation::Read(_) => Patterns::authorize(path, vec![]), + DescriptorOperation::Write(_) => Patterns::authorize(path, vec![]), + DescriptorOperation::ReadDirectory => Patterns::authorize(path, vec![]), + DescriptorOperation::Sync => Patterns::authorize(path, vec![]), + DescriptorOperation::CreateDirectoryAt(args) => { + Patterns::authorize(path, vec![args.path]) + } + DescriptorOperation::Stat => Patterns::authorize(path, vec![]), + DescriptorOperation::StatAt(args) => Patterns::authorize(path, vec![args.path]), + DescriptorOperation::SetTimesAt(args) => Patterns::authorize(path, vec![args.path]), + DescriptorOperation::LinkAt(args) => { + Patterns::authorize(path, vec![args.old_path, args.new_path]) + } + DescriptorOperation::OpenAt(args) => Patterns::authorize(path, vec![args.path]), + DescriptorOperation::ReadlinkAt(args) => Patterns::authorize(path, vec![args.path]), + DescriptorOperation::RemoveDirectoryAt(args) => { + Patterns::authorize(path, vec![args.path]) + } + DescriptorOperation::RenameAt(args) => { + Patterns::authorize(path, vec![args.old_path, args.new_path]) + } + DescriptorOperation::SymlinkAt(args) => { + Patterns::authorize(path, vec![args.old_path, args.new_path]) + } + DescriptorOperation::UnlinkFileAt(args) => { + Patterns::authorize(path, vec![args.path]) + } + DescriptorOperation::MetadataHash => Patterns::authorize(path, vec![]), + DescriptorOperation::MetadataHashAt(args) => { + Patterns::authorize(path, vec![args.path]) + } + }, + Operation::DirectoryEntryStream((_, path, directory_entry_stream_operation)) => { + match directory_entry_stream_operation { + DirectoryEntryStreamOperation::ReadDirectoryEntry(directory_entry) => { + Patterns::authorize(path, vec![directory_entry.name]) + } + } + } + } + } +} + +fn get_error_code(value: String) -> Option { + match value.as_str() { + "access" => Some(ErrorCode::Access), + "wouldblock" => Some(ErrorCode::WouldBlock), + "already" => Some(ErrorCode::Already), + "bad-descriptor" => Some(ErrorCode::BadDescriptor), + "busy" => Some(ErrorCode::Busy), + "deadlock" => Some(ErrorCode::Deadlock), + "quota" => Some(ErrorCode::Quota), + "exist" => Some(ErrorCode::Exist), + "file-too-large" => Some(ErrorCode::FileTooLarge), + "illegal-byte-sequence" => Some(ErrorCode::IllegalByteSequence), + "in-progress" => Some(ErrorCode::InProgress), + "interrupted" => Some(ErrorCode::Interrupted), + "invalid" => Some(ErrorCode::Invalid), + "io" => Some(ErrorCode::Io), + "is-directory" => Some(ErrorCode::IsDirectory), + "loop" => Some(ErrorCode::Loop), + "too-many-links" => Some(ErrorCode::TooManyLinks), + "message-size" => Some(ErrorCode::MessageSize), + "name-too-long" => Some(ErrorCode::NameTooLong), + "no-device" => Some(ErrorCode::NoDevice), + "no-entry" => Some(ErrorCode::NoEntry), + "no-lock" => Some(ErrorCode::NoLock), + "insufficient-memory" => Some(ErrorCode::InsufficientMemory), + "insufficient-space" => Some(ErrorCode::InsufficientSpace), + "not-directory" => Some(ErrorCode::NotDirectory), + "not-empty" => Some(ErrorCode::NotEmpty), + "not-recoverable" => Some(ErrorCode::NotRecoverable), + "unsupported" => Some(ErrorCode::Unsupported), + "no-tty" => Some(ErrorCode::NoTty), + "no-such-device" => Some(ErrorCode::NoSuchDevice), + "overflow" => Some(ErrorCode::Overflow), + "not-permitted" => Some(ErrorCode::NotPermitted), + "pipe" => Some(ErrorCode::Pipe), + "read-only" => Some(ErrorCode::ReadOnly), + "invalid-seek" => Some(ErrorCode::InvalidSeek), + "text-file-busy" => Some(ErrorCode::TextFileBusy), + "cross-device" => Some(ErrorCode::CrossDevice), + _ => None, + } +} + +wit_bindgen::generate!({ + path: "../../wit", + world: "filesystem-latch", + generate_all +}); + +export!(GlobLatch); From 6b6640a00cc397e25d3fd483b5a157d920dd6ee4 Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Thu, 11 Jun 2026 15:45:34 -0400 Subject: [PATCH 08/21] permit -> grant Signed-off-by: Scott Andrews --- Makefile | 16 ++++++++-------- README.md | 2 +- components/latch-glob/README.md | 4 ++-- components/latch-glob/src/lib.rs | 22 +++++++++++----------- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Makefile b/Makefile index 5626e03..de18477 100644 --- a/Makefile +++ b/Makefile @@ -42,18 +42,18 @@ $(foreach component,$(TEST_COMPONENTS),$(eval $(call TEST_COMPONENT,$(component) lib/tests/logging-to-stdout.wasm: wkg oci pull ghcr.io/componentized/logging/to-stdout:v0.2.1 -o "lib/tests/logging-to-stdout.wasm" -lib/tests/permit.wasm: lib/gate.wasm lib/latch-permit-all.wasm +lib/tests/grant.wasm: lib/gate.wasm lib/latch-grant-all.wasm wac plug lib/gate.wasm \ - --plug lib/latch-permit-all.wasm \ - -o lib/tests/permit.wasm + --plug lib/latch-grant-all.wasm \ + -o lib/tests/grant.wasm -lib/tests/filesystem-cli-permit.wasm: lib/tests/filesystem-cli.wasm lib/tests/permit.wasm lib/tests/logging-to-stdout.wasm +lib/tests/filesystem-cli-grant.wasm: lib/tests/filesystem-cli.wasm lib/tests/grant.wasm lib/tests/logging-to-stdout.wasm wac plug lib/tests/filesystem-cli.wasm \ --plug <( \ - wac plug lib/tests/permit.wasm \ + wac plug lib/tests/grant.wasm \ --plug lib/tests/logging-to-stdout.wasm \ ) \ - -o lib/tests/filesystem-cli-permit.wasm + -o lib/tests/filesystem-cli-grant.wasm lib/tests/deny.wasm: lib/gate.wasm lib/latch-deny-all.wasm wac plug lib/gate.wasm \ @@ -68,7 +68,7 @@ lib/tests/filesystem-cli-deny.wasm: lib/tests/filesystem-cli.wasm lib/tests/deny ) \ -o lib/tests/filesystem-cli-deny.wasm -lib/tests/readonly.wasm: lib/gate.wasm lib/latch-n2.wasm lib/latch-readonly.wasm lib/latch-permit-all.wasm +lib/tests/readonly.wasm: lib/gate.wasm lib/latch-n2.wasm lib/latch-readonly.wasm lib/latch-grant-all.wasm wac plug lib/gate.wasm \ --plug lib/latch-readonly.wasm \ -o lib/tests/readonly.wasm @@ -82,7 +82,7 @@ lib/tests/filesystem-cli-readonly.wasm: lib/tests/filesystem-cli.wasm lib/tests/ -o lib/tests/filesystem-cli-readonly.wasm .PHONY: tests -tests: $(foreach component,$(TEST_COMPONENTS),lib/tests/$(component).wasm) lib/tests/filesystem-cli-permit.wasm lib/tests/filesystem-cli-deny.wasm lib/tests/filesystem-cli-readonly.wasm +tests: $(foreach component,$(TEST_COMPONENTS),lib/tests/$(component).wasm) lib/tests/filesystem-cli-grant.wasm lib/tests/filesystem-cli-deny.wasm lib/tests/filesystem-cli-readonly.wasm .PHONY: test test: components diff --git a/README.md b/README.md index 773efec..df52dea 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ A collection of utility components that remix wasi:filesystem types and interfac - [`latch-n3`](./components/latch-n3/) - [`latch-n4`](./components/latch-n4/) - [`latch-deny-all`](./components/latch-deny-all/) -- [`latch-permit-all`](./components/latch-permit-all/) +- [`latch-grant-all`](./components/latch-grant-all/) - [`latch-glob`](./components/latch-glob/) - [`latch-readonly`](./components/latch-readonly/) - ~~[`readonly`](./components/readonly/)~~ (deprecated, favor gate with readonly latch) diff --git a/components/latch-glob/README.md b/components/latch-glob/README.md index e033cc2..e8cc14b 100644 --- a/components/latch-glob/README.md +++ b/components/latch-glob/README.md @@ -1,8 +1,8 @@ # `latch-glob` -Filesystem latch that uses glob patterns to permit or deny operations on a path. +Filesystem latch that uses glob patterns to grant or deny operations on a path. -The patterns are defined in a wasi:config/store. Keys starting with `deny` are parsed as globs with matching path operations being denied. Multiple patterns are allowed by defining unique config keys (e.g. `deny-1`, `deny-2`, etc). Keys starting with `permit` are parsed as globs with matching path operations being permitted. +The patterns are defined in a wasi:config/store. Keys starting with `deny` are parsed as globs with matching path operations being denied. Multiple patterns are allowed by defining unique config keys (e.g. `deny-1`, `deny-2`, etc). Keys starting with `grant` are parsed as globs with matching path operations being granted. Operations are evaluated against the pattern with the base path for the operation joined with a relative path, if any. For example, the file descriptor open-at operation will join the descriptor's path with the argument's path, the resulting path is matched to the patterns. diff --git a/components/latch-glob/src/lib.rs b/components/latch-glob/src/lib.rs index a5313bd..9b012c0 100644 --- a/components/latch-glob/src/lib.rs +++ b/components/latch-glob/src/lib.rs @@ -17,7 +17,7 @@ struct GlobLatch {} struct Patterns { initialized: bool, denies: Option>>, - permits: Option>>, + grants: Option>>, deny_reason: Option, } @@ -28,7 +28,7 @@ impl Patterns { } let mut denies: Vec> = vec![]; - let mut permits: Vec> = vec![]; + let mut grants: Vec> = vec![]; let mut reason = ErrorCode::NotPermitted; for (key, value) in wasi::config::store::get_all().expect("config must be available") { @@ -36,8 +36,8 @@ impl Patterns { denies.push(Box::new( glob(&value).expect("config value must parse as a glob"), )); - } else if key.starts_with("permit") { - permits.push(Box::new( + } else if key.starts_with("grant") { + grants.push(Box::new( glob(&value).expect("config value must parse as a glob"), )); } else if key == "reason" { @@ -47,7 +47,7 @@ impl Patterns { unsafe { STATE.denies = Some(denies); - STATE.permits = Some(permits); + STATE.grants = Some(grants); STATE.deny_reason = Some(reason); STATE.initialized = true; }; @@ -63,9 +63,9 @@ impl Patterns { for p in paths { let path = Path::new(&path).join(p); match unsafe { STATE.authorize_path(&path) } { - // return denies immediately, buffer permits + // return denies immediately, buffer grants Some(Decision::Denied(reason)) => return Some(Decision::Denied(reason)), - Some(Decision::Permitted) => decision = Some(Decision::Permitted), + Some(Decision::Granted) => decision = Some(Decision::Granted), None => {} } } @@ -78,9 +78,9 @@ impl Patterns { return Some(Decision::Denied(self.deny_reason.unwrap())); } } - for permit in self.permits.as_ref().unwrap() { - if permit.matches(path) { - return Some(Decision::Permitted); + for grant in self.grants.as_ref().unwrap() { + if grant.matches(path) { + return Some(Decision::Granted); } } None @@ -90,7 +90,7 @@ impl Patterns { static mut STATE: Patterns = Patterns { initialized: false, denies: None, - permits: None, + grants: None, deny_reason: None, }; From 67c35a736974bf11ac5cebf8ad4de958b2e2c065 Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Sun, 14 Jun 2026 10:27:25 -0400 Subject: [PATCH 09/21] migrate to wasi 0.3.0 Signed-off-by: Scott Andrews --- Cargo.lock | 55 ++ components/gate/src/lib.rs | 33 + components/latch-glob/src/lib.rs | 101 ++- components/latch-readonly/src/lib.rs | 1 - wit/deps/wasi-clocks-0.3.0/package.wit | 19 + wit/deps/wasi-config-0.2.0-rc.1/package.wit | 33 + wit/deps/wasi-filesystem-0.3.0/package.wit | 575 ++++++++++++++++++ wit/deps/wasi-logging-0.1.0-draft/package.wit | 36 ++ wit/latch.wit | 10 - 9 files changed, 797 insertions(+), 66 deletions(-) create mode 100644 wit/deps/wasi-clocks-0.3.0/package.wit create mode 100644 wit/deps/wasi-config-0.2.0-rc.1/package.wit create mode 100644 wit/deps/wasi-filesystem-0.3.0/package.wit create mode 100644 wit/deps/wasi-logging-0.1.0-draft/package.wit diff --git a/Cargo.lock b/Cargo.lock index 78efccc..eaaf112 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -179,6 +229,11 @@ dependencies = [ name = "componentized-rt" version = "0.1.0" dependencies = [ + "componentized-rt", + "tempfile", + "test-harness", + "tokio", + "wasmtime", "wit-bindgen", ] diff --git a/components/gate/src/lib.rs b/components/gate/src/lib.rs index 2c6bd16..5a5a951 100644 --- a/components/gate/src/lib.rs +++ b/components/gate/src/lib.rs @@ -1169,6 +1169,39 @@ impl Display for types::PathFlags { } } +impl Display for types::NewTimestamp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + types::NewTimestamp::NoChange => f.write_str("no-change"), + types::NewTimestamp::Now => f.write_str("now"), + types::NewTimestamp::Timestamp(instant) => { + f.write_fmt(format_args!("timestamp<{instant}>")) + } + } + } +} + +impl Display for types::Instant { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let date = DateTime::from_timestamp(self.seconds as i64, self.nanoseconds) + .unwrap() + .format("%Y-%m-%d %H:%M:%S.%3fZ"); + + f.write_fmt(format_args!("{date}")) + } +} + +impl Display for types::DescriptorType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + types::DescriptorType::Other(Some(type_)) => { + f.write_fmt(format_args!("other<{type_}>")) + } + _ => f.write_str(&self.to_string().to_kebab_case()), + } + } +} + wit_bindgen::generate!({ path: "../wit", world: "filesystem", diff --git a/components/latch-glob/src/lib.rs b/components/latch-glob/src/lib.rs index 9b012c0..6917d98 100644 --- a/components/latch-glob/src/lib.rs +++ b/components/latch-glob/src/lib.rs @@ -6,8 +6,7 @@ use path_matchers::{glob, PathMatcher}; use crate::{ exports::componentized::filesystem::latch::{ - Decision, DescriptorOperation, DirectoryEntryStreamOperation, Guest as Latch, Operation, - PreopensOperation, + Decision, DescriptorOperation, Guest as Latch, Operation, PreopensOperation, }, wasi::filesystem::types::ErrorCode, }; @@ -18,7 +17,7 @@ struct Patterns { initialized: bool, denies: Option>>, grants: Option>>, - deny_reason: Option, + deny_reason: ErrorCode, } impl Patterns { @@ -41,14 +40,14 @@ impl Patterns { glob(&value).expect("config value must parse as a glob"), )); } else if key == "reason" { - reason = get_error_code(value).unwrap_or(ErrorCode::NotPermitted); + reason = get_error_code(value); } } unsafe { STATE.denies = Some(denies); STATE.grants = Some(grants); - STATE.deny_reason = Some(reason); + STATE.deny_reason = reason; STATE.initialized = true; }; } @@ -75,7 +74,7 @@ impl Patterns { fn authorize_path(&self, path: &Path) -> Option { for deny in self.denies.as_ref().unwrap() { if deny.matches(path) { - return Some(Decision::Denied(self.deny_reason.unwrap())); + return Some(Decision::Denied(self.deny_reason.clone())); } } for grant in self.grants.as_ref().unwrap() { @@ -91,7 +90,7 @@ static mut STATE: Patterns = Patterns { initialized: false, denies: None, grants: None, - deny_reason: None, + deny_reason: ErrorCode::NotPermitted, }; impl Latch for GlobLatch { @@ -114,8 +113,6 @@ impl Latch for GlobLatch { DescriptorOperation::GetType => Patterns::authorize(path, vec![]), DescriptorOperation::SetSize(_) => Patterns::authorize(path, vec![]), DescriptorOperation::SetTimes(_) => Patterns::authorize(path, vec![]), - DescriptorOperation::Read(_) => Patterns::authorize(path, vec![]), - DescriptorOperation::Write(_) => Patterns::authorize(path, vec![]), DescriptorOperation::ReadDirectory => Patterns::authorize(path, vec![]), DescriptorOperation::Sync => Patterns::authorize(path, vec![]), DescriptorOperation::CreateDirectoryAt(args) => { @@ -146,63 +143,57 @@ impl Latch for GlobLatch { Patterns::authorize(path, vec![args.path]) } }, - Operation::DirectoryEntryStream((_, path, directory_entry_stream_operation)) => { - match directory_entry_stream_operation { - DirectoryEntryStreamOperation::ReadDirectoryEntry(directory_entry) => { - Patterns::authorize(path, vec![directory_entry.name]) - } - } - } } } } -fn get_error_code(value: String) -> Option { +fn get_error_code(value: String) -> ErrorCode { match value.as_str() { - "access" => Some(ErrorCode::Access), - "wouldblock" => Some(ErrorCode::WouldBlock), - "already" => Some(ErrorCode::Already), - "bad-descriptor" => Some(ErrorCode::BadDescriptor), - "busy" => Some(ErrorCode::Busy), - "deadlock" => Some(ErrorCode::Deadlock), - "quota" => Some(ErrorCode::Quota), - "exist" => Some(ErrorCode::Exist), - "file-too-large" => Some(ErrorCode::FileTooLarge), - "illegal-byte-sequence" => Some(ErrorCode::IllegalByteSequence), - "in-progress" => Some(ErrorCode::InProgress), - "interrupted" => Some(ErrorCode::Interrupted), - "invalid" => Some(ErrorCode::Invalid), - "io" => Some(ErrorCode::Io), - "is-directory" => Some(ErrorCode::IsDirectory), - "loop" => Some(ErrorCode::Loop), - "too-many-links" => Some(ErrorCode::TooManyLinks), - "message-size" => Some(ErrorCode::MessageSize), - "name-too-long" => Some(ErrorCode::NameTooLong), - "no-device" => Some(ErrorCode::NoDevice), - "no-entry" => Some(ErrorCode::NoEntry), - "no-lock" => Some(ErrorCode::NoLock), - "insufficient-memory" => Some(ErrorCode::InsufficientMemory), - "insufficient-space" => Some(ErrorCode::InsufficientSpace), - "not-directory" => Some(ErrorCode::NotDirectory), - "not-empty" => Some(ErrorCode::NotEmpty), - "not-recoverable" => Some(ErrorCode::NotRecoverable), - "unsupported" => Some(ErrorCode::Unsupported), - "no-tty" => Some(ErrorCode::NoTty), - "no-such-device" => Some(ErrorCode::NoSuchDevice), - "overflow" => Some(ErrorCode::Overflow), - "not-permitted" => Some(ErrorCode::NotPermitted), - "pipe" => Some(ErrorCode::Pipe), - "read-only" => Some(ErrorCode::ReadOnly), - "invalid-seek" => Some(ErrorCode::InvalidSeek), - "text-file-busy" => Some(ErrorCode::TextFileBusy), - "cross-device" => Some(ErrorCode::CrossDevice), - _ => None, + "access" => ErrorCode::Access, + "already" => ErrorCode::Already, + "bad-descriptor" => ErrorCode::BadDescriptor, + "busy" => ErrorCode::Busy, + "deadlock" => ErrorCode::Deadlock, + "quota" => ErrorCode::Quota, + "exist" => ErrorCode::Exist, + "file-too-large" => ErrorCode::FileTooLarge, + "illegal-byte-sequence" => ErrorCode::IllegalByteSequence, + "in-progress" => ErrorCode::InProgress, + "interrupted" => ErrorCode::Interrupted, + "invalid" => ErrorCode::Invalid, + "io" => ErrorCode::Io, + "is-directory" => ErrorCode::IsDirectory, + "loop" => ErrorCode::Loop, + "too-many-links" => ErrorCode::TooManyLinks, + "message-size" => ErrorCode::MessageSize, + "name-too-long" => ErrorCode::NameTooLong, + "no-device" => ErrorCode::NoDevice, + "no-entry" => ErrorCode::NoEntry, + "no-lock" => ErrorCode::NoLock, + "insufficient-memory" => ErrorCode::InsufficientMemory, + "insufficient-space" => ErrorCode::InsufficientSpace, + "not-directory" => ErrorCode::NotDirectory, + "not-empty" => ErrorCode::NotEmpty, + "not-recoverable" => ErrorCode::NotRecoverable, + "unsupported" => ErrorCode::Unsupported, + "no-tty" => ErrorCode::NoTty, + "no-such-device" => ErrorCode::NoSuchDevice, + "overflow" => ErrorCode::Overflow, + "not-permitted" => ErrorCode::NotPermitted, + "pipe" => ErrorCode::Pipe, + "read-only" => ErrorCode::ReadOnly, + "invalid-seek" => ErrorCode::InvalidSeek, + "text-file-busy" => ErrorCode::TextFileBusy, + "cross-device" => ErrorCode::CrossDevice, + "other" => ErrorCode::Other(None), + _ => ErrorCode::Other(Some(value)), } } wit_bindgen::generate!({ path: "../../wit", world: "filesystem-latch", + merge_structurally_equal_types: true, generate_all }); diff --git a/components/latch-readonly/src/lib.rs b/components/latch-readonly/src/lib.rs index 2a47eeb..4d0780a 100644 --- a/components/latch-readonly/src/lib.rs +++ b/components/latch-readonly/src/lib.rs @@ -64,7 +64,6 @@ impl Latch for ReadOnlyLatch { DescriptorOperation::MetadataHash(_) => Ok(Decision::Deferred), DescriptorOperation::MetadataHashAt(_) => Ok(Decision::Deferred), }, - Operation::DirectoryEntryStream(_) => None, } } diff --git a/wit/deps/wasi-clocks-0.3.0/package.wit b/wit/deps/wasi-clocks-0.3.0/package.wit new file mode 100644 index 0000000..2594e02 --- /dev/null +++ b/wit/deps/wasi-clocks-0.3.0/package.wit @@ -0,0 +1,19 @@ +package wasi:clocks@0.3.0; + +interface types { + type duration = u64; +} + +interface system-clock { + use types.{duration}; + + record instant { + seconds: s64, + nanoseconds: u32, + } + + now: func() -> instant; + + get-resolution: func() -> duration; +} + diff --git a/wit/deps/wasi-config-0.2.0-rc.1/package.wit b/wit/deps/wasi-config-0.2.0-rc.1/package.wit new file mode 100644 index 0000000..d8950ee --- /dev/null +++ b/wit/deps/wasi-config-0.2.0-rc.1/package.wit @@ -0,0 +1,33 @@ +package wasi:config@0.2.0-rc.1; + +interface store { + /// An error type that encapsulates the different errors that can occur fetching configuration values. + variant error { + /// This indicates an error from an "upstream" config source. + /// As this could be almost _anything_ (such as Vault, Kubernetes ConfigMaps, KeyValue buckets, etc), + /// the error message is a string. + upstream(string), + /// This indicates an error from an I/O operation. + /// As this could be almost _anything_ (such as a file read, network connection, etc), + /// the error message is a string. + /// Depending on how this ends up being consumed, + /// we may consider moving this to use the `wasi:io/error` type instead. + /// For simplicity right now in supporting multiple implementations, it is being left as a string. + io(string), + } + + /// Gets a configuration value of type `string` associated with the `key`. + /// + /// The value is returned as an `option`. If the key is not found, + /// `Ok(none)` is returned. If an error occurs, an `Err(error)` is returned. + get: func(key: string) -> result, error>; + + /// Gets a list of configuration key-value pairs of type `string`. + /// + /// If an error occurs, an `Err(error)` is returned. + get-all: func() -> result>, error>; +} + +world imports { + import store; +} diff --git a/wit/deps/wasi-filesystem-0.3.0/package.wit b/wit/deps/wasi-filesystem-0.3.0/package.wit new file mode 100644 index 0000000..e4a778f --- /dev/null +++ b/wit/deps/wasi-filesystem-0.3.0/package.wit @@ -0,0 +1,575 @@ +package wasi:filesystem@0.3.0; + +/// WASI filesystem is a filesystem API primarily intended to let users run WASI +/// programs that access their files on their existing filesystems, without +/// significant overhead. +/// +/// Paths are passed as interface-type `string`s, meaning they must consist of +/// a sequence of Unicode Scalar Values (USVs). Some filesystems may contain +/// paths which are not accessible by this API. +/// +/// The directory separator in WASI is always the forward-slash (`/`). +/// +/// All paths in WASI are relative paths, and are interpreted relative to a +/// `descriptor` referring to a base directory. If a `path` argument to any WASI +/// function starts with `/`, or if any step of resolving a `path`, including +/// `..` and symbolic link steps, reaches a directory outside of the base +/// directory, or reaches a symlink to an absolute or rooted path in the +/// underlying filesystem, the function fails with `error-code::not-permitted`. +/// +/// For more information about WASI path resolution and sandboxing, see +/// [WASI filesystem path resolution]. +/// +/// Though this package presents a portable interface modelled on POSIX, it +/// prioritizes compatibility over portability: allowing users to access their +/// files on their machine is more important than exposing a single semantics +/// across all platforms. Notably, depending on the underlying operating system +/// and file system: +/// * Paths may be case-folded or not. +/// * Deleting (unlinking) a file may fail if there are other file descriptors +/// open. +/// * Durability and atomicity of changes to underlying files when there are +/// concurrent writers. +/// +/// Users that need well-defined, portable semantics should use a key-value +/// store or a database instead. +/// +/// [WASI filesystem path resolution]: https://github.com/WebAssembly/wasi-filesystem/blob/main/path-resolution.md +@since(version = 0.3.0) +interface types { + @since(version = 0.3.0) + use wasi:clocks/system-clock@0.3.0.{instant}; + + /// File size or length of a region within a file. + @since(version = 0.3.0) + type filesize = u64; + + /// The type of a filesystem object referenced by a descriptor. + /// + /// Note: This was called `filetype` in earlier versions of WASI. + @since(version = 0.3.0) + variant descriptor-type { + /// The descriptor refers to a block device inode. + block-device, + /// The descriptor refers to a character device inode. + character-device, + /// The descriptor refers to a directory inode. + directory, + /// The descriptor refers to a named pipe. + fifo, + /// The file refers to a symbolic link inode. + symbolic-link, + /// The descriptor refers to a regular file inode. + regular-file, + /// The descriptor refers to a socket. + socket, + /// The type of the descriptor or file is different from any of the + /// other types specified. + other(option), + } + + /// Descriptor flags. + /// + /// Note: This was called `fdflags` in earlier versions of WASI. + @since(version = 0.3.0) + flags descriptor-flags { + /// Read mode: Data can be read. + read, + /// Write mode: Data can be written to. + write, + /// Request that writes be performed according to synchronized I/O file + /// integrity completion. The data stored in the file and the file's + /// metadata are synchronized. This is similar to `O_SYNC` in POSIX. + /// + /// The precise semantics of this operation have not yet been defined for + /// WASI. At this time, it should be interpreted as a request, and not a + /// requirement. + file-integrity-sync, + /// Request that writes be performed according to synchronized I/O data + /// integrity completion. Only the data stored in the file is + /// synchronized. This is similar to `O_DSYNC` in POSIX. + /// + /// The precise semantics of this operation have not yet been defined for + /// WASI. At this time, it should be interpreted as a request, and not a + /// requirement. + data-integrity-sync, + /// Requests that reads be performed at the same level of integrity + /// requested for writes. This is similar to `O_RSYNC` in POSIX. + /// + /// The precise semantics of this operation have not yet been defined for + /// WASI. At this time, it should be interpreted as a request, and not a + /// requirement. + requested-write-sync, + /// Mutating directories mode: Directory contents may be mutated. + /// + /// When this flag is unset on a descriptor, operations using the + /// descriptor which would create, rename, delete, modify the data or + /// metadata of filesystem objects, or obtain another handle which + /// would permit any of those, shall fail with `error-code::read-only` if + /// they would otherwise succeed. + /// + /// This may only be set on directories. + mutate-directory, + } + + /// Flags determining the method of how paths are resolved. + @since(version = 0.3.0) + flags path-flags { + /// As long as the resolved path corresponds to a symbolic link, it is + /// expanded. + symlink-follow, + } + + /// Open flags used by `open-at`. + @since(version = 0.3.0) + flags open-flags { + /// Create file if it does not exist, similar to `O_CREAT` in POSIX. + create, + /// Fail if not a directory, similar to `O_DIRECTORY` in POSIX. + directory, + /// Fail if file already exists, similar to `O_EXCL` in POSIX. + exclusive, + /// Truncate file to size 0, similar to `O_TRUNC` in POSIX. + truncate, + } + + /// Number of hard links to an inode. + @since(version = 0.3.0) + type link-count = u64; + + /// File attributes. + /// + /// Note: This was called `filestat` in earlier versions of WASI. + @since(version = 0.3.0) + record descriptor-stat { + /// File type. + %type: descriptor-type, + /// Number of hard links to the file. + link-count: link-count, + /// For regular files, the file size in bytes. For symbolic links, the + /// length in bytes of the pathname contained in the symbolic link. + size: filesize, + /// Last data access timestamp. + /// + /// If the `option` is none, the platform doesn't maintain an access + /// timestamp for this file. + data-access-timestamp: option, + /// Last data modification timestamp. + /// + /// If the `option` is none, the platform doesn't maintain a + /// modification timestamp for this file. + data-modification-timestamp: option, + /// Last file status-change timestamp. + /// + /// If the `option` is none, the platform doesn't maintain a + /// status-change timestamp for this file. + status-change-timestamp: option, + } + + /// When setting a timestamp, this gives the value to set it to. + @since(version = 0.3.0) + variant new-timestamp { + /// Leave the timestamp set to its previous value. + no-change, + /// Set the timestamp to the current time of the system clock associated + /// with the filesystem. + now, + /// Set the timestamp to the given value. + timestamp(instant), + } + + /// A directory entry. + @since(version = 0.3.0) + record directory-entry { + /// The type of the file referred to by this directory entry. + %type: descriptor-type, + /// The name of the object. + name: string, + } + + /// Error codes returned by functions, similar to `errno` in POSIX. + /// Not all of these error codes are returned by the functions provided by this + /// API; some are used in higher-level library layers, and others are provided + /// merely for alignment with POSIX. + @since(version = 0.3.0) + variant error-code { + /// Permission denied, similar to `EACCES` in POSIX. + access, + /// Connection already in progress, similar to `EALREADY` in POSIX. + already, + /// Bad descriptor, similar to `EBADF` in POSIX. + bad-descriptor, + /// Device or resource busy, similar to `EBUSY` in POSIX. + busy, + /// Resource deadlock would occur, similar to `EDEADLK` in POSIX. + deadlock, + /// Storage quota exceeded, similar to `EDQUOT` in POSIX. + quota, + /// File exists, similar to `EEXIST` in POSIX. + exist, + /// File too large, similar to `EFBIG` in POSIX. + file-too-large, + /// Illegal byte sequence, similar to `EILSEQ` in POSIX. + illegal-byte-sequence, + /// Operation in progress, similar to `EINPROGRESS` in POSIX. + in-progress, + /// Interrupted function, similar to `EINTR` in POSIX. + interrupted, + /// Invalid argument, similar to `EINVAL` in POSIX. + invalid, + /// I/O error, similar to `EIO` in POSIX. + io, + /// Is a directory, similar to `EISDIR` in POSIX. + is-directory, + /// Too many levels of symbolic links, similar to `ELOOP` in POSIX. + loop, + /// Too many links, similar to `EMLINK` in POSIX. + too-many-links, + /// Message too large, similar to `EMSGSIZE` in POSIX. + message-size, + /// Filename too long, similar to `ENAMETOOLONG` in POSIX. + name-too-long, + /// No such device, similar to `ENODEV` in POSIX. + no-device, + /// No such file or directory, similar to `ENOENT` in POSIX. + no-entry, + /// No locks available, similar to `ENOLCK` in POSIX. + no-lock, + /// Not enough space, similar to `ENOMEM` in POSIX. + insufficient-memory, + /// No space left on device, similar to `ENOSPC` in POSIX. + insufficient-space, + /// Not a directory or a symbolic link to a directory, similar to `ENOTDIR` in POSIX. + not-directory, + /// Directory not empty, similar to `ENOTEMPTY` in POSIX. + not-empty, + /// State not recoverable, similar to `ENOTRECOVERABLE` in POSIX. + not-recoverable, + /// Not supported, similar to `ENOTSUP` and `ENOSYS` in POSIX. + unsupported, + /// Inappropriate I/O control operation, similar to `ENOTTY` in POSIX. + no-tty, + /// No such device or address, similar to `ENXIO` in POSIX. + no-such-device, + /// Value too large to be stored in data type, similar to `EOVERFLOW` in POSIX. + overflow, + /// Operation not permitted, similar to `EPERM` in POSIX. + not-permitted, + /// Broken pipe, similar to `EPIPE` in POSIX. + pipe, + /// Read-only file system, similar to `EROFS` in POSIX. + read-only, + /// Invalid seek, similar to `ESPIPE` in POSIX. + invalid-seek, + /// Text file busy, similar to `ETXTBSY` in POSIX. + text-file-busy, + /// Cross-device link, similar to `EXDEV` in POSIX. + cross-device, + /// A catch-all for errors not captured by the existing variants. + /// Implementations can use this to extend the error type without + /// breaking existing code. + other(option), + } + + /// File or memory access pattern advisory information. + @since(version = 0.3.0) + enum advice { + /// The application has no advice to give on its behavior with respect + /// to the specified data. + normal, + /// The application expects to access the specified data sequentially + /// from lower offsets to higher offsets. + sequential, + /// The application expects to access the specified data in a random + /// order. + random, + /// The application expects to access the specified data in the near + /// future. + will-need, + /// The application expects that it will not access the specified data + /// in the near future. + dont-need, + /// The application expects to access the specified data once and then + /// not reuse it thereafter. + no-reuse, + } + + /// A 128-bit hash value, split into parts because wasm doesn't have a + /// 128-bit integer type. + @since(version = 0.3.0) + record metadata-hash-value { + /// 64 bits of a 128-bit hash value. + lower: u64, + /// Another 64 bits of a 128-bit hash value. + upper: u64, + } + + /// A descriptor is a reference to a filesystem object, which may be a file, + /// directory, named pipe, special file, or other object on which filesystem + /// calls may be made. + @since(version = 0.3.0) + resource descriptor { + /// Return a stream for reading from a file. + /// + /// Multiple read, write, and append streams may be active on the same open + /// file and they do not interfere with each other. + /// + /// This function returns a `stream` which provides the data received from the + /// file, and a `future` providing additional error information in case an + /// error is encountered. + /// + /// If no error is encountered, `stream.read` on the `stream` will return + /// `read-status::closed` with no `error-context` and the future resolves to + /// the value `ok`. If an error is encountered, `stream.read` on the + /// `stream` returns `read-status::closed` with an `error-context` and the future + /// resolves to `err` with an `error-code`. + /// + /// Note: This is similar to `pread` in POSIX. + @since(version = 0.3.0) + read-via-stream: func(offset: filesize) -> tuple, future>>; + /// Return a stream for writing to a file, if available. + /// + /// May fail with an error-code describing why the file cannot be written. + /// + /// It is valid to write past the end of a file; the file is extended to the + /// extent of the write, with bytes between the previous end and the start of + /// the write set to zero. + /// + /// This function returns once either full contents of the stream are + /// written or an error is encountered. + /// + /// Note: This is similar to `pwrite` in POSIX. + @since(version = 0.3.0) + write-via-stream: func(data: stream, offset: filesize) -> future>; + /// Return a stream for appending to a file, if available. + /// + /// May fail with an error-code describing why the file cannot be appended. + /// + /// This function returns once either full contents of the stream are + /// written or an error is encountered. + /// + /// Note: This is similar to `write` with `O_APPEND` in POSIX. + @since(version = 0.3.0) + append-via-stream: func(data: stream) -> future>; + /// Provide file advisory information on a descriptor. + /// + /// This is similar to `posix_fadvise` in POSIX. + @since(version = 0.3.0) + advise: async func(offset: filesize, length: filesize, advice: advice) -> result<_, error-code>; + /// Synchronize the data of a file to disk. + /// + /// This function succeeds with no effect if the file descriptor is not + /// opened for writing. + /// + /// Note: This is similar to `fdatasync` in POSIX. + @since(version = 0.3.0) + sync-data: async func() -> result<_, error-code>; + /// Get flags associated with a descriptor. + /// + /// Note: This returns similar flags to `fcntl(fd, F_GETFL)` in POSIX. + /// + /// Note: This returns the value that was the `fs_flags` value returned + /// from `fdstat_get` in earlier versions of WASI. + @since(version = 0.3.0) + get-flags: async func() -> result; + /// Get the dynamic type of a descriptor. + /// + /// Note: This returns the same value as the `type` field of the `fd-stat` + /// returned by `stat`, `stat-at` and similar. + /// + /// Note: This returns similar flags to the `st_mode & S_IFMT` value provided + /// by `fstat` in POSIX. + /// + /// Note: This returns the value that was the `fs_filetype` value returned + /// from `fdstat_get` in earlier versions of WASI. + @since(version = 0.3.0) + get-type: async func() -> result; + /// Adjust the size of an open file. If this increases the file's size, the + /// extra bytes are filled with zeros. + /// + /// Note: This was called `fd_filestat_set_size` in earlier versions of WASI. + @since(version = 0.3.0) + set-size: async func(size: filesize) -> result<_, error-code>; + /// Adjust the timestamps of an open file or directory. + /// + /// Note: This is similar to `futimens` in POSIX. + /// + /// Note: This was called `fd_filestat_set_times` in earlier versions of WASI. + @since(version = 0.3.0) + set-times: async func(data-access-timestamp: new-timestamp, data-modification-timestamp: new-timestamp) -> result<_, error-code>; + /// Read directory entries from a directory. + /// + /// On filesystems where directories contain entries referring to themselves + /// and their parents, often named `.` and `..` respectively, these entries + /// are omitted. + /// + /// This always returns a new stream which starts at the beginning of the + /// directory. Multiple streams may be active on the same directory, and they + /// do not interfere with each other. + /// + /// This function returns a future, which will resolve to an error code if + /// reading full contents of the directory fails. + @since(version = 0.3.0) + read-directory: func() -> tuple, future>>; + /// Synchronize the data and metadata of a file to disk. + /// + /// This function succeeds with no effect if the file descriptor is not + /// opened for writing. + /// + /// Note: This is similar to `fsync` in POSIX. + @since(version = 0.3.0) + sync: async func() -> result<_, error-code>; + /// Create a directory. + /// + /// Note: This is similar to `mkdirat` in POSIX. + @since(version = 0.3.0) + create-directory-at: async func(path: string) -> result<_, error-code>; + /// Return the attributes of an open file or directory. + /// + /// Note: This is similar to `fstat` in POSIX, except that it does not return + /// device and inode information. For testing whether two descriptors refer to + /// the same underlying filesystem object, use `is-same-object`. To obtain + /// additional data that can be used do determine whether a file has been + /// modified, use `metadata-hash`. + /// + /// Note: This was called `fd_filestat_get` in earlier versions of WASI. + @since(version = 0.3.0) + stat: async func() -> result; + /// Return the attributes of a file or directory. + /// + /// Note: This is similar to `fstatat` in POSIX, except that it does not + /// return device and inode information. See the `stat` description for a + /// discussion of alternatives. + /// + /// Note: This was called `path_filestat_get` in earlier versions of WASI. + @since(version = 0.3.0) + stat-at: async func(path-flags: path-flags, path: string) -> result; + /// Adjust the timestamps of a file or directory. + /// + /// Note: This is similar to `utimensat` in POSIX. + /// + /// Note: This was called `path_filestat_set_times` in earlier versions of + /// WASI. + @since(version = 0.3.0) + set-times-at: async func(path-flags: path-flags, path: string, data-access-timestamp: new-timestamp, data-modification-timestamp: new-timestamp) -> result<_, error-code>; + /// Create a hard link. + /// + /// Fails with `error-code::no-entry` if the old path does not exist, + /// with `error-code::exist` if the new path already exists, and + /// `error-code::not-permitted` if the old path is not a file. + /// + /// Note: This is similar to `linkat` in POSIX. + @since(version = 0.3.0) + link-at: async func(old-path-flags: path-flags, old-path: string, new-descriptor: borrow, new-path: string) -> result<_, error-code>; + /// Open a file or directory. + /// + /// If `flags` contains `descriptor-flags::mutate-directory`, and the base + /// descriptor doesn't have `descriptor-flags::mutate-directory` set, + /// `open-at` fails with `error-code::read-only`. + /// + /// If `flags` contains `write` or `mutate-directory`, or `open-flags` + /// contains `truncate` or `create`, and the base descriptor doesn't have + /// `descriptor-flags::mutate-directory` set, `open-at` fails with + /// `error-code::read-only`. + /// + /// Note: This is similar to `openat` in POSIX. + @since(version = 0.3.0) + open-at: async func(path-flags: path-flags, path: string, open-flags: open-flags, %flags: descriptor-flags) -> result; + /// Read the contents of a symbolic link. + /// + /// If the contents contain an absolute or rooted path in the underlying + /// filesystem, this function fails with `error-code::not-permitted`. + /// + /// Note: This is similar to `readlinkat` in POSIX. + @since(version = 0.3.0) + readlink-at: async func(path: string) -> result; + /// Remove a directory. + /// + /// Return `error-code::not-empty` if the directory is not empty. + /// + /// Note: This is similar to `unlinkat(fd, path, AT_REMOVEDIR)` in POSIX. + @since(version = 0.3.0) + remove-directory-at: async func(path: string) -> result<_, error-code>; + /// Rename a filesystem object. + /// + /// Note: This is similar to `renameat` in POSIX. + @since(version = 0.3.0) + rename-at: async func(old-path: string, new-descriptor: borrow, new-path: string) -> result<_, error-code>; + /// Create a symbolic link (also known as a "symlink"). + /// + /// If `old-path` starts with `/`, the function fails with + /// `error-code::not-permitted`. + /// + /// Note: This is similar to `symlinkat` in POSIX. + @since(version = 0.3.0) + symlink-at: async func(old-path: string, new-path: string) -> result<_, error-code>; + /// Unlink a filesystem object that is not a directory. + /// + /// This is similar to `unlinkat(fd, path, 0)` in POSIX. + /// + /// Error returns are as specified by POSIX. + /// + /// If the filesystem object is a directory, `error-code::access` or + /// `error-code::is-directory` may be returned instead of the + /// POSIX-specified `error-code::not-permitted`. + @since(version = 0.3.0) + unlink-file-at: async func(path: string) -> result<_, error-code>; + /// Test whether two descriptors refer to the same filesystem object. + /// + /// In POSIX, this corresponds to testing whether the two descriptors have the + /// same device (`st_dev`) and inode (`st_ino` or `d_ino`) numbers. + /// wasi-filesystem does not expose device and inode numbers, so this function + /// may be used instead. + @since(version = 0.3.0) + is-same-object: async func(other: borrow) -> bool; + /// Return a hash of the metadata associated with a filesystem object referred + /// to by a descriptor. + /// + /// This returns a hash of the last-modification timestamp and file size, and + /// may also include the inode number, device number, birth timestamp, and + /// other metadata fields that may change when the file is modified or + /// replaced. It may also include a secret value chosen by the + /// implementation and not otherwise exposed. + /// + /// Implementations are encouraged to provide the following properties: + /// + /// - If the file is not modified or replaced, the computed hash value should + /// usually not change. + /// - If the object is modified or replaced, the computed hash value should + /// usually change. + /// - The inputs to the hash should not be easily computable from the + /// computed hash. + /// + /// However, none of these is required. + @since(version = 0.3.0) + metadata-hash: async func() -> result; + /// Return a hash of the metadata associated with a filesystem object referred + /// to by a directory descriptor and a relative path. + /// + /// This performs the same hash computation as `metadata-hash`. + @since(version = 0.3.0) + metadata-hash-at: async func(path-flags: path-flags, path: string) -> result; + } +} + +@since(version = 0.3.0) +interface preopens { + @since(version = 0.3.0) + use types.{descriptor}; + + /// Return the set of preopened directories, and their paths. + @since(version = 0.3.0) + get-directories: func() -> list>; +} + +@since(version = 0.3.0) +world imports { + @since(version = 0.3.0) + import wasi:clocks/types@0.3.0; + @since(version = 0.3.0) + import wasi:clocks/system-clock@0.3.0; + @since(version = 0.3.0) + import types; + @since(version = 0.3.0) + import preopens; +} diff --git a/wit/deps/wasi-logging-0.1.0-draft/package.wit b/wit/deps/wasi-logging-0.1.0-draft/package.wit new file mode 100644 index 0000000..164cb5b --- /dev/null +++ b/wit/deps/wasi-logging-0.1.0-draft/package.wit @@ -0,0 +1,36 @@ +package wasi:logging@0.1.0-draft; + +/// WASI Logging is a logging API intended to let users emit log messages with +/// simple priority levels and context values. +interface logging { + /// A log level, describing a kind of message. + enum level { + /// Describes messages about the values of variables and the flow of + /// control within a program. + trace, + /// Describes messages likely to be of interest to someone debugging a + /// program. + debug, + /// Describes messages likely to be of interest to someone monitoring a + /// program. + info, + /// Describes messages indicating hazardous situations. + warn, + /// Describes messages indicating serious errors. + error, + /// Describes messages indicating fatal errors. + critical, + } + + /// Emit a log message. + /// + /// A log message has a `level` describing what kind of message is being + /// sent, a context, which is an uninterpreted string meant to help + /// consumers group similar messages, and a string containing the message + /// text. + log: func(level: level, context: string, message: string); +} + +world imports { + import logging; +} diff --git a/wit/latch.wit b/wit/latch.wit index 52adddd..43abf67 100644 --- a/wit/latch.wit +++ b/wit/latch.wit @@ -29,16 +29,6 @@ interface latch { data-modification-timestamp: new-timestamp, } - record descriptor-read-args { - length: filesize, - offset: filesize, - } - - record descriptor-write-args { - buffer-length: u64, // derived from buffer: list - offset: filesize, - } - record descriptor-create-directory-at-args { path: string, } From 2943e7d286249173f007f1a96dc3708466dc6019 Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Sun, 20 Sep 2026 20:23:00 -0400 Subject: [PATCH 10/21] wip Signed-off-by: Scott Andrews --- components/latch-defer-all/Cargo.toml | 4 + components/latch-glob/src/lib.rs | 126 +++++++++++--------------- wit/latch.wit | 4 + 3 files changed, 63 insertions(+), 71 deletions(-) diff --git a/components/latch-defer-all/Cargo.toml b/components/latch-defer-all/Cargo.toml index dfb1edf..b6b936f 100644 --- a/components/latch-defer-all/Cargo.toml +++ b/components/latch-defer-all/Cargo.toml @@ -1,5 +1,9 @@ [package] +<<<<<<<< HEAD:components/latch-defer-all/Cargo.toml name = "latch-defer-all" +======== +name = "latch-abstain-all" +>>>>>>>> 23bdc66 (wip):components/latch-abstain-all/Cargo.toml version = "0.1.0" edition = "2021" license = "Apache-2.0" diff --git a/components/latch-glob/src/lib.rs b/components/latch-glob/src/lib.rs index 6917d98..6d30dfb 100644 --- a/components/latch-glob/src/lib.rs +++ b/components/latch-glob/src/lib.rs @@ -4,11 +4,9 @@ use std::path::Path; use path_matchers::{glob, PathMatcher}; -use crate::{ - exports::componentized::filesystem::latch::{ - Decision, DescriptorOperation, Guest as Latch, Operation, PreopensOperation, - }, - wasi::filesystem::types::ErrorCode, +use crate::exports::componentized::filesystem::latch::{ + Decision, DescriptorOperation, ErrorCode, FilesystemErrorCode, Guest as Latch, Operation, + PreopensOperation, }; struct GlobLatch {} @@ -16,8 +14,7 @@ struct GlobLatch {} struct Patterns { initialized: bool, denies: Option>>, - grants: Option>>, - deny_reason: ErrorCode, + deny_reason: FilesystemErrorCode, } impl Patterns { @@ -27,18 +24,13 @@ impl Patterns { } let mut denies: Vec> = vec![]; - let mut grants: Vec> = vec![]; - let mut reason = ErrorCode::NotPermitted; + let mut reason = FilesystemErrorCode::NotPermitted; for (key, value) in wasi::config::store::get_all().expect("config must be available") { if key.starts_with("deny") { denies.push(Box::new( glob(&value).expect("config value must parse as a glob"), )); - } else if key.starts_with("grant") { - grants.push(Box::new( - glob(&value).expect("config value must parse as a glob"), - )); } else if key == "reason" { reason = get_error_code(value); } @@ -46,55 +38,47 @@ impl Patterns { unsafe { STATE.denies = Some(denies); - STATE.grants = Some(grants); STATE.deny_reason = reason; STATE.initialized = true; }; } #[allow(static_mut_refs)] - fn authorize(path: String, paths: Vec) -> Option { + fn authorize(path: String, paths: Vec) -> Result { if paths.len() == 0 { let path = Path::new(&path); return unsafe { STATE.authorize_path(path) }; } - let mut decision = None; + let mut decision = Decision::Abstained; for p in paths { let path = Path::new(&path).join(p); - match unsafe { STATE.authorize_path(&path) } { + match unsafe { STATE.authorize_path(&path)? } { // return denies immediately, buffer grants - Some(Decision::Denied(reason)) => return Some(Decision::Denied(reason)), - Some(Decision::Granted) => decision = Some(Decision::Granted), - None => {} + Decision::Denied(reason) => return Ok(Decision::Denied(reason)), + Decision::Abstained => {} } } - decision + Ok(decision) } - fn authorize_path(&self, path: &Path) -> Option { + fn authorize_path(&self, path: &Path) -> Result { for deny in self.denies.as_ref().unwrap() { if deny.matches(path) { - return Some(Decision::Denied(self.deny_reason.clone())); - } - } - for grant in self.grants.as_ref().unwrap() { - if grant.matches(path) { - return Some(Decision::Granted); + return Ok(Decision::Denied(self.deny_reason.clone())); } } - None + Ok(Decision::Abstained) } } static mut STATE: Patterns = Patterns { initialized: false, denies: None, - grants: None, - deny_reason: ErrorCode::NotPermitted, + deny_reason: FilesystemErrorCode::NotPermitted, }; impl Latch for GlobLatch { - fn authorize(operation: Operation) -> Option { + fn authorize(operation: Operation) -> Result { Patterns::initialize(); match operation { @@ -147,46 +131,46 @@ impl Latch for GlobLatch { } } -fn get_error_code(value: String) -> ErrorCode { +fn get_error_code(value: String) -> FilesystemErrorCode { match value.as_str() { - "access" => ErrorCode::Access, - "already" => ErrorCode::Already, - "bad-descriptor" => ErrorCode::BadDescriptor, - "busy" => ErrorCode::Busy, - "deadlock" => ErrorCode::Deadlock, - "quota" => ErrorCode::Quota, - "exist" => ErrorCode::Exist, - "file-too-large" => ErrorCode::FileTooLarge, - "illegal-byte-sequence" => ErrorCode::IllegalByteSequence, - "in-progress" => ErrorCode::InProgress, - "interrupted" => ErrorCode::Interrupted, - "invalid" => ErrorCode::Invalid, - "io" => ErrorCode::Io, - "is-directory" => ErrorCode::IsDirectory, - "loop" => ErrorCode::Loop, - "too-many-links" => ErrorCode::TooManyLinks, - "message-size" => ErrorCode::MessageSize, - "name-too-long" => ErrorCode::NameTooLong, - "no-device" => ErrorCode::NoDevice, - "no-entry" => ErrorCode::NoEntry, - "no-lock" => ErrorCode::NoLock, - "insufficient-memory" => ErrorCode::InsufficientMemory, - "insufficient-space" => ErrorCode::InsufficientSpace, - "not-directory" => ErrorCode::NotDirectory, - "not-empty" => ErrorCode::NotEmpty, - "not-recoverable" => ErrorCode::NotRecoverable, - "unsupported" => ErrorCode::Unsupported, - "no-tty" => ErrorCode::NoTty, - "no-such-device" => ErrorCode::NoSuchDevice, - "overflow" => ErrorCode::Overflow, - "not-permitted" => ErrorCode::NotPermitted, - "pipe" => ErrorCode::Pipe, - "read-only" => ErrorCode::ReadOnly, - "invalid-seek" => ErrorCode::InvalidSeek, - "text-file-busy" => ErrorCode::TextFileBusy, - "cross-device" => ErrorCode::CrossDevice, - "other" => ErrorCode::Other(None), - _ => ErrorCode::Other(Some(value)), + "access" => FilesystemErrorCode::Access, + "already" => FilesystemErrorCode::Already, + "bad-descriptor" => FilesystemErrorCode::BadDescriptor, + "busy" => FilesystemErrorCode::Busy, + "deadlock" => FilesystemErrorCode::Deadlock, + "quota" => FilesystemErrorCode::Quota, + "exist" => FilesystemErrorCode::Exist, + "file-too-large" => FilesystemErrorCode::FileTooLarge, + "illegal-byte-sequence" => FilesystemErrorCode::IllegalByteSequence, + "in-progress" => FilesystemErrorCode::InProgress, + "interrupted" => FilesystemErrorCode::Interrupted, + "invalid" => FilesystemErrorCode::Invalid, + "io" => FilesystemErrorCode::Io, + "is-directory" => FilesystemErrorCode::IsDirectory, + "loop" => FilesystemErrorCode::Loop, + "too-many-links" => FilesystemErrorCode::TooManyLinks, + "message-size" => FilesystemErrorCode::MessageSize, + "name-too-long" => FilesystemErrorCode::NameTooLong, + "no-device" => FilesystemErrorCode::NoDevice, + "no-entry" => FilesystemErrorCode::NoEntry, + "no-lock" => FilesystemErrorCode::NoLock, + "insufficient-memory" => FilesystemErrorCode::InsufficientMemory, + "insufficient-space" => FilesystemErrorCode::InsufficientSpace, + "not-directory" => FilesystemErrorCode::NotDirectory, + "not-empty" => FilesystemErrorCode::NotEmpty, + "not-recoverable" => FilesystemErrorCode::NotRecoverable, + "unsupported" => FilesystemErrorCode::Unsupported, + "no-tty" => FilesystemErrorCode::NoTty, + "no-such-device" => FilesystemErrorCode::NoSuchDevice, + "overflow" => FilesystemErrorCode::Overflow, + "not-permitted" => FilesystemErrorCode::NotPermitted, + "pipe" => FilesystemErrorCode::Pipe, + "read-only" => FilesystemErrorCode::ReadOnly, + "invalid-seek" => FilesystemErrorCode::InvalidSeek, + "text-file-busy" => FilesystemErrorCode::TextFileBusy, + "cross-device" => FilesystemErrorCode::CrossDevice, + "other" => FilesystemErrorCode::Other(None), + _ => FilesystemErrorCode::Other(Some(value)), } } diff --git a/wit/latch.wit b/wit/latch.wit index 43abf67..b66c8e5 100644 --- a/wit/latch.wit +++ b/wit/latch.wit @@ -1,6 +1,10 @@ interface latch { + <<<<<<< HEAD use wasi:filesystem/types@0.3.0.{advice, descriptor, descriptor-flags, directory-entry, error-code as filesystem-error-code, filesize, open-flags, new-timestamp, path-flags}; + ======= + use wasi:filesystem/types@0.3.0.{advice, descriptor, descriptor-flags, error-code as filesystem-error-code, filesize, open-flags, new-timestamp, path-flags}; + >>>>>>> 23bdc66 (wip) record descriptor-get-directory-args { path: string, From 80b11548aa08546adaeaba3f65d4bbb23a442a67 Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Sat, 17 Oct 2026 20:19:11 -0400 Subject: [PATCH 11/21] wip Signed-off-by: Scott Andrews --- Cargo.lock | 55 -- components/latch-defer-all/Cargo.toml | 4 - components/latch-readonly/src/lib.rs | 51 +- wit/deps/wasi-clocks-0.3.0/package.wit | 19 - wit/deps/wasi-config-0.2.0-rc.1/package.wit | 33 - wit/deps/wasi-filesystem-0.3.0/package.wit | 575 ------------------ wit/deps/wasi-logging-0.1.0-draft/package.wit | 36 -- wit/latch.wit | 4 - 8 files changed, 35 insertions(+), 742 deletions(-) delete mode 100644 wit/deps/wasi-clocks-0.3.0/package.wit delete mode 100644 wit/deps/wasi-config-0.2.0-rc.1/package.wit delete mode 100644 wit/deps/wasi-filesystem-0.3.0/package.wit delete mode 100644 wit/deps/wasi-logging-0.1.0-draft/package.wit diff --git a/Cargo.lock b/Cargo.lock index eaaf112..78efccc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,56 +38,6 @@ dependencies = [ "libc", ] -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys", -] - [[package]] name = "anyhow" version = "1.0.104" @@ -229,11 +179,6 @@ dependencies = [ name = "componentized-rt" version = "0.1.0" dependencies = [ - "componentized-rt", - "tempfile", - "test-harness", - "tokio", - "wasmtime", "wit-bindgen", ] diff --git a/components/latch-defer-all/Cargo.toml b/components/latch-defer-all/Cargo.toml index b6b936f..dfb1edf 100644 --- a/components/latch-defer-all/Cargo.toml +++ b/components/latch-defer-all/Cargo.toml @@ -1,9 +1,5 @@ [package] -<<<<<<<< HEAD:components/latch-defer-all/Cargo.toml name = "latch-defer-all" -======== -name = "latch-abstain-all" ->>>>>>>> 23bdc66 (wip):components/latch-abstain-all/Cargo.toml version = "0.1.0" edition = "2021" license = "Apache-2.0" diff --git a/components/latch-readonly/src/lib.rs b/components/latch-readonly/src/lib.rs index 4d0780a..7c39ada 100644 --- a/components/latch-readonly/src/lib.rs +++ b/components/latch-readonly/src/lib.rs @@ -1,8 +1,9 @@ use crate::{ exports::componentized::filesystem::latch::{ - Decision, DescriptorOpenAtArgs, DescriptorOperation, Guest as Latch, Operation, + Decision, DescriptorOpenAtArgs, DescriptorOperation, ErrorCode, FilesystemErrorCode, + Guest as Latch, Operation, }, - wasi::filesystem::types::{DescriptorFlags, ErrorCode, OpenFlags}, + wasi::filesystem::types::{DescriptorFlags, OpenFlags}, }; struct ReadOnlyLatch {} @@ -14,26 +15,38 @@ impl Latch for ReadOnlyLatch { Operation::Preopens(_preopens_operation) => Ok(Decision::Deferred), Operation::Descriptor(descriptor_operation) => match descriptor_operation { DescriptorOperation::ReadViaStream(_) => Ok(Decision::Deferred), - DescriptorOperation::WriteViaStream(_) => Ok(Decision::Denied(ErrorCode::ReadOnly)), + DescriptorOperation::WriteViaStream(_) => { + Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) + } DescriptorOperation::AppendViaStream(_) => { - Ok(Decision::Denied(ErrorCode::ReadOnly)) + Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) } DescriptorOperation::Advise(_) => Ok(Decision::Deferred), - DescriptorOperation::SyncData(_) => Ok(Decision::Denied(ErrorCode::ReadOnly)), + DescriptorOperation::SyncData(_) => { + Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) + } DescriptorOperation::GetFlags(_) => Ok(Decision::Deferred), DescriptorOperation::GetType(_) => Ok(Decision::Deferred), - DescriptorOperation::SetSize(_) => Ok(Decision::Denied(ErrorCode::ReadOnly)), - DescriptorOperation::SetTimes(_) => Ok(Decision::Denied(ErrorCode::ReadOnly)), + DescriptorOperation::SetSize(_) => { + Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) + } + DescriptorOperation::SetTimes(_) => { + Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) + } DescriptorOperation::ReadDirectory(_) => Ok(Decision::Deferred), DescriptorOperation::ReadDirectoryEntry(_) => Ok(Decision::Deferred), - DescriptorOperation::Sync(_) => Ok(Decision::Denied(ErrorCode::ReadOnly)), + DescriptorOperation::Sync(_) => Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)), DescriptorOperation::CreateDirectoryAt(_) => { - Ok(Decision::Denied(ErrorCode::ReadOnly)) + Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) } DescriptorOperation::Stat(_) => Ok(Decision::Deferred), DescriptorOperation::StatAt(_) => Ok(Decision::Deferred), - DescriptorOperation::SetTimesAt(_) => Ok(Decision::Denied(ErrorCode::ReadOnly)), - DescriptorOperation::LinkAt(_) => Ok(Decision::Denied(ErrorCode::ReadOnly)), + DescriptorOperation::SetTimesAt(_) => { + Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) + } + DescriptorOperation::LinkAt(_) => { + Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) + } DescriptorOperation::OpenAt(( _, DescriptorOpenAtArgs { @@ -49,18 +62,24 @@ impl Latch for ReadOnlyLatch { || flags.contains(DescriptorFlags::REQUESTED_WRITE_SYNC) || flags.contains(DescriptorFlags::MUTATE_DIRECTORY) { - Ok(Decision::Denied(ErrorCode::ReadOnly)) + Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) } else { Ok(Decision::Deferred) } } DescriptorOperation::ReadlinkAt(_) => Ok(Decision::Deferred), DescriptorOperation::RemoveDirectoryAt(_) => { - Ok(Decision::Denied(ErrorCode::ReadOnly)) + Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) + } + DescriptorOperation::RenameAt(_) => { + Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) + } + DescriptorOperation::SymlinkAt(_) => { + Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) + } + DescriptorOperation::UnlinkFileAt(_) => { + Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) } - DescriptorOperation::RenameAt(_) => Ok(Decision::Denied(ErrorCode::ReadOnly)), - DescriptorOperation::SymlinkAt(_) => Ok(Decision::Denied(ErrorCode::ReadOnly)), - DescriptorOperation::UnlinkFileAt(_) => Ok(Decision::Denied(ErrorCode::ReadOnly)), DescriptorOperation::MetadataHash(_) => Ok(Decision::Deferred), DescriptorOperation::MetadataHashAt(_) => Ok(Decision::Deferred), }, diff --git a/wit/deps/wasi-clocks-0.3.0/package.wit b/wit/deps/wasi-clocks-0.3.0/package.wit deleted file mode 100644 index 2594e02..0000000 --- a/wit/deps/wasi-clocks-0.3.0/package.wit +++ /dev/null @@ -1,19 +0,0 @@ -package wasi:clocks@0.3.0; - -interface types { - type duration = u64; -} - -interface system-clock { - use types.{duration}; - - record instant { - seconds: s64, - nanoseconds: u32, - } - - now: func() -> instant; - - get-resolution: func() -> duration; -} - diff --git a/wit/deps/wasi-config-0.2.0-rc.1/package.wit b/wit/deps/wasi-config-0.2.0-rc.1/package.wit deleted file mode 100644 index d8950ee..0000000 --- a/wit/deps/wasi-config-0.2.0-rc.1/package.wit +++ /dev/null @@ -1,33 +0,0 @@ -package wasi:config@0.2.0-rc.1; - -interface store { - /// An error type that encapsulates the different errors that can occur fetching configuration values. - variant error { - /// This indicates an error from an "upstream" config source. - /// As this could be almost _anything_ (such as Vault, Kubernetes ConfigMaps, KeyValue buckets, etc), - /// the error message is a string. - upstream(string), - /// This indicates an error from an I/O operation. - /// As this could be almost _anything_ (such as a file read, network connection, etc), - /// the error message is a string. - /// Depending on how this ends up being consumed, - /// we may consider moving this to use the `wasi:io/error` type instead. - /// For simplicity right now in supporting multiple implementations, it is being left as a string. - io(string), - } - - /// Gets a configuration value of type `string` associated with the `key`. - /// - /// The value is returned as an `option`. If the key is not found, - /// `Ok(none)` is returned. If an error occurs, an `Err(error)` is returned. - get: func(key: string) -> result, error>; - - /// Gets a list of configuration key-value pairs of type `string`. - /// - /// If an error occurs, an `Err(error)` is returned. - get-all: func() -> result>, error>; -} - -world imports { - import store; -} diff --git a/wit/deps/wasi-filesystem-0.3.0/package.wit b/wit/deps/wasi-filesystem-0.3.0/package.wit deleted file mode 100644 index e4a778f..0000000 --- a/wit/deps/wasi-filesystem-0.3.0/package.wit +++ /dev/null @@ -1,575 +0,0 @@ -package wasi:filesystem@0.3.0; - -/// WASI filesystem is a filesystem API primarily intended to let users run WASI -/// programs that access their files on their existing filesystems, without -/// significant overhead. -/// -/// Paths are passed as interface-type `string`s, meaning they must consist of -/// a sequence of Unicode Scalar Values (USVs). Some filesystems may contain -/// paths which are not accessible by this API. -/// -/// The directory separator in WASI is always the forward-slash (`/`). -/// -/// All paths in WASI are relative paths, and are interpreted relative to a -/// `descriptor` referring to a base directory. If a `path` argument to any WASI -/// function starts with `/`, or if any step of resolving a `path`, including -/// `..` and symbolic link steps, reaches a directory outside of the base -/// directory, or reaches a symlink to an absolute or rooted path in the -/// underlying filesystem, the function fails with `error-code::not-permitted`. -/// -/// For more information about WASI path resolution and sandboxing, see -/// [WASI filesystem path resolution]. -/// -/// Though this package presents a portable interface modelled on POSIX, it -/// prioritizes compatibility over portability: allowing users to access their -/// files on their machine is more important than exposing a single semantics -/// across all platforms. Notably, depending on the underlying operating system -/// and file system: -/// * Paths may be case-folded or not. -/// * Deleting (unlinking) a file may fail if there are other file descriptors -/// open. -/// * Durability and atomicity of changes to underlying files when there are -/// concurrent writers. -/// -/// Users that need well-defined, portable semantics should use a key-value -/// store or a database instead. -/// -/// [WASI filesystem path resolution]: https://github.com/WebAssembly/wasi-filesystem/blob/main/path-resolution.md -@since(version = 0.3.0) -interface types { - @since(version = 0.3.0) - use wasi:clocks/system-clock@0.3.0.{instant}; - - /// File size or length of a region within a file. - @since(version = 0.3.0) - type filesize = u64; - - /// The type of a filesystem object referenced by a descriptor. - /// - /// Note: This was called `filetype` in earlier versions of WASI. - @since(version = 0.3.0) - variant descriptor-type { - /// The descriptor refers to a block device inode. - block-device, - /// The descriptor refers to a character device inode. - character-device, - /// The descriptor refers to a directory inode. - directory, - /// The descriptor refers to a named pipe. - fifo, - /// The file refers to a symbolic link inode. - symbolic-link, - /// The descriptor refers to a regular file inode. - regular-file, - /// The descriptor refers to a socket. - socket, - /// The type of the descriptor or file is different from any of the - /// other types specified. - other(option), - } - - /// Descriptor flags. - /// - /// Note: This was called `fdflags` in earlier versions of WASI. - @since(version = 0.3.0) - flags descriptor-flags { - /// Read mode: Data can be read. - read, - /// Write mode: Data can be written to. - write, - /// Request that writes be performed according to synchronized I/O file - /// integrity completion. The data stored in the file and the file's - /// metadata are synchronized. This is similar to `O_SYNC` in POSIX. - /// - /// The precise semantics of this operation have not yet been defined for - /// WASI. At this time, it should be interpreted as a request, and not a - /// requirement. - file-integrity-sync, - /// Request that writes be performed according to synchronized I/O data - /// integrity completion. Only the data stored in the file is - /// synchronized. This is similar to `O_DSYNC` in POSIX. - /// - /// The precise semantics of this operation have not yet been defined for - /// WASI. At this time, it should be interpreted as a request, and not a - /// requirement. - data-integrity-sync, - /// Requests that reads be performed at the same level of integrity - /// requested for writes. This is similar to `O_RSYNC` in POSIX. - /// - /// The precise semantics of this operation have not yet been defined for - /// WASI. At this time, it should be interpreted as a request, and not a - /// requirement. - requested-write-sync, - /// Mutating directories mode: Directory contents may be mutated. - /// - /// When this flag is unset on a descriptor, operations using the - /// descriptor which would create, rename, delete, modify the data or - /// metadata of filesystem objects, or obtain another handle which - /// would permit any of those, shall fail with `error-code::read-only` if - /// they would otherwise succeed. - /// - /// This may only be set on directories. - mutate-directory, - } - - /// Flags determining the method of how paths are resolved. - @since(version = 0.3.0) - flags path-flags { - /// As long as the resolved path corresponds to a symbolic link, it is - /// expanded. - symlink-follow, - } - - /// Open flags used by `open-at`. - @since(version = 0.3.0) - flags open-flags { - /// Create file if it does not exist, similar to `O_CREAT` in POSIX. - create, - /// Fail if not a directory, similar to `O_DIRECTORY` in POSIX. - directory, - /// Fail if file already exists, similar to `O_EXCL` in POSIX. - exclusive, - /// Truncate file to size 0, similar to `O_TRUNC` in POSIX. - truncate, - } - - /// Number of hard links to an inode. - @since(version = 0.3.0) - type link-count = u64; - - /// File attributes. - /// - /// Note: This was called `filestat` in earlier versions of WASI. - @since(version = 0.3.0) - record descriptor-stat { - /// File type. - %type: descriptor-type, - /// Number of hard links to the file. - link-count: link-count, - /// For regular files, the file size in bytes. For symbolic links, the - /// length in bytes of the pathname contained in the symbolic link. - size: filesize, - /// Last data access timestamp. - /// - /// If the `option` is none, the platform doesn't maintain an access - /// timestamp for this file. - data-access-timestamp: option, - /// Last data modification timestamp. - /// - /// If the `option` is none, the platform doesn't maintain a - /// modification timestamp for this file. - data-modification-timestamp: option, - /// Last file status-change timestamp. - /// - /// If the `option` is none, the platform doesn't maintain a - /// status-change timestamp for this file. - status-change-timestamp: option, - } - - /// When setting a timestamp, this gives the value to set it to. - @since(version = 0.3.0) - variant new-timestamp { - /// Leave the timestamp set to its previous value. - no-change, - /// Set the timestamp to the current time of the system clock associated - /// with the filesystem. - now, - /// Set the timestamp to the given value. - timestamp(instant), - } - - /// A directory entry. - @since(version = 0.3.0) - record directory-entry { - /// The type of the file referred to by this directory entry. - %type: descriptor-type, - /// The name of the object. - name: string, - } - - /// Error codes returned by functions, similar to `errno` in POSIX. - /// Not all of these error codes are returned by the functions provided by this - /// API; some are used in higher-level library layers, and others are provided - /// merely for alignment with POSIX. - @since(version = 0.3.0) - variant error-code { - /// Permission denied, similar to `EACCES` in POSIX. - access, - /// Connection already in progress, similar to `EALREADY` in POSIX. - already, - /// Bad descriptor, similar to `EBADF` in POSIX. - bad-descriptor, - /// Device or resource busy, similar to `EBUSY` in POSIX. - busy, - /// Resource deadlock would occur, similar to `EDEADLK` in POSIX. - deadlock, - /// Storage quota exceeded, similar to `EDQUOT` in POSIX. - quota, - /// File exists, similar to `EEXIST` in POSIX. - exist, - /// File too large, similar to `EFBIG` in POSIX. - file-too-large, - /// Illegal byte sequence, similar to `EILSEQ` in POSIX. - illegal-byte-sequence, - /// Operation in progress, similar to `EINPROGRESS` in POSIX. - in-progress, - /// Interrupted function, similar to `EINTR` in POSIX. - interrupted, - /// Invalid argument, similar to `EINVAL` in POSIX. - invalid, - /// I/O error, similar to `EIO` in POSIX. - io, - /// Is a directory, similar to `EISDIR` in POSIX. - is-directory, - /// Too many levels of symbolic links, similar to `ELOOP` in POSIX. - loop, - /// Too many links, similar to `EMLINK` in POSIX. - too-many-links, - /// Message too large, similar to `EMSGSIZE` in POSIX. - message-size, - /// Filename too long, similar to `ENAMETOOLONG` in POSIX. - name-too-long, - /// No such device, similar to `ENODEV` in POSIX. - no-device, - /// No such file or directory, similar to `ENOENT` in POSIX. - no-entry, - /// No locks available, similar to `ENOLCK` in POSIX. - no-lock, - /// Not enough space, similar to `ENOMEM` in POSIX. - insufficient-memory, - /// No space left on device, similar to `ENOSPC` in POSIX. - insufficient-space, - /// Not a directory or a symbolic link to a directory, similar to `ENOTDIR` in POSIX. - not-directory, - /// Directory not empty, similar to `ENOTEMPTY` in POSIX. - not-empty, - /// State not recoverable, similar to `ENOTRECOVERABLE` in POSIX. - not-recoverable, - /// Not supported, similar to `ENOTSUP` and `ENOSYS` in POSIX. - unsupported, - /// Inappropriate I/O control operation, similar to `ENOTTY` in POSIX. - no-tty, - /// No such device or address, similar to `ENXIO` in POSIX. - no-such-device, - /// Value too large to be stored in data type, similar to `EOVERFLOW` in POSIX. - overflow, - /// Operation not permitted, similar to `EPERM` in POSIX. - not-permitted, - /// Broken pipe, similar to `EPIPE` in POSIX. - pipe, - /// Read-only file system, similar to `EROFS` in POSIX. - read-only, - /// Invalid seek, similar to `ESPIPE` in POSIX. - invalid-seek, - /// Text file busy, similar to `ETXTBSY` in POSIX. - text-file-busy, - /// Cross-device link, similar to `EXDEV` in POSIX. - cross-device, - /// A catch-all for errors not captured by the existing variants. - /// Implementations can use this to extend the error type without - /// breaking existing code. - other(option), - } - - /// File or memory access pattern advisory information. - @since(version = 0.3.0) - enum advice { - /// The application has no advice to give on its behavior with respect - /// to the specified data. - normal, - /// The application expects to access the specified data sequentially - /// from lower offsets to higher offsets. - sequential, - /// The application expects to access the specified data in a random - /// order. - random, - /// The application expects to access the specified data in the near - /// future. - will-need, - /// The application expects that it will not access the specified data - /// in the near future. - dont-need, - /// The application expects to access the specified data once and then - /// not reuse it thereafter. - no-reuse, - } - - /// A 128-bit hash value, split into parts because wasm doesn't have a - /// 128-bit integer type. - @since(version = 0.3.0) - record metadata-hash-value { - /// 64 bits of a 128-bit hash value. - lower: u64, - /// Another 64 bits of a 128-bit hash value. - upper: u64, - } - - /// A descriptor is a reference to a filesystem object, which may be a file, - /// directory, named pipe, special file, or other object on which filesystem - /// calls may be made. - @since(version = 0.3.0) - resource descriptor { - /// Return a stream for reading from a file. - /// - /// Multiple read, write, and append streams may be active on the same open - /// file and they do not interfere with each other. - /// - /// This function returns a `stream` which provides the data received from the - /// file, and a `future` providing additional error information in case an - /// error is encountered. - /// - /// If no error is encountered, `stream.read` on the `stream` will return - /// `read-status::closed` with no `error-context` and the future resolves to - /// the value `ok`. If an error is encountered, `stream.read` on the - /// `stream` returns `read-status::closed` with an `error-context` and the future - /// resolves to `err` with an `error-code`. - /// - /// Note: This is similar to `pread` in POSIX. - @since(version = 0.3.0) - read-via-stream: func(offset: filesize) -> tuple, future>>; - /// Return a stream for writing to a file, if available. - /// - /// May fail with an error-code describing why the file cannot be written. - /// - /// It is valid to write past the end of a file; the file is extended to the - /// extent of the write, with bytes between the previous end and the start of - /// the write set to zero. - /// - /// This function returns once either full contents of the stream are - /// written or an error is encountered. - /// - /// Note: This is similar to `pwrite` in POSIX. - @since(version = 0.3.0) - write-via-stream: func(data: stream, offset: filesize) -> future>; - /// Return a stream for appending to a file, if available. - /// - /// May fail with an error-code describing why the file cannot be appended. - /// - /// This function returns once either full contents of the stream are - /// written or an error is encountered. - /// - /// Note: This is similar to `write` with `O_APPEND` in POSIX. - @since(version = 0.3.0) - append-via-stream: func(data: stream) -> future>; - /// Provide file advisory information on a descriptor. - /// - /// This is similar to `posix_fadvise` in POSIX. - @since(version = 0.3.0) - advise: async func(offset: filesize, length: filesize, advice: advice) -> result<_, error-code>; - /// Synchronize the data of a file to disk. - /// - /// This function succeeds with no effect if the file descriptor is not - /// opened for writing. - /// - /// Note: This is similar to `fdatasync` in POSIX. - @since(version = 0.3.0) - sync-data: async func() -> result<_, error-code>; - /// Get flags associated with a descriptor. - /// - /// Note: This returns similar flags to `fcntl(fd, F_GETFL)` in POSIX. - /// - /// Note: This returns the value that was the `fs_flags` value returned - /// from `fdstat_get` in earlier versions of WASI. - @since(version = 0.3.0) - get-flags: async func() -> result; - /// Get the dynamic type of a descriptor. - /// - /// Note: This returns the same value as the `type` field of the `fd-stat` - /// returned by `stat`, `stat-at` and similar. - /// - /// Note: This returns similar flags to the `st_mode & S_IFMT` value provided - /// by `fstat` in POSIX. - /// - /// Note: This returns the value that was the `fs_filetype` value returned - /// from `fdstat_get` in earlier versions of WASI. - @since(version = 0.3.0) - get-type: async func() -> result; - /// Adjust the size of an open file. If this increases the file's size, the - /// extra bytes are filled with zeros. - /// - /// Note: This was called `fd_filestat_set_size` in earlier versions of WASI. - @since(version = 0.3.0) - set-size: async func(size: filesize) -> result<_, error-code>; - /// Adjust the timestamps of an open file or directory. - /// - /// Note: This is similar to `futimens` in POSIX. - /// - /// Note: This was called `fd_filestat_set_times` in earlier versions of WASI. - @since(version = 0.3.0) - set-times: async func(data-access-timestamp: new-timestamp, data-modification-timestamp: new-timestamp) -> result<_, error-code>; - /// Read directory entries from a directory. - /// - /// On filesystems where directories contain entries referring to themselves - /// and their parents, often named `.` and `..` respectively, these entries - /// are omitted. - /// - /// This always returns a new stream which starts at the beginning of the - /// directory. Multiple streams may be active on the same directory, and they - /// do not interfere with each other. - /// - /// This function returns a future, which will resolve to an error code if - /// reading full contents of the directory fails. - @since(version = 0.3.0) - read-directory: func() -> tuple, future>>; - /// Synchronize the data and metadata of a file to disk. - /// - /// This function succeeds with no effect if the file descriptor is not - /// opened for writing. - /// - /// Note: This is similar to `fsync` in POSIX. - @since(version = 0.3.0) - sync: async func() -> result<_, error-code>; - /// Create a directory. - /// - /// Note: This is similar to `mkdirat` in POSIX. - @since(version = 0.3.0) - create-directory-at: async func(path: string) -> result<_, error-code>; - /// Return the attributes of an open file or directory. - /// - /// Note: This is similar to `fstat` in POSIX, except that it does not return - /// device and inode information. For testing whether two descriptors refer to - /// the same underlying filesystem object, use `is-same-object`. To obtain - /// additional data that can be used do determine whether a file has been - /// modified, use `metadata-hash`. - /// - /// Note: This was called `fd_filestat_get` in earlier versions of WASI. - @since(version = 0.3.0) - stat: async func() -> result; - /// Return the attributes of a file or directory. - /// - /// Note: This is similar to `fstatat` in POSIX, except that it does not - /// return device and inode information. See the `stat` description for a - /// discussion of alternatives. - /// - /// Note: This was called `path_filestat_get` in earlier versions of WASI. - @since(version = 0.3.0) - stat-at: async func(path-flags: path-flags, path: string) -> result; - /// Adjust the timestamps of a file or directory. - /// - /// Note: This is similar to `utimensat` in POSIX. - /// - /// Note: This was called `path_filestat_set_times` in earlier versions of - /// WASI. - @since(version = 0.3.0) - set-times-at: async func(path-flags: path-flags, path: string, data-access-timestamp: new-timestamp, data-modification-timestamp: new-timestamp) -> result<_, error-code>; - /// Create a hard link. - /// - /// Fails with `error-code::no-entry` if the old path does not exist, - /// with `error-code::exist` if the new path already exists, and - /// `error-code::not-permitted` if the old path is not a file. - /// - /// Note: This is similar to `linkat` in POSIX. - @since(version = 0.3.0) - link-at: async func(old-path-flags: path-flags, old-path: string, new-descriptor: borrow, new-path: string) -> result<_, error-code>; - /// Open a file or directory. - /// - /// If `flags` contains `descriptor-flags::mutate-directory`, and the base - /// descriptor doesn't have `descriptor-flags::mutate-directory` set, - /// `open-at` fails with `error-code::read-only`. - /// - /// If `flags` contains `write` or `mutate-directory`, or `open-flags` - /// contains `truncate` or `create`, and the base descriptor doesn't have - /// `descriptor-flags::mutate-directory` set, `open-at` fails with - /// `error-code::read-only`. - /// - /// Note: This is similar to `openat` in POSIX. - @since(version = 0.3.0) - open-at: async func(path-flags: path-flags, path: string, open-flags: open-flags, %flags: descriptor-flags) -> result; - /// Read the contents of a symbolic link. - /// - /// If the contents contain an absolute or rooted path in the underlying - /// filesystem, this function fails with `error-code::not-permitted`. - /// - /// Note: This is similar to `readlinkat` in POSIX. - @since(version = 0.3.0) - readlink-at: async func(path: string) -> result; - /// Remove a directory. - /// - /// Return `error-code::not-empty` if the directory is not empty. - /// - /// Note: This is similar to `unlinkat(fd, path, AT_REMOVEDIR)` in POSIX. - @since(version = 0.3.0) - remove-directory-at: async func(path: string) -> result<_, error-code>; - /// Rename a filesystem object. - /// - /// Note: This is similar to `renameat` in POSIX. - @since(version = 0.3.0) - rename-at: async func(old-path: string, new-descriptor: borrow, new-path: string) -> result<_, error-code>; - /// Create a symbolic link (also known as a "symlink"). - /// - /// If `old-path` starts with `/`, the function fails with - /// `error-code::not-permitted`. - /// - /// Note: This is similar to `symlinkat` in POSIX. - @since(version = 0.3.0) - symlink-at: async func(old-path: string, new-path: string) -> result<_, error-code>; - /// Unlink a filesystem object that is not a directory. - /// - /// This is similar to `unlinkat(fd, path, 0)` in POSIX. - /// - /// Error returns are as specified by POSIX. - /// - /// If the filesystem object is a directory, `error-code::access` or - /// `error-code::is-directory` may be returned instead of the - /// POSIX-specified `error-code::not-permitted`. - @since(version = 0.3.0) - unlink-file-at: async func(path: string) -> result<_, error-code>; - /// Test whether two descriptors refer to the same filesystem object. - /// - /// In POSIX, this corresponds to testing whether the two descriptors have the - /// same device (`st_dev`) and inode (`st_ino` or `d_ino`) numbers. - /// wasi-filesystem does not expose device and inode numbers, so this function - /// may be used instead. - @since(version = 0.3.0) - is-same-object: async func(other: borrow) -> bool; - /// Return a hash of the metadata associated with a filesystem object referred - /// to by a descriptor. - /// - /// This returns a hash of the last-modification timestamp and file size, and - /// may also include the inode number, device number, birth timestamp, and - /// other metadata fields that may change when the file is modified or - /// replaced. It may also include a secret value chosen by the - /// implementation and not otherwise exposed. - /// - /// Implementations are encouraged to provide the following properties: - /// - /// - If the file is not modified or replaced, the computed hash value should - /// usually not change. - /// - If the object is modified or replaced, the computed hash value should - /// usually change. - /// - The inputs to the hash should not be easily computable from the - /// computed hash. - /// - /// However, none of these is required. - @since(version = 0.3.0) - metadata-hash: async func() -> result; - /// Return a hash of the metadata associated with a filesystem object referred - /// to by a directory descriptor and a relative path. - /// - /// This performs the same hash computation as `metadata-hash`. - @since(version = 0.3.0) - metadata-hash-at: async func(path-flags: path-flags, path: string) -> result; - } -} - -@since(version = 0.3.0) -interface preopens { - @since(version = 0.3.0) - use types.{descriptor}; - - /// Return the set of preopened directories, and their paths. - @since(version = 0.3.0) - get-directories: func() -> list>; -} - -@since(version = 0.3.0) -world imports { - @since(version = 0.3.0) - import wasi:clocks/types@0.3.0; - @since(version = 0.3.0) - import wasi:clocks/system-clock@0.3.0; - @since(version = 0.3.0) - import types; - @since(version = 0.3.0) - import preopens; -} diff --git a/wit/deps/wasi-logging-0.1.0-draft/package.wit b/wit/deps/wasi-logging-0.1.0-draft/package.wit deleted file mode 100644 index 164cb5b..0000000 --- a/wit/deps/wasi-logging-0.1.0-draft/package.wit +++ /dev/null @@ -1,36 +0,0 @@ -package wasi:logging@0.1.0-draft; - -/// WASI Logging is a logging API intended to let users emit log messages with -/// simple priority levels and context values. -interface logging { - /// A log level, describing a kind of message. - enum level { - /// Describes messages about the values of variables and the flow of - /// control within a program. - trace, - /// Describes messages likely to be of interest to someone debugging a - /// program. - debug, - /// Describes messages likely to be of interest to someone monitoring a - /// program. - info, - /// Describes messages indicating hazardous situations. - warn, - /// Describes messages indicating serious errors. - error, - /// Describes messages indicating fatal errors. - critical, - } - - /// Emit a log message. - /// - /// A log message has a `level` describing what kind of message is being - /// sent, a context, which is an uninterpreted string meant to help - /// consumers group similar messages, and a string containing the message - /// text. - log: func(level: level, context: string, message: string); -} - -world imports { - import logging; -} diff --git a/wit/latch.wit b/wit/latch.wit index b66c8e5..43abf67 100644 --- a/wit/latch.wit +++ b/wit/latch.wit @@ -1,10 +1,6 @@ interface latch { - <<<<<<< HEAD use wasi:filesystem/types@0.3.0.{advice, descriptor, descriptor-flags, directory-entry, error-code as filesystem-error-code, filesize, open-flags, new-timestamp, path-flags}; - ======= - use wasi:filesystem/types@0.3.0.{advice, descriptor, descriptor-flags, error-code as filesystem-error-code, filesize, open-flags, new-timestamp, path-flags}; - >>>>>>> 23bdc66 (wip) record descriptor-get-directory-args { path: string, From 1dd6686b7302ac384b30258a769d94f703d1a887 Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Sat, 17 Oct 2026 21:12:49 -0400 Subject: [PATCH 12/21] finish gate Signed-off-by: Scott Andrews --- Cargo.lock | 1 + components/gate/Cargo.toml | 1 + components/gate/build.rs | 7 + components/gate/src/lib.rs | 256 +++++++++++++++++++++++-------------- wit/latch.wit | 4 - 5 files changed, 170 insertions(+), 99 deletions(-) create mode 100644 components/gate/build.rs diff --git a/Cargo.lock b/Cargo.lock index 78efccc..62ef78c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -650,6 +650,7 @@ name = "gate" version = "0.1.0" dependencies = [ "chrono", + "componentized-rt", "heck", "wit-bindgen", ] diff --git a/components/gate/Cargo.toml b/components/gate/Cargo.toml index fe14ecf..ceed52b 100644 --- a/components/gate/Cargo.toml +++ b/components/gate/Cargo.toml @@ -9,5 +9,6 @@ crate-type = ["cdylib"] [dependencies] chrono = { workspace = true } +componentized-rt = { workspace = true, features = ["executor"] } heck = { workspace = true } wit-bindgen = { workspace = true } diff --git a/components/gate/build.rs b/components/gate/build.rs new file mode 100644 index 0000000..cd4e5a1 --- /dev/null +++ b/components/gate/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/gate/src/lib.rs b/components/gate/src/lib.rs index 5a5a951..3825e62 100644 --- a/components/gate/src/lib.rs +++ b/components/gate/src/lib.rs @@ -1,7 +1,9 @@ use std::fmt::Display; +use std::rc::Rc; use chrono::DateTime; use heck::ToKebabCase; +use wit_bindgen::{FutureReader, StreamReader, StreamResult}; use exports::wasi::filesystem::preopens::Guest as Preopens; use exports::wasi::filesystem::types::{ @@ -26,7 +28,8 @@ use crate::componentized::filesystem::latch::{ }; use crate::componentized::filesystem::latch::{ Decision::{Deferred, Denied}, - DescriptorReadViaStreamArgs, ErrorCode as LatchErrorCode, Operation, + DescriptorReadViaStreamArgs, DescriptorWriteViaStreamArgs, ErrorCode as LatchErrorCode, + Operation, }; macro_rules! error { @@ -56,15 +59,6 @@ macro_rules! trace { }; } -macro_rules! trace { - ($dst:expr, $($arg:tt)*) => { - log(Level::Trace, "componentized-gate", &format!($dst, $($arg)*)); - }; - ($dst:expr) => { - log(Level::Trace, "componentized-gate", &format!($dst)); - }; -} - /// Authorize the operation with the latch, then report the decision back to the latch. Latches /// act on the final decision in `observe-decision`, never while authorizing. Failing to observe /// the decision fails the operation, the same as a latch error. @@ -109,6 +103,7 @@ impl Preopens for GatedFilesystem { Err(code) => { panic!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ) } @@ -121,14 +116,18 @@ impl Types for GatedFilesystem { } struct GatedDescriptor { - fd: types::Descriptor, + /// Shared with background tasks that outlive the export which started them. + fd: Rc, /// What the descriptor is, the base `PATH` arguments are resolved against. location: Location, } impl GatedDescriptor { fn new(fd: types::Descriptor, location: Location) -> Self { - Self { fd, location } + Self { + fd: Rc::new(fd), + location, + } } } @@ -167,15 +166,16 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { ))) { Ok(Denied(reason)) => { warn!("Denied REASON={reason} {}", call_summary()); - Err(reason)? + error_stream(reason) } Ok(Deferred) => self.fd.read_via_stream(offset), Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); - Err(code)? + error_stream(code.into()) } } } @@ -200,7 +200,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { ) -> wit_bindgen::FutureReader> { let call_summary = || { format!( - "OPERATION=wasi:filesystem/types#descriptor.write-via-stream FD={self} OFFSET={offset}" + "OPERATION=wasi:filesystem/types#descriptor.write-via-stream FD={self} OFFSET={offset}", ) }; match authorize(&Operation::Descriptor(DescriptorOperation::WriteViaStream( @@ -208,15 +208,16 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { ))) { Ok(Denied(reason)) => { warn!("Denied REASON={reason} {}", call_summary()); - Err(reason)? + error_future(reason) } Ok(Deferred) => self.fd.write_via_stream(data, offset), Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); - Err(code)? + error_future(code.into()) } } } @@ -241,15 +242,16 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { )) { Ok(Denied(reason)) => { warn!("Denied REASON={reason} {}", call_summary()); - Err(reason)? + error_future(reason) } Ok(Deferred) => self.fd.append_via_stream(data), Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); - Err(code)? + error_future(code.into()) } } } @@ -266,7 +268,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { ) -> Result<(), ErrorCode> { let call_summary = || { format!( - "OPERATION=wasi:filesystem/types#descriptor.advise FD={self} OFFSET={offset} LENGTH={length} ADVICE={advice}" + "OPERATION=wasi:filesystem/types#descriptor.advise FD={self} OFFSET={offset} LENGTH={length} ADVICE={advice}", ) }; match authorize(&Operation::Descriptor(DescriptorOperation::Advise(( @@ -285,6 +287,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); Err(code)? @@ -313,6 +316,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); Err(code)? @@ -341,6 +345,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); Err(code)? @@ -373,6 +378,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); Err(code)? @@ -400,6 +406,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); Err(code)? @@ -419,7 +426,8 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { data_modification_timestamp: NewTimestamp, ) -> Result<(), ErrorCode> { let call_summary = || { - format!("OPERATION=wasi:filesystem/types#descriptor.set-times FD={self} ACCESS-TIMESTAMP={data_access_timestamp} MODIFICATION-TIMESTAMP={data_modification_timestamp}") + format!("OPERATION=wasi:filesystem/types#descriptor.set-times FD={self} ACCESS-TIMESTAMP={data_access_timestamp} MODIFICATION-TIMESTAMP={data_modification_timestamp}", + ) }; match authorize(&Operation::Descriptor(DescriptorOperation::SetTimes(( &self.fd, @@ -440,6 +448,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); Err(code)? @@ -473,15 +482,40 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { ))) { Ok(Denied(reason)) => { warn!("Denied REASON={reason} {}", call_summary()); - Err(reason) + error_stream(reason) + } + Ok(Deferred) => { + let (entries, result) = self.fd.read_directory(); + let (mut tx, rx) = wit_stream::new(); + let (result_tx, result_rx) = wit_future::new(|| Err(ErrorCode::Other(None))); + let fd = self.fd.clone(); + let call_summary = call_summary(); + // a sync export can't wait for the entries, they are filtered in the background + componentized_rt::executor::spawn(async move { + let result = match filter_directory_entries( + &fd, + entries, + &mut tx, + &call_summary, + ) + .await + { + Ok(()) => result.await, + Err(code) => Err(code), + }; + // the stream ends before the result is known + drop(tx); + let _ = result_tx.write(result).await; + }); + (rx, result_rx) } - Ok(Deferred) => self.fd.read_directory(), Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); - Err(code)? + error_stream(code.into()) } } } @@ -506,6 +540,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); Err(code)? @@ -524,7 +559,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { match authorize(&Operation::Descriptor( DescriptorOperation::CreateDirectoryAt(( &self.fd, - DescriptorCreateDirectoryAtArgs { path }, + DescriptorCreateDirectoryAtArgs { path: path.clone() }, )), )) { Ok(Denied(reason)) => { @@ -535,6 +570,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); Err(code)? @@ -565,6 +601,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); Err(code)? @@ -603,6 +640,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); Err(code)? @@ -625,7 +663,8 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { data_modification_timestamp: NewTimestamp, ) -> Result<(), ErrorCode> { let call_summary = || { - format!("OPERATION=wasi:filesystem/types#descriptor.set-times-at FD={self} PATH-FLAGS={path_flags} PATH={path} ACCESS-TIMESTAMP={data_access_timestamp} MODIFICATION-TIMESTAMP={data_modification_timestamp}") + format!("OPERATION=wasi:filesystem/types#descriptor.set-times-at FD={self} PATH-FLAGS={path_flags} PATH={path} ACCESS-TIMESTAMP={data_access_timestamp} MODIFICATION-TIMESTAMP={data_modification_timestamp}", + ) }; match authorize(&Operation::Descriptor(DescriptorOperation::SetTimesAt(( &self.fd, @@ -653,6 +692,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); Err(code)? @@ -700,6 +740,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); Err(code)? @@ -761,6 +802,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); Err(code)? @@ -791,6 +833,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); Err(code)? @@ -822,6 +865,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); Err(code)? @@ -863,6 +907,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); Err(code)? @@ -896,6 +941,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); Err(code)? @@ -931,6 +977,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); Err(code)? @@ -984,6 +1031,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); Err(code)? @@ -1021,6 +1069,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { Err(code) => { error!( "Latch error CODE={code} {summary}", + code = DisplayLatchErrorCode(code.clone()), summary = call_summary() ); Err(code)? @@ -1029,6 +1078,74 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { } } +/// Forward the directory entries the latch does not deny, until either end of the stream is +/// dropped. A latch error ends the stream and fails the read. +async fn filter_directory_entries( + fd: &types::Descriptor, + mut entries: StreamReader, + to: &mut wit_bindgen::StreamWriter, + call_summary: &str, +) -> Result<(), ErrorCode> { + loop { + let (status, batch) = entries.read(Vec::with_capacity(64)).await; + let mut allowed = Vec::with_capacity(batch.len()); + for entry in batch { + match authorize(&Operation::Descriptor( + DescriptorOperation::ReadDirectoryEntry((fd, entry.clone())), + )) { + Ok(Denied(reason)) => { + trace!( + "Denied REASON={reason} {call_summary} NAME={name} TYPE={type_}", + name = entry.name, + type_ = entry.type_ + ); + } + Ok(Deferred) => allowed.push(entry), + Err(code) => { + error!( + "Latch error CODE={code} {call_summary} NAME={name} TYPE={type_}", + code = DisplayLatchErrorCode(code.clone()), + name = entry.name, + type_ = entry.type_ + ); + return Err(code.into()); + } + } + } + if !allowed.is_empty() && !to.write_all(allowed).await.is_empty() { + // the reader was dropped + return Ok(()); + } + if !matches!(status, StreamResult::Complete(_)) { + return Ok(()); + } + } +} + +/// A future resolved to `err`, for the sync exports that return a future. +fn error_future(code: ErrorCode) -> FutureReader> { + // the default is only written if the write is cancelled + let (tx, rx) = wit_future::new(|| Err(ErrorCode::Other(None))); + // 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(code)).await; + }); + rx +} + +/// An empty stream with a future resolved to `err`, for the sync exports that return a stream. +fn error_stream( + code: ErrorCode, +) -> (StreamReader, FutureReader>) +where + T: wit_stream::StreamPayload, +{ + // dropping the writer closes the stream without items + let (_, rx) = wit_stream::new::(); + (rx, error_future(code)) +} + impl From for ErrorCode { fn from(value: LatchErrorCode) -> Self { match value { @@ -1046,18 +1163,18 @@ impl From for ErrorCode { } } -struct DisplayLatchError<'a>(&'a LatchErrorCode); -impl std::fmt::Display for DisplayLatchError<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self(LatchErrorCode::InvalidConfig(latch)) => { +struct DisplayLatchErrorCode(LatchErrorCode); +impl std::fmt::Display for DisplayLatchErrorCode { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match &self.0 { + LatchErrorCode::InvalidConfig(latch) => { write!(f, "invalid-config<{latch}>") } - Self(LatchErrorCode::ObservationFailed(latch)) => { + LatchErrorCode::ObservationFailed(latch) => { write!(f, "observation-failed<{latch}>") } - Self(LatchErrorCode::Other(Some(message))) => f.write_str(message), - Self(LatchErrorCode::Other(None)) => f.write_str("other"), + LatchErrorCode::Other(Some(message)) => f.write_str(message), + LatchErrorCode::Other(None) => f.write_str("other"), } } } @@ -1133,71 +1250,20 @@ impl Display for types::Instant { } } -impl Display for types::Advice { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.to_string().to_kebab_case()) - } -} - -impl Display for types::DescriptorFlags { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let names: Vec = self - .iter_names() - .map(|(name, _flags)| name.to_kebab_case()) - .collect(); - f.write_fmt(format_args!("({})", &names.join("|"))) - } -} - -impl Display for types::OpenFlags { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let names: Vec = self - .iter_names() - .map(|(name, _flags)| name.to_kebab_case()) - .collect(); - f.write_fmt(format_args!("({})", &names.join("|"))) - } -} - -impl Display for types::PathFlags { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let names: Vec = self - .iter_names() - .map(|(name, _flags)| name.to_kebab_case()) - .collect(); - f.write_fmt(format_args!("({})", &names.join("|"))) - } -} - -impl Display for types::NewTimestamp { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - types::NewTimestamp::NoChange => f.write_str("no-change"), - types::NewTimestamp::Now => f.write_str("now"), - types::NewTimestamp::Timestamp(instant) => { - f.write_fmt(format_args!("timestamp<{instant}>")) - } - } - } -} - -impl Display for types::Instant { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let date = DateTime::from_timestamp(self.seconds as i64, self.nanoseconds) - .unwrap() - .format("%Y-%m-%d %H:%M:%S.%3fZ"); - - f.write_fmt(format_args!("{date}")) - } -} - impl Display for types::DescriptorType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { + match &self { + types::DescriptorType::BlockDevice => f.write_str("block-device"), + types::DescriptorType::CharacterDevice => f.write_str("character-device"), + types::DescriptorType::Directory => f.write_str("directory"), + types::DescriptorType::Fifo => f.write_str("fifo"), + types::DescriptorType::SymbolicLink => f.write_str("symbolic-link"), + types::DescriptorType::RegularFile => f.write_str("regular-file"), + types::DescriptorType::Socket => f.write_str("socket"), + types::DescriptorType::Other(None) => f.write_str("other"), types::DescriptorType::Other(Some(type_)) => { - f.write_fmt(format_args!("other<{type_}>")) + write!(f, "other<{type_}>") } - _ => f.write_str(&self.to_string().to_kebab_case()), } } } diff --git a/wit/latch.wit b/wit/latch.wit index 43abf67..58fd8d2 100644 --- a/wit/latch.wit +++ b/wit/latch.wit @@ -2,10 +2,6 @@ interface latch { use wasi:filesystem/types@0.3.0.{advice, descriptor, descriptor-flags, directory-entry, error-code as filesystem-error-code, filesize, open-flags, new-timestamp, path-flags}; - record descriptor-get-directory-args { - path: string, - } - record descriptor-read-via-stream-args { offset: filesize, } From 3c44d74a9d63bdf96a98624af0ba6458e7b9658b Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Sun, 18 Oct 2026 10:19:37 -0400 Subject: [PATCH 13/21] latch-path-glob Signed-off-by: Scott Andrews --- Cargo.lock | 127 +++ Cargo.toml | 6 +- Makefile | 59 -- README.md | 6 +- components/gate/Cargo.toml | 6 + components/gate/src/lib.rs | 268 ++++- components/gate/tests/gate.rs | 928 ++++++++++++++++++ components/latch-glob/README.md | 9 - components/latch-glob/src/lib.rs | 184 ---- .../Cargo.toml | 8 +- components/latch-path-glob/README.md | 66 ++ components/latch-path-glob/build.rs | 59 ++ components/latch-path-glob/src/lib.rs | 637 ++++++++++++ components/latch-readonly/src/lib.rs | 1 + crates/test-harness/Cargo.toml | 1 + crates/test-harness/src/lib.rs | 446 ++++++++- wit/latch.wit | 82 +- 17 files changed, 2535 insertions(+), 358 deletions(-) create mode 100644 components/gate/tests/gate.rs delete mode 100644 components/latch-glob/README.md delete mode 100644 components/latch-glob/src/lib.rs rename components/{latch-glob => latch-path-glob}/Cargo.toml (57%) create mode 100644 components/latch-path-glob/README.md create mode 100644 components/latch-path-glob/build.rs create mode 100644 components/latch-path-glob/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 62ef78c..40061f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "gimli", ] +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "allocator-api2" version = "0.2.21" @@ -61,6 +67,18 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "auditable-serde" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d026218ae25ba5c72834245412dd1338f6d270d2c5109ee03a4badec288d4056" +dependencies = [ + "semver", + "serde", + "serde_json", + "topological-sort", +] + [[package]] name = "base64" version = "0.22.1" @@ -535,6 +553,17 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "fnv" version = "1.0.7" @@ -652,6 +681,10 @@ dependencies = [ "chrono", "componentized-rt", "heck", + "tempfile", + "test-harness", + "tokio", + "wasmtime", "wit-bindgen", ] @@ -700,6 +733,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "hashbrown" version = "0.16.1" @@ -996,6 +1035,16 @@ dependencies = [ "filesystem-latch-n", ] +[[package]] +name = "latch-path-glob" +version = "0.1.0" +dependencies = [ + "glob", + "heck", + "wit-bindgen", + "wit-parser 0.259.0", +] + [[package]] name = "latch-readonly" version = "0.1.0" @@ -1092,6 +1141,16 @@ dependencies = [ "rustix", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.4" @@ -1434,6 +1493,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simdutf8" version = "0.1.5" @@ -1465,6 +1530,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spdx" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "646c5a367da914e3babcb9cd8e91c37ccaac42325b8f86f036513ad103f2a716" +dependencies = [ + "smallvec", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1537,6 +1611,7 @@ name = "test-harness" version = "0.1.0" dependencies = [ "tokio", + "wac-graph", "wasmtime", "wasmtime-wasi", ] @@ -1656,6 +1731,12 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" +[[package]] +name = "topological-sort" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" + [[package]] name = "trace" version = "0.1.0" @@ -1752,6 +1833,39 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wac-graph" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0418130ea1c8f995ac51fd495080942604bca6455e6fb11f110aa1cefd3f549" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "petgraph", + "semver", + "thiserror 1.0.69", + "wac-types", + "wasm-encoder 0.258.3", + "wasm-metadata 0.258.3", + "wasmparser 0.258.3", +] + +[[package]] +name = "wac-types" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5580726576266490359b1457abbc0393e1780295e48a6892500f2b7e68edc74d" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "semver", + "wasm-encoder 0.258.3", + "wasmparser 0.258.3", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1857,7 +1971,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e10808eb27efde692f1b1706089fe19296728bf872291f5b4e0d50fba88f2f" dependencies = [ "anyhow", + "auditable-serde", + "flate2", "indexmap", + "serde", + "serde_derive", + "serde_json", + "spdx", + "url", "wasm-encoder 0.258.3", "wasmparser 0.258.3", ] @@ -2754,6 +2875,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 463db6b..13f07a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,12 +3,13 @@ resolver = "2" members = [ "components/chroot", "components/gate", + "components/latch-defer-all", + "components/latch-deny-all", "components/latch-n2", "components/latch-n3", "components/latch-n4", "components/latch-n5", - "components/latch-defer-all", - "components/latch-deny-all", + "components/latch-path-glob", "components/latch-readonly", "components/readonly", "components/trace", @@ -24,6 +25,7 @@ heck = "0.5" tempfile = "3" test-harness = { path = "./crates/test-harness" } tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] } +wac-graph = "0.12" wasmtime = { version = "49", features = ["component-model-async"] } wasmtime-wasi = { version = "49", features = ["p3"] } wit-bindgen = { version = "0.62.0", features = ["async-spawn"] } diff --git a/Makefile b/Makefile index de18477..24bdad4 100644 --- a/Makefile +++ b/Makefile @@ -25,65 +25,6 @@ clean: clean-components clean-wit clean-all: clean-components clean-tools clean-wit cargo clean -define TEST_COMPONENT - -lib/tests/$1.wasm: Cargo.toml Cargo.lock wit/deps $(shell find tests/$1 -type f) - cargo component build -p $1 --target wasm32-wasip2 --release - cp target/wasm32-wasip2/release/$1.wasm lib/tests/$1.wasm - -lib/tests/$1.debug.wasm: Cargo.toml Cargo.lock wit/deps $(shell find tests/$1 -type f) - cargo component build -p $1 --target wasm32-wasip2 - cp target/wasm32-wasip2/debug/$1.wasm lib/tests/$1.debug.wasm - -endef - -$(foreach component,$(TEST_COMPONENTS),$(eval $(call TEST_COMPONENT,$(component)))) - -lib/tests/logging-to-stdout.wasm: - wkg oci pull ghcr.io/componentized/logging/to-stdout:v0.2.1 -o "lib/tests/logging-to-stdout.wasm" - -lib/tests/grant.wasm: lib/gate.wasm lib/latch-grant-all.wasm - wac plug lib/gate.wasm \ - --plug lib/latch-grant-all.wasm \ - -o lib/tests/grant.wasm - -lib/tests/filesystem-cli-grant.wasm: lib/tests/filesystem-cli.wasm lib/tests/grant.wasm lib/tests/logging-to-stdout.wasm - wac plug lib/tests/filesystem-cli.wasm \ - --plug <( \ - wac plug lib/tests/grant.wasm \ - --plug lib/tests/logging-to-stdout.wasm \ - ) \ - -o lib/tests/filesystem-cli-grant.wasm - -lib/tests/deny.wasm: lib/gate.wasm lib/latch-deny-all.wasm - wac plug lib/gate.wasm \ - --plug lib/latch-deny-all.wasm \ - -o lib/tests/deny.wasm - -lib/tests/filesystem-cli-deny.wasm: lib/tests/filesystem-cli.wasm lib/tests/deny.wasm lib/tests/logging-to-stdout.wasm - wac plug lib/tests/filesystem-cli.wasm \ - --plug <( \ - wac plug lib/tests/deny.wasm \ - --plug lib/tests/logging-to-stdout.wasm \ - ) \ - -o lib/tests/filesystem-cli-deny.wasm - -lib/tests/readonly.wasm: lib/gate.wasm lib/latch-n2.wasm lib/latch-readonly.wasm lib/latch-grant-all.wasm - wac plug lib/gate.wasm \ - --plug lib/latch-readonly.wasm \ - -o lib/tests/readonly.wasm - -lib/tests/filesystem-cli-readonly.wasm: lib/tests/filesystem-cli.wasm lib/tests/readonly.wasm lib/tests/logging-to-stdout.wasm - wac plug lib/tests/filesystem-cli.wasm \ - --plug <( \ - wac plug lib/tests/readonly.wasm \ - --plug lib/tests/logging-to-stdout.wasm \ - ) \ - -o lib/tests/filesystem-cli-readonly.wasm - -.PHONY: tests -tests: $(foreach component,$(TEST_COMPONENTS),lib/tests/$(component).wasm) lib/tests/filesystem-cli-grant.wasm lib/tests/filesystem-cli-deny.wasm lib/tests/filesystem-cli-readonly.wasm - .PHONY: test test: components cargo test --workspace diff --git a/README.md b/README.md index df52dea..fd280f6 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,12 @@ A collection of utility components that remix wasi:filesystem types and interfac - [`chroot`](./components/chroot/) - [`gate`](./components/gate/) +- [`latch-deny-all`](./components/latch-deny-all/) +- [`latch-defer-all`](./components/latch-defer-all/) - [`latch-n2`](./components/latch-n2/) - [`latch-n3`](./components/latch-n3/) - [`latch-n4`](./components/latch-n4/) -- [`latch-deny-all`](./components/latch-deny-all/) -- [`latch-grant-all`](./components/latch-grant-all/) -- [`latch-glob`](./components/latch-glob/) +- [`latch-n5`](./components/latch-n5/) - [`latch-readonly`](./components/latch-readonly/) - ~~[`readonly`](./components/readonly/)~~ (deprecated, favor gate with readonly latch) - [`trace`](./components/trace/) diff --git a/components/gate/Cargo.toml b/components/gate/Cargo.toml index ceed52b..fee30fe 100644 --- a/components/gate/Cargo.toml +++ b/components/gate/Cargo.toml @@ -12,3 +12,9 @@ chrono = { workspace = true } componentized-rt = { workspace = true, features = ["executor"] } heck = { workspace = true } wit-bindgen = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/gate/src/lib.rs b/components/gate/src/lib.rs index 3825e62..b57d592 100644 --- a/components/gate/src/lib.rs +++ b/components/gate/src/lib.rs @@ -77,7 +77,11 @@ impl Preopens for GatedFilesystem { let call_summary = || format!("OPERATION=wasi:filesystem/preopens#get-directories"); match authorize(&Operation::Preopens(PreopensOperation::GetDirectories)) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); vec![] } Ok(Deferred) => preopens::get_directories() @@ -87,7 +91,11 @@ impl Preopens for GatedFilesystem { (fd, path.clone()), ))) { Ok(Denied(reason)) => { - trace!("Denied REASON={reason} {} PATH={path}", call_summary()); + trace!( + "Denied REASON={reason} {} PATH={path}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); false } _ => true, @@ -120,13 +128,17 @@ struct GatedDescriptor { fd: Rc, /// What the descriptor is, the base `PATH` arguments are resolved against. location: Location, + /// Resolved path contained within the location, pre-computed for efficiency + path: String, } impl GatedDescriptor { fn new(fd: types::Descriptor, location: Location) -> Self { + let path = location.to_string(); Self { fd: Rc::new(fd), location, + path, } } } @@ -162,10 +174,18 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { ) }; match authorize(&Operation::Descriptor(DescriptorOperation::ReadViaStream( - (&self.fd, DescriptorReadViaStreamArgs { offset }), + ( + &self.fd, + self.path.clone(), + DescriptorReadViaStreamArgs { offset }, + ), ))) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); error_stream(reason) } Ok(Deferred) => self.fd.read_via_stream(offset), @@ -204,10 +224,18 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { ) }; match authorize(&Operation::Descriptor(DescriptorOperation::WriteViaStream( - (&self.fd, DescriptorWriteViaStreamArgs { offset }), + ( + &self.fd, + self.path.clone(), + DescriptorWriteViaStreamArgs { offset }, + ), ))) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); error_future(reason) } Ok(Deferred) => self.fd.write_via_stream(data, offset), @@ -238,10 +266,14 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { let call_summary = || format!("OPERATION=wasi:filesystem/types#descriptor.append-via-stream FD={self}"); match authorize(&Operation::Descriptor( - DescriptorOperation::AppendViaStream((&self.fd,)), + DescriptorOperation::AppendViaStream((&self.fd, self.path.clone())), )) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); error_future(reason) } Ok(Deferred) => self.fd.append_via_stream(data), @@ -273,6 +305,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { }; match authorize(&Operation::Descriptor(DescriptorOperation::Advise(( &self.fd, + self.path.clone(), DescriptorAdviseArgs { offset, length, @@ -280,7 +313,11 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { }, )))) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); Err(reason)? } Ok(Deferred) => self.fd.advise(offset, length, advice).await, @@ -307,9 +344,14 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { || format!("OPERATION=wasi:filesystem/types#descriptor.sync-data FD={self}"); match authorize(&Operation::Descriptor(DescriptorOperation::SyncData(( &self.fd, + self.path.clone(), )))) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); Err(reason)? } Ok(Deferred) => self.fd.sync_data().await, @@ -336,9 +378,14 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { || format!("OPERATION=wasi:filesystem/types#descriptor.get-flags FD={self}"); match authorize(&Operation::Descriptor(DescriptorOperation::GetFlags(( &self.fd, + self.path.clone(), )))) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); Err(reason) } Ok(Deferred) => self.fd.get_flags().await, @@ -369,9 +416,14 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { || format!("OPERATION=wasi:filesystem/types#descriptor.get-type FD={self}"); match authorize(&Operation::Descriptor(DescriptorOperation::GetType(( &self.fd, + self.path.clone(), )))) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); Err(reason) } Ok(Deferred) => self.fd.get_type().await, @@ -396,10 +448,15 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { || format!("OPERATION=wasi:filesystem/types#descriptor.set-size FD={self} SIZE={size}"); match authorize(&Operation::Descriptor(DescriptorOperation::SetSize(( &self.fd, + self.path.clone(), DescriptorSetSizeArgs { size }, )))) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); Err(reason) } Ok(Deferred) => self.fd.set_size(size).await, @@ -431,13 +488,18 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { }; match authorize(&Operation::Descriptor(DescriptorOperation::SetTimes(( &self.fd, + self.path.clone(), DescriptorSetTimesArgs { data_access_timestamp, data_modification_timestamp, }, )))) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); Err(reason) } Ok(Deferred) => { @@ -478,10 +540,14 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { let call_summary = || format!("OPERATION=wasi:filesystem/types#descriptor.read-directory FD={self}"); match authorize(&Operation::Descriptor(DescriptorOperation::ReadDirectory( - (&self.fd,), + (&self.fd, self.path.clone()), ))) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); error_stream(reason) } Ok(Deferred) => { @@ -489,20 +555,17 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { let (mut tx, rx) = wit_stream::new(); let (result_tx, result_rx) = wit_future::new(|| Err(ErrorCode::Other(None))); let fd = self.fd.clone(); + let path = self.path.clone(); let call_summary = call_summary(); // a sync export can't wait for the entries, they are filtered in the background componentized_rt::executor::spawn(async move { - let result = match filter_directory_entries( - &fd, - entries, - &mut tx, - &call_summary, - ) - .await - { - Ok(()) => result.await, - Err(code) => Err(code), - }; + let result = + match filter_directory_entries(&fd, path, entries, &mut tx, &call_summary) + .await + { + Ok(()) => result.await, + Err(code) => Err(code), + }; // the stream ends before the result is known drop(tx); let _ = result_tx.write(result).await; @@ -531,9 +594,14 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { let call_summary = || format!("OPERATION=wasi:filesystem/types#descriptor.sync FD={self}"); match authorize(&Operation::Descriptor(DescriptorOperation::Sync(( &self.fd, + self.path.clone(), )))) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); Err(reason) } Ok(Deferred) => self.fd.sync().await, @@ -559,11 +627,16 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { match authorize(&Operation::Descriptor( DescriptorOperation::CreateDirectoryAt(( &self.fd, + self.path.clone(), DescriptorCreateDirectoryAtArgs { path: path.clone() }, )), )) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); Err(reason) } Ok(Deferred) => self.fd.create_directory_at(path).await, @@ -592,9 +665,14 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { let call_summary = || format!("OPERATION=wasi:filesystem/types#descriptor.stat FD={self}"); match authorize(&Operation::Descriptor(DescriptorOperation::Stat(( &self.fd, + self.path.clone(), )))) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); Err(reason) } Ok(Deferred) => self.fd.stat().await, @@ -627,13 +705,18 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { }; match authorize(&Operation::Descriptor(DescriptorOperation::StatAt(( &self.fd, + self.path.clone(), DescriptorStatAtArgs { path_flags, path: path.clone(), }, )))) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); Err(reason) } Ok(Deferred) => self.fd.stat_at(path_flags, path).await, @@ -668,6 +751,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { }; match authorize(&Operation::Descriptor(DescriptorOperation::SetTimesAt(( &self.fd, + self.path.clone(), DescriptorSetTimesAtArgs { path_flags, path: path.clone(), @@ -676,7 +760,11 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { }, )))) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); Err(reason) } Ok(Deferred) => { @@ -721,6 +809,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { }; match authorize(&Operation::Descriptor(DescriptorOperation::LinkAt(( &self.fd, + self.path.clone(), DescriptorLinkAtArgs { old_path_flags, old_path: old_path.clone(), @@ -729,7 +818,11 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { }, )))) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); Err(reason) } Ok(Deferred) => { @@ -773,6 +866,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { }; match authorize(&Operation::Descriptor(DescriptorOperation::OpenAt(( &self.fd, + self.path.clone(), DescriptorOpenAtArgs { path_flags, path: path.clone(), @@ -781,7 +875,11 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { }, )))) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); Err(reason) } Ok(Deferred) => { @@ -823,10 +921,15 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { }; match authorize(&Operation::Descriptor(DescriptorOperation::ReadlinkAt(( &self.fd, + self.path.clone(), DescriptorReadlinkAtArgs { path: path.clone() }, )))) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); Err(reason) } Ok(Deferred) => self.fd.readlink_at(path).await, @@ -854,11 +957,16 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { match authorize(&Operation::Descriptor( DescriptorOperation::RemoveDirectoryAt(( &self.fd, + self.path.clone(), DescriptorRemoveDirectoryAtArgs { path: path.clone() }, )), )) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); Err(reason) } Ok(Deferred) => self.fd.remove_directory_at(path).await, @@ -889,6 +997,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { }; match authorize(&Operation::Descriptor(DescriptorOperation::RenameAt(( &self.fd, + self.path.clone(), DescriptorRenameAtArgs { old_path: old_path.clone(), new_descriptor: &new_descriptor.fd, @@ -896,7 +1005,11 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { }, )))) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); Err(reason) } Ok(Deferred) => { @@ -928,13 +1041,18 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { }; match authorize(&Operation::Descriptor(DescriptorOperation::SymlinkAt(( &self.fd, + self.path.clone(), DescriptorSymlinkAtArgs { old_path: old_path.clone(), new_path: new_path.clone(), }, )))) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); Err(reason) } Ok(Deferred) => self.fd.symlink_at(old_path, new_path).await, @@ -967,10 +1085,15 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { }; match authorize(&Operation::Descriptor(DescriptorOperation::UnlinkFileAt(( &self.fd, + self.path.clone(), DescriptorUnlinkFileAtArgs { path: path.clone() }, )))) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); Err(reason) } Ok(Deferred) => self.fd.unlink_file_at(path).await, @@ -1022,9 +1145,14 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { || format!("OPERATION=wasi:filesystem/types#descriptor.metadata-hash FD={self}"); match authorize(&Operation::Descriptor(DescriptorOperation::MetadataHash(( &self.fd, + self.path.clone(), )))) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); Err(reason) } Ok(Deferred) => self.fd.metadata_hash().await, @@ -1055,6 +1183,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { match authorize(&Operation::Descriptor(DescriptorOperation::MetadataHashAt( ( &self.fd, + self.path.clone(), DescriptorMetadataHashAtArgs { path_flags, path: path.clone(), @@ -1062,7 +1191,11 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { ), ))) { Ok(Denied(reason)) => { - warn!("Denied REASON={reason} {}", call_summary()); + warn!( + "Denied REASON={reason} {}", + call_summary(), + reason = DisplayErrorCode(&reason) + ); Err(reason) } Ok(Deferred) => self.fd.metadata_hash_at(path_flags, path).await, @@ -1082,6 +1215,7 @@ impl exports::wasi::filesystem::types::GuestDescriptor for GatedDescriptor { /// dropped. A latch error ends the stream and fails the read. async fn filter_directory_entries( fd: &types::Descriptor, + path: String, mut entries: StreamReader, to: &mut wit_bindgen::StreamWriter, call_summary: &str, @@ -1091,11 +1225,12 @@ async fn filter_directory_entries( let mut allowed = Vec::with_capacity(batch.len()); for entry in batch { match authorize(&Operation::Descriptor( - DescriptorOperation::ReadDirectoryEntry((fd, entry.clone())), + DescriptorOperation::ReadDirectoryEntry((fd, path.clone(), entry.clone())), )) { Ok(Denied(reason)) => { trace!( "Denied REASON={reason} {call_summary} NAME={name} TYPE={type_}", + reason = DisplayErrorCode(&reason), name = entry.name, type_ = entry.type_ ); @@ -1163,6 +1298,55 @@ impl From for ErrorCode { } } +/// Displays a denial reason the way the gate logs it, e.g. `not-permitted`. +/// +/// The generated bindings already implement `Display` for [`ErrorCode`], with its debug form. +struct DisplayErrorCode<'a>(&'a ErrorCode); +impl std::fmt::Display for DisplayErrorCode<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str(match self.0 { + ErrorCode::Access => "access", + ErrorCode::Already => "already", + ErrorCode::BadDescriptor => "bad-descriptor", + ErrorCode::Busy => "busy", + ErrorCode::Deadlock => "deadlock", + ErrorCode::Quota => "quota", + ErrorCode::Exist => "exist", + ErrorCode::FileTooLarge => "file-too-large", + ErrorCode::IllegalByteSequence => "illegal-byte-sequence", + ErrorCode::InProgress => "in-progress", + ErrorCode::Interrupted => "interrupted", + ErrorCode::Invalid => "invalid", + ErrorCode::Io => "io", + ErrorCode::IsDirectory => "is-directory", + ErrorCode::Loop => "loop", + ErrorCode::TooManyLinks => "too-many-links", + ErrorCode::MessageSize => "message-size", + ErrorCode::NameTooLong => "name-too-long", + ErrorCode::NoDevice => "no-device", + ErrorCode::NoEntry => "no-entry", + ErrorCode::NoLock => "no-lock", + ErrorCode::InsufficientMemory => "insufficient-memory", + ErrorCode::InsufficientSpace => "insufficient-space", + ErrorCode::NotDirectory => "not-directory", + ErrorCode::NotEmpty => "not-empty", + ErrorCode::NotRecoverable => "not-recoverable", + ErrorCode::Unsupported => "unsupported", + ErrorCode::NoTty => "no-tty", + ErrorCode::NoSuchDevice => "no-such-device", + ErrorCode::Overflow => "overflow", + ErrorCode::NotPermitted => "not-permitted", + ErrorCode::Pipe => "pipe", + ErrorCode::ReadOnly => "read-only", + ErrorCode::InvalidSeek => "invalid-seek", + ErrorCode::TextFileBusy => "text-file-busy", + ErrorCode::CrossDevice => "cross-device", + ErrorCode::Other(Some(message)) => message, + ErrorCode::Other(None) => "other", + }) + } +} + struct DisplayLatchErrorCode(LatchErrorCode); impl std::fmt::Display for DisplayLatchErrorCode { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { diff --git a/components/gate/tests/gate.rs b/components/gate/tests/gate.rs new file mode 100644 index 0000000..b6344b1 --- /dev/null +++ b/components/gate/tests/gate.rs @@ -0,0 +1,928 @@ +//! The gate asks its latch to authorize every filesystem call, and passes the call through unless +//! the latch denies it. A denied call fails with the reason the latch gave, a latch error fails it +//! with `other`, and both are logged in the `componentized-gate` context. +//! +//! The `latch-deny-all` and `latch-defer-all` components are the gate's latch, alone and +//! aggregated. A scripted host latch denies or fails individual operations, and records the +//! operations it was asked about. +//! +//! Each test preopens a fresh copy of this tree read-write as `/`: +//! +//! ```text +//! file.txt "hello" +//! link -> file.txt +//! empty/ +//! dir/nested.txt "nested" +//! ``` + +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::symlink; +use std::path::Path; + +use tempfile::TempDir; +use test_harness::bindings::exports::wasi::filesystem::types::{ + Advice, DirectoryEntry, GuestDescriptor, NewTimestamp, +}; +use test_harness::{ + Ctx, DescriptorFlags, DescriptorType, ErrorCode, Exports, Harness, HostLatch, LogEntry, + Observation, OpenFlags, PathFlags, Perms, TestSubject, +}; +use wasmtime::component::{Accessor, ResourceAny}; + +const CONTEXT: &str = "componentized-gate"; +const TYPES: &str = "OPERATION=wasi:filesystem/types#descriptor"; + +/// Every descriptor operation but `open-at`, which the tests need to open their handles. +const DESCRIPTOR_OPERATIONS: &[&str] = &[ + "descriptor.read-via-stream", + "descriptor.write-via-stream", + "descriptor.append-via-stream", + "descriptor.advise", + "descriptor.sync-data", + "descriptor.get-flags", + "descriptor.get-type", + "descriptor.set-size", + "descriptor.set-times", + "descriptor.read-directory", + "descriptor.sync", + "descriptor.create-directory-at", + "descriptor.stat", + "descriptor.stat-at", + "descriptor.set-times-at", + "descriptor.link-at", + "descriptor.readlink-at", + "descriptor.remove-directory-at", + "descriptor.rename-at", + "descriptor.symlink-at", + "descriptor.unlink-file-at", + "descriptor.metadata-hash", + "descriptor.metadata-hash-at", +]; + +struct Fixture { + dir: TempDir, +} + +impl Fixture { + fn new() -> Self { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + fs::write(root.join("file.txt"), "hello").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() + } + + /// The gate with the fixture preopened as `/`. + fn gate(&self) -> Harness { + Harness::new("gate").preopen(self.path(), "/", Perms::ReadWrite) + } +} + +/// Descriptors opened through the gate. +#[derive(Clone, Copy)] +struct Handles { + /// The preopen, `/` + root: ResourceAny, + /// `/dir` + dir: ResourceAny, + /// `/file.txt`, opened for reading and writing + file: ResourceAny, +} + +fn descriptor(exports: &Exports) -> GuestDescriptor<'_> { + exports.wasi_filesystem_types().descriptor() +} + +async fn preopens( + accessor: &Accessor, + exports: &Exports, +) -> wasmtime::Result> { + exports + .wasi_filesystem_preopens() + .call_get_directories(accessor) + .await +} + +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 (root, _) = preopens(accessor, exports) + .await? + .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 | DescriptorFlags::WRITE, + ) + .await? + .expect("open file.txt"); + Ok(Handles { root, dir, file }) +} + +/// Build the gate and run the test body against handles opened through it. +async fn run( + gate: Harness, + f: impl AsyncFnOnce(&Accessor, &Exports, Handles) -> wasmtime::Result + Send, +) -> (R, TestSubject) { + let mut gate = gate.build().await.expect("gate"); + let result = gate + .run(async |accessor, exports| { + let handles = handles(accessor, exports).await?; + f(accessor, exports, handles).await + }) + .await + .expect("run"); + (result, gate) +} + +/// The bytes of a stream the gate returned. +async fn read_bytes( + accessor: &Accessor, + data: wasmtime::component::StreamReader, +) -> wasmtime::Result> { + let mut data = test_harness::collect(accessor, data)?; + let mut bytes = vec![]; + while let Some(byte) = data.recv().await { + bytes.push(byte); + } + Ok(bytes) +} + +/// The entries of a directory stream the gate returned, sorted by name, and the read's result. +async fn read_entries( + accessor: &Accessor, + exports: &Exports, + fd: ResourceAny, +) -> wasmtime::Result<(Vec, Result<(), ErrorCode>)> { + let (entries, result) = descriptor(exports) + .call_read_directory(accessor, fd) + .await?; + let mut entries = test_harness::collect(accessor, entries)?; + let mut names = vec![]; + while let Some(DirectoryEntry { name, .. }) = entries.recv().await { + names.push(name); + } + names.sort(); + Ok((names, test_harness::resolve(accessor, result).await?)) +} + +fn ok(result: Result) -> Result<(), E> { + result.map(|_| ()) +} + +fn warn(message: impl Into) -> LogEntry { + LogEntry::warn(CONTEXT, message) +} + +fn trace(message: impl Into) -> LogEntry { + LogEntry::trace(CONTEXT, message) +} + +fn error(message: impl Into) -> LogEntry { + LogEntry::error(CONTEXT, message) +} + +// --- latch-deny-all --- + +#[tokio::test(flavor = "multi_thread")] +async fn deny_all_hides_every_preopen() { + let fixture = Fixture::new(); + let mut gate = fixture + .gate() + .preopen(fixture.path().join("dir"), "/data", Perms::ReadWrite) + .latch("latch-deny-all") + .build() + .await + .unwrap(); + let names = gate + .run(async |accessor, exports| { + let dirs = preopens(accessor, exports).await?; + Ok(dirs.into_iter().map(|(_, name)| name).collect::>()) + }) + .await + .unwrap(); + assert!(names.is_empty(), "{names:?}"); + assert_eq!( + gate.recorder().logs(), + [warn( + "Denied REASON=not-permitted OPERATION=wasi:filesystem/preopens#get-directories" + )] + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn deny_all_wins_over_defer_all() { + let fixture = Fixture::new(); + let mut gate = fixture + .gate() + .latch("latch-defer-all") + .latch("latch-deny-all") + .build() + .await + .unwrap(); + let dirs = gate + .run(async |accessor, exports| Ok(preopens(accessor, exports).await?.len())) + .await + .unwrap(); + assert_eq!(dirs, 0); + assert_eq!( + gate.recorder().logs(), + [warn( + "Denied REASON=not-permitted OPERATION=wasi:filesystem/preopens#get-directories" + )] + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn deny_all_decision_is_observed_by_every_latch() { + let fixture = Fixture::new(); + let mut gate = fixture + .gate() + .latch("latch-deny-all") + .host_latch(HostLatch::defer()) + .build() + .await + .unwrap(); + gate.run(async |accessor, exports| Ok(preopens(accessor, exports).await?.len())) + .await + .unwrap(); + let recorder = gate.recorder(); + // the host latch is aggregated after latch-deny-all, it is not asked once the call is denied + assert!(recorder.operations().is_empty(), "{:?}", recorder.operations()); + assert_eq!( + recorder.observations(), + [Observation { + operation: "preopens.get-directories".into(), + denied: true, + }] + ); +} + +// --- latch-defer-all --- + +#[tokio::test(flavor = "multi_thread")] +async fn defer_all_passes_preopens_through() { + let fixture = Fixture::new(); + let mut gate = fixture + .gate() + .preopen(fixture.path().join("dir"), "/data", Perms::ReadWrite) + .latch("latch-defer-all") + .build() + .await + .unwrap(); + let names = gate + .run(async |accessor, exports| { + let dirs = preopens(accessor, exports).await?; + Ok(dirs.into_iter().map(|(_, name)| name).collect::>()) + }) + .await + .unwrap(); + assert_eq!(names, ["/", "/data"]); + assert_eq!(gate.recorder().logs(), []); +} + +#[tokio::test(flavor = "multi_thread")] +async fn defer_all_passes_reads_through() { + let fixture = Fixture::new(); + let ((data, offset, entries, stat, stat_at, kind, hashes, target, advised), gate) = run( + fixture.gate().latch("latch-defer-all"), + async |accessor, exports, h| { + let d = descriptor(exports); + let (data, _) = d.call_read_via_stream(accessor, h.file, 0).await?; + let data = read_bytes(accessor, data).await?; + let (offset, _) = d.call_read_via_stream(accessor, h.file, 1).await?; + let offset = read_bytes(accessor, offset).await?; + let entries = read_entries(accessor, exports, h.root).await?; + let stat = d.call_stat(accessor, h.file).await?.expect("stat"); + let stat_at = d + .call_stat_at(accessor, h.root, PathFlags::empty(), "link".into()) + .await? + .expect("stat-at"); + let kind = d.call_get_type(accessor, h.dir).await?; + let hashes = ( + d.call_metadata_hash(accessor, h.file).await?, + d.call_metadata_hash_at(accessor, h.root, PathFlags::empty(), "file.txt".into()) + .await?, + ); + let target = d.call_readlink_at(accessor, h.root, "link".into()).await?; + let advised = d + .call_advise(accessor, h.file, 0, 0, Advice::Sequential) + .await?; + Ok(( + data, offset, entries, stat, stat_at, kind, hashes, target, advised, + )) + }, + ) + .await; + assert_eq!(data, b"hello"); + assert_eq!(offset, b"ello"); + assert_eq!( + entries, + ( + vec!["dir".into(), "empty".into(), "file.txt".into(), "link".into()], + Ok(()) + ) + ); + assert_eq!(stat.type_, DescriptorType::RegularFile); + assert_eq!(stat.size, 5); + assert_eq!(stat_at.type_, DescriptorType::SymbolicLink); + assert_eq!(kind, Ok(DescriptorType::Directory)); + assert!(hashes.0.is_ok(), "{hashes:?}"); + assert_eq!(hashes.0, hashes.1); + assert_eq!(target, Ok("file.txt".into())); + assert_eq!(advised, Ok(())); + assert_eq!(gate.recorder().logs(), []); +} + +#[tokio::test(flavor = "multi_thread")] +async fn defer_all_passes_writes_through() { + let fixture = Fixture::new(); + let (results, gate) = run( + fixture.gate().latch("latch-defer-all"), + async |accessor, exports, h| { + let d = descriptor(exports); + let now = || NewTimestamp::Now; + Ok(vec![ + ok(d.call_get_flags(accessor, h.file).await?), + d.call_set_size(accessor, h.file, 2).await?, + d.call_set_times(accessor, h.file, now(), now()).await?, + d.call_sync_data(accessor, h.file).await?, + d.call_sync(accessor, h.file).await?, + d.call_create_directory_at(accessor, h.root, "new".into()) + .await?, + d.call_set_times_at( + accessor, + h.root, + PathFlags::empty(), + "new".into(), + now(), + now(), + ) + .await?, + d.call_symlink_at(accessor, h.root, "file.txt".into(), "new-link".into()) + .await?, + d.call_link_at( + accessor, + h.root, + PathFlags::empty(), + "file.txt".into(), + h.dir, + "hard.txt".into(), + ) + .await?, + d.call_rename_at(accessor, h.root, "new".into(), h.dir, "renamed".into()) + .await?, + d.call_remove_directory_at(accessor, h.dir, "renamed".into()) + .await?, + d.call_unlink_file_at(accessor, h.root, "new-link".into()) + .await?, + d.call_remove_directory_at(accessor, h.root, "empty".into()) + .await?, + ]) + }, + ) + .await; + for (index, result) in results.into_iter().enumerate() { + assert_eq!(result, Ok(()), "operation {index}"); + } + let root = fixture.path(); + assert_eq!(fs::read(root.join("file.txt")).unwrap(), b"he"); + assert_eq!(fs::read(root.join("dir/hard.txt")).unwrap(), b"he"); + assert!(!root.join("new").exists()); + assert!(!root.join("dir/renamed").exists()); + assert!(!root.join("new-link").exists()); + assert!(!root.join("empty").exists()); + assert_eq!(gate.recorder().logs(), []); +} + +// --- authorization --- + +#[tokio::test(flavor = "multi_thread")] +async fn every_operation_is_authorized_and_observed() { + let fixture = Fixture::new(); + let (_, gate) = run( + fixture.gate().host_latch(HostLatch::defer()), + async |accessor, exports, h| { + let d = descriptor(exports); + let now = || NewTimestamp::Now; + let (data, _) = d.call_read_via_stream(accessor, h.file, 1).await?; + read_bytes(accessor, data).await?; + let data = test_harness::stream(accessor, b"!".to_vec())?; + let _ = d.call_write_via_stream(accessor, h.file, data, 5).await?; + let data = test_harness::stream(accessor, b"!".to_vec())?; + let _ = d.call_append_via_stream(accessor, h.file, data).await?; + let _ = d.call_advise(accessor, h.file, 0, 0, Advice::Random).await?; + let _ = d.call_sync_data(accessor, h.file).await?; + let _ = d.call_get_flags(accessor, h.file).await?; + let _ = d.call_get_type(accessor, h.file).await?; + let _ = d.call_set_size(accessor, h.file, 5).await?; + let _ = d.call_set_times(accessor, h.file, now(), NewTimestamp::NoChange) + .await?; + let _ = read_entries(accessor, exports, h.dir).await?; + let _ = d.call_sync(accessor, h.file).await?; + let _ = d.call_create_directory_at(accessor, h.root, "new".into()) + .await?; + let _ = d.call_stat(accessor, h.file).await?; + let _ = d.call_stat_at(accessor, h.root, PathFlags::SYMLINK_FOLLOW, "link".into()) + .await?; + let _ = d.call_set_times_at( + accessor, + h.root, + PathFlags::empty(), + "new".into(), + NewTimestamp::NoChange, + now(), + ) + .await?; + let _ = d.call_link_at( + accessor, + h.root, + PathFlags::empty(), + "file.txt".into(), + h.dir, + "hard.txt".into(), + ) + .await?; + let _ = d.call_readlink_at(accessor, h.root, "link".into()).await?; + let _ = d.call_remove_directory_at(accessor, h.root, "empty".into()) + .await?; + let _ = d.call_rename_at(accessor, h.root, "new".into(), h.dir, "renamed".into()) + .await?; + let _ = d.call_symlink_at(accessor, h.root, "file.txt".into(), "new-link".into()) + .await?; + let _ = d.call_unlink_file_at(accessor, h.root, "new-link".into()) + .await?; + let _ = d.call_metadata_hash(accessor, h.file).await?; + let _ = d.call_metadata_hash_at(accessor, h.root, PathFlags::empty(), "file.txt".into()) + .await?; + Ok(()) + }, + ) + .await; + let recorder = gate.recorder(); + let operations = recorder.operations(); + let expected: Vec<&str> = [ + "preopens.get-directories", + "preopens.get-directories-item", + "descriptor.open-at", + "descriptor.open-at", + ] + .into_iter() + .chain(DESCRIPTOR_OPERATIONS.iter().copied().flat_map(|op| match op { + // the entries of `dir` are authorized one by one + "descriptor.read-directory" => vec![op, "descriptor.read-directory-entry"], + _ => vec![op], + })) + .collect(); + assert_eq!(operations, expected); + // every decision is observed, in the order it was made + assert_eq!( + recorder.observations(), + operations + .iter() + .map(|operation| Observation { + operation: operation.clone(), + denied: false, + }) + .collect::>() + ); + assert_eq!(recorder.logs(), []); +} + +#[tokio::test(flavor = "multi_thread")] +async fn operations_are_authorized_with_their_args() { + let fixture = Fixture::new(); + let (_, gate) = run( + fixture.gate().host_latch(HostLatch::defer()), + async |accessor, exports, h| { + let d = descriptor(exports); + let (data, _) = d.call_read_via_stream(accessor, h.file, 1).await?; + read_bytes(accessor, data).await?; + let _ = d.call_set_size(accessor, h.file, 5).await?; + let _ = read_entries(accessor, exports, h.dir).await?; + let _ = d.call_stat_at(accessor, h.root, PathFlags::SYMLINK_FOLLOW, "link".into()) + .await?; + let _ = d.call_rename_at(accessor, h.root, "empty".into(), h.dir, "renamed".into()) + .await?; + Ok(()) + }, + ) + .await; + let args: Vec<(String, String)> = gate + .recorder() + .authorizations() + .into_iter() + .map(|auth| (auth.operation, auth.args)) + .collect(); + let args: Vec<(&str, &str)> = args + .iter() + .map(|(operation, args)| (operation.as_str(), args.as_str())) + .collect(); + assert_eq!( + args, + [ + ("preopens.get-directories", ""), + ("preopens.get-directories-item", "\"/\""), + ( + "descriptor.open-at", + "DescriptorOpenAtArgs { path-flags: (), path: \"dir\", open-flags: (DIRECTORY), flags: (READ) }" + ), + ( + "descriptor.open-at", + "DescriptorOpenAtArgs { path-flags: (), path: \"file.txt\", open-flags: (), flags: (READ|WRITE) }" + ), + ( + "descriptor.read-via-stream", + "DescriptorReadViaStreamArgs { offset: 1 }" + ), + ("descriptor.set-size", "DescriptorSetSizeArgs { size: 5 }"), + ("descriptor.read-directory", ""), + ( + "descriptor.read-directory-entry", + "DirectoryEntry { type: DescriptorType::RegularFile, name: \"nested.txt\" }" + ), + ( + "descriptor.stat-at", + "DescriptorStatAtArgs { path-flags: (SYMLINK_FOLLOW), path: \"link\" }" + ), + ( + "descriptor.rename-at", + "old_path: \"empty\", new_path: \"renamed\"" + ), + ] + ); +} + +// --- denied --- + +#[tokio::test(flavor = "multi_thread")] +async fn denied_operations_fail_with_the_reason_and_are_logged() { + let fixture = Fixture::new(); + let (results, gate) = run( + fixture.gate().host_latch(HostLatch::deny(DESCRIPTOR_OPERATIONS)), + async |accessor, exports, h| { + let d = descriptor(exports); + let now = || NewTimestamp::Now; + let mut results = vec![]; + + let (data, result) = d.call_read_via_stream(accessor, h.file, 1).await?; + assert_eq!(read_bytes(accessor, data).await?, b"", "read-via-stream data"); + results.push(test_harness::resolve(accessor, result).await?); + let data = test_harness::stream(accessor, b"!".to_vec())?; + let result = d.call_write_via_stream(accessor, h.file, data, 5).await?; + results.push(test_harness::resolve(accessor, result).await?); + let data = test_harness::stream(accessor, b"!".to_vec())?; + let result = d.call_append_via_stream(accessor, h.file, data).await?; + results.push(test_harness::resolve(accessor, result).await?); + results.push(d.call_advise(accessor, h.file, 0, 0, Advice::Random).await?); + results.push(d.call_sync_data(accessor, h.file).await?); + results.push(ok(d.call_get_flags(accessor, h.file).await?)); + results.push(ok(d.call_get_type(accessor, h.file).await?)); + results.push(d.call_set_size(accessor, h.file, 0).await?); + results.push( + d.call_set_times(accessor, h.file, now(), NewTimestamp::NoChange) + .await?, + ); + let (entries, result) = read_entries(accessor, exports, h.dir).await?; + assert!(entries.is_empty(), "read-directory entries {entries:?}"); + results.push(result); + results.push(d.call_sync(accessor, h.file).await?); + results.push( + d.call_create_directory_at(accessor, h.root, "new".into()) + .await?, + ); + results.push(ok(d.call_stat(accessor, h.file).await?)); + results.push(ok(d + .call_stat_at(accessor, h.root, PathFlags::SYMLINK_FOLLOW, "link".into()) + .await?)); + results.push( + d.call_set_times_at( + accessor, + h.root, + PathFlags::empty(), + "file.txt".into(), + NewTimestamp::NoChange, + now(), + ) + .await?, + ); + results.push( + d.call_link_at( + accessor, + h.root, + PathFlags::empty(), + "file.txt".into(), + h.dir, + "hard.txt".into(), + ) + .await?, + ); + results.push(ok(d + .call_readlink_at(accessor, h.root, "link".into()) + .await?)); + results.push( + d.call_remove_directory_at(accessor, h.root, "empty".into()) + .await?, + ); + results.push( + d.call_rename_at(accessor, h.root, "empty".into(), h.dir, "renamed".into()) + .await?, + ); + results.push( + d.call_symlink_at(accessor, h.root, "file.txt".into(), "new-link".into()) + .await?, + ); + results.push( + d.call_unlink_file_at(accessor, h.root, "link".into()) + .await?, + ); + results.push(ok(d.call_metadata_hash(accessor, h.file).await?)); + results.push(ok(d + .call_metadata_hash_at(accessor, h.root, PathFlags::empty(), "file.txt".into()) + .await?)); + Ok(results) + }, + ) + .await; + assert_eq!(results.len(), DESCRIPTOR_OPERATIONS.len()); + for (operation, result) in DESCRIPTOR_OPERATIONS.iter().zip(results) { + assert_eq!(result, Err(ErrorCode::Access), "{operation}"); + } + + // nothing was changed + let root = fixture.path(); + assert_eq!(fs::read(root.join("file.txt")).unwrap(), b"hello"); + assert!(root.join("empty").is_dir()); + assert!(root.join("link").is_symlink()); + assert!(!root.join("new").exists()); + assert!(!root.join("dir/hard.txt").exists()); + + let denied = |call: &str| warn(format!("Denied REASON=access {TYPES}.{call}")); + assert_eq!( + gate.recorder().logs(), + [ + denied("read-via-stream FD=/file.txt OFFSET=1"), + denied("write-via-stream FD=/file.txt OFFSET=5"), + denied("append-via-stream FD=/file.txt"), + denied("advise FD=/file.txt OFFSET=0 LENGTH=0 ADVICE=random"), + denied("sync-data FD=/file.txt"), + denied("get-flags FD=/file.txt"), + denied("get-type FD=/file.txt"), + denied("set-size FD=/file.txt SIZE=0"), + denied( + "set-times FD=/file.txt ACCESS-TIMESTAMP=now MODIFICATION-TIMESTAMP=no-change" + ), + denied("read-directory FD=/dir"), + denied("sync FD=/file.txt"), + denied("create-directory-at FD=/ PATH=new"), + denied("stat FD=/file.txt"), + denied("stat-at FD=/ PATH-FLAGS=(symlink-follow) PATH=link"), + denied( + "set-times-at FD=/ PATH-FLAGS=() PATH=file.txt ACCESS-TIMESTAMP=no-change MODIFICATION-TIMESTAMP=now" + ), + denied( + "link-at FD=/ OLD-PATH-FLAGS=() OLD-PATH=file.txt NEW-DESCRIPTOR=/dir NEW-PATH=hard.txt" + ), + denied("readlink-at FD=/ PATH=link"), + denied("remove-directory-at FD=/ PATH=empty"), + denied("rename-at FD=/ OLD-PATH=empty NEW-DESCRIPTOR=/dir NEW-PATH=renamed"), + denied("symlink-at FD=/ OLD-PATH=file.txt NEW-PATH=new-link"), + denied("unlink-file-at FD=/ PATH=link"), + denied("metadata-hash FD=/file.txt"), + denied("metadata-hash-at FD=/ PATH-FLAGS=() PATH=file.txt"), + ] + ); + // denials are observed too + let observations = gate.recorder().observations(); + let denied: Vec<&str> = observations + .iter() + .filter(|observation| observation.denied) + .map(|observation| observation.operation.as_str()) + .collect(); + assert_eq!(denied, DESCRIPTOR_OPERATIONS); +} + +#[tokio::test(flavor = "multi_thread")] +async fn denied_open_fails_with_the_reason_and_is_logged() { + let fixture = Fixture::new(); + let mut gate = fixture + .gate() + .host_latch(HostLatch::deny(&["descriptor.open-at"])) + .build() + .await + .unwrap(); + let result = gate + .run(async |accessor, exports| { + let (root, _) = preopens(accessor, exports).await?.into_iter().next().unwrap(); + Ok(ok(open( + accessor, + exports, + root, + "file.txt", + OpenFlags::CREATE, + DescriptorFlags::READ | DescriptorFlags::WRITE, + ) + .await?)) + }) + .await + .unwrap(); + assert_eq!(result, Err(ErrorCode::Access)); + assert_eq!( + gate.recorder().logs(), + [warn(format!( + "Denied REASON=access {TYPES}.open-at FD=/ PATH-FLAGS=() PATH=file.txt OPEN-FLAGS=(create) FLAGS=(read|write)" + ))] + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn denied_preopens_are_omitted_and_logged() { + let fixture = Fixture::new(); + let mut gate = fixture + .gate() + .preopen(fixture.path().join("dir"), "/data", Perms::ReadWrite) + .host_latch(HostLatch::new(|auth| { + if auth.operation == "preopens.get-directories-item" && auth.args == "\"/data\"" { + Ok(test_harness::Decision::Denied( + test_harness::FilesystemErrorCode::Access, + )) + } else { + Ok(test_harness::Decision::Deferred) + } + })) + .build() + .await + .unwrap(); + let names = gate + .run(async |accessor, exports| { + let dirs = preopens(accessor, exports).await?; + Ok(dirs.into_iter().map(|(_, name)| name).collect::>()) + }) + .await + .unwrap(); + assert_eq!(names, ["/"]); + assert_eq!( + gate.recorder().logs(), + [trace( + "Denied REASON=access OPERATION=wasi:filesystem/preopens#get-directories PATH=/data" + )] + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn denied_directory_entries_are_omitted_and_logged() { + let fixture = Fixture::new(); + let (entries, gate) = run( + fixture.gate().host_latch(HostLatch::new(|auth| { + if auth.operation == "descriptor.read-directory-entry" + && (auth.args.contains("\"file.txt\"") || auth.args.contains("\"empty\"")) + { + Ok(test_harness::Decision::Denied( + test_harness::FilesystemErrorCode::Access, + )) + } else { + Ok(test_harness::Decision::Deferred) + } + })), + async |accessor, exports, h| read_entries(accessor, exports, h.root).await, + ) + .await; + assert_eq!(entries, (vec!["dir".into(), "link".into()], Ok(()))); + let mut logs = gate.recorder().logs(); + // the order of the entries is up to the host + logs.sort_by(|a, b| a.message.cmp(&b.message)); + assert_eq!( + logs, + [ + trace(format!( + "Denied REASON=access {TYPES}.read-directory FD=/ NAME=empty TYPE=directory" + )), + trace(format!( + "Denied REASON=access {TYPES}.read-directory FD=/ NAME=file.txt TYPE=regular-file" + )), + ] + ); +} + +// --- latch errors --- + +#[tokio::test(flavor = "multi_thread")] +async fn latch_errors_fail_operations_and_are_logged() { + let fixture = Fixture::new(); + let (results, gate) = run( + fixture.gate().host_latch(HostLatch::error( + &[ + "descriptor.read-via-stream", + "descriptor.write-via-stream", + "descriptor.read-directory", + "descriptor.stat", + ], + "boom", + )), + async |accessor, exports, h| { + let d = descriptor(exports); + let mut results = vec![]; + let (data, result) = d.call_read_via_stream(accessor, h.file, 0).await?; + assert_eq!(read_bytes(accessor, data).await?, b""); + results.push(test_harness::resolve(accessor, result).await?); + let data = test_harness::stream(accessor, b"!".to_vec())?; + let result = d.call_write_via_stream(accessor, h.file, data, 0).await?; + results.push(test_harness::resolve(accessor, result).await?); + let (entries, result) = read_entries(accessor, exports, h.dir).await?; + assert!(entries.is_empty(), "{entries:?}"); + results.push(result); + results.push(ok(d.call_stat(accessor, h.file).await?)); + Ok(results) + }, + ) + .await; + let failed = Err(ErrorCode::Other(Some("latch-error: boom".into()))); + assert_eq!(results, [failed.clone(), failed.clone(), failed.clone(), failed]); + let latch_error = |call: &str| error(format!("Latch error CODE=boom {TYPES}.{call}")); + assert_eq!( + gate.recorder().logs(), + [ + latch_error("read-via-stream FD=/file.txt OFFSET=0"), + latch_error("write-via-stream FD=/file.txt OFFSET=0"), + latch_error("read-directory FD=/dir"), + latch_error("stat FD=/file.txt"), + ] + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn directory_entry_latch_error_fails_the_read() { + let fixture = Fixture::new(); + let (entries, gate) = run( + fixture + .gate() + .host_latch(HostLatch::error(&["descriptor.read-directory-entry"], "boom")), + async |accessor, exports, h| read_entries(accessor, exports, h.dir).await, + ) + .await; + assert_eq!( + entries, + ( + vec![], + Err(ErrorCode::Other(Some("latch-error: boom".into()))) + ) + ); + assert_eq!( + gate.recorder().logs(), + [error(format!( + "Latch error CODE=boom {TYPES}.read-directory FD=/dir NAME=nested.txt TYPE=regular-file" + ))] + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn failed_observation_fails_the_operation() { + let fixture = Fixture::new(); + let mut gate = fixture + .gate() + .host_latch(HostLatch::defer().fail_observe()) + .build() + .await + .unwrap(); + // a latch error getting the preopens can't be returned, the gate panics + let result = gate + .run(async |accessor, exports| Ok(preopens(accessor, exports).await?.len())) + .await; + assert!(result.is_err(), "{result:?}"); +} diff --git a/components/latch-glob/README.md b/components/latch-glob/README.md deleted file mode 100644 index e8cc14b..0000000 --- a/components/latch-glob/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# `latch-glob` - -Filesystem latch that uses glob patterns to grant or deny operations on a path. - -The patterns are defined in a wasi:config/store. Keys starting with `deny` are parsed as globs with matching path operations being denied. Multiple patterns are allowed by defining unique config keys (e.g. `deny-1`, `deny-2`, etc). Keys starting with `grant` are parsed as globs with matching path operations being granted. - -Operations are evaluated against the pattern with the base path for the operation joined with a relative path, if any. For example, the file descriptor open-at operation will join the descriptor's path with the argument's path, the resulting path is matched to the patterns. - -For operations with multiple paths, each path is evaluated individually. Any path matching a deny pattern will result in a denial. diff --git a/components/latch-glob/src/lib.rs b/components/latch-glob/src/lib.rs deleted file mode 100644 index 6d30dfb..0000000 --- a/components/latch-glob/src/lib.rs +++ /dev/null @@ -1,184 +0,0 @@ -#![no_main] - -use std::path::Path; - -use path_matchers::{glob, PathMatcher}; - -use crate::exports::componentized::filesystem::latch::{ - Decision, DescriptorOperation, ErrorCode, FilesystemErrorCode, Guest as Latch, Operation, - PreopensOperation, -}; - -struct GlobLatch {} - -struct Patterns { - initialized: bool, - denies: Option>>, - deny_reason: FilesystemErrorCode, -} - -impl Patterns { - fn initialize() { - if unsafe { STATE.initialized } { - return; - } - - let mut denies: Vec> = vec![]; - let mut reason = FilesystemErrorCode::NotPermitted; - - for (key, value) in wasi::config::store::get_all().expect("config must be available") { - if key.starts_with("deny") { - denies.push(Box::new( - glob(&value).expect("config value must parse as a glob"), - )); - } else if key == "reason" { - reason = get_error_code(value); - } - } - - unsafe { - STATE.denies = Some(denies); - STATE.deny_reason = reason; - STATE.initialized = true; - }; - } - - #[allow(static_mut_refs)] - fn authorize(path: String, paths: Vec) -> Result { - if paths.len() == 0 { - let path = Path::new(&path); - return unsafe { STATE.authorize_path(path) }; - } - let mut decision = Decision::Abstained; - for p in paths { - let path = Path::new(&path).join(p); - match unsafe { STATE.authorize_path(&path)? } { - // return denies immediately, buffer grants - Decision::Denied(reason) => return Ok(Decision::Denied(reason)), - Decision::Abstained => {} - } - } - Ok(decision) - } - - fn authorize_path(&self, path: &Path) -> Result { - for deny in self.denies.as_ref().unwrap() { - if deny.matches(path) { - return Ok(Decision::Denied(self.deny_reason.clone())); - } - } - Ok(Decision::Abstained) - } -} - -static mut STATE: Patterns = Patterns { - initialized: false, - denies: None, - deny_reason: FilesystemErrorCode::NotPermitted, -}; - -impl Latch for GlobLatch { - fn authorize(operation: Operation) -> Result { - Patterns::initialize(); - - match operation { - Operation::Preopens(preopens_operation) => match preopens_operation { - PreopensOperation::GetDirectoriesItem((_, path)) => { - Patterns::authorize(path, vec![]) - } - }, - Operation::Descriptor((_, path, descriptor_operation)) => match descriptor_operation { - DescriptorOperation::ReadViaStream(_) => Patterns::authorize(path, vec![]), - DescriptorOperation::WriteViaStream(_) => Patterns::authorize(path, vec![]), - DescriptorOperation::AppendViaStream => Patterns::authorize(path, vec![]), - DescriptorOperation::Advise(_) => Patterns::authorize(path, vec![]), - DescriptorOperation::SyncData => Patterns::authorize(path, vec![]), - DescriptorOperation::GetFlags => Patterns::authorize(path, vec![]), - DescriptorOperation::GetType => Patterns::authorize(path, vec![]), - DescriptorOperation::SetSize(_) => Patterns::authorize(path, vec![]), - DescriptorOperation::SetTimes(_) => Patterns::authorize(path, vec![]), - DescriptorOperation::ReadDirectory => Patterns::authorize(path, vec![]), - DescriptorOperation::Sync => Patterns::authorize(path, vec![]), - DescriptorOperation::CreateDirectoryAt(args) => { - Patterns::authorize(path, vec![args.path]) - } - DescriptorOperation::Stat => Patterns::authorize(path, vec![]), - DescriptorOperation::StatAt(args) => Patterns::authorize(path, vec![args.path]), - DescriptorOperation::SetTimesAt(args) => Patterns::authorize(path, vec![args.path]), - DescriptorOperation::LinkAt(args) => { - Patterns::authorize(path, vec![args.old_path, args.new_path]) - } - DescriptorOperation::OpenAt(args) => Patterns::authorize(path, vec![args.path]), - DescriptorOperation::ReadlinkAt(args) => Patterns::authorize(path, vec![args.path]), - DescriptorOperation::RemoveDirectoryAt(args) => { - Patterns::authorize(path, vec![args.path]) - } - DescriptorOperation::RenameAt(args) => { - Patterns::authorize(path, vec![args.old_path, args.new_path]) - } - DescriptorOperation::SymlinkAt(args) => { - Patterns::authorize(path, vec![args.old_path, args.new_path]) - } - DescriptorOperation::UnlinkFileAt(args) => { - Patterns::authorize(path, vec![args.path]) - } - DescriptorOperation::MetadataHash => Patterns::authorize(path, vec![]), - DescriptorOperation::MetadataHashAt(args) => { - Patterns::authorize(path, vec![args.path]) - } - }, - } - } -} - -fn get_error_code(value: String) -> FilesystemErrorCode { - match value.as_str() { - "access" => FilesystemErrorCode::Access, - "already" => FilesystemErrorCode::Already, - "bad-descriptor" => FilesystemErrorCode::BadDescriptor, - "busy" => FilesystemErrorCode::Busy, - "deadlock" => FilesystemErrorCode::Deadlock, - "quota" => FilesystemErrorCode::Quota, - "exist" => FilesystemErrorCode::Exist, - "file-too-large" => FilesystemErrorCode::FileTooLarge, - "illegal-byte-sequence" => FilesystemErrorCode::IllegalByteSequence, - "in-progress" => FilesystemErrorCode::InProgress, - "interrupted" => FilesystemErrorCode::Interrupted, - "invalid" => FilesystemErrorCode::Invalid, - "io" => FilesystemErrorCode::Io, - "is-directory" => FilesystemErrorCode::IsDirectory, - "loop" => FilesystemErrorCode::Loop, - "too-many-links" => FilesystemErrorCode::TooManyLinks, - "message-size" => FilesystemErrorCode::MessageSize, - "name-too-long" => FilesystemErrorCode::NameTooLong, - "no-device" => FilesystemErrorCode::NoDevice, - "no-entry" => FilesystemErrorCode::NoEntry, - "no-lock" => FilesystemErrorCode::NoLock, - "insufficient-memory" => FilesystemErrorCode::InsufficientMemory, - "insufficient-space" => FilesystemErrorCode::InsufficientSpace, - "not-directory" => FilesystemErrorCode::NotDirectory, - "not-empty" => FilesystemErrorCode::NotEmpty, - "not-recoverable" => FilesystemErrorCode::NotRecoverable, - "unsupported" => FilesystemErrorCode::Unsupported, - "no-tty" => FilesystemErrorCode::NoTty, - "no-such-device" => FilesystemErrorCode::NoSuchDevice, - "overflow" => FilesystemErrorCode::Overflow, - "not-permitted" => FilesystemErrorCode::NotPermitted, - "pipe" => FilesystemErrorCode::Pipe, - "read-only" => FilesystemErrorCode::ReadOnly, - "invalid-seek" => FilesystemErrorCode::InvalidSeek, - "text-file-busy" => FilesystemErrorCode::TextFileBusy, - "cross-device" => FilesystemErrorCode::CrossDevice, - "other" => FilesystemErrorCode::Other(None), - _ => FilesystemErrorCode::Other(Some(value)), - } -} - -wit_bindgen::generate!({ - path: "../../wit", - world: "filesystem-latch", - merge_structurally_equal_types: true, - generate_all -}); - -export!(GlobLatch); diff --git a/components/latch-glob/Cargo.toml b/components/latch-path-glob/Cargo.toml similarity index 57% rename from components/latch-glob/Cargo.toml rename to components/latch-path-glob/Cargo.toml index 862df7b..3b423a9 100644 --- a/components/latch-glob/Cargo.toml +++ b/components/latch-path-glob/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "latch-glob" +name = "latch-path-glob" version = "0.1.0" edition = "2021" license = "Apache-2.0" @@ -9,4 +9,8 @@ crate-type = ["cdylib"] [dependencies] wit-bindgen = { workspace = true } -path-matchers = "1.0" +glob = "0.3" + +[build-dependencies] +heck = { workspace = true } +wit-parser = "0.259" diff --git a/components/latch-path-glob/README.md b/components/latch-path-glob/README.md new file mode 100644 index 0000000..26bf9c6 --- /dev/null +++ b/components/latch-path-glob/README.md @@ -0,0 +1,66 @@ +# `latch-path-glob` + +Filesystem latch that uses glob patterns to defer or deny operations on a path. + +The patterns are defined in a wasi:config/store. Keys starting with `deny` are parsed as globs with matching paths being denied. Multiple patterns are allowed by defining unique config keys (e.g. `deny-1`, `deny-2`, etc). Keys starting with `defer` are parsed as globs with matching paths deferring the decision. + +A single `*` matches within a path segment, between `/` separators, while `**` spans segments. + +``` +/data/* + +/data/file.txt -> MATCHES +/data/dir/file.txt -> NO MATCH +/other/file.txt -> NO MATCH +``` + +``` +/data/** + +/data/file.txt -> MATCHES +/data/dir/file.txt -> MATCHES +/other/file.txt -> NO MATCH +``` + +Operations are evaluated against the patterns with the base path for the operation joined with a relative path, if any. For example, the descriptor `open-at` operation joins the descriptor's path with the argument's path, and the resulting path is matched against the patterns. For operations with multiple paths, like `rename-at`, each path is decided individually and any denied path denies the operation. + +When several patterns match a path, the most specific pattern decides, regardless of whether it is a `deny` or `defer` pattern or the order the keys are defined in. Patterns are compared segment by segment starting from the root (`data` in `/data/file.txt`), the first segment that differs decides. Within a segment, a literal (`file.txt`) is more specific than a partial wildcard (`*.txt`), which is more specific than `*`, which is more specific than `**`. Between two partial wildcards, the one with more literal characters is more specific. When the matching patterns are equally specific, `deny` wins. + +``` +deny-1=/** +defer-1=/data/** +deny-2=/data/secret.txt + +/etc/passwd -> DENIED (deny-1) +/data/file.txt -> DEFERRED (defer-1) +/data/secret.txt -> DENIED (deny-2) +``` + +Whether the host filesystem is case sensitive is not known to the latch, so matching is case insensitive by default: `deny=/secret/**` also denies `/SECRET/file.txt`. Set `case-sensitive=true` when the host filesystem is known to be case sensitive. With case insensitive matching a `defer` pattern also matches other casings, which on a case sensitive filesystem are different paths, e.g. `default=deny` with `defer=/data/**` defers `/DATA/file.txt`. + +Paths that do not match any pattern use the `default` decision, either `defer` (the default) or `deny`. + +Denied operations fail with `not-permitted` unless the `reason` key is set. Any `wasi:filesystem` error code is supported, e.g. `access` or `read-only`, any other value is reported as `other` with the value as the message. The reason applies to both matching `deny` patterns and a `deny` default, and does not change the default decision. + +``` +default=deny +reason=access +defer=/data/** + +/data/file.txt -> DEFERRED +/etc/passwd -> DENIED (access) +``` + +If the config is invalid (a pattern that does not parse as a glob, or an unknown `default` or `case-sensitive` value), the cause is logged when the config is loaded and every operation fails with an `invalid-config` latch error. + +## Interfaces + +Imports: + +- `wasi:logging/logging@0.1.0-draft`: logs an invalid config +- `wasi:config/store@0.2.0-rc.1`: the glob patterns +- `wasi:filesystem/types@0.3.0`: the descriptor types of the operations being authorized + +Exports: + +- `componentized:filesystem/latch@0.3.0-dev`: the latch diff --git a/components/latch-path-glob/build.rs b/components/latch-path-glob/build.rs new file mode 100644 index 0000000..b33e08d --- /dev/null +++ b/components/latch-path-glob/build.rs @@ -0,0 +1,59 @@ +//! Generate `error_code_from_name`, which parses the kebab-case name of every case of +//! `wasi:filesystem/types#error-code` without a payload, from the same WIT the bindings are +//! generated from, so a case added to the WIT is parsed without changing the latch. + +use std::fmt::Write as _; +use std::path::PathBuf; + +use heck::ToUpperCamelCase; +use wit_parser::{Resolve, TypeDefKind}; + +const WIT: &str = "../wit"; + +fn main() { + println!("cargo:rerun-if-changed={WIT}"); + + let mut resolve = Resolve::default(); + resolve.push_path(WIT).expect("WIT must parse"); + + let (_, types) = resolve + .interfaces + .iter() + .find(|(_, interface)| { + interface.name.as_deref() == Some("types") + && interface.package.is_some_and(|package| { + let name = &resolve.packages[package].name; + name.namespace == "wasi" && name.name == "filesystem" + }) + }) + .expect("wasi:filesystem/types must be in the WIT"); + let error_code = types.types["error-code"]; + let TypeDefKind::Variant(variant) = &resolve.types[error_code].kind else { + panic!("wasi:filesystem/types#error-code must be a variant"); + }; + + let mut arms = String::new(); + for case in &variant.cases { + match (&case.ty, case.name.as_str()) { + (None, name) => { + // the case name as wit-bindgen generates it + let ident = name.to_upper_camel_case(); + writeln!(arms, " {name:?} => Some(FilesystemErrorCode::{ident}),").unwrap(); + } + // `other` carries a message, it is parsed by hand + (Some(_), "other") => {} + (Some(_), name) => panic!( + "error-code case `{name}` has a payload, extend build.rs to parse it" + ), + } + } + + let source = format!( + "/// The `error-code` case without a payload with the kebab-case name, e.g. `not-permitted`.\n\ + fn error_code_from_name(name: &str) -> Option {{\n \ + match name {{\n{arms} _ => None,\n }}\n\ + }}\n" + ); + let out = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("error_code.rs"); + std::fs::write(out, source).unwrap(); +} diff --git a/components/latch-path-glob/src/lib.rs b/components/latch-path-glob/src/lib.rs new file mode 100644 index 0000000..98e22e1 --- /dev/null +++ b/components/latch-path-glob/src/lib.rs @@ -0,0 +1,637 @@ +use std::cell::RefCell; +use std::path::Path; + +use glob::{MatchOptions, Pattern}; + +use crate::{ + componentized::filesystem::latch::{Decision::Deferred, DescriptorOperation}, + exports::componentized::filesystem::latch::{ + Decision, ErrorCode, FilesystemErrorCode, Guest as Latch, Operation, PreopensOperation, + }, + wasi::logging::logging::{log, Level}, +}; + +const LATCH_NAME: &str = "latch-path-glob"; + +struct PathGlobLatch {} + +/// Whether the host filesystem is case sensitive is unknown, matching is case insensitive unless +/// the config turns case sensitivity on. +fn match_options(case_sensitive: bool) -> MatchOptions { + MatchOptions { + case_sensitive, + // a single `*` must not match across path segments, `**` spans them + require_literal_separator: true, + require_literal_leading_dot: false, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Action { + // ordered so that deny sorts first when patterns are equally specific + Deny, + Defer, +} + +struct Rule { + pattern: Pattern, + specificity: Vec<(u8, usize)>, + action: Action, +} + +impl Rule { + fn new(pattern: &str, action: Action) -> Result { + Ok(Rule { + pattern: Pattern::new(pattern).map_err(|err| err.to_string())?, + specificity: specificity(pattern), + action, + }) + } +} + +/// Rank each segment of a pattern, starting with the segment closest to the root. +/// +/// Rankings compare segment by segment, the first segment that differs decides which pattern is +/// more specific. Within a segment, a literal beats a partial wildcard (more literal characters +/// win), which beats `*`, which beats `**`. +fn specificity(pattern: &str) -> Vec<(u8, usize)> { + pattern + .split('/') + .filter(|segment| !segment.is_empty()) + .map(|segment| match segment { + "**" => (0, 0), + "*" => (2, 0), + _ if segment.contains(['*', '?', '[']) => (3, literal_chars(segment)), + _ => (4, segment.len()), + }) + .collect() +} + +/// A pattern that has no more segments only matches when the path also has no more segments, +/// which is more specific than `**` but less specific than any other segment. +const END_OF_PATTERN: (u8, usize) = (1, 0); + +fn literal_chars(segment: &str) -> usize { + let mut count = 0; + let mut in_class = false; + for c in segment.chars() { + match c { + '[' => in_class = true, + ']' => in_class = false, + '*' | '?' => {} + _ if !in_class => count += 1, + _ => {} + } + } + count +} + +fn compare_specificity(a: &[(u8, usize)], b: &[(u8, usize)]) -> std::cmp::Ordering { + (0..a.len().max(b.len())) + .map(|i| { + let a = a.get(i).unwrap_or(&END_OF_PATTERN); + let b = b.get(i).unwrap_or(&END_OF_PATTERN); + a.cmp(b) + }) + .find(|ordering| ordering.is_ne()) + .unwrap_or(std::cmp::Ordering::Equal) +} + +struct Patterns { + /// most specific first + rules: Vec, + reason: FilesystemErrorCode, + default: Action, + match_options: MatchOptions, +} + +impl Patterns { + /// Load the patterns from config, logging why when the config is invalid. + fn load() -> Result { + wasi::config::store::get_all() + .map_err(|err| match err { + wasi::config::store::Error::Upstream(message) => { + format!("ERROR=upstream: {message}") + } + wasi::config::store::Error::Io(message) => format!("ERROR=io: {message}"), + }) + .and_then(Patterns::parse) + .map_err(|message| { + log( + Level::Critical, + "componentized-latch", + &format!("Invalid config LATCH={LATCH_NAME} {message}"), + ); + ErrorCode::InvalidConfig(LATCH_NAME.to_string()) + }) + } + + fn parse(config: Vec<(String, String)>) -> Result { + let mut rules: Vec = vec![]; + let mut reason = FilesystemErrorCode::NotPermitted; + let mut default = Action::Defer; + let mut case_sensitive = false; + + for (key, value) in config { + let invalid = |err: String| format!("KEY={key} VALUE={value} ERROR={err}"); + if key.starts_with("deny") { + rules.push(Rule::new(&value, Action::Deny).map_err(invalid)?); + } else if key.starts_with("defer") { + rules.push(Rule::new(&value, Action::Defer).map_err(invalid)?); + } else if key == "reason" { + reason = get_error_code(value).unwrap_or(FilesystemErrorCode::NotPermitted); + } else if key == "default" { + default = match value.as_str() { + "deny" => Action::Deny, + "defer" => Action::Defer, + _ => return Err(invalid("expected 'deny' or 'defer'".to_string())), + } + } else if key == "case-sensitive" { + case_sensitive = match value.as_str() { + "true" => true, + "false" => false, + _ => return Err(invalid("expected 'true' or 'false'".to_string())), + } + } + } + + rules.sort_by(|a, b| { + compare_specificity(&b.specificity, &a.specificity).then(a.action.cmp(&b.action)) + }); + + Ok(Patterns { + rules, + reason, + default, + match_options: match_options(case_sensitive), + }) + } + + fn authorize(path: String, paths: Vec) -> Result { + // config is loaded once, an invalid config fails every authorization + STATE.with_borrow_mut(|state| match state.get_or_insert_with(Patterns::load) { + Ok(patterns) => Ok(patterns.decide(&path, &paths)), + Err(err) => Err(err.clone()), + }) + } + + /// Deny when the base path, or any of the paths relative to it, is denied. + /// + /// Each path is decided by the most specific matching pattern, or the default when no pattern + /// matches. + fn decide(&self, path: &str, paths: &[String]) -> Decision { + let base = Path::new(path); + let denied = match paths { + [] => self.action(base) == Action::Deny, + paths => paths + .iter() + .any(|p| self.action(&base.join(p)) == Action::Deny), + }; + match denied { + true => Decision::Denied(self.reason.clone()), + false => Decision::Deferred, + } + } + + fn action(&self, path: &Path) -> Action { + self.authorize_path(path).unwrap_or(self.default) + } + + /// The action of the most specific matching pattern. + fn authorize_path(&self, path: &Path) -> Option { + self.rules + .iter() + .find(|rule| rule.pattern.matches_path_with(path, self.match_options)) + .map(|rule| rule.action) + } +} + +thread_local! { + /// `None` until the config is loaded. Components are single threaded, this is the latch's only + /// state. + static STATE: RefCell>> = const { RefCell::new(None) }; +} + +impl Latch for PathGlobLatch { + fn authorize(operation: Operation) -> Result { + match operation { + Operation::Preopens(preopens_operation) => match preopens_operation { + PreopensOperation::GetDirectories => Ok(Decision::Deferred), + PreopensOperation::GetDirectoriesItem((_, path)) => { + Patterns::authorize(path, vec![]) + } + }, + Operation::Descriptor(descriptor_operation) => match descriptor_operation { + DescriptorOperation::ReadViaStream(_) => Ok(Deferred), + DescriptorOperation::WriteViaStream(_) => Ok(Deferred), + DescriptorOperation::AppendViaStream(_) => Ok(Deferred), + DescriptorOperation::Advise(_) => Ok(Deferred), + DescriptorOperation::SyncData(_) => Ok(Deferred), + DescriptorOperation::GetFlags(_) => Ok(Deferred), + DescriptorOperation::GetType(_) => Ok(Deferred), + DescriptorOperation::SetSize(_) => Ok(Deferred), + DescriptorOperation::SetTimes(_) => Ok(Deferred), + DescriptorOperation::ReadDirectory(_) => Ok(Deferred), + DescriptorOperation::ReadDirectoryEntry((_, path, entry)) => { + Patterns::authorize(path, vec![entry.name]) + } + DescriptorOperation::Sync(_) => Ok(Deferred), + DescriptorOperation::CreateDirectoryAt((_, path, args)) => { + Patterns::authorize(path, vec![args.path]) + } + DescriptorOperation::Stat(_) => Ok(Deferred), + DescriptorOperation::StatAt((_, path, args)) => { + Patterns::authorize(path, vec![args.path]) + } + DescriptorOperation::SetTimesAt((_, path, args)) => { + Patterns::authorize(path, vec![args.path]) + } + DescriptorOperation::LinkAt((_, path, args)) => { + Patterns::authorize(path, vec![args.old_path, args.new_path]) + } + DescriptorOperation::OpenAt((_, path, args)) => { + Patterns::authorize(path, vec![args.path]) + } + DescriptorOperation::ReadlinkAt((_, path, args)) => { + Patterns::authorize(path, vec![args.path]) + } + DescriptorOperation::RemoveDirectoryAt((_, path, args)) => { + Patterns::authorize(path, vec![args.path]) + } + DescriptorOperation::RenameAt((_, path, args)) => { + Patterns::authorize(path, vec![args.old_path, args.new_path]) + } + DescriptorOperation::SymlinkAt((_, path, args)) => { + Patterns::authorize(path, vec![args.old_path, args.new_path]) + } + DescriptorOperation::UnlinkFileAt((_, path, args)) => { + Patterns::authorize(path, vec![args.path]) + } + DescriptorOperation::MetadataHash(_) => Ok(Deferred), + DescriptorOperation::MetadataHashAt((_, path, args)) => { + Patterns::authorize(path, vec![args.path]) + } + }, + } + } + + #[doc = " Observe the final decision for an operation, after `authorize` returned a decision."] + #[doc = ""] + #[doc = " Called for both deferred and denied operations, including operations this latch was not"] + #[doc = " asked to authorize because an aggregated latch denied them first. The final decision is"] + #[doc = " what the gate enforces, so this is where a latch updates any state it keeps, for example"] + #[doc = " remembering peers the caller is allowed to exchange traffic with. Not called when"] + #[doc = " `authorize` returned an error."] + #[doc = ""] + #[doc = " An error fails the operation, since the latch could not act on the decision."] + #[allow(async_fn_in_trait)] + fn observe_decision( + _final_decision: Decision, + _operation: Operation<'_>, + ) -> Result<(), ErrorCode> { + Ok(()) + } +} + +/// The reason to deny with, `None` when the value is empty. Any value that is not the name of an +/// `error-code` case is `other` with the value as the message. +fn get_error_code(value: String) -> Option { + match value.as_str() { + "" => None, + "other" => Some(FilesystemErrorCode::Other(None)), + // error_code_from_name is defined by build.rs + name => Some(error_code_from_name(name).unwrap_or(FilesystemErrorCode::Other(Some(value)))), + } +} + +include!(concat!(env!("OUT_DIR"), "/error_code.rs")); + +wit_bindgen::generate!({ + path: "../wit", + world: "latch", + merge_structurally_equal_types: true, + generate_all +}); + +export!(PathGlobLatch); + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(config: &[(&str, &str)]) -> Result { + Patterns::parse( + config + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + ) + } + + fn patterns(config: &[(&str, &str)]) -> Patterns { + parse(config).expect("valid config") + } + + fn decision(patterns: &Patterns, path: &str, paths: &[&str]) -> String { + let paths: Vec = paths.iter().map(|p| p.to_string()).collect(); + format!("{:?}", patterns.decide(path, &paths)) + } + + fn denied(reason: FilesystemErrorCode) -> String { + format!("{:?}", Decision::Denied(reason)) + } + + fn deferred() -> String { + format!("{:?}", Decision::Deferred) + } + + #[test] + fn matching_base_path_is_denied() { + let patterns = patterns(&[("deny", "/secret")]); + assert_eq!( + decision(&patterns, "/secret", &[]), + denied(FilesystemErrorCode::NotPermitted) + ); + assert_eq!(decision(&patterns, "/", &[]), deferred()); + } + + #[test] + fn relative_paths_are_joined_to_the_base_path() { + let patterns = patterns(&[("deny", "/data/*.key")]); + assert_eq!( + decision(&patterns, "/data", &["id.key"]), + denied(FilesystemErrorCode::NotPermitted) + ); + assert_eq!(decision(&patterns, "/data", &["id.pub"]), deferred()); + // only the joined paths are matched, not the base path alone + assert_eq!(decision(&patterns, "/data/id.key", &["x"]), deferred()); + } + + #[test] + fn any_matching_path_denies() { + let patterns = patterns(&[("deny", "/data/*.key")]); + assert_eq!( + decision(&patterns, "/data", &["id.pub", "id.key"]), + denied(FilesystemErrorCode::NotPermitted) + ); + } + + #[test] + fn multiple_deny_keys() { + let patterns = patterns(&[("deny-1", "/a"), ("deny-2", "/b")]); + let not_permitted = denied(FilesystemErrorCode::NotPermitted); + assert_eq!(decision(&patterns, "/a", &[]), not_permitted); + assert_eq!(decision(&patterns, "/b", &[]), not_permitted); + assert_eq!(decision(&patterns, "/c", &[]), deferred()); + } + + #[test] + fn unrelated_keys_are_ignored() { + let patterns = patterns(&[("other", "/a")]); + assert_eq!(decision(&patterns, "/a", &[]), deferred()); + } + + #[test] + fn denies_with_configured_reason() { + for (value, expected) in [ + ("access", FilesystemErrorCode::Access), + ("read-only", FilesystemErrorCode::ReadOnly), + ("other", FilesystemErrorCode::Other(None)), + ("", FilesystemErrorCode::NotPermitted), + ( + "custom-reason", + FilesystemErrorCode::Other(Some("custom-reason".to_string())), + ), + ] { + let patterns = patterns(&[("reason", value), ("deny", "/a")]); + assert_eq!( + decision(&patterns, "/a", &[]), + denied(expected), + "reason={value}" + ); + } + } + + fn action(patterns: &Patterns, path: &str) -> Option { + patterns.authorize_path(Path::new(path)) + } + + #[test] + fn single_star_matches_within_a_segment() { + let patterns = patterns(&[("deny", "/data/*")]); + assert_eq!(action(&patterns, "/data/file.txt"), Some(Action::Deny)); + assert_eq!(action(&patterns, "/data/dir/file.txt"), None); + assert_eq!(action(&patterns, "/other/file.txt"), None); + } + + #[test] + fn double_star_spans_segments() { + let patterns = patterns(&[("deny", "/data/**")]); + assert_eq!(action(&patterns, "/data/file.txt"), Some(Action::Deny)); + assert_eq!(action(&patterns, "/data/dir/file.txt"), Some(Action::Deny)); + assert_eq!(action(&patterns, "/other/file.txt"), None); + } + + #[test] + fn matching_is_case_insensitive_by_default() { + let patterns = patterns(&[("deny", "/Data/*.TXT")]); + assert_eq!(action(&patterns, "/Data/file.TXT"), Some(Action::Deny)); + assert_eq!(action(&patterns, "/data/file.txt"), Some(Action::Deny)); + assert_eq!(action(&patterns, "/DATA/FILE.txt"), Some(Action::Deny)); + } + + #[test] + fn matching_is_case_sensitive_when_configured() { + for config in [ + [("case-sensitive", "true"), ("deny", "/Data/*")], + [("deny", "/Data/*"), ("case-sensitive", "true")], + ] { + let patterns = patterns(&config); + assert_eq!(action(&patterns, "/Data/file.txt"), Some(Action::Deny)); + assert_eq!(action(&patterns, "/data/file.txt"), None); + } + let patterns = patterns(&[("case-sensitive", "false"), ("deny", "/Data/*")]); + assert_eq!(action(&patterns, "/data/file.txt"), Some(Action::Deny)); + } + + #[test] + fn unknown_case_sensitive_is_invalid() { + assert_eq!( + parse(&[("case-sensitive", "yes")]).err().unwrap(), + "KEY=case-sensitive VALUE=yes ERROR=expected 'true' or 'false'" + ); + } + + #[test] + fn more_specific_defer_overrides_general_deny() { + for config in [ + [("deny", "/data/**"), ("defer", "/data/public/*")], + [("defer", "/data/public/*"), ("deny", "/data/**")], + ] { + let patterns = patterns(&config); + assert_eq!(action(&patterns, "/data/public/a.txt"), Some(Action::Defer)); + assert_eq!(action(&patterns, "/data/private/a.txt"), Some(Action::Deny)); + } + } + + #[test] + fn more_specific_deny_overrides_general_defer() { + for config in [ + [("defer", "/data/**"), ("deny", "/data/*.key")], + [("deny", "/data/*.key"), ("defer", "/data/**")], + ] { + let patterns = patterns(&config); + assert_eq!(action(&patterns, "/data/id.key"), Some(Action::Deny)); + assert_eq!(action(&patterns, "/data/id.pub"), Some(Action::Defer)); + } + } + + #[test] + fn nested_exceptions() { + let patterns = patterns(&[ + ("deny-1", "/**"), + ("defer-1", "/data/**"), + ("deny-2", "/data/secret.txt"), + ]); + assert_eq!(action(&patterns, "/etc/passwd"), Some(Action::Deny)); + assert_eq!(action(&patterns, "/data/a.txt"), Some(Action::Defer)); + assert_eq!(action(&patterns, "/data/secret.txt"), Some(Action::Deny)); + } + + #[test] + fn segments_closer_to_the_root_are_more_significant() { + // `data` is a literal where the other pattern has a wildcard + let patterns = patterns(&[("deny", "/*/a.txt"), ("defer", "/data/*")]); + assert_eq!(action(&patterns, "/data/a.txt"), Some(Action::Defer)); + } + + #[test] + fn literal_segment_beats_wildcards() { + let star = patterns(&[("deny", "/*"), ("defer", "/a.txt")]); + assert_eq!(action(&star, "/a.txt"), Some(Action::Defer)); + assert_eq!(action(&star, "/b.txt"), Some(Action::Deny)); + + let partial = patterns(&[("deny", "/a*"), ("defer", "/a.txt")]); + assert_eq!(action(&partial, "/a.txt"), Some(Action::Defer)); + assert_eq!(action(&partial, "/ab.txt"), Some(Action::Deny)); + } + + #[test] + fn partial_wildcard_with_more_literals_wins() { + let patterns = patterns(&[("deny", "/*.txt"), ("defer", "/read*.txt")]); + assert_eq!(action(&patterns, "/readme.txt"), Some(Action::Defer)); + assert_eq!(action(&patterns, "/notes.txt"), Some(Action::Deny)); + } + + #[test] + fn single_star_beats_double_star() { + let patterns = patterns(&[("deny", "/data/**"), ("defer", "/data/*")]); + assert_eq!(action(&patterns, "/data/a.txt"), Some(Action::Defer)); + assert_eq!(action(&patterns, "/data/dir/a.txt"), Some(Action::Deny)); + } + + #[test] + fn deny_wins_when_equally_specific() { + for config in [ + [("deny", "/a*"), ("defer", "/*z")], + [("defer", "/*z"), ("deny", "/a*")], + ] { + let patterns = patterns(&config); + assert_eq!(action(&patterns, "/abz"), Some(Action::Deny)); + } + } + + #[test] + fn specificity_ranks_segments_from_the_root() { + assert_eq!(specificity("/**/re?d*.txt/*"), vec![(0, 0), (3, 7), (2, 0)]); + assert_eq!(specificity("[abc]x/data"), vec![(3, 1), (4, 4)]); + } + + #[test] + fn a_deferred_path_does_not_hide_a_denied_one() { + let patterns = patterns(&[("default", "deny"), ("defer", "/data/*")]); + assert_eq!( + decision(&patterns, "/data", &["a.txt", "b.txt"]), + deferred() + ); + assert_eq!( + decision(&patterns, "/data", &["a.txt", "../etc/passwd"]), + denied(FilesystemErrorCode::NotPermitted) + ); + } + + #[test] + fn default_deny_uses_configured_reason_regardless_of_order() { + for config in [ + [("reason", "access"), ("default", "deny")], + [("default", "deny"), ("reason", "access")], + ] { + let patterns = patterns(&config); + assert_eq!( + decision(&patterns, "/a", &[]), + denied(FilesystemErrorCode::Access) + ); + } + } + + #[test] + fn reason_does_not_change_the_default() { + let patterns = patterns(&[("reason", "access")]); + assert_eq!(decision(&patterns, "/a", &[]), deferred()); + } + + #[test] + fn default_defer() { + let patterns = patterns(&[("default", "defer")]); + assert_eq!(decision(&patterns, "/a", &[]), deferred()); + } + + #[test] + fn matching_pattern_overrides_default() { + let patterns = patterns(&[("default", "deny"), ("defer", "/data/**")]); + assert_eq!(decision(&patterns, "/data/a.txt", &[]), deferred()); + assert_eq!( + decision(&patterns, "/etc/passwd", &[]), + denied(FilesystemErrorCode::NotPermitted) + ); + } + + #[test] + fn unknown_default_is_invalid() { + assert_eq!( + parse(&[("default", "grant")]).err().unwrap(), + "KEY=default VALUE=grant ERROR=expected 'deny' or 'defer'" + ); + } + + #[test] + fn every_error_code_is_parsed_by_name() { + for (name, code) in [ + ("not-permitted", FilesystemErrorCode::NotPermitted), + ("access", FilesystemErrorCode::Access), + ("cross-device", FilesystemErrorCode::CrossDevice), + ( + "illegal-byte-sequence", + FilesystemErrorCode::IllegalByteSequence, + ), + ("other", FilesystemErrorCode::Other(None)), + ( + "i-am-a-teapot", + FilesystemErrorCode::Other(Some("i-am-a-teapot".to_string())), + ), + ] { + assert_eq!( + format!("{:?}", get_error_code(name.to_string())), + format!("{:?}", Some(code)), + "{name}" + ); + } + assert!(get_error_code(String::new()).is_none()); + } + + #[test] + fn invalid_glob_is_invalid() { + let err = parse(&[("deny-1", "/a"), ("deny-2", "[")]).err().unwrap(); + assert!(err.starts_with("KEY=deny-2 VALUE=[ ERROR="), "{err}"); + } +} diff --git a/components/latch-readonly/src/lib.rs b/components/latch-readonly/src/lib.rs index 7c39ada..1a30d39 100644 --- a/components/latch-readonly/src/lib.rs +++ b/components/latch-readonly/src/lib.rs @@ -48,6 +48,7 @@ impl Latch for ReadOnlyLatch { Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) } DescriptorOperation::OpenAt(( + _, _, DescriptorOpenAtArgs { open_flags, flags, .. diff --git a/crates/test-harness/Cargo.toml b/crates/test-harness/Cargo.toml index 12d41de..e019f53 100644 --- a/crates/test-harness/Cargo.toml +++ b/crates/test-harness/Cargo.toml @@ -7,5 +7,6 @@ publish = false [dependencies] tokio = { workspace = true } +wac-graph = { workspace = true } wasmtime = { workspace = true } wasmtime-wasi = { workspace = true } diff --git a/crates/test-harness/src/lib.rs b/crates/test-harness/src/lib.rs index 73ed088..cf6efae 100644 --- a/crates/test-harness/src/lib.rs +++ b/crates/test-harness/src/lib.rs @@ -1,8 +1,11 @@ //! Test harness for the filesystem components. //! //! A [`Harness`] instantiates a component (`target/components/*/*.wasm`) with real directories -//! preopened from the host by wasmtime-wasi, values for `wasi:config/store`, and captured -//! `wasi:logging` output. Tests call the component's exported `wasi:filesystem` interfaces. +//! preopened from the host by wasmtime-wasi, a scripted [`HostLatch`], values for +//! `wasi:config/store`, and captured `wasi:logging` output. Latch components from +//! `target/components/` can be installed in place of, or alongside, the host latch, they are +//! composed into the component before it is instantiated. Tests call the component's exported +//! `wasi:filesystem` interfaces. use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -12,6 +15,10 @@ use std::sync::{Arc, Mutex}; use std::task::Poll; use tokio::sync::{mpsc, oneshot}; +use wac_graph::{ + CompositionGraph, EncodeOptions, + types::{Package, Types}, +}; use wasmtime::component::{ Accessor, Component, FutureConsumer, FutureReader, HasSelf, Lift, Linker, ResourceTable, Source, StreamConsumer, StreamReader, StreamResult, @@ -23,6 +30,9 @@ use wasmtime_wasi::{FsPerms, WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView}; use crate::bindings::exports::wasi::filesystem::{preopens, types}; use crate::bindings::wasi::config::store; use crate::bindings::wasi::logging::logging; +use crate::latch_bindings::componentized::filesystem::latch::{ + self, DescriptorOperation, Operation, PreopensOperation, +}; pub mod bindings { wasmtime::component::bindgen!({ @@ -43,10 +53,34 @@ pub mod bindings { }); } +/// The latch the test subject imports, generated apart from [`bindings`], its operations borrow +/// host descriptors, which aren't comparable. +pub mod latch_bindings { + wasmtime::component::bindgen!({ + path: "../../components/wit", + inline: " + package componentized:test-harness; + + world latch { + import componentized:filesystem/latch@0.3.0-dev; + } + ", + world: "componentized:test-harness/latch", + require_store_data_send: true, + with: { + "wasi:filesystem": wasmtime_wasi::p3::bindings::filesystem, + "wasi:clocks": wasmtime_wasi::p3::bindings::clocks, + }, + }); +} + pub use bindings::exports::wasi::filesystem::types::{ DescriptorFlags, DescriptorStat, DescriptorType, ErrorCode, OpenFlags, PathFlags, }; pub use bindings::wasi::logging::logging::Level; +pub use latch_bindings::componentized::filesystem::latch::{ + Decision, ErrorCode as LatchErrorCode, FilesystemErrorCode, +}; pub use wasmtime_wasi::FsPerms as Perms; /// Receive the items written to a guest stream on a channel. @@ -141,36 +175,53 @@ pub fn component_path(name: &str) -> PathBuf { workspace_dir().join(format!("target/components/{name}/{name}.wasm")) } -/// Rebuild the named component in `target/components/` with make, unless it was already built by -/// this process. -fn ensure_built(name: &str) -> Result<()> { +/// Rebuild the named components in `target/components/` with make, unless they were already built +/// by this process. +fn ensure_built(names: &[&str]) -> Result<()> { static BUILT: Mutex>> = Mutex::new(None); // hold the lock while building so concurrent tests don't run make over each other let mut built = BUILT.lock().unwrap_or_else(|err| err.into_inner()); let built = built.get_or_insert_with(HashSet::new); - if built.contains(name) { + let targets: Vec = names + .iter() + .filter(|name| !built.contains(**name)) + .map(|name| format!("target/components/{name}/{name}.wasm")) + .collect(); + if targets.is_empty() { return Ok(()); } - let target = format!("target/components/{name}/{name}.wasm"); let output = Command::new("make") .arg("-C") .arg(workspace_dir()) - .arg(&target) + .args(&targets) .output() .context("failed to run make")?; if !output.status.success() { bail!( - "failed to build {target}:\n{}{}", + "failed to build {}:\n{}{}", + targets.join(" "), String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); } - built.insert(name.to_string()); + built.extend(names.iter().map(|name| name.to_string())); Ok(()) } +/// An operation the host latch was asked to authorize. +/// +/// Resource handles are not retained, only the operation name and a debug rendering of its +/// arguments. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Authorization { + /// Operation name, e.g. `descriptor.open-at` + pub operation: String, + /// Debug rendering of the operation arguments, excluding the descriptor operated on + pub args: String, +} + /// A message logged by the test subject via `wasi:logging`. #[derive(Clone, Debug, PartialEq, Eq)] pub struct LogEntry { @@ -179,13 +230,125 @@ pub struct LogEntry { pub message: String, } +impl LogEntry { + /// A trace message logged by the test subject. + pub fn trace(context: impl Into, message: impl Into) -> Self { + Self::log(Level::Trace, context, message) + } + + /// A warning message logged by the test subject. + pub fn warn(context: impl Into, message: impl Into) -> Self { + Self::log(Level::Warn, context, message) + } + + /// An error message logged by the test subject. + pub fn error(context: impl Into, message: impl Into) -> Self { + Self::log(Level::Error, context, message) + } + + fn log(level: Level, context: impl Into, message: impl Into) -> Self { + Self { + level, + context: context.into(), + message: message.into(), + } + } +} + +type Policy = dyn FnMut(&Authorization) -> Result + Send; + +/// Scripted latch implemented by the host. +/// +/// It is the test subject's latch unless latch components are installed, see [`Harness::latch`]. +pub struct HostLatch { + policy: Box, + fail_observe: bool, +} + +impl HostLatch { + /// Defer every operation. + pub fn defer() -> Self { + Self::new(|_| Ok(Decision::Deferred)) + } + + /// Deny the named operations with `access`, defer the rest. + pub fn deny(operations: &[&str]) -> Self { + let operations: Vec = operations.iter().map(|s| s.to_string()).collect(); + Self::new(move |auth| { + if operations.contains(&auth.operation) { + Ok(Decision::Denied(FilesystemErrorCode::Access)) + } else { + Ok(Decision::Deferred) + } + }) + } + + /// Fail the named operations with a latch error, defer the rest. + pub fn error(operations: &[&str], message: &str) -> Self { + let operations: Vec = operations.iter().map(|s| s.to_string()).collect(); + let message = message.to_string(); + Self::new(move |auth| { + if operations.contains(&auth.operation) { + Err(LatchErrorCode::Other(Some(message.clone()))) + } else { + Ok(Decision::Deferred) + } + }) + } + + /// Decide each operation with a custom policy. + pub fn new( + policy: impl FnMut(&Authorization) -> Result + Send + 'static, + ) -> Self { + Self { + policy: Box::new(policy), + fail_observe: false, + } + } + + /// Fail every `observe-decision` call. + pub fn fail_observe(mut self) -> Self { + self.fail_observe = true; + self + } +} + +/// A decision the host latch was told about with `observe-decision`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Observation { + /// Operation name, e.g. `descriptor.open-at` + pub operation: String, + /// Whether the final decision denied the operation + pub denied: bool, +} + /// Observations recorded while the test subject runs. #[derive(Clone, Default)] pub struct Recorder { + authorizations: Arc>>, + observations: Arc>>, logs: Arc>>, } impl Recorder { + /// Operations the host latch was asked to authorize, in order. + pub fn authorizations(&self) -> Vec { + self.authorizations.lock().unwrap().clone() + } + + /// Names of the operations the host latch was asked to authorize, in order. + pub fn operations(&self) -> Vec { + self.authorizations() + .into_iter() + .map(|a| a.operation) + .collect() + } + + /// Decisions the host latch observed, in order. + pub fn observations(&self) -> Vec { + self.observations.lock().unwrap().clone() + } + /// Messages logged by the test subject, in order. pub fn logs(&self) -> Vec { self.logs.lock().unwrap().clone() @@ -195,6 +358,7 @@ impl Recorder { pub struct Ctx { wasi: WasiCtx, table: ResourceTable, + latch: HostLatch, config: Vec<(String, String)>, recorder: Recorder, } @@ -208,6 +372,143 @@ impl WasiView for Ctx { } } +impl latch::Host for Ctx { + fn authorize(&mut self, operation: Operation) -> Result { + let authorization = describe(&operation); + self.recorder + .authorizations + .lock() + .unwrap() + .push(authorization.clone()); + (self.latch.policy)(&authorization) + } + + fn observe_decision( + &mut self, + decision: Decision, + operation: Operation, + ) -> Result<(), LatchErrorCode> { + self.recorder + .observations + .lock() + .unwrap() + .push(Observation { + operation: describe(&operation).operation, + denied: matches!(decision, Decision::Denied(_)), + }); + match self.latch.fail_observe { + true => Err(LatchErrorCode::ObservationFailed("host".to_string())), + false => Ok(()), + } + } +} + +/// Define the host latch under a named import, e.g. `latch1` of a `latch-n` component. +fn add_named_latch_to_linker(linker: &mut Linker, name: &str) -> Result<()> { + let mut instance = linker.instance(name)?; + instance.func_wrap( + "authorize", + |mut store: StoreContextMut<'_, Ctx>, (operation,): (Operation,)| { + Ok((latch::Host::authorize(store.data_mut(), operation),)) + }, + )?; + instance.func_wrap( + "observe-decision", + |mut store: StoreContextMut<'_, Ctx>, (decision, operation): (Decision, Operation)| { + Ok((latch::Host::observe_decision( + store.data_mut(), + decision, + operation, + ),)) + }, + )?; + Ok(()) +} + +fn describe(operation: &Operation) -> Authorization { + let (operation, args) = match operation { + Operation::Preopens(op) => match op { + PreopensOperation::GetDirectories => ("preopens.get-directories", String::new()), + PreopensOperation::GetDirectoriesItem((_, path)) => { + ("preopens.get-directories-item", format!("{path:?}")) + } + }, + Operation::Descriptor(op) => match op { + DescriptorOperation::ReadViaStream((_, _, args)) => { + ("descriptor.read-via-stream", format!("{args:?}")) + } + DescriptorOperation::WriteViaStream((_, _, args)) => { + ("descriptor.write-via-stream", format!("{args:?}")) + } + DescriptorOperation::AppendViaStream(_) => { + ("descriptor.append-via-stream", String::new()) + } + DescriptorOperation::Advise((_, _, args)) => ("descriptor.advise", format!("{args:?}")), + DescriptorOperation::SyncData(_) => ("descriptor.sync-data", String::new()), + DescriptorOperation::GetFlags(_) => ("descriptor.get-flags", String::new()), + DescriptorOperation::GetType(_) => ("descriptor.get-type", String::new()), + DescriptorOperation::SetSize((_, _, args)) => { + ("descriptor.set-size", format!("{args:?}")) + } + DescriptorOperation::SetTimes((_, _, args)) => { + ("descriptor.set-times", format!("{args:?}")) + } + DescriptorOperation::ReadDirectory(_) => ("descriptor.read-directory", String::new()), + DescriptorOperation::ReadDirectoryEntry((_, _, entry)) => { + ("descriptor.read-directory-entry", format!("{entry:?}")) + } + DescriptorOperation::Sync(_) => ("descriptor.sync", String::new()), + DescriptorOperation::CreateDirectoryAt((_, _, args)) => { + ("descriptor.create-directory-at", format!("{args:?}")) + } + DescriptorOperation::Stat(_) => ("descriptor.stat", String::new()), + DescriptorOperation::StatAt((_, _, args)) => { + ("descriptor.stat-at", format!("{args:?}")) + } + DescriptorOperation::SetTimesAt((_, _, args)) => { + ("descriptor.set-times-at", format!("{args:?}")) + } + DescriptorOperation::LinkAt((_, _, args)) => ( + "descriptor.link-at", + format!( + "old_path_flags: {:?}, old_path: {:?}, new_path: {:?}", + args.old_path_flags, args.old_path, args.new_path + ), + ), + DescriptorOperation::OpenAt((_, _, args)) => { + ("descriptor.open-at", format!("{args:?}")) + } + DescriptorOperation::ReadlinkAt((_, _, args)) => { + ("descriptor.readlink-at", format!("{args:?}")) + } + DescriptorOperation::RemoveDirectoryAt((_, _, args)) => { + ("descriptor.remove-directory-at", format!("{args:?}")) + } + DescriptorOperation::RenameAt((_, _, args)) => ( + "descriptor.rename-at", + format!( + "old_path: {:?}, new_path: {:?}", + args.old_path, args.new_path + ), + ), + DescriptorOperation::SymlinkAt((_, _, args)) => { + ("descriptor.symlink-at", format!("{args:?}")) + } + DescriptorOperation::UnlinkFileAt((_, _, args)) => { + ("descriptor.unlink-file-at", format!("{args:?}")) + } + DescriptorOperation::MetadataHash(_) => ("descriptor.metadata-hash", String::new()), + DescriptorOperation::MetadataHashAt((_, _, args)) => { + ("descriptor.metadata-hash-at", format!("{args:?}")) + } + }, + }; + Authorization { + operation: operation.to_string(), + args, + } +} + impl store::Host for Ctx { fn get(&mut self, key: String) -> Result, store::Error> { Ok(self @@ -236,6 +537,8 @@ impl logging::Host for Ctx { /// Builds a subject instance for a test. pub struct Harness { subject: String, + latches: Vec, + host_latch: Option, config: Vec<(String, String)>, preopens: Vec<(PathBuf, String, FsPerms)>, } @@ -245,11 +548,31 @@ impl Harness { pub fn new(component_name: &str) -> Self { Self { subject: component_name.to_string(), + latches: vec![], + host_latch: None, config: vec![], preopens: vec![], } } + /// Install a latch component from `target/components/`. + /// + /// Without latch components the host latch is the test subject's latch. A single latch + /// component replaces it, unless the latch imports a latch, then it wraps the host latch. + /// Otherwise several latches, including the host latch when one is set with + /// [`Harness::host_latch`], are aggregated with the `latch-n` component of the same size, + /// in the order installed with the host latch last. + pub fn latch(mut self, name: &str) -> Self { + self.latches.push(name.to_string()); + self + } + + /// Replace the default deferring host latch, it is aggregated with any latch components. + pub fn host_latch(mut self, latch: HostLatch) -> Self { + self.host_latch = Some(latch); + self + } + /// Add a `wasi:config/store` value. pub fn config(mut self, key: &str, value: &str) -> Self { self.config.push((key.to_string(), value.to_string())); @@ -271,22 +594,25 @@ impl Harness { self } - /// Instantiate the test subject. + /// Compose and instantiate the test subject. pub async fn build(self) -> Result { - ensure_built(&self.subject)?; - let mut config = Config::new(); config.wasm_component_model_async(true); // componentized-rt's background executor runs on a component model thread config.wasm_component_model_threading(true); + // wac-graph encodes the named `latch{i}` imports of an aggregated latch-n with `implements` + config.wasm_component_model_implements(true); let engine = Engine::new(&config)?; - let path = component_path(&self.subject); - let component = Component::from_file(&engine, &path) - .with_context(|| format!("failed to read {}", path.display()))?; + let bytes = self.compose()?; + let component = Component::new(&engine, &bytes)?; let mut linker = Linker::new(&engine); wasmtime_wasi::p3::add_to_linker(&mut linker)?; + latch::add_to_linker::<_, HasSelf>(&mut linker, |ctx| ctx)?; + for slot in 0..LATCH_N_MAX { + add_named_latch_to_linker(&mut linker, &format!("latch{slot}"))?; + } store::add_to_linker::<_, HasSelf>(&mut linker, |ctx| ctx)?; logging::add_to_linker::<_, HasSelf>(&mut linker, |ctx| ctx)?; @@ -303,6 +629,7 @@ impl Harness { Ctx { wasi: wasi.build(), table: ResourceTable::new(), + latch: self.host_latch.unwrap_or_else(HostLatch::defer), config: self.config, recorder: recorder.clone(), }, @@ -325,6 +652,91 @@ impl Harness { } } +impl Harness { + fn compose(&self) -> Result> { + let mut names = vec![self.subject.as_str()]; + names.extend(self.latches.iter().map(String::as_str)); + ensure_built(&names)?; + + let read = |name: &str| { + let path = component_path(name); + std::fs::read(&path).with_context(|| format!("failed to read {}", path.display())) + }; + + let bytes = read(&self.subject)?; + let latch = match self.latches.as_slice() { + [] => return Ok(bytes), + // a latch that wraps another latch wraps the host latch, its import is left for the host + [latch] if self.host_latch.is_none() || imports_latch(&read(latch)?)? => read(latch)?, + latches => { + // the host latch takes the last slot of latch-n, left unsatisfied it is imported + let slots = latches.len() + usize::from(self.host_latch.is_some()); + if slots > LATCH_N_MAX { + bail!("at most {LATCH_N_MAX} latches can be aggregated, got {slots}"); + } + let latch_n = format!("latch-n{slots}"); + ensure_built(&[&latch_n])?; + let latches = latches + .iter() + .map(|name| read(name)) + .collect::>>()?; + aggregate(read(&latch_n)?, latches)? + } + }; + plug(&self.subject, bytes, latch) + } +} + +/// The most latches a `latch-n` component aggregates. +const LATCH_N_MAX: usize = 5; + +const LATCH_INTERFACE: &str = "componentized:filesystem/latch@0.3.0-dev"; + +/// Whether the latch component imports a latch, which it wraps. +fn imports_latch(latch: &[u8]) -> Result { + let mut types = Types::default(); + let package = Package::from_bytes("test:latch", None, latch.to_vec(), &mut types) + .map_err(|err| format_err!("{err:#}"))?; + Ok(types[package.ty()].imports.contains_key(LATCH_INTERFACE)) +} + +/// Satisfy the leading `latch{i}` imports of a `latch-n` component with the latches, any +/// remaining slot is left for the host. +fn aggregate(latch_n: Vec, latches: Vec>) -> Result> { + let mut graph = CompositionGraph::new(); + let latch_n = Package::from_bytes("test:latch-n", None, latch_n, graph.types_mut()) + .map_err(|err| format_err!("{err:#}"))?; + let latch_n = graph.register_package(latch_n)?; + let latch_n = graph.instantiate(latch_n); + for (slot, latch) in latches.into_iter().enumerate() { + let latch = + Package::from_bytes(&format!("test:latch{slot}"), None, latch, graph.types_mut()) + .map_err(|err| format_err!("{err:#}"))?; + let latch = graph.register_package(latch)?; + let latch = graph.instantiate(latch); + let export = graph.alias_instance_export(latch, LATCH_INTERFACE)?; + graph.set_instantiation_argument(latch_n, &format!("latch{slot}"), export)?; + } + let export = graph.alias_instance_export(latch_n, LATCH_INTERFACE)?; + graph.export(export, LATCH_INTERFACE)?; + Ok(graph.encode(EncodeOptions::default())?) +} + +/// Satisfy the socket's imports with the plug's exports. +fn plug(name: &str, socket: Vec, plug: Vec) -> Result> { + let mut graph = CompositionGraph::new(); + let socket = Package::from_bytes("test:socket", None, socket, graph.types_mut()) + .map_err(|err| format_err!("{err:#}"))?; + let socket = graph.register_package(socket)?; + let plug = Package::from_bytes("test:plug", None, plug, graph.types_mut()) + .map_err(|err| format_err!("{err:#}"))?; + let plug = graph.register_package(plug)?; + if let Err(err) = wac_graph::plug(&mut graph, vec![plug], socket) { + bail!("failed to plug latch into {name}: {err}"); + } + Ok(graph.encode(EncodeOptions::default())?) +} + /// The test subject's exported interfaces. pub struct Exports { types: types::Guest, @@ -351,7 +763,7 @@ pub struct TestSubject { } impl TestSubject { - /// Observations recorded by the logger. + /// Observations recorded by the host latch and logger. pub fn recorder(&self) -> Recorder { self.recorder.clone() } diff --git a/wit/latch.wit b/wit/latch.wit index 58fd8d2..0821141 100644 --- a/wit/latch.wit +++ b/wit/latch.wit @@ -2,6 +2,8 @@ interface latch { use wasi:filesystem/types@0.3.0.{advice, descriptor, descriptor-flags, directory-entry, error-code as filesystem-error-code, filesize, open-flags, new-timestamp, path-flags}; + type path = string; + record descriptor-read-via-stream-args { offset: filesize, } @@ -26,95 +28,95 @@ interface latch { } record descriptor-create-directory-at-args { - path: string, + path: path, } record descriptor-stat-at-args { path-flags: path-flags, - path: string, + path: path, } record descriptor-set-times-at-args { path-flags: path-flags, - path: string, + path: path, data-access-timestamp: new-timestamp, data-modification-timestamp: new-timestamp, } record descriptor-link-at-args { old-path-flags: path-flags, - old-path: string, + old-path: path, new-descriptor: borrow, - new-path: string, + new-path: path, } record descriptor-open-at-args { path-flags: path-flags, - path: string, + path: path, open-flags: open-flags, %flags: descriptor-flags, } record descriptor-readlink-at-args { - path: string, + path: path, } record descriptor-remove-directory-at-args { - path: string, + path: path, } record descriptor-rename-at-args { - old-path: string, + old-path: path, new-descriptor: borrow, - new-path: string, + new-path: path, } record descriptor-symlink-at-args { - old-path: string, - new-path: string, + old-path: path, + new-path: path, } record descriptor-unlink-file-at-args { - path: string, + path: path, } record descriptor-metadata-hash-at-args { path-flags: path-flags, - path: string, + path: path, } variant preopens-operation { get-directories, - get-directories-item(tuple, string>), + get-directories-item(tuple, path>), } variant descriptor-operation { - read-via-stream(tuple, descriptor-read-via-stream-args>), - write-via-stream(tuple, descriptor-write-via-stream-args>), - append-via-stream(tuple>), - advise(tuple, descriptor-advise-args>), - sync-data(tuple>), - get-flags(tuple>), - get-type(tuple>), - set-size(tuple, descriptor-set-size-args>), - set-times(tuple, descriptor-set-times-args>), - read-directory(tuple>), - read-directory-entry(tuple, directory-entry>), - sync(tuple>), - create-directory-at(tuple, descriptor-create-directory-at-args>), - stat(tuple>), - stat-at(tuple, descriptor-stat-at-args>), - set-times-at(tuple, descriptor-set-times-at-args>), - link-at(tuple, descriptor-link-at-args>), - open-at(tuple, descriptor-open-at-args>), - readlink-at(tuple, descriptor-readlink-at-args>), - remove-directory-at(tuple, descriptor-remove-directory-at-args>), - rename-at(tuple, descriptor-rename-at-args>), - symlink-at(tuple, descriptor-symlink-at-args>), - unlink-file-at(tuple, descriptor-unlink-file-at-args>), - metadata-hash(tuple>), - metadata-hash-at(tuple, descriptor-metadata-hash-at-args>), + read-via-stream(tuple, path, descriptor-read-via-stream-args>), + write-via-stream(tuple, path, descriptor-write-via-stream-args>), + append-via-stream(tuple, path>), + advise(tuple, path, descriptor-advise-args>), + sync-data(tuple, path>), + get-flags(tuple, path>), + get-type(tuple, path>), + set-size(tuple, path, descriptor-set-size-args>), + set-times(tuple, path, descriptor-set-times-args>), + read-directory(tuple, path>), + read-directory-entry(tuple, path, directory-entry>), + sync(tuple, path>), + create-directory-at(tuple, path, descriptor-create-directory-at-args>), + stat(tuple, path>), + stat-at(tuple, path, descriptor-stat-at-args>), + set-times-at(tuple, path, descriptor-set-times-at-args>), + link-at(tuple, path, descriptor-link-at-args>), + open-at(tuple, path, descriptor-open-at-args>), + readlink-at(tuple, path, descriptor-readlink-at-args>), + remove-directory-at(tuple, path, descriptor-remove-directory-at-args>), + rename-at(tuple, path, descriptor-rename-at-args>), + symlink-at(tuple, path, descriptor-symlink-at-args>), + unlink-file-at(tuple, path, descriptor-unlink-file-at-args>), + metadata-hash(tuple, path>), + metadata-hash-at(tuple, path, descriptor-metadata-hash-at-args>), } /// Operations for the wasi:sockets package. From 0c62bf95183bceb080a8d0902b8ce4e72e90294f Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Sun, 18 Oct 2026 10:46:02 -0400 Subject: [PATCH 14/21] read only patterns Signed-off-by: Scott Andrews --- components/latch-path-glob/README.md | 13 ++ components/latch-path-glob/src/lib.rs | 268 +++++++++++++++++++++++--- 2 files changed, 249 insertions(+), 32 deletions(-) diff --git a/components/latch-path-glob/README.md b/components/latch-path-glob/README.md index 26bf9c6..f2553f3 100644 --- a/components/latch-path-glob/README.md +++ b/components/latch-path-glob/README.md @@ -51,6 +51,19 @@ defer=/data/** /etc/passwd -> DENIED (access) ``` +Keys starting with `readonly` are parsed as globs for paths that may be read but not changed. They are only evaluated when the operation would otherwise be deferred, by a matching `defer` pattern or a `defer` default, and when the operation would change the filesystem. A matching operation is denied with `read-only`, regardless of the `reason` key. Operations that change the filesystem are writing, appending, syncing, resizing, setting times, creating directories, linking, renaming, removing, unlinking, and opening with flags that allow changes (`create`, `exclusive` or `truncate` open flags, or `write`, `mutate-directory` or sync descriptor flags). Operations on a descriptor without a path argument, like writing to a file, are evaluated against the descriptor's path and are only checked against `readonly` patterns, the `deny` and `defer` patterns were already evaluated when the descriptor was opened. + +``` +reason=access +deny=/data/secret/** +readonly=/data/** + +open /data/file.txt for reading -> DEFERRED +open /data/file.txt for writing -> DENIED (read-only) +open /data/secret/file.txt for reading -> DENIED (access) +open /data/secret/file.txt for writing -> DENIED (access) +``` + If the config is invalid (a pattern that does not parse as a glob, or an unknown `default` or `case-sensitive` value), the cause is logged when the config is loaded and every operation fails with an `invalid-config` latch error. ## Interfaces diff --git a/components/latch-path-glob/src/lib.rs b/components/latch-path-glob/src/lib.rs index 98e22e1..c3b612d 100644 --- a/components/latch-path-glob/src/lib.rs +++ b/components/latch-path-glob/src/lib.rs @@ -1,5 +1,5 @@ use std::cell::RefCell; -use std::path::Path; +use std::path::{Path, PathBuf}; use glob::{MatchOptions, Pattern}; @@ -8,6 +8,7 @@ use crate::{ exports::componentized::filesystem::latch::{ Decision, ErrorCode, FilesystemErrorCode, Guest as Latch, Operation, PreopensOperation, }, + wasi::filesystem::types::{DescriptorFlags, OpenFlags}, wasi::logging::logging::{log, Level}, }; @@ -97,9 +98,32 @@ fn compare_specificity(a: &[(u8, usize)], b: &[(u8, usize)]) -> std::cmp::Orderi .unwrap_or(std::cmp::Ordering::Equal) } +/// Whether an operation only reads the filesystem, or may change it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Access { + Read, + Mutate, +} + +/// Whether opening a file with the flags would let the descriptor change the filesystem. +fn open_access(open_flags: OpenFlags, flags: DescriptorFlags) -> Access { + let mutating_open = OpenFlags::CREATE | OpenFlags::EXCLUSIVE | OpenFlags::TRUNCATE; + let mutating_flags = DescriptorFlags::WRITE + | DescriptorFlags::FILE_INTEGRITY_SYNC + | DescriptorFlags::DATA_INTEGRITY_SYNC + | DescriptorFlags::REQUESTED_WRITE_SYNC + | DescriptorFlags::MUTATE_DIRECTORY; + match open_flags.intersects(mutating_open) || flags.intersects(mutating_flags) { + true => Access::Mutate, + false => Access::Read, + } +} + struct Patterns { /// most specific first rules: Vec, + /// paths that may be read but not changed, when the rules defer + readonly: Vec, reason: FilesystemErrorCode, default: Action, match_options: MatchOptions, @@ -128,6 +152,7 @@ impl Patterns { fn parse(config: Vec<(String, String)>) -> Result { let mut rules: Vec = vec![]; + let mut readonly: Vec = vec![]; let mut reason = FilesystemErrorCode::NotPermitted; let mut default = Action::Defer; let mut case_sensitive = false; @@ -138,6 +163,8 @@ impl Patterns { rules.push(Rule::new(&value, Action::Deny).map_err(invalid)?); } else if key.starts_with("defer") { rules.push(Rule::new(&value, Action::Defer).map_err(invalid)?); + } else if key.starts_with("readonly") { + readonly.push(Pattern::new(&value).map_err(|err| invalid(err.to_string()))?); } else if key == "reason" { reason = get_error_code(value).unwrap_or(FilesystemErrorCode::NotPermitted); } else if key == "default" { @@ -161,16 +188,30 @@ impl Patterns { Ok(Patterns { rules, + readonly, reason, default, match_options: match_options(case_sensitive), }) } - fn authorize(path: String, paths: Vec) -> Result { + /// Decide an operation on the base path, or on the paths relative to it. + fn authorize(path: String, paths: Vec, access: Access) -> Result { + Patterns::with(|patterns| patterns.decide(&path, &paths, access)) + } + + /// Decide an operation that changes the descriptor at the path, without a path argument. + /// + /// The deny and defer patterns are not evaluated, only the readonly patterns: the descriptor was + /// already deferred when it was opened or preopened, it can't be obtained otherwise. + fn authorize_mutation(path: String) -> Result { + Patterns::with(|patterns| patterns.readonly_decision(&[PathBuf::from(path)])) + } + + fn with(decide: impl FnOnce(&Patterns) -> Decision) -> Result { // config is loaded once, an invalid config fails every authorization STATE.with_borrow_mut(|state| match state.get_or_insert_with(Patterns::load) { - Ok(patterns) => Ok(patterns.decide(&path, &paths)), + Ok(patterns) => Ok(decide(patterns)), Err(err) => Err(err.clone()), }) } @@ -178,17 +219,33 @@ impl Patterns { /// Deny when the base path, or any of the paths relative to it, is denied. /// /// Each path is decided by the most specific matching pattern, or the default when no pattern - /// matches. - fn decide(&self, path: &str, paths: &[String]) -> Decision { + /// matches. An operation that is otherwise deferred and changes the filesystem is denied with + /// `read-only` when any of its paths matches a readonly pattern. + fn decide(&self, path: &str, paths: &[String], access: Access) -> Decision { let base = Path::new(path); - let denied = match paths { - [] => self.action(base) == Action::Deny, - paths => paths - .iter() - .any(|p| self.action(&base.join(p)) == Action::Deny), + let paths: Vec = match paths { + [] => vec![base.to_path_buf()], + paths => paths.iter().map(|p| base.join(p)).collect(), }; - match denied { - true => Decision::Denied(self.reason.clone()), + if paths.iter().any(|p| self.action(p) == Action::Deny) { + return Decision::Denied(self.reason.clone()); + } + match access { + Access::Read => Decision::Deferred, + Access::Mutate => self.readonly_decision(&paths), + } + } + + /// Deny with `read-only` when any of the paths matches a readonly pattern, regardless of the + /// configured reason. + fn readonly_decision(&self, paths: &[PathBuf]) -> Decision { + let readonly = paths.iter().any(|path| { + self.readonly + .iter() + .any(|pattern| pattern.matches_path_with(path, self.match_options)) + }); + match readonly { + true => Decision::Denied(FilesystemErrorCode::ReadOnly), false => Decision::Deferred, } } @@ -214,62 +271,69 @@ thread_local! { impl Latch for PathGlobLatch { fn authorize(operation: Operation) -> Result { + use Access::{Mutate, Read}; + match operation { Operation::Preopens(preopens_operation) => match preopens_operation { PreopensOperation::GetDirectories => Ok(Decision::Deferred), PreopensOperation::GetDirectoriesItem((_, path)) => { - Patterns::authorize(path, vec![]) + Patterns::authorize(path, vec![], Read) } }, Operation::Descriptor(descriptor_operation) => match descriptor_operation { DescriptorOperation::ReadViaStream(_) => Ok(Deferred), - DescriptorOperation::WriteViaStream(_) => Ok(Deferred), - DescriptorOperation::AppendViaStream(_) => Ok(Deferred), + DescriptorOperation::WriteViaStream((_, path, _)) => { + Patterns::authorize_mutation(path) + } + DescriptorOperation::AppendViaStream((_, path)) => { + Patterns::authorize_mutation(path) + } DescriptorOperation::Advise(_) => Ok(Deferred), - DescriptorOperation::SyncData(_) => Ok(Deferred), + DescriptorOperation::SyncData((_, path)) => Patterns::authorize_mutation(path), DescriptorOperation::GetFlags(_) => Ok(Deferred), DescriptorOperation::GetType(_) => Ok(Deferred), - DescriptorOperation::SetSize(_) => Ok(Deferred), - DescriptorOperation::SetTimes(_) => Ok(Deferred), + DescriptorOperation::SetSize((_, path, _)) => Patterns::authorize_mutation(path), + DescriptorOperation::SetTimes((_, path, _)) => Patterns::authorize_mutation(path), DescriptorOperation::ReadDirectory(_) => Ok(Deferred), DescriptorOperation::ReadDirectoryEntry((_, path, entry)) => { - Patterns::authorize(path, vec![entry.name]) + Patterns::authorize(path, vec![entry.name], Read) } - DescriptorOperation::Sync(_) => Ok(Deferred), + DescriptorOperation::Sync((_, path)) => Patterns::authorize_mutation(path), DescriptorOperation::CreateDirectoryAt((_, path, args)) => { - Patterns::authorize(path, vec![args.path]) + Patterns::authorize(path, vec![args.path], Mutate) } DescriptorOperation::Stat(_) => Ok(Deferred), DescriptorOperation::StatAt((_, path, args)) => { - Patterns::authorize(path, vec![args.path]) + Patterns::authorize(path, vec![args.path], Read) } DescriptorOperation::SetTimesAt((_, path, args)) => { - Patterns::authorize(path, vec![args.path]) + Patterns::authorize(path, vec![args.path], Mutate) } DescriptorOperation::LinkAt((_, path, args)) => { - Patterns::authorize(path, vec![args.old_path, args.new_path]) + Patterns::authorize(path, vec![args.old_path, args.new_path], Mutate) } DescriptorOperation::OpenAt((_, path, args)) => { - Patterns::authorize(path, vec![args.path]) + let access = open_access(args.open_flags, args.flags); + Patterns::authorize(path, vec![args.path], access) } DescriptorOperation::ReadlinkAt((_, path, args)) => { - Patterns::authorize(path, vec![args.path]) + Patterns::authorize(path, vec![args.path], Read) } DescriptorOperation::RemoveDirectoryAt((_, path, args)) => { - Patterns::authorize(path, vec![args.path]) + Patterns::authorize(path, vec![args.path], Mutate) } DescriptorOperation::RenameAt((_, path, args)) => { - Patterns::authorize(path, vec![args.old_path, args.new_path]) + Patterns::authorize(path, vec![args.old_path, args.new_path], Mutate) } DescriptorOperation::SymlinkAt((_, path, args)) => { - Patterns::authorize(path, vec![args.old_path, args.new_path]) + Patterns::authorize(path, vec![args.old_path, args.new_path], Mutate) } DescriptorOperation::UnlinkFileAt((_, path, args)) => { - Patterns::authorize(path, vec![args.path]) + Patterns::authorize(path, vec![args.path], Mutate) } DescriptorOperation::MetadataHash(_) => Ok(Deferred), DescriptorOperation::MetadataHashAt((_, path, args)) => { - Patterns::authorize(path, vec![args.path]) + Patterns::authorize(path, vec![args.path], Read) } }, } @@ -333,8 +397,16 @@ mod tests { } fn decision(patterns: &Patterns, path: &str, paths: &[&str]) -> String { + decision_for(patterns, path, paths, Access::Read) + } + + fn mutation(patterns: &Patterns, path: &str, paths: &[&str]) -> String { + decision_for(patterns, path, paths, Access::Mutate) + } + + fn decision_for(patterns: &Patterns, path: &str, paths: &[&str], access: Access) -> String { let paths: Vec = paths.iter().map(|p| p.to_string()).collect(); - format!("{:?}", patterns.decide(path, &paths)) + format!("{:?}", patterns.decide(path, &paths, access)) } fn denied(reason: FilesystemErrorCode) -> String { @@ -629,6 +701,138 @@ mod tests { assert!(get_error_code(String::new()).is_none()); } + #[test] + fn readonly_paths_deny_mutations_with_read_only() { + let patterns = patterns(&[("readonly", "/data/**")]); + let read_only = denied(FilesystemErrorCode::ReadOnly); + assert_eq!(mutation(&patterns, "/data", &["a.txt"]), read_only); + assert_eq!(decision(&patterns, "/data", &["a.txt"]), deferred()); + assert_eq!(mutation(&patterns, "/data/a.txt", &[]), read_only); + assert_eq!(mutation(&patterns, "/other", &["a.txt"]), deferred()); + } + + #[test] + fn readonly_paths_allow_reads() { + let patterns = patterns(&[("readonly", "/data/**")]); + assert_eq!(decision(&patterns, "/data", &["a.txt"]), deferred()); + } + + #[test] + fn readonly_ignores_the_configured_reason() { + let patterns = patterns(&[("reason", "access"), ("readonly", "/data/**")]); + assert_eq!( + mutation(&patterns, "/data", &["a.txt"]), + denied(FilesystemErrorCode::ReadOnly) + ); + } + + #[test] + fn readonly_is_only_evaluated_when_otherwise_deferred() { + let denying = patterns(&[ + ("reason", "access"), + ("deny", "/data/secret/*"), + ("readonly", "/data/**"), + ]); + // the deny pattern decides first, with the configured reason + assert_eq!( + mutation(&denying, "/data/secret", &["a.txt"]), + denied(FilesystemErrorCode::Access) + ); + assert_eq!( + mutation(&denying, "/data", &["a.txt"]), + denied(FilesystemErrorCode::ReadOnly) + ); + assert_eq!(decision(&denying, "/data", &["a.txt"]), deferred()); + + let default_deny = patterns(&[("default", "deny"), ("readonly", "/data/**")]); + assert_eq!( + mutation(&default_deny, "/data", &["a.txt"]), + denied(FilesystemErrorCode::NotPermitted) + ); + } + + #[test] + fn deferring_pattern_does_not_override_readonly() { + let patterns = patterns(&[ + ("default", "deny"), + ("defer", "/data/**"), + ("readonly", "/data/archive/**"), + ]); + assert_eq!(mutation(&patterns, "/data", &["a.txt"]), deferred()); + assert_eq!( + mutation(&patterns, "/data/archive", &["a.txt"]), + denied(FilesystemErrorCode::ReadOnly) + ); + assert_eq!(decision(&patterns, "/data/archive", &["a.txt"]), deferred()); + assert_eq!( + mutation(&patterns, "/", &["a.txt"]), + denied(FilesystemErrorCode::NotPermitted) + ); + } + + #[test] + fn any_readonly_path_denies_a_mutation() { + let patterns = patterns(&[("readonly", "/data/archive/**")]); + // moving a file out of a readonly directory changes it + assert_eq!( + mutation(&patterns, "/data", &["archive/a.txt", "a.txt"]), + denied(FilesystemErrorCode::ReadOnly) + ); + } + + #[test] + fn readonly_matching_follows_case_sensitivity() { + let insensitive = patterns(&[("readonly", "/Data/**")]); + assert_eq!( + mutation(&insensitive, "/data", &["a.txt"]), + denied(FilesystemErrorCode::ReadOnly) + ); + let sensitive = patterns(&[("case-sensitive", "true"), ("readonly", "/Data/**")]); + assert_eq!(mutation(&sensitive, "/data", &["a.txt"]), deferred()); + } + + #[test] + fn readonly_descriptor_mutations() { + let patterns = patterns(&[("readonly", "/data/**")]); + let decide = + |path: &str| format!("{:?}", patterns.readonly_decision(&[PathBuf::from(path)])); + assert_eq!(decide("/data/a.txt"), denied(FilesystemErrorCode::ReadOnly)); + assert_eq!(decide("/other/a.txt"), deferred()); + } + + #[test] + fn opening_for_mutation() { + let read = DescriptorFlags::READ; + assert_eq!(open_access(OpenFlags::empty(), read), Access::Read); + assert_eq!(open_access(OpenFlags::DIRECTORY, read), Access::Read); + for open_flags in [OpenFlags::CREATE, OpenFlags::EXCLUSIVE, OpenFlags::TRUNCATE] { + assert_eq!( + open_access(open_flags, read), + Access::Mutate, + "{open_flags:?}" + ); + } + for flags in [ + DescriptorFlags::WRITE, + DescriptorFlags::FILE_INTEGRITY_SYNC, + DescriptorFlags::DATA_INTEGRITY_SYNC, + DescriptorFlags::REQUESTED_WRITE_SYNC, + DescriptorFlags::MUTATE_DIRECTORY, + ] { + assert_eq!( + open_access(OpenFlags::empty(), read | flags), + Access::Mutate, + "{flags:?}" + ); + } + } + + #[test] + fn invalid_readonly_glob_is_invalid() { + let err = parse(&[("readonly", "[")]).err().unwrap(); + assert!(err.starts_with("KEY=readonly VALUE=[ ERROR="), "{err}"); + } + #[test] fn invalid_glob_is_invalid() { let err = parse(&[("deny-1", "/a"), ("deny-2", "[")]).err().unwrap(); From fec0c572c3bee85c66b20d80fc44029fc6fe009d Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Sun, 18 Oct 2026 10:54:05 -0400 Subject: [PATCH 15/21] redefine latch-readonly as a configured latch-path-glob Signed-off-by: Scott Andrews --- Cargo.lock | 7 +- components/latch-path-glob/README.md | 1 + components/latch-path-glob/src/lib.rs | 12 + components/latch-readonly-config/README.md | 15 ++ .../latch-readonly-config.properties | 1 + components/latch-readonly/Cargo.toml | 15 +- components/latch-readonly/README.md | 14 ++ components/latch-readonly/latch-readonly.wac | 6 + components/latch-readonly/src/lib.rs | 105 --------- components/latch-readonly/tests/latch.rs | 212 ++++++++++++++++++ tools/Cargo.toml | 2 + 11 files changed, 277 insertions(+), 113 deletions(-) create mode 100644 components/latch-readonly-config/README.md create mode 100644 components/latch-readonly-config/latch-readonly-config.properties create mode 100644 components/latch-readonly/latch-readonly.wac delete mode 100644 components/latch-readonly/src/lib.rs create mode 100644 components/latch-readonly/tests/latch.rs diff --git a/Cargo.lock b/Cargo.lock index 40061f6..6faecfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1046,10 +1046,13 @@ dependencies = [ ] [[package]] -name = "latch-readonly" +name = "latch-readonly-tests" version = "0.1.0" dependencies = [ - "wit-bindgen", + "tempfile", + "test-harness", + "tokio", + "wasmtime", ] [[package]] diff --git a/components/latch-path-glob/README.md b/components/latch-path-glob/README.md index f2553f3..52c5322 100644 --- a/components/latch-path-glob/README.md +++ b/components/latch-path-glob/README.md @@ -73,6 +73,7 @@ Imports: - `wasi:logging/logging@0.1.0-draft`: logs an invalid config - `wasi:config/store@0.2.0-rc.1`: the glob patterns - `wasi:filesystem/types@0.3.0`: the descriptor types of the operations being authorized +- `wasi:clocks/system-clock@0.3.0`: the timestamp types of the operations being authorized Exports: diff --git a/components/latch-path-glob/src/lib.rs b/components/latch-path-glob/src/lib.rs index c3b612d..259e946 100644 --- a/components/latch-path-glob/src/lib.rs +++ b/components/latch-path-glob/src/lib.rs @@ -833,6 +833,18 @@ mod tests { assert!(err.starts_with("KEY=readonly VALUE=[ ERROR="), "{err}"); } + #[test] + fn double_star_matches_every_path() { + let patterns = patterns(&[("readonly", "**")]); + for path in ["/", "/a.txt", "/a/b/c.txt", ".", "./a.txt", "data", "data/a/b.txt", ".hidden/.x"] { + assert_eq!( + format!("{:?}", patterns.readonly_decision(&[PathBuf::from(path)])), + denied(FilesystemErrorCode::ReadOnly), + "{path}" + ); + } + } + #[test] fn invalid_glob_is_invalid() { let err = parse(&[("deny-1", "/a"), ("deny-2", "[")]).err().unwrap(); diff --git a/components/latch-readonly-config/README.md b/components/latch-readonly-config/README.md new file mode 100644 index 0000000..dbda123 --- /dev/null +++ b/components/latch-readonly-config/README.md @@ -0,0 +1,15 @@ +# `latch-readonly-config` + +Config for `latch-path-glob` that makes every path readonly. + +Every operation that would change the filesystem is denied with `read-only`, every other operation is deferred. + +```properties +readonly=** +``` + +## Interfaces + +Exports: + +- `wasi:config/store@0.2.0-rc.1`: the config diff --git a/components/latch-readonly-config/latch-readonly-config.properties b/components/latch-readonly-config/latch-readonly-config.properties new file mode 100644 index 0000000..b639162 --- /dev/null +++ b/components/latch-readonly-config/latch-readonly-config.properties @@ -0,0 +1 @@ +readonly=** diff --git a/components/latch-readonly/Cargo.toml b/components/latch-readonly/Cargo.toml index 8e3f98b..d8f2ea2 100644 --- a/components/latch-readonly/Cargo.toml +++ b/components/latch-readonly/Cargo.toml @@ -1,11 +1,14 @@ [package] -name = "latch-readonly" +name = "latch-readonly-tests" version = "0.1.0" -edition = "2021" +edition = "2024" license = "Apache-2.0" +publish = false -[lib] -crate-type = ["cdylib"] +# only tests, the component is built from latch-readonly.wac -[dependencies] -wit-bindgen = { workspace = true } +[dev-dependencies] +tempfile = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/latch-readonly/README.md b/components/latch-readonly/README.md index 79e5e96..087a717 100644 --- a/components/latch-readonly/README.md +++ b/components/latch-readonly/README.md @@ -1,3 +1,17 @@ # `latch-readonly` Filesystem latch that denies operations which would mutate the filesystem. + +`latch-path-glob` composed with [`latch-readonly-config`](../latch-readonly-config/). Operations that would change the filesystem are denied with `read-only`, every other operation is deferred. See [`latch-path-glob`](../latch-path-glob/) for the operations that change the filesystem. + +## Interfaces + +Imports: + +- `wasi:logging/logging@0.1.0-draft`: logs an invalid config +- `wasi:filesystem/types@0.3.0`: the descriptor types of the operations being authorized +- `wasi:clocks/system-clock@0.3.0`: the timestamp types of the operations being authorized + +Exports: + +- `componentized:filesystem/latch@0.3.0-dev`: the latch diff --git a/components/latch-readonly/latch-readonly.wac b/components/latch-readonly/latch-readonly.wac new file mode 100644 index 0000000..7c2baba --- /dev/null +++ b/components/latch-readonly/latch-readonly.wac @@ -0,0 +1,6 @@ +package componentized:filesystem; + +export new local:latch-path-glob { + store: new local:latch-readonly-config{}.store, + ... +}...; diff --git a/components/latch-readonly/src/lib.rs b/components/latch-readonly/src/lib.rs deleted file mode 100644 index 1a30d39..0000000 --- a/components/latch-readonly/src/lib.rs +++ /dev/null @@ -1,105 +0,0 @@ -use crate::{ - exports::componentized::filesystem::latch::{ - Decision, DescriptorOpenAtArgs, DescriptorOperation, ErrorCode, FilesystemErrorCode, - Guest as Latch, Operation, - }, - wasi::filesystem::types::{DescriptorFlags, OpenFlags}, -}; - -struct ReadOnlyLatch {} - -impl Latch for ReadOnlyLatch { - #[allow(async_fn_in_trait)] - fn authorize(operation: Operation) -> Result { - match operation { - Operation::Preopens(_preopens_operation) => Ok(Decision::Deferred), - Operation::Descriptor(descriptor_operation) => match descriptor_operation { - DescriptorOperation::ReadViaStream(_) => Ok(Decision::Deferred), - DescriptorOperation::WriteViaStream(_) => { - Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) - } - DescriptorOperation::AppendViaStream(_) => { - Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) - } - DescriptorOperation::Advise(_) => Ok(Decision::Deferred), - DescriptorOperation::SyncData(_) => { - Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) - } - DescriptorOperation::GetFlags(_) => Ok(Decision::Deferred), - DescriptorOperation::GetType(_) => Ok(Decision::Deferred), - DescriptorOperation::SetSize(_) => { - Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) - } - DescriptorOperation::SetTimes(_) => { - Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) - } - DescriptorOperation::ReadDirectory(_) => Ok(Decision::Deferred), - DescriptorOperation::ReadDirectoryEntry(_) => Ok(Decision::Deferred), - DescriptorOperation::Sync(_) => Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)), - DescriptorOperation::CreateDirectoryAt(_) => { - Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) - } - DescriptorOperation::Stat(_) => Ok(Decision::Deferred), - DescriptorOperation::StatAt(_) => Ok(Decision::Deferred), - DescriptorOperation::SetTimesAt(_) => { - Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) - } - DescriptorOperation::LinkAt(_) => { - Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) - } - DescriptorOperation::OpenAt(( - _, - _, - DescriptorOpenAtArgs { - open_flags, flags, .. - }, - )) => { - if open_flags.contains(OpenFlags::CREATE) - || open_flags.contains(OpenFlags::EXCLUSIVE) - || open_flags.contains(OpenFlags::TRUNCATE) - || flags.contains(DescriptorFlags::WRITE) - || flags.contains(DescriptorFlags::FILE_INTEGRITY_SYNC) - || flags.contains(DescriptorFlags::DATA_INTEGRITY_SYNC) - || flags.contains(DescriptorFlags::REQUESTED_WRITE_SYNC) - || flags.contains(DescriptorFlags::MUTATE_DIRECTORY) - { - Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) - } else { - Ok(Decision::Deferred) - } - } - DescriptorOperation::ReadlinkAt(_) => Ok(Decision::Deferred), - DescriptorOperation::RemoveDirectoryAt(_) => { - Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) - } - DescriptorOperation::RenameAt(_) => { - Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) - } - DescriptorOperation::SymlinkAt(_) => { - Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) - } - DescriptorOperation::UnlinkFileAt(_) => { - Ok(Decision::Denied(FilesystemErrorCode::ReadOnly)) - } - DescriptorOperation::MetadataHash(_) => Ok(Decision::Deferred), - DescriptorOperation::MetadataHashAt(_) => Ok(Decision::Deferred), - }, - } - } - - #[allow(async_fn_in_trait)] - fn observe_decision( - _final_decision: Decision, - _operation: Operation<'_>, - ) -> Result<(), ErrorCode> { - Ok(()) - } -} - -wit_bindgen::generate!({ - path: "../wit", - world: "latch", - generate_all -}); - -export!(ReadOnlyLatch); diff --git a/components/latch-readonly/tests/latch.rs b/components/latch-readonly/tests/latch.rs new file mode 100644 index 0000000..361f004 --- /dev/null +++ b/components/latch-readonly/tests/latch.rs @@ -0,0 +1,212 @@ +//! Tests for `latch-readonly`, composed from `latch-readonly.wac`, as the gate's latch. +//! +//! Each test preopens a fresh copy of this tree read-write as `/`, so any denial comes from the +//! latch rather than the host: +//! +//! ```text +//! file.txt "hello" +//! link -> file.txt +//! empty/ +//! dir/nested.txt "nested" +//! ``` + +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::symlink; + +use tempfile::TempDir; +use test_harness::bindings::exports::wasi::filesystem::types::{GuestDescriptor, NewTimestamp}; +use test_harness::{ + Ctx, DescriptorFlags, DescriptorType, ErrorCode, Exports, Harness, OpenFlags, PathFlags, Perms, +}; +use wasmtime::component::{Accessor, ResourceAny}; + +fn fixture() -> TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + fs::write(root.join("file.txt"), "hello").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(); + dir +} + +fn descriptor(exports: &Exports) -> GuestDescriptor<'_> { + exports.wasi_filesystem_types().descriptor() +} + +async fn root(accessor: &Accessor, exports: &Exports) -> wasmtime::Result { + let dirs = exports + .wasi_filesystem_preopens() + .call_get_directories(accessor) + .await?; + Ok(dirs.into_iter().next().expect("a preopen").0) +} + +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 +} + +fn ok(result: Result) -> Result<(), E> { + result.map(|_| ()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn reads_are_deferred() -> wasmtime::Result<()> { + let dir = fixture(); + let mut gate = Harness::new("gate") + .preopen(dir.path(), "/", Perms::ReadWrite) + .latch("latch-readonly") + .build() + .await?; + let (data, stat, target, nested) = gate + .run(async |accessor, exports| { + let d = descriptor(exports); + let root = root(accessor, exports).await?; + let read = DescriptorFlags::READ; + let file = open(accessor, exports, root, "file.txt", OpenFlags::empty(), read) + .await? + .expect("open file.txt"); + let (data, _) = d.call_read_via_stream(accessor, file, 0).await?; + let mut data = test_harness::collect(accessor, data)?; + let mut bytes = vec![]; + while let Some(byte) = data.recv().await { + bytes.push(byte); + } + let stat = d.call_stat(accessor, file).await?.expect("stat"); + let target = d.call_readlink_at(accessor, root, "link".into()).await?; + let nested = ok(open( + accessor, + exports, + root, + "dir/nested.txt", + OpenFlags::empty(), + read, + ) + .await?); + Ok((bytes, stat, target, nested)) + }) + .await?; + assert_eq!(data, b"hello"); + assert_eq!(stat.type_, DescriptorType::RegularFile); + assert_eq!(target, Ok("file.txt".into())); + assert_eq!(nested, Ok(())); + assert_eq!(gate.recorder().logs(), []); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn mutations_are_denied_with_read_only() -> wasmtime::Result<()> { + let dir = fixture(); + let mut gate = Harness::new("gate") + .preopen(dir.path(), "/", Perms::ReadWrite) + .latch("latch-readonly") + .build() + .await?; + let results = gate + .run(async |accessor, exports| { + let d = descriptor(exports); + let root = root(accessor, exports).await?; + let read = DescriptorFlags::READ; + let file = open(accessor, exports, root, "file.txt", OpenFlags::empty(), read) + .await? + .expect("open file.txt"); + let now = || NewTimestamp::Now; + let mut results = vec![]; + for (path, open_flags, flags) in [ + ("file.txt", OpenFlags::empty(), read | DescriptorFlags::WRITE), + ("file.txt", OpenFlags::TRUNCATE, read), + ("new.txt", OpenFlags::CREATE, read), + (".", OpenFlags::DIRECTORY, read | DescriptorFlags::MUTATE_DIRECTORY), + ] { + results.push(ok( + open(accessor, exports, root, path, open_flags, flags).await? + )); + } + let data = test_harness::stream(accessor, b"changed".to_vec())?; + let result = d.call_write_via_stream(accessor, file, data, 0).await?; + results.push(test_harness::resolve(accessor, result).await?); + let data = test_harness::stream(accessor, b"changed".to_vec())?; + let result = d.call_append_via_stream(accessor, file, data).await?; + results.push(test_harness::resolve(accessor, result).await?); + results.push(d.call_set_size(accessor, file, 0).await?); + results.push(d.call_set_times(accessor, root, now(), now()).await?); + results.push(d.call_sync(accessor, file).await?); + results.push(d.call_sync_data(accessor, file).await?); + results.push( + d.call_create_directory_at(accessor, root, "new".into()) + .await?, + ); + results.push( + d.call_set_times_at( + accessor, + root, + PathFlags::empty(), + "file.txt".into(), + now(), + now(), + ) + .await?, + ); + results.push( + d.call_link_at( + accessor, + root, + PathFlags::empty(), + "file.txt".into(), + root, + "hard.txt".into(), + ) + .await?, + ); + results.push( + d.call_rename_at(accessor, root, "file.txt".into(), root, "moved.txt".into()) + .await?, + ); + results.push( + d.call_symlink_at(accessor, root, "file.txt".into(), "new-link".into()) + .await?, + ); + results.push( + d.call_remove_directory_at(accessor, root, "empty".into()) + .await?, + ); + results.push( + d.call_unlink_file_at(accessor, root, "link".into()) + .await?, + ); + Ok(results) + }) + .await?; + for (index, result) in results.into_iter().enumerate() { + assert_eq!(result, Err(ErrorCode::ReadOnly), "operation {index}"); + } + + // nothing was changed + let root = dir.path(); + assert_eq!(fs::read(root.join("file.txt")).unwrap(), b"hello"); + assert!(root.join("empty").is_dir()); + assert!(root.join("link").is_symlink()); + for path in ["new.txt", "new", "hard.txt", "moved.txt", "new-link"] { + assert!(!root.join(path).exists(), "{path}"); + } + Ok(()) +} diff --git a/tools/Cargo.toml b/tools/Cargo.toml index 6e46084..7b30b30 100644 --- a/tools/Cargo.toml +++ b/tools/Cargo.toml @@ -15,11 +15,13 @@ path = "lib.rs" # the binaries each tool installs, the Makefile runs each by path from a variable named after it, # e.g. `$(WASM_TOOLS)` for `wasm-tools` [package.metadata.bins] +componentized-static-config-cli = ["static-config"] wac-cli = ["wac"] wasm-tools = ["wasm-tools"] wkg = ["wkg"] [dependencies] +componentized-static-config-cli = "=0.3.0" wac-cli = "=0.12.0" wasm-tools = "=1.261.0" wkg = "=0.16.1" From 4ad77af88bca8f68647057ebfe6dd00858002436 Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Sun, 18 Oct 2026 10:58:32 -0400 Subject: [PATCH 16/21] drop readonly component Signed-off-by: Scott Andrews --- Cargo.lock | 12 - Cargo.toml | 1 - README.md | 1 - components/readonly/Cargo.toml | 18 - components/readonly/README.md | 7 - components/readonly/build.rs | 7 - components/readonly/src/lib.rs | 243 -------- components/readonly/tests/readonly.rs | 821 -------------------------- 8 files changed, 1110 deletions(-) delete mode 100644 components/readonly/Cargo.toml delete mode 100644 components/readonly/README.md delete mode 100644 components/readonly/build.rs delete mode 100644 components/readonly/src/lib.rs delete mode 100644 components/readonly/tests/readonly.rs diff --git a/Cargo.lock b/Cargo.lock index 6faecfd..558aa02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1332,18 +1332,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "readonly" -version = "0.1.0" -dependencies = [ - "componentized-rt", - "tempfile", - "test-harness", - "tokio", - "wasmtime", - "wit-bindgen", -] - [[package]] name = "redox_users" version = "0.4.6" diff --git a/Cargo.toml b/Cargo.toml index 13f07a7..4e464ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,6 @@ members = [ "components/latch-n5", "components/latch-path-glob", "components/latch-readonly", - "components/readonly", "components/trace", "crates/filesystem-latch-n", "crates/test-harness", diff --git a/README.md b/README.md index fd280f6..e879bd8 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,6 @@ A collection of utility components that remix wasi:filesystem types and interfac - [`latch-n4`](./components/latch-n4/) - [`latch-n5`](./components/latch-n5/) - [`latch-readonly`](./components/latch-readonly/) -- ~~[`readonly`](./components/readonly/)~~ (deprecated, favor gate with readonly latch) - [`trace`](./components/trace/) ## Build diff --git a/components/readonly/Cargo.toml b/components/readonly/Cargo.toml deleted file mode 100644 index 3e5511f..0000000 --- a/components/readonly/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "readonly" -version = "0.1.0" -edition = "2021" -license = "Apache-2.0" - -[lib] -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 deleted file mode 100644 index fd6a97e..0000000 --- a/components/readonly/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# `readonly` - -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 deleted file mode 100644 index cd4e5a1..0000000 --- a/components/readonly/build.rs +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 3a12d07..0000000 --- a/components/readonly/src/lib.rs +++ /dev/null @@ -1,243 +0,0 @@ -use exports::wasi::filesystem::preopens::Guest as Preopens; -use exports::wasi::filesystem::types::{ - Advice, Descriptor, DescriptorBorrow, DescriptorFlags, DescriptorStat, DescriptorType, - DirectoryEntry, ErrorCode, Filesize, Guest as Types, MetadataHashValue, NewTimestamp, - OpenFlags, PathFlags, -}; -use wasi::filesystem::preopens; -use wasi::filesystem::types; - -struct FilesystemReadOnly {} - -impl Preopens for FilesystemReadOnly { - #[doc = " Return the set of preopened directories, and their path."] - fn get_directories() -> Vec<(Descriptor, String)> { - preopens::get_directories() - .into_iter() - .map(|(fd, path)| { - let fd = Descriptor::new(ReadOnlyDescriptor::new(fd)); - (fd, path) - }) - .collect() - } -} - -impl Types for FilesystemReadOnly { - type Descriptor = ReadOnlyDescriptor; -} - -struct ReadOnlyDescriptor { - fd: types::Descriptor, -} - -impl ReadOnlyDescriptor { - fn new(fd: types::Descriptor) -> Self { - Self { fd } - } -} - -impl exports::wasi::filesystem::types::GuestDescriptor for ReadOnlyDescriptor { - fn read_via_stream( - &self, - offset: Filesize, - ) -> ( - wit_bindgen::StreamReader, - wit_bindgen::FutureReader>, - ) { - self.fd.read_via_stream(offset) - } - - fn write_via_stream( - &self, - _data: wit_bindgen::StreamReader, - _offset: Filesize, - ) -> wit_bindgen::FutureReader> { - read_only() - } - - fn append_via_stream( - &self, - _data: wit_bindgen::StreamReader, - ) -> wit_bindgen::FutureReader> { - read_only() - } - - async fn advise( - &self, - offset: Filesize, - length: Filesize, - advice: Advice, - ) -> Result<(), ErrorCode> { - self.fd.advise(offset, length, advice).await - } - - async fn sync_data(&self) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - async fn get_flags(&self) -> Result { - self.fd.get_flags().await.map(|flags| { - flags - .difference(DescriptorFlags::WRITE) - .difference(DescriptorFlags::FILE_INTEGRITY_SYNC) - .difference(DescriptorFlags::DATA_INTEGRITY_SYNC) - .difference(DescriptorFlags::MUTATE_DIRECTORY) - }) - } - - async fn get_type(&self) -> Result { - self.fd.get_type().await - } - - async fn set_size(&self, _size: Filesize) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - async fn set_times( - &self, - _data_access_timestamp: NewTimestamp, - _data_modification_timestamp: NewTimestamp, - ) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - fn read_directory( - &self, - ) -> ( - wit_bindgen::StreamReader, - wit_bindgen::FutureReader>, - ) { - self.fd.read_directory() - } - - async fn sync(&self) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - async fn create_directory_at(&self, _path: String) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - async fn stat(&self) -> Result { - self.fd.stat().await - } - - async fn stat_at( - &self, - path_flags: PathFlags, - path: String, - ) -> Result { - self.fd.stat_at(path_flags, path).await - } - - async fn set_times_at( - &self, - _path_flags: PathFlags, - _path: String, - _data_access_timestamp: NewTimestamp, - _data_modification_timestamp: NewTimestamp, - ) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - async fn link_at( - &self, - _old_path_flags: PathFlags, - _old_path: String, - _new_descriptor: DescriptorBorrow<'_>, - _new_path: String, - ) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - async fn open_at( - &self, - path_flags: PathFlags, - path: String, - open_flags: OpenFlags, - flags: DescriptorFlags, - ) -> Result { - if open_flags.contains(OpenFlags::CREATE) - || open_flags.contains(OpenFlags::EXCLUSIVE) - || open_flags.contains(OpenFlags::TRUNCATE) - || flags.contains(DescriptorFlags::WRITE) - || 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); - } - - match self - .fd - .open_at(path_flags, path.clone(), open_flags, flags) - .await - { - Ok(fd) => Ok(Descriptor::new(ReadOnlyDescriptor::new(fd))), - Err(error_code) => Err(error_code), - } - } - - async fn readlink_at(&self, path: String) -> Result { - self.fd.readlink_at(path).await - } - - async fn remove_directory_at(&self, _path: String) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - async fn rename_at( - &self, - _old_path: String, - _new_descriptor: DescriptorBorrow<'_>, - _new_path: String, - ) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - async fn symlink_at(&self, _old_path: String, _new_path: String) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - async fn unlink_file_at(&self, _path: String) -> Result<(), ErrorCode> { - Err(ErrorCode::ReadOnly) - } - - async fn is_same_object(&self, other: DescriptorBorrow<'_>) -> bool { - let other: &Self = other.get(); - self.fd.is_same_object(&other.fd).await - } - - async fn metadata_hash(&self) -> Result { - self.fd.metadata_hash().await - } - - async fn metadata_hash_at( - &self, - path_flags: PathFlags, - path: String, - ) -> Result { - self.fd.metadata_hash_at(path_flags, path).await - } -} - -/// 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", - merge_structurally_equal_types: true, - generate_all -}); - -export!(FilesystemReadOnly); diff --git a/components/readonly/tests/readonly.rs b/components/readonly/tests/readonly.rs deleted file mode 100644 index da1938d..0000000 --- a/components/readonly/tests/readonly.rs +++ /dev/null @@ -1,821 +0,0 @@ -//! 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; -} From b015214176a92002b33ba96110740a052a1a3660 Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Sun, 18 Oct 2026 14:02:01 -0400 Subject: [PATCH 17/21] update latch doc Signed-off-by: Scott Andrews --- wit/latch.wit | 154 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 140 insertions(+), 14 deletions(-) diff --git a/wit/latch.wit b/wit/latch.wit index 0821141..5084643 100644 --- a/wit/latch.wit +++ b/wit/latch.wit @@ -1,139 +1,268 @@ - +/// Access control for `wasi:filesystem`. +/// +/// A gate consults a latch before performing each filesystem operation. The latch either defers, +/// raising no objection, or denies the operation. A latch never grants an operation, an +/// operation proceeds when the latch defers. Several latches can be aggregated into one, the +/// operation is denied when any of them denies it. +/// +/// Deciding and acting on a decision are separate steps. `authorize` decides without side +/// effects, then the gate reports the final decision to `observe-decision`, where a latch updates +/// any state it keeps. A latch aggregated with others may not be asked to authorize an operation +/// another latch already denied, but it always observes the final decision. +@since(version = 0.3.0-dev) interface latch { + @since(version = 0.3.0-dev) use wasi:filesystem/types@0.3.0.{advice, descriptor, descriptor-flags, directory-entry, error-code as filesystem-error-code, filesize, open-flags, new-timestamp, path-flags}; + /// The path of a descriptor, the directory or file an operation is performed on, and that the + /// relative path arguments of the operation are resolved against. + /// + /// A preopen is labeled with its name, e.g. `/` or `.`. A descriptor opened with `open-at` is + /// labeled with the path it was opened at, joined to the path of the descriptor it was opened + /// from: `.` and `..` are applied, and symlinks are followed where the host follows them. + /// Path arguments of an operation are passed as given by the caller, relative to the + /// descriptor's path. + @since(version = 0.3.0-dev) type path = string; + /// Arguments to wasi:filesystem/types#descriptor.read-via-stream. + @since(version = 0.3.0-dev) record descriptor-read-via-stream-args { + /// The offset in the file to start reading at. offset: filesize, } + /// Arguments to wasi:filesystem/types#descriptor.write-via-stream. + @since(version = 0.3.0-dev) record descriptor-write-via-stream-args { + /// The offset in the file to start writing at. offset: filesize, } + /// Arguments to wasi:filesystem/types#descriptor.advise. + @since(version = 0.3.0-dev) record descriptor-advise-args { + /// The offset of the range the advice applies to. offset: filesize, + /// The length of the range the advice applies to. length: filesize, + /// The advice for the range. advice: advice, } + /// Arguments to wasi:filesystem/types#descriptor.set-size. + @since(version = 0.3.0-dev) record descriptor-set-size-args { + /// The new size of the file. size: filesize, } + /// Arguments to wasi:filesystem/types#descriptor.set-times. + @since(version = 0.3.0-dev) record descriptor-set-times-args { + /// The new access time. data-access-timestamp: new-timestamp, + /// The new modification time. data-modification-timestamp: new-timestamp, } + /// Arguments to wasi:filesystem/types#descriptor.create-directory-at. + @since(version = 0.3.0-dev) record descriptor-create-directory-at-args { + /// The directory to create, relative to the descriptor's path. path: path, } + /// Arguments to wasi:filesystem/types#descriptor.stat-at. + @since(version = 0.3.0-dev) record descriptor-stat-at-args { + /// Whether a symlink at the last component of the path is followed. path-flags: path-flags, + /// The file or directory to stat, relative to the descriptor's path. path: path, } + /// Arguments to wasi:filesystem/types#descriptor.set-times-at. + @since(version = 0.3.0-dev) record descriptor-set-times-at-args { + /// Whether a symlink at the last component of the path is followed. path-flags: path-flags, + /// The file or directory to change, relative to the descriptor's path. path: path, + /// The new access time. data-access-timestamp: new-timestamp, + /// The new modification time. data-modification-timestamp: new-timestamp, } + /// Arguments to wasi:filesystem/types#descriptor.link-at. + @since(version = 0.3.0-dev) record descriptor-link-at-args { + /// Whether a symlink at the last component of the old path is followed. old-path-flags: path-flags, + /// The existing file to link to, relative to the descriptor's path. old-path: path, + /// The directory the new path is relative to, a latch may query it. new-descriptor: borrow, + /// The link to create, relative to the new descriptor. new-path: path, } + /// Arguments to wasi:filesystem/types#descriptor.open-at. + @since(version = 0.3.0-dev) record descriptor-open-at-args { + /// Whether a symlink at the last component of the path is followed. path-flags: path-flags, + /// The file or directory to open, relative to the descriptor's path. path: path, + /// How the file or directory is opened, e.g. whether it is created or truncated. open-flags: open-flags, + /// What the opened descriptor may be used for, e.g. reading, writing or changing a + /// directory. %flags: descriptor-flags, } + /// Arguments to wasi:filesystem/types#descriptor.readlink-at. + @since(version = 0.3.0-dev) record descriptor-readlink-at-args { + /// The symlink to read, relative to the descriptor's path. path: path, } + /// Arguments to wasi:filesystem/types#descriptor.remove-directory-at. + @since(version = 0.3.0-dev) record descriptor-remove-directory-at-args { + /// The directory to remove, relative to the descriptor's path. path: path, } + /// Arguments to wasi:filesystem/types#descriptor.rename-at. + @since(version = 0.3.0-dev) record descriptor-rename-at-args { + /// The file or directory to rename, relative to the descriptor's path. old-path: path, + /// The directory the new path is relative to, a latch may query it. new-descriptor: borrow, + /// The new path, relative to the new descriptor. new-path: path, } + /// Arguments to wasi:filesystem/types#descriptor.symlink-at. + @since(version = 0.3.0-dev) record descriptor-symlink-at-args { + /// The contents of the symlink, the path it points to. It is not resolved by the gate. old-path: path, + /// The symlink to create, relative to the descriptor's path. new-path: path, } + /// Arguments to wasi:filesystem/types#descriptor.unlink-file-at. + @since(version = 0.3.0-dev) record descriptor-unlink-file-at-args { + /// The file to unlink, relative to the descriptor's path. path: path, } - + /// Arguments to wasi:filesystem/types#descriptor.metadata-hash-at. + @since(version = 0.3.0-dev) record descriptor-metadata-hash-at-args { + /// Whether a symlink at the last component of the path is followed. path-flags: path-flags, + /// The file or directory to hash, relative to the descriptor's path. path: path, } + /// Operations of the wasi:filesystem/preopens interface. + @since(version = 0.3.0-dev) variant preopens-operation { + /// Authorized before the preopens are returned. A denial returns no preopens. get-directories, + /// Authorized for each preopen, with its name. A denied preopen is left out of the + /// preopens returned, the other preopens are still returned. get-directories-item(tuple, path>), } + /// Operations of the wasi:filesystem/types#descriptor resource. + /// + /// The borrowed descriptor is the descriptor the operation is performed on, followed by its + /// path, a latch may query the descriptor, for example for its type or flags. A denial fails + /// the operation with the denial's error code, unless noted otherwise. + @since(version = 0.3.0-dev) variant descriptor-operation { + /// Authorized before reading a file. A denial returns an empty stream, the future + /// resolves to the error code. read-via-stream(tuple, path, descriptor-read-via-stream-args>), + /// Authorized before writing a file. A denial reads nothing from the stream, the future + /// resolves to the error code. write-via-stream(tuple, path, descriptor-write-via-stream-args>), + /// Authorized before appending to a file. A denial reads nothing from the stream, the + /// future resolves to the error code. append-via-stream(tuple, path>), + /// Authorized before giving advice for a file. advise(tuple, path, descriptor-advise-args>), + /// Authorized before syncing the data of a file. sync-data(tuple, path>), + /// Authorized before reading the flags of a descriptor. get-flags(tuple, path>), + /// Authorized before reading the type of a descriptor. get-type(tuple, path>), + /// Authorized before changing the size of a file. set-size(tuple, path, descriptor-set-size-args>), + /// Authorized before changing the times of a file or directory. set-times(tuple, path, descriptor-set-times-args>), + /// Authorized before reading a directory. A denial returns an empty stream, the future + /// resolves to the error code. read-directory(tuple, path>), + /// Authorized for each entry of a directory being read. A denied entry is left out of the + /// stream, the other entries are still returned. read-directory-entry(tuple, path, directory-entry>), + /// Authorized before syncing the data and metadata of a file or directory. sync(tuple, path>), + /// Authorized before creating a directory. create-directory-at(tuple, path, descriptor-create-directory-at-args>), + /// Authorized before reading the attributes of a file or directory. stat(tuple, path>), + /// Authorized before reading the attributes of a file or directory at a path. stat-at(tuple, path, descriptor-stat-at-args>), + /// Authorized before changing the times of a file or directory at a path. set-times-at(tuple, path, descriptor-set-times-at-args>), + /// Authorized before creating a hard link. link-at(tuple, path, descriptor-link-at-args>), + /// Authorized before opening a file or directory. open-at(tuple, path, descriptor-open-at-args>), + /// Authorized before reading the contents of a symlink. readlink-at(tuple, path, descriptor-readlink-at-args>), + /// Authorized before removing a directory. remove-directory-at(tuple, path, descriptor-remove-directory-at-args>), + /// Authorized before renaming a file or directory. rename-at(tuple, path, descriptor-rename-at-args>), + /// Authorized before creating a symlink. symlink-at(tuple, path, descriptor-symlink-at-args>), + /// Authorized before unlinking a file. unlink-file-at(tuple, path, descriptor-unlink-file-at-args>), + /// Authorized before hashing the metadata of a file or directory. metadata-hash(tuple, path>), + /// Authorized before hashing the metadata of a file or directory at a path. metadata-hash-at(tuple, path, descriptor-metadata-hash-at-args>), } - /// Operations for the wasi:sockets package. + /// Operations of the wasi:filesystem package a gate asks a latch to authorize. @since(version = 0.3.0-dev) variant operation { + /// An operation of wasi:filesystem/preopens. preopens(preopens-operation), + /// An operation on a wasi:filesystem/types#descriptor. descriptor(descriptor-operation), } /// Whether the operation is allowed to proceed. @since(version = 0.3.0-dev) variant decision { - /// No objection to the operation. + /// The latch raises no objection, the operation proceeds unless another latch denies it. deferred, /// The operation must not proceed. The error code is returned to the caller for - /// operations that fail. For traffic that is dropped instead, like an accepted connection - /// or a received datagram, it is only logged. + /// operations that fail. For items that are left out instead, a preopen or a directory + /// entry, it is only logged. denied(filesystem-error-code), } @@ -141,9 +270,9 @@ interface latch { @since(version = 0.3.0-dev) type latch-name = string; - /// A latch was unable to decide or observe. The gate fails the operation, except for an - /// accepted connection, which is closed as if it were denied. - @since(version = 0.1.0) + /// A latch was unable to decide or observe. The gate fails the operation, a directory entry + /// fails the directory read. Getting the preopens can't fail, the gate traps instead. + @since(version = 0.3.0-dev) variant error-code { /// The latch's configuration is invalid, the cause is logged by the latch. invalid-config(latch-name), @@ -165,13 +294,10 @@ interface latch { /// /// Called for both deferred and denied operations, including operations this latch was not /// asked to authorize because an aggregated latch denied them first. The final decision is - /// what the gate enforces, so this is where a latch updates any state it keeps, for example - /// remembering peers the caller is allowed to exchange traffic with. Not called when - /// `authorize` returned an error. + /// what the gate enforces, so this is where a latch updates any state it keeps. Not called + /// when `authorize` returned an error. /// /// An error fails the operation, since the latch could not act on the decision. @since(version = 0.3.0-dev) observe-decision: func(final-decision: decision, operation: operation) -> result<_, error-code>; } - - From e7bbfeb31f4d4c092ace6f94bd5d4dfaa9df2799 Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Sun, 18 Oct 2026 14:47:05 -0400 Subject: [PATCH 18/21] latch-deny-symlink-at Signed-off-by: Scott Andrews --- Cargo.lock | 11 ++ Cargo.toml | 1 + README.md | 1 + components/latch-deny-symlink-at/Cargo.toml | 17 ++ components/latch-deny-symlink-at/README.md | 18 ++ components/latch-deny-symlink-at/src/lib.rs | 33 ++++ .../latch-deny-symlink-at/tests/latch.rs | 172 ++++++++++++++++++ 7 files changed, 253 insertions(+) create mode 100644 components/latch-deny-symlink-at/Cargo.toml create mode 100644 components/latch-deny-symlink-at/README.md create mode 100644 components/latch-deny-symlink-at/src/lib.rs create mode 100644 components/latch-deny-symlink-at/tests/latch.rs diff --git a/Cargo.lock b/Cargo.lock index 558aa02..dc19071 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1007,6 +1007,17 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "latch-deny-symlink-at" +version = "0.1.0" +dependencies = [ + "tempfile", + "test-harness", + "tokio", + "wasmtime", + "wit-bindgen", +] + [[package]] name = "latch-n2" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 4e464ef..a6084b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "components/gate", "components/latch-defer-all", "components/latch-deny-all", + "components/latch-deny-symlink-at", "components/latch-n2", "components/latch-n3", "components/latch-n4", diff --git a/README.md b/README.md index e879bd8..f8d5d87 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A collection of utility components that remix wasi:filesystem types and interfac - [`chroot`](./components/chroot/) - [`gate`](./components/gate/) - [`latch-deny-all`](./components/latch-deny-all/) +- [`latch-deny-symlink-at`](./components/latch-deny-symlink-at/) - [`latch-defer-all`](./components/latch-defer-all/) - [`latch-n2`](./components/latch-n2/) - [`latch-n3`](./components/latch-n3/) diff --git a/components/latch-deny-symlink-at/Cargo.toml b/components/latch-deny-symlink-at/Cargo.toml new file mode 100644 index 0000000..0485920 --- /dev/null +++ b/components/latch-deny-symlink-at/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "latch-deny-symlink-at" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/latch-deny-symlink-at/README.md b/components/latch-deny-symlink-at/README.md new file mode 100644 index 0000000..29cf432 --- /dev/null +++ b/components/latch-deny-symlink-at/README.md @@ -0,0 +1,18 @@ +# `latch-deny-symlink-at` + +Filesystem latch that denies creating symlinks. + +`symlink-at` fails with `not-permitted`, every other operation is deferred. Existing symlinks may still be read, followed, renamed and removed. + +Path based latches, like `latch-path-glob`, match the paths an operation is given, not the paths symlinks resolve to. A symlink inside a preopen can reach a path a pattern protects through a path the pattern does not match. Symlinks outside the preopen are not followed by the host. When the preopened directories are known to not contain symlinks, this latch keeps it that way, so the guest can not create a symlink around the patterns. + +## Interfaces + +Imports: + +- `wasi:filesystem/types@0.3.0`: the descriptor types of the operations being authorized +- `wasi:clocks/system-clock@0.3.0`: the timestamp types of the operations being authorized + +Exports: + +- `componentized:filesystem/latch@0.3.0-dev`: the latch diff --git a/components/latch-deny-symlink-at/src/lib.rs b/components/latch-deny-symlink-at/src/lib.rs new file mode 100644 index 0000000..0b3f1d6 --- /dev/null +++ b/components/latch-deny-symlink-at/src/lib.rs @@ -0,0 +1,33 @@ +use crate::exports::componentized::filesystem::latch::{ + Decision, DescriptorOperation, ErrorCode, FilesystemErrorCode, Guest as Latch, Operation, +}; + +struct DenySymlinkAtLatch {} + +impl Latch for DenySymlinkAtLatch { + fn authorize(operation: Operation) -> Result { + match operation { + Operation::Descriptor(DescriptorOperation::SymlinkAt(_)) => { + Ok(Decision::Denied(FilesystemErrorCode::NotPermitted)) + } + _ => Ok(Decision::Deferred), + } + } + + fn observe_decision( + _final_decision: Decision, + _operation: Operation<'_>, + ) -> Result<(), ErrorCode> { + // no side effects + Ok(()) + } +} + +wit_bindgen::generate!({ + path: "../wit", + world: "latch", + merge_structurally_equal_types: true, + generate_all +}); + +export!(DenySymlinkAtLatch); diff --git a/components/latch-deny-symlink-at/tests/latch.rs b/components/latch-deny-symlink-at/tests/latch.rs new file mode 100644 index 0000000..e495010 --- /dev/null +++ b/components/latch-deny-symlink-at/tests/latch.rs @@ -0,0 +1,172 @@ +//! Tests for `latch-deny-symlink-at`, as the gate's latch. +//! +//! Each test preopens a fresh copy of this tree read-write as `/`, so any denial comes from the +//! latch rather than the host: +//! +//! ```text +//! file.txt "hello" +//! link -> file.txt +//! ``` + +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::symlink; + +use tempfile::TempDir; +use test_harness::bindings::exports::wasi::filesystem::types::{GuestDescriptor, NewTimestamp}; +use test_harness::{ + Ctx, DescriptorFlags, DescriptorType, ErrorCode, Exports, Harness, LogEntry, OpenFlags, + PathFlags, Perms, TestSubject, +}; +use wasmtime::component::{Accessor, ResourceAny}; + +fn fixture() -> TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write(dir.path().join("file.txt"), "hello").unwrap(); + symlink("file.txt", dir.path().join("link")).unwrap(); + dir +} + +async fn gate(dir: &TempDir) -> TestSubject { + Harness::new("gate") + .preopen(dir.path(), "/", Perms::ReadWrite) + .latch("latch-deny-symlink-at") + .build() + .await + .expect("gate") +} + +fn descriptor(exports: &Exports) -> GuestDescriptor<'_> { + exports.wasi_filesystem_types().descriptor() +} + +async fn root(accessor: &Accessor, exports: &Exports) -> wasmtime::Result { + let dirs = exports + .wasi_filesystem_preopens() + .call_get_directories(accessor) + .await?; + Ok(dirs.into_iter().next().expect("a preopen").0) +} + +async fn open( + accessor: &Accessor, + exports: &Exports, + root: ResourceAny, + path_flags: PathFlags, + path: &str, +) -> wasmtime::Result> { + let result = descriptor(exports) + .call_open_at( + accessor, + root, + path_flags, + path.into(), + OpenFlags::empty(), + DescriptorFlags::READ, + ) + .await?; + Ok(result.map(|_| ())) +} + +fn ok(result: Result) -> Result<(), E> { + result.map(|_| ()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn creating_symlinks_is_denied() -> wasmtime::Result<()> { + let dir = fixture(); + let mut gate = gate(&dir).await; + let result = gate + .run(async |accessor, exports| { + let root = root(accessor, exports).await?; + descriptor(exports) + .call_symlink_at(accessor, root, "file.txt".into(), "new-link".into()) + .await + }) + .await?; + assert_eq!(result, Err(ErrorCode::NotPermitted)); + assert!(fs::symlink_metadata(dir.path().join("new-link")).is_err()); + assert_eq!( + gate.recorder().logs(), + [LogEntry::warn( + "componentized-gate", + "Denied REASON=not-permitted OPERATION=wasi:filesystem/types#descriptor.symlink-at FD=/ OLD-PATH=file.txt NEW-PATH=new-link" + )] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn existing_symlinks_are_followed() -> wasmtime::Result<()> { + let dir = fixture(); + let mut gate = gate(&dir).await; + let (opened, stat, hash, times) = gate + .run(async |accessor, exports| { + let d = descriptor(exports); + let root = root(accessor, exports).await?; + let follow = PathFlags::SYMLINK_FOLLOW; + Ok(( + open(accessor, exports, root, follow, "link").await?, + d.call_stat_at(accessor, root, follow, "link".into()) + .await? + .map(|stat| stat.type_), + ok(d.call_metadata_hash_at(accessor, root, follow, "link".into()) + .await?), + d.call_set_times_at( + accessor, + root, + follow, + "link".into(), + NewTimestamp::Now, + NewTimestamp::Now, + ) + .await?, + )) + }) + .await?; + assert_eq!(opened, Ok(())); + assert_eq!(stat, Ok(DescriptorType::RegularFile)); + assert_eq!(hash, Ok(())); + assert_eq!(times, Ok(())); + assert_eq!(gate.recorder().logs(), []); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn symlinks_are_read_without_following() -> wasmtime::Result<()> { + let dir = fixture(); + let mut gate = gate(&dir).await; + let (stat, target) = gate + .run(async |accessor, exports| { + let d = descriptor(exports); + let root = root(accessor, exports).await?; + Ok(( + d.call_stat_at(accessor, root, PathFlags::empty(), "link".into()) + .await? + .map(|stat| stat.type_), + d.call_readlink_at(accessor, root, "link".into()).await?, + )) + }) + .await?; + assert_eq!(stat, Ok(DescriptorType::SymbolicLink)); + assert_eq!(target, Ok("file.txt".into())); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn symlinks_can_be_removed() -> wasmtime::Result<()> { + let dir = fixture(); + let mut gate = gate(&dir).await; + let result = gate + .run(async |accessor, exports| { + let root = root(accessor, exports).await?; + descriptor(exports) + .call_unlink_file_at(accessor, root, "link".into()) + .await + }) + .await?; + assert_eq!(result, Ok(())); + assert!(fs::symlink_metadata(dir.path().join("link")).is_err()); + Ok(()) +} From 1dd63bb63200b4cd3716d0dc5bc3d7ce6a02a307 Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Sun, 18 Oct 2026 14:59:49 -0400 Subject: [PATCH 19/21] doc polish Signed-off-by: Scott Andrews --- components/gate/README.md | 40 ++++++++++++++++++++++++++- components/gate/tests/gate.rs | 4 +-- components/latch-deny-all/README.md | 2 ++ components/latch-deny-all/src/lib.rs | 2 +- components/latch-path-glob/README.md | 17 +++++++----- components/latch-path-glob/src/lib.rs | 40 +++++++++++++-------------- 6 files changed, 74 insertions(+), 31 deletions(-) diff --git a/components/gate/README.md b/components/gate/README.md index 2a982e1..7c7375e 100644 --- a/components/gate/README.md +++ b/components/gate/README.md @@ -1,3 +1,41 @@ # `gate` -Filesystem gate access control. +Filesystem gate access control for preopens and descriptors. + +Each operation is authorized by the imported latch before it reaches the filesystem, then the latch is told the final decision with `observe-decision`. Descriptor operations are authorized with the descriptor, its path, and the operation's arguments. The path of a preopen is its name, e.g. `/`, a descriptor opened with `open-at` has the path it was opened at, with `.` and `..` applied and symlinks followed where the host follows them. Testing whether two descriptors are the same object with `is-same-object` is not gated. + +How a denial is enforced depends on the operation: + +- Most operations fail with the error code the latch denied them with. +- `read-via-stream` and `read-directory` return an empty stream, and the future resolves to the error. +- `write-via-stream` and `append-via-stream` do not read the data stream, and the future resolves to the error. +- Getting the preopens is authorized once, a denial returns no preopens. Each preopen is then authorized individually, a denied preopen is left out and the other preopens are returned. +- Directory entries are authorized individually as they are read. A denied entry is left out of the stream and the read continues with the next entry. + +Denials are logged as warnings, except denied preopens and directory entries, which are left out rather than failed and are logged at the trace level: + +``` +Denied REASON=access OPERATION=wasi:filesystem/types#descriptor.open-at FD=/ PATH-FLAGS=() PATH=file.txt OPEN-FLAGS=(create) FLAGS=(read|write) +Denied REASON=access OPERATION=wasi:filesystem/types#descriptor.read-directory FD=/ NAME=file.txt TYPE=regular-file +``` + +A latch error, or a failure to observe the decision, fails the operation with `other`, prefixed with `latch-error:`, and is logged as an error. A latch error for a directory entry ends the stream and fails the read. Getting the preopens can not fail, a latch error traps instead. + +``` +Latch error CODE=invalid-config OPERATION=wasi:filesystem/types#descriptor.stat FD=/file.txt +``` + +## Interfaces + +Imports: + +- `componentized:filesystem/latch@0.3.0-dev`: decides whether each operation may proceed +- `wasi:logging/logging@0.1.0-draft`: logs denials and latch errors +- `wasi:clocks/system-clock@0.3.0`: types used by wasi:filesystem/types +- `wasi:filesystem/types@0.3.0`: the descriptors the gated descriptors wrap +- `wasi:filesystem/preopens@0.3.0`: the preopens the gated preopens wrap + +Exports: + +- `wasi:filesystem/types@0.3.0`: descriptors gated by the latch +- `wasi:filesystem/preopens@0.3.0`: preopens gated by the latch diff --git a/components/gate/tests/gate.rs b/components/gate/tests/gate.rs index b6344b1..b16a46d 100644 --- a/components/gate/tests/gate.rs +++ b/components/gate/tests/gate.rs @@ -241,7 +241,7 @@ async fn deny_all_hides_every_preopen() { assert_eq!( gate.recorder().logs(), [warn( - "Denied REASON=not-permitted OPERATION=wasi:filesystem/preopens#get-directories" + "Denied REASON=access OPERATION=wasi:filesystem/preopens#get-directories" )] ); } @@ -264,7 +264,7 @@ async fn deny_all_wins_over_defer_all() { assert_eq!( gate.recorder().logs(), [warn( - "Denied REASON=not-permitted OPERATION=wasi:filesystem/preopens#get-directories" + "Denied REASON=access OPERATION=wasi:filesystem/preopens#get-directories" )] ); } diff --git a/components/latch-deny-all/README.md b/components/latch-deny-all/README.md index c619098..7fdad02 100644 --- a/components/latch-deny-all/README.md +++ b/components/latch-deny-all/README.md @@ -1,3 +1,5 @@ # `latch-deny-all` Filesystem latch that implicitly denies all operations. + +Every operation is denied with `access`. Getting the preopens returns no preopens, so no descriptor can be obtained. diff --git a/components/latch-deny-all/src/lib.rs b/components/latch-deny-all/src/lib.rs index 823fbf3..cd9a12d 100644 --- a/components/latch-deny-all/src/lib.rs +++ b/components/latch-deny-all/src/lib.rs @@ -7,7 +7,7 @@ struct LatchDenyAll {} impl Latch for LatchDenyAll { #[allow(async_fn_in_trait)] fn authorize(_operation: Operation) -> Result { - Ok(Decision::Denied(FilesystemErrorCode::NotPermitted)) + Ok(Decision::Denied(FilesystemErrorCode::Access)) } #[allow(async_fn_in_trait)] diff --git a/components/latch-path-glob/README.md b/components/latch-path-glob/README.md index 52c5322..ffc8a39 100644 --- a/components/latch-path-glob/README.md +++ b/components/latch-path-glob/README.md @@ -2,7 +2,10 @@ Filesystem latch that uses glob patterns to defer or deny operations on a path. -The patterns are defined in a wasi:config/store. Keys starting with `deny` are parsed as globs with matching paths being denied. Multiple patterns are allowed by defining unique config keys (e.g. `deny-1`, `deny-2`, etc). Keys starting with `defer` are parsed as globs with matching paths deferring the decision. +The patterns are defined in a `wasi:config/store`. Keys starting with `deny` are parsed as globs with matching paths being denied. Multiple patterns are allowed by defining unique config keys (e.g. `deny-1`, `deny-2`, etc). Keys starting with `defer` are parsed as globs with matching paths deferring the decision. + +> [!CAUTION] +> Symlinks and overlapping preopen directories may provide alternate paths to the same file either matching or bypassing an expected pattern. Patterns are applied to paths as provided to calls on the `wasi:filesystem` interface. A single `*` matches within a path segment, between `/` separators, while `**` spans segments. @@ -40,28 +43,28 @@ Whether the host filesystem is case sensitive is not known to the latch, so matc Paths that do not match any pattern use the `default` decision, either `defer` (the default) or `deny`. -Denied operations fail with `not-permitted` unless the `reason` key is set. Any `wasi:filesystem` error code is supported, e.g. `access` or `read-only`, any other value is reported as `other` with the value as the message. The reason applies to both matching `deny` patterns and a `deny` default, and does not change the default decision. +Denied operations fail with `access` unless the `reason` key is set. Any `wasi:filesystem` error code is supported, e.g. `no-entry` to hide denied paths or `not-permitted`, any other value is reported as `other` with the value as the message. The reason applies to both matching `deny` patterns and a `deny` default, and does not change the default decision. ``` default=deny -reason=access +reason=no-entry defer=/data/** /data/file.txt -> DEFERRED -/etc/passwd -> DENIED (access) +/etc/passwd -> DENIED (no-entry) ``` Keys starting with `readonly` are parsed as globs for paths that may be read but not changed. They are only evaluated when the operation would otherwise be deferred, by a matching `defer` pattern or a `defer` default, and when the operation would change the filesystem. A matching operation is denied with `read-only`, regardless of the `reason` key. Operations that change the filesystem are writing, appending, syncing, resizing, setting times, creating directories, linking, renaming, removing, unlinking, and opening with flags that allow changes (`create`, `exclusive` or `truncate` open flags, or `write`, `mutate-directory` or sync descriptor flags). Operations on a descriptor without a path argument, like writing to a file, are evaluated against the descriptor's path and are only checked against `readonly` patterns, the `deny` and `defer` patterns were already evaluated when the descriptor was opened. ``` -reason=access +reason=no-entry deny=/data/secret/** readonly=/data/** open /data/file.txt for reading -> DEFERRED open /data/file.txt for writing -> DENIED (read-only) -open /data/secret/file.txt for reading -> DENIED (access) -open /data/secret/file.txt for writing -> DENIED (access) +open /data/secret/file.txt for reading -> DENIED (no-entry) +open /data/secret/file.txt for writing -> DENIED (no-entry) ``` If the config is invalid (a pattern that does not parse as a glob, or an unknown `default` or `case-sensitive` value), the cause is logged when the config is loaded and every operation fails with an `invalid-config` latch error. diff --git a/components/latch-path-glob/src/lib.rs b/components/latch-path-glob/src/lib.rs index 259e946..761c260 100644 --- a/components/latch-path-glob/src/lib.rs +++ b/components/latch-path-glob/src/lib.rs @@ -153,7 +153,7 @@ impl Patterns { fn parse(config: Vec<(String, String)>) -> Result { let mut rules: Vec = vec![]; let mut readonly: Vec = vec![]; - let mut reason = FilesystemErrorCode::NotPermitted; + let mut reason = FilesystemErrorCode::Access; let mut default = Action::Defer; let mut case_sensitive = false; @@ -166,7 +166,7 @@ impl Patterns { } else if key.starts_with("readonly") { readonly.push(Pattern::new(&value).map_err(|err| invalid(err.to_string()))?); } else if key == "reason" { - reason = get_error_code(value).unwrap_or(FilesystemErrorCode::NotPermitted); + reason = get_error_code(value).unwrap_or(FilesystemErrorCode::Access); } else if key == "default" { default = match value.as_str() { "deny" => Action::Deny, @@ -422,7 +422,7 @@ mod tests { let patterns = patterns(&[("deny", "/secret")]); assert_eq!( decision(&patterns, "/secret", &[]), - denied(FilesystemErrorCode::NotPermitted) + denied(FilesystemErrorCode::Access) ); assert_eq!(decision(&patterns, "/", &[]), deferred()); } @@ -432,7 +432,7 @@ mod tests { let patterns = patterns(&[("deny", "/data/*.key")]); assert_eq!( decision(&patterns, "/data", &["id.key"]), - denied(FilesystemErrorCode::NotPermitted) + denied(FilesystemErrorCode::Access) ); assert_eq!(decision(&patterns, "/data", &["id.pub"]), deferred()); // only the joined paths are matched, not the base path alone @@ -444,16 +444,16 @@ mod tests { let patterns = patterns(&[("deny", "/data/*.key")]); assert_eq!( decision(&patterns, "/data", &["id.pub", "id.key"]), - denied(FilesystemErrorCode::NotPermitted) + denied(FilesystemErrorCode::Access) ); } #[test] fn multiple_deny_keys() { let patterns = patterns(&[("deny-1", "/a"), ("deny-2", "/b")]); - let not_permitted = denied(FilesystemErrorCode::NotPermitted); - assert_eq!(decision(&patterns, "/a", &[]), not_permitted); - assert_eq!(decision(&patterns, "/b", &[]), not_permitted); + let access = denied(FilesystemErrorCode::Access); + assert_eq!(decision(&patterns, "/a", &[]), access); + assert_eq!(decision(&patterns, "/b", &[]), access); assert_eq!(decision(&patterns, "/c", &[]), deferred()); } @@ -469,7 +469,7 @@ mod tests { ("access", FilesystemErrorCode::Access), ("read-only", FilesystemErrorCode::ReadOnly), ("other", FilesystemErrorCode::Other(None)), - ("", FilesystemErrorCode::NotPermitted), + ("", FilesystemErrorCode::Access), ( "custom-reason", FilesystemErrorCode::Other(Some("custom-reason".to_string())), @@ -628,27 +628,27 @@ mod tests { ); assert_eq!( decision(&patterns, "/data", &["a.txt", "../etc/passwd"]), - denied(FilesystemErrorCode::NotPermitted) + denied(FilesystemErrorCode::Access) ); } #[test] fn default_deny_uses_configured_reason_regardless_of_order() { for config in [ - [("reason", "access"), ("default", "deny")], - [("default", "deny"), ("reason", "access")], + [("reason", "no-entry"), ("default", "deny")], + [("default", "deny"), ("reason", "no-entry")], ] { let patterns = patterns(&config); assert_eq!( decision(&patterns, "/a", &[]), - denied(FilesystemErrorCode::Access) + denied(FilesystemErrorCode::NoEntry) ); } } #[test] fn reason_does_not_change_the_default() { - let patterns = patterns(&[("reason", "access")]); + let patterns = patterns(&[("reason", "no-entry")]); assert_eq!(decision(&patterns, "/a", &[]), deferred()); } @@ -664,7 +664,7 @@ mod tests { assert_eq!(decision(&patterns, "/data/a.txt", &[]), deferred()); assert_eq!( decision(&patterns, "/etc/passwd", &[]), - denied(FilesystemErrorCode::NotPermitted) + denied(FilesystemErrorCode::Access) ); } @@ -719,7 +719,7 @@ mod tests { #[test] fn readonly_ignores_the_configured_reason() { - let patterns = patterns(&[("reason", "access"), ("readonly", "/data/**")]); + let patterns = patterns(&[("reason", "no-entry"), ("readonly", "/data/**")]); assert_eq!( mutation(&patterns, "/data", &["a.txt"]), denied(FilesystemErrorCode::ReadOnly) @@ -729,14 +729,14 @@ mod tests { #[test] fn readonly_is_only_evaluated_when_otherwise_deferred() { let denying = patterns(&[ - ("reason", "access"), + ("reason", "no-entry"), ("deny", "/data/secret/*"), ("readonly", "/data/**"), ]); // the deny pattern decides first, with the configured reason assert_eq!( mutation(&denying, "/data/secret", &["a.txt"]), - denied(FilesystemErrorCode::Access) + denied(FilesystemErrorCode::NoEntry) ); assert_eq!( mutation(&denying, "/data", &["a.txt"]), @@ -747,7 +747,7 @@ mod tests { let default_deny = patterns(&[("default", "deny"), ("readonly", "/data/**")]); assert_eq!( mutation(&default_deny, "/data", &["a.txt"]), - denied(FilesystemErrorCode::NotPermitted) + denied(FilesystemErrorCode::Access) ); } @@ -766,7 +766,7 @@ mod tests { assert_eq!(decision(&patterns, "/data/archive", &["a.txt"]), deferred()); assert_eq!( mutation(&patterns, "/", &["a.txt"]), - denied(FilesystemErrorCode::NotPermitted) + denied(FilesystemErrorCode::Access) ); } From 1eb361f97f98a4a8820b4f5f80bb7365916634cd Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Sun, 18 Oct 2026 15:23:35 -0400 Subject: [PATCH 20/21] latch-deny-random pull common latch bits into crates/filesystem-latch Signed-off-by: Scott Andrews --- Cargo.lock | 24 +- Cargo.toml | 3 + README.md | 1 + components/latch-deny-random/Cargo.toml | 18 ++ components/latch-deny-random/README.md | 42 +++ components/latch-deny-random/src/lib.rs | 313 ++++++++++++++++++++ components/latch-deny-random/tests/latch.rs | 178 +++++++++++ components/latch-path-glob/Cargo.toml | 7 +- components/latch-path-glob/build.rs | 59 ---- components/latch-path-glob/src/lib.rs | 90 +----- components/wit/worlds.wit | 4 + components/wkg.lock | 9 + crates/filesystem-latch/Cargo.toml | 13 + crates/filesystem-latch/build.rs | 121 ++++++++ crates/filesystem-latch/src/lib.rs | 140 +++++++++ crates/test-harness/src/lib.rs | 5 + 16 files changed, 884 insertions(+), 143 deletions(-) create mode 100644 components/latch-deny-random/Cargo.toml create mode 100644 components/latch-deny-random/README.md create mode 100644 components/latch-deny-random/src/lib.rs create mode 100644 components/latch-deny-random/tests/latch.rs delete mode 100644 components/latch-path-glob/build.rs create mode 100644 crates/filesystem-latch/Cargo.toml create mode 100644 crates/filesystem-latch/build.rs create mode 100644 crates/filesystem-latch/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index dc19071..a504e54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -534,6 +534,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "filesystem-latch" +version = "0.1.0" +dependencies = [ + "heck", + "wit-bindgen", + "wit-parser 0.259.0", +] + [[package]] name = "filesystem-latch-n" version = "0.1.0" @@ -1007,6 +1016,18 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "latch-deny-random" +version = "0.1.0" +dependencies = [ + "filesystem-latch", + "tempfile", + "test-harness", + "tokio", + "wasmtime", + "wit-bindgen", +] + [[package]] name = "latch-deny-symlink-at" version = "0.1.0" @@ -1050,10 +1071,9 @@ dependencies = [ name = "latch-path-glob" version = "0.1.0" dependencies = [ + "filesystem-latch", "glob", - "heck", "wit-bindgen", - "wit-parser 0.259.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a6084b3..ec3ed3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "components/gate", "components/latch-defer-all", "components/latch-deny-all", + "components/latch-deny-random", "components/latch-deny-symlink-at", "components/latch-n2", "components/latch-n3", @@ -13,6 +14,7 @@ members = [ "components/latch-path-glob", "components/latch-readonly", "components/trace", + "crates/filesystem-latch", "crates/filesystem-latch-n", "crates/test-harness", ] @@ -20,6 +22,7 @@ members = [ [workspace.dependencies] chrono = { git = "https://github.com/chronotope/chrono.git", branch = "0.5.x" } componentized-rt = { path = "./crates/componentized-rt" } +filesystem-latch = { path = "./crates/filesystem-latch" } filesystem-latch-n = { path = "./crates/filesystem-latch-n" } heck = "0.5" tempfile = "3" diff --git a/README.md b/README.md index f8d5d87..bbc4ee2 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A collection of utility components that remix wasi:filesystem types and interfac - [`chroot`](./components/chroot/) - [`gate`](./components/gate/) - [`latch-deny-all`](./components/latch-deny-all/) +- [`latch-deny-random`](./components/latch-deny-random/) - [`latch-deny-symlink-at`](./components/latch-deny-symlink-at/) - [`latch-defer-all`](./components/latch-defer-all/) - [`latch-n2`](./components/latch-n2/) diff --git a/components/latch-deny-random/Cargo.toml b/components/latch-deny-random/Cargo.toml new file mode 100644 index 0000000..98c4010 --- /dev/null +++ b/components/latch-deny-random/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "latch-deny-random" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +filesystem-latch = { workspace = true } +wit-bindgen = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +test-harness = { workspace = true } +tokio = { workspace = true } +wasmtime = { workspace = true } diff --git a/components/latch-deny-random/README.md b/components/latch-deny-random/README.md new file mode 100644 index 0000000..57c0740 --- /dev/null +++ b/components/latch-deny-random/README.md @@ -0,0 +1,42 @@ +# `latch-deny-random` + +Filesystem latch that randomly denies operations, to prove a component is resilient to failures. + +Every operation is at risk: getting the preopens and each preopen, opening, reading and writing files, reading directories and each directory entry, and every other descriptor operation. A denied operation fails with `access`, or the configured reason, while a denied preopen or directory entry is left out. + +The latch is configured with a wasi:config/store: + +- `probability`: the fraction of operations denied, from `0` to `1`, defaults to `0.1` +- `probability.`: the fraction of the operations of an interface denied, `preopens` or `descriptor`, e.g. `probability.preopens`, defaults to `probability` +- `probability..`: the fraction of an operation denied, named as in the latch WIT, e.g. `probability.descriptor.open-at` or `probability.preopens.get-directories-item`, defaults to the probability of its interface +- `seed`: an unsigned 64 bit integer, the same seed denies the same operations, defaults to a random seed from `wasi:random` +- `reason`: the wasi:filesystem `error-code` denied operations fail with, as written in the WIT, e.g. `io` or `no-entry`, defaults to `access`. Any other value is reported as `other` with the value as the message + +The most specific probability decides, an unknown interface or operation is an invalid config. The preopens are often configured statically by the host, setting their probability to `0` puts only the guest's own operations at risk: + +``` +probability=0.2 +probability.preopens=0 +``` + +The seed is logged as a warning when the latch starts, set it in the config to reproduce a failure. + +``` +Randomly denying wasi:filesystem operations PROBABILITY=0.2 SEED=9383211634937261427 PROBABILITY.preopens=0 +``` + +The decision for an operation depends only on the seed and the number of operations observed before it. Operations another latch denies first still count, so aggregating the latch with others does not change which operations it denies. + +## Interfaces + +Imports: + +- `wasi:logging/logging@0.1.0-draft`: logs the seed, and an invalid config +- `wasi:config/store@0.2.0-rc.1`: the latch config +- `wasi:random/insecure@0.3.0`: seeds the decisions when no `seed` is configured +- `wasi:filesystem/types@0.3.0`: the descriptor types of the operations being authorized +- `wasi:clocks/system-clock@0.3.0`: the timestamp types of the operations being authorized + +Exports: + +- `componentized:filesystem/latch@0.3.0-dev`: the latch diff --git a/components/latch-deny-random/src/lib.rs b/components/latch-deny-random/src/lib.rs new file mode 100644 index 0000000..17ab482 --- /dev/null +++ b/components/latch-deny-random/src/lib.rs @@ -0,0 +1,313 @@ +use std::cell::RefCell; +use std::collections::BTreeMap; + +use filesystem_latch::{Decision, ErrorCode, FilesystemErrorCode, Latch, Operation}; + +const LATCH_NAME: &str = "latch-deny-random"; + +/// The fraction of operations denied when `probability` is not configured. +const DEFAULT_PROBABILITY: f64 = 0.1; + +mod random { + wit_bindgen::generate!({ + path: "../wit", + world: "insecure-random", + generate_all + }); +} + +struct Config { + /// The fraction of operations denied, from 0 to 1, unless the interface or operation has its + /// own probability. + probability: f64, + /// The probability of the operations of an interface, by interface name, e.g. `preopens`. + interfaces: BTreeMap, + /// The probability of an operation, by operation name, e.g. `descriptor.open-at`. + operations: BTreeMap, + /// Seeds the decisions, the same seed denies the same operations. + seed: u64, + reason: FilesystemErrorCode, +} + +impl Config { + /// Load the config, logging why when the config is invalid. + fn load() -> Result { + let config = filesystem_latch::load_config(LATCH_NAME, |config| { + Config::parse(config, || { + random::wasi::random::insecure::get_insecure_random_u64() + }) + })?; + // the seed reproduces the decisions, e.g. a failure the random seed uncovered + let overrides: String = config + .interfaces + .iter() + .chain(&config.operations) + .map(|(name, probability)| format!(" PROBABILITY.{name}={probability}")) + .collect(); + filesystem_latch::warn!( + "Randomly denying wasi:filesystem operations PROBABILITY={} SEED={}{overrides}", + config.probability, + config.seed + ); + Ok(config) + } + + fn parse( + config: Vec<(String, String)>, + random_seed: impl FnOnce() -> u64, + ) -> Result { + let mut probability = DEFAULT_PROBABILITY; + let mut interfaces = BTreeMap::new(); + let mut operations = BTreeMap::new(); + let mut seed = None; + let mut reason = FilesystemErrorCode::Access; + + for (key, value) in config { + let invalid = |err: &str| format!("KEY={key} VALUE={value} ERROR={err}"); + let parse_probability = || { + value + .parse::() + .ok() + .filter(|probability| (0.0..=1.0).contains(probability)) + .ok_or_else(|| invalid("expected a number from 0 to 1")) + }; + match key.as_str() { + "probability" => probability = parse_probability()?, + _ if key.starts_with("probability.") => { + let name = &key["probability.".len()..]; + if filesystem_latch::INTERFACE_NAMES.contains(&name) { + interfaces.insert(name.to_string(), parse_probability()?); + } else if filesystem_latch::OPERATION_NAMES.contains(&name) { + operations.insert(name.to_string(), parse_probability()?); + } else { + return Err(invalid("unknown interface or operation")); + } + } + "seed" => { + seed = Some( + value + .parse::() + .map_err(|_| invalid("expected an unsigned 64 bit integer"))?, + ); + } + "reason" => { + reason = filesystem_latch::reason(value).unwrap_or(FilesystemErrorCode::Access); + } + _ => {} + } + } + + Ok(Config { + probability, + interfaces, + operations, + seed: seed.unwrap_or_else(random_seed), + reason, + }) + } + + /// The probability of the named operation, e.g. `descriptor.open-at`: its own, its interface's, + /// or the default. + fn probability(&self, operation: &str) -> f64 { + let interface = operation.split_once('.').map_or(operation, |(interface, _)| interface); + self.operations + .get(operation) + .or_else(|| self.interfaces.get(interface)) + .copied() + .unwrap_or(self.probability) + } + + /// The decision for the named operation observed after `observed` others. + fn decide(&self, operation: &str, observed: u64) -> Decision { + match sample(self.seed, observed) < self.probability(operation) { + true => Decision::Denied(self.reason.clone()), + false => Decision::Deferred, + } + } +} + +/// A uniformly distributed number from 0 up to 1, the same for the same seed and index. +fn sample(seed: u64, index: u64) -> f64 { + // splitmix64, each index is a step of the sequence the seed starts + let mut z = seed.wrapping_add(index.wrapping_add(1).wrapping_mul(0x9e37_79b9_7f4a_7c15)); + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^= z >> 31; + // the top 53 bits, the precision of an f64 + (z >> 11) as f64 / (1u64 << 53) as f64 +} + +thread_local! { + /// `None` until the config is loaded. + static CONFIG: RefCell>> = const { RefCell::new(None) }; + + /// The number of operations observed. + static OBSERVED: RefCell = const { RefCell::new(0) }; +} + +struct RandomLatch {} + +impl Latch for RandomLatch { + fn authorize(operation: Operation) -> Result { + // the decision only depends on how many operations were observed, authorizing does not + // change it, so an operation another latch denied first does not shift the decisions + CONFIG.with_borrow_mut(|config| match config.get_or_insert_with(Config::load) { + Ok(config) => { + let observed = OBSERVED.with_borrow(|observed| *observed); + Ok(config.decide(filesystem_latch::operation_name(&operation), observed)) + } + Err(err) => Err(err.clone()), + }) + } + + fn observe_decision(_final_decision: Decision, _operation: Operation) -> Result<(), ErrorCode> { + OBSERVED.with_borrow_mut(|observed| *observed += 1); + Ok(()) + } +} + +filesystem_latch::export!(RandomLatch with_types_in filesystem_latch::bindings); + +#[cfg(test)] +mod tests { + use super::*; + + fn config(entries: &[(&str, &str)]) -> Result { + let entries = entries + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(); + Config::parse(entries, || 42) + } + + fn denied(config: &Config, operations: u64) -> Vec { + (0..operations) + .filter(|observed| matches!(config.decide("descriptor.stat", *observed), Decision::Denied(_))) + .collect() + } + + #[test] + fn defaults() { + let config = config(&[]).expect("valid config"); + assert_eq!(config.probability, DEFAULT_PROBABILITY); + assert_eq!(config.seed, 42, "seeded randomly"); + assert!(matches!(config.reason, FilesystemErrorCode::Access)); + } + + #[test] + fn denies_about_the_configured_fraction() { + for probability in [0.1, 0.5, 0.9] { + let config = config(&[("probability", &probability.to_string()), ("seed", "7")]) + .expect("valid config"); + let fraction = denied(&config, 10_000).len() as f64 / 10_000.0; + assert!( + (fraction - probability).abs() < 0.02, + "probability={probability} fraction={fraction}" + ); + } + } + + #[test] + fn never_or_always_denies() { + let never = config(&[("probability", "0")]).expect("valid config"); + assert!(denied(&never, 1_000).is_empty()); + let always = config(&[("probability", "1")]).expect("valid config"); + assert_eq!(denied(&always, 1_000).len(), 1_000); + } + + #[test] + fn same_seed_same_decisions() { + let first = config(&[("probability", "0.5"), ("seed", "1")]).expect("valid config"); + let again = config(&[("probability", "0.5"), ("seed", "1")]).expect("valid config"); + let other = config(&[("probability", "0.5"), ("seed", "2")]).expect("valid config"); + assert_eq!(denied(&first, 100), denied(&again, 100)); + assert_ne!(denied(&first, 100), denied(&other, 100)); + } + + #[test] + fn denies_with_configured_reason() { + let config = config(&[("probability", "1"), ("reason", "io")]).expect("valid config"); + assert!(matches!( + config.decide("descriptor.stat", 0), + Decision::Denied(FilesystemErrorCode::Io) + )); + } + + #[test] + fn unknown_reason_is_other() { + let config = + config(&[("probability", "1"), ("reason", "chaos-monkey")]).expect("valid config"); + assert!(matches!( + config.decide("descriptor.stat", 0), + Decision::Denied(FilesystemErrorCode::Other(Some(ref message))) if message == "chaos-monkey" + )); + } + + #[test] + fn probability_varies_by_interface_and_operation() { + let varied = config(&[ + ("probability", "0.2"), + ("probability.preopens", "0"), + ("probability.descriptor", "0.5"), + ("probability.descriptor.open-at", "1"), + ("probability.preopens.get-directories-item", "0.3"), + ]) + .expect("valid config"); + assert_eq!(varied.probability("preopens.get-directories"), 0.0); + assert_eq!(varied.probability("preopens.get-directories-item"), 0.3); + assert_eq!(varied.probability("descriptor.stat"), 0.5); + assert_eq!(varied.probability("descriptor.open-at"), 1.0); + + let default = config(&[("probability", "0.2")]).expect("valid config"); + assert_eq!(default.probability("preopens.get-directories"), 0.2); + assert_eq!(default.probability("descriptor.open-at"), 0.2); + } + + #[test] + fn operation_probability_decides() { + let config = config(&[("probability", "1"), ("probability.preopens", "0")]) + .expect("valid config"); + assert!(matches!( + config.decide("preopens.get-directories", 0), + Decision::Deferred + )); + assert!(matches!( + config.decide("descriptor.stat", 0), + Decision::Denied(_) + )); + } + + #[test] + fn invalid_config() { + for (key, value) in [ + ("probability", "1.5"), + ("probability", "-0.1"), + ("probability", "often"), + ("seed", "-1"), + ("seed", "random"), + ("probability.preopens", "often"), + ("probability.descriptor.open-at", "2"), + ] { + let err = config(&[(key, value)]).err().expect("invalid config"); + assert!( + err.starts_with(&format!("KEY={key} VALUE={value} ERROR=")), + "{err}" + ); + } + } + + #[test] + fn unknown_interface_or_operation_is_invalid() { + for key in [ + "probability.sockets", + "probability.descriptor.open", + "probability.open-at", + "probability.", + ] { + assert_eq!( + config(&[(key, "0")]).err().expect("invalid config"), + format!("KEY={key} VALUE=0 ERROR=unknown interface or operation") + ); + } + } +} diff --git a/components/latch-deny-random/tests/latch.rs b/components/latch-deny-random/tests/latch.rs new file mode 100644 index 0000000..9d48c5b --- /dev/null +++ b/components/latch-deny-random/tests/latch.rs @@ -0,0 +1,178 @@ +//! Tests for `latch-deny-random`, as the gate's latch. +//! +//! Each test preopens an empty directory as `/`, and gets the preopens repeatedly. Getting the +//! preopens and the preopen are each authorized, a denial of either returns no preopens. + +use tempfile::TempDir; +use test_harness::{ErrorCode, Harness, Level, LogEntry, Perms, TestSubject}; + +const LATCH: &str = "latch-deny-random"; + +/// A gate with the latch, configured with the entries. +async fn gate(dir: &TempDir, config: &[(&str, &str)]) -> wasmtime::Result { + let mut harness = Harness::new("gate") + .preopen(dir.path(), "/", Perms::ReadWrite) + .latch(LATCH); + for (key, value) in config { + harness = harness.config(key, value); + } + harness.build().await +} + +/// Get the preopens, whether each get was denied. +async fn gets(gate: &mut TestSubject, count: usize) -> wasmtime::Result> { + gate.run(async move |accessor, exports| { + let mut denied = vec![]; + for _ in 0..count { + let dirs = exports + .wasi_filesystem_preopens() + .call_get_directories(accessor) + .await?; + denied.push(dirs.is_empty()); + } + Ok(denied) + }) + .await +} + +/// The messages the latch logged. +fn latch_logs(gate: &TestSubject) -> Vec { + gate.recorder() + .logs() + .into_iter() + .filter(|entry| entry.context == "componentized-latch") + .collect() +} + +#[tokio::test(flavor = "multi_thread")] +async fn always_denies() -> wasmtime::Result<()> { + let dir = tempfile::tempdir()?; + let mut gate = gate(&dir, &[("probability", "1"), ("seed", "7")]).await?; + assert_eq!(gets(&mut gate, 3).await?, vec![true, true, true]); + assert_eq!( + latch_logs(&gate), + vec![LogEntry::warn( + "componentized-latch", + "Randomly denying wasi:filesystem operations PROBABILITY=1 SEED=7" + )] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn denies_with_the_configured_reason() -> wasmtime::Result<()> { + let dir = tempfile::tempdir()?; + let mut gate = gate(&dir, &[("probability", "1"), ("reason", "io")]).await?; + gets(&mut gate, 1).await?; + assert_eq!( + gate.recorder() + .logs() + .into_iter() + .filter(|entry| entry.context == "componentized-gate") + .collect::>(), + vec![LogEntry::warn( + "componentized-gate", + "Denied REASON=io OPERATION=wasi:filesystem/preopens#get-directories" + )] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn never_denies() -> wasmtime::Result<()> { + let dir = tempfile::tempdir()?; + let mut gate = gate(&dir, &[("probability", "0")]).await?; + assert_eq!(gets(&mut gate, 3).await?, vec![false, false, false]); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn same_seed_denies_the_same_operations() -> wasmtime::Result<()> { + let dir = tempfile::tempdir()?; + let config = [("probability", "0.5"), ("seed", "1234")]; + let first = gets(&mut gate(&dir, &config).await?, 32).await?; + let again = gets(&mut gate(&dir, &config).await?, 32).await?; + assert_eq!(first, again); + // some of each, two coin flips for each of 32 gets + assert!(first.contains(&true) && first.contains(&false), "{first:?}"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn random_seed_is_logged() -> wasmtime::Result<()> { + let dir = tempfile::tempdir()?; + let mut logged = vec![]; + for _ in 0..2 { + let mut gate = gate(&dir, &[]).await?; + gets(&mut gate, 1).await?; + let logs = latch_logs(&gate); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].level, Level::Warn); + let seed = logs[0] + .message + .strip_prefix("Randomly denying wasi:filesystem operations PROBABILITY=0.1 SEED=") + .expect("seed is logged") + .parse::()?; + logged.push(seed); + } + // each instance draws its own seed + assert_ne!(logged[0], logged[1]); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn invalid_config_is_logged() -> wasmtime::Result<()> { + let dir = tempfile::tempdir()?; + let mut gate = gate(&dir, &[("probability", "often")]).await?; + // a latch error getting the preopens can't be returned, the gate traps + assert!(gets(&mut gate, 1).await.is_err()); + assert_eq!( + latch_logs(&gate), + vec![LogEntry::critical( + "componentized-latch", + "Invalid config LATCH=latch-deny-random KEY=probability VALUE=often ERROR=expected a number from 0 to 1" + )] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn probability_varies_by_operation() -> wasmtime::Result<()> { + let dir = tempfile::tempdir()?; + let mut gate = gate( + &dir, + &[ + ("probability", "1"), + ("probability.preopens", "0"), + ("seed", "7"), + ], + ) + .await?; + let (preopens, stat) = gate + .run(async |accessor, exports| { + let dirs = exports + .wasi_filesystem_preopens() + .call_get_directories(accessor) + .await?; + let count = dirs.len(); + let (root, _) = dirs.into_iter().next().expect("a preopen"); + let stat = exports + .wasi_filesystem_types() + .descriptor() + .call_stat(accessor, root) + .await?; + Ok((count, stat.map(|_| ()))) + }) + .await?; + // the preopens are never denied, every other operation is + assert_eq!(preopens, 1); + assert_eq!(stat, Err(ErrorCode::Access)); + assert_eq!( + latch_logs(&gate), + vec![LogEntry::warn( + "componentized-latch", + "Randomly denying wasi:filesystem operations PROBABILITY=1 SEED=7 PROBABILITY.preopens=0" + )] + ); + Ok(()) +} diff --git a/components/latch-path-glob/Cargo.toml b/components/latch-path-glob/Cargo.toml index 3b423a9..7bbedf0 100644 --- a/components/latch-path-glob/Cargo.toml +++ b/components/latch-path-glob/Cargo.toml @@ -8,9 +8,6 @@ license = "Apache-2.0" crate-type = ["cdylib"] [dependencies] -wit-bindgen = { workspace = true } +filesystem-latch = { workspace = true } glob = "0.3" - -[build-dependencies] -heck = { workspace = true } -wit-parser = "0.259" +wit-bindgen = { workspace = true } diff --git a/components/latch-path-glob/build.rs b/components/latch-path-glob/build.rs deleted file mode 100644 index b33e08d..0000000 --- a/components/latch-path-glob/build.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! Generate `error_code_from_name`, which parses the kebab-case name of every case of -//! `wasi:filesystem/types#error-code` without a payload, from the same WIT the bindings are -//! generated from, so a case added to the WIT is parsed without changing the latch. - -use std::fmt::Write as _; -use std::path::PathBuf; - -use heck::ToUpperCamelCase; -use wit_parser::{Resolve, TypeDefKind}; - -const WIT: &str = "../wit"; - -fn main() { - println!("cargo:rerun-if-changed={WIT}"); - - let mut resolve = Resolve::default(); - resolve.push_path(WIT).expect("WIT must parse"); - - let (_, types) = resolve - .interfaces - .iter() - .find(|(_, interface)| { - interface.name.as_deref() == Some("types") - && interface.package.is_some_and(|package| { - let name = &resolve.packages[package].name; - name.namespace == "wasi" && name.name == "filesystem" - }) - }) - .expect("wasi:filesystem/types must be in the WIT"); - let error_code = types.types["error-code"]; - let TypeDefKind::Variant(variant) = &resolve.types[error_code].kind else { - panic!("wasi:filesystem/types#error-code must be a variant"); - }; - - let mut arms = String::new(); - for case in &variant.cases { - match (&case.ty, case.name.as_str()) { - (None, name) => { - // the case name as wit-bindgen generates it - let ident = name.to_upper_camel_case(); - writeln!(arms, " {name:?} => Some(FilesystemErrorCode::{ident}),").unwrap(); - } - // `other` carries a message, it is parsed by hand - (Some(_), "other") => {} - (Some(_), name) => panic!( - "error-code case `{name}` has a payload, extend build.rs to parse it" - ), - } - } - - let source = format!( - "/// The `error-code` case without a payload with the kebab-case name, e.g. `not-permitted`.\n\ - fn error_code_from_name(name: &str) -> Option {{\n \ - match name {{\n{arms} _ => None,\n }}\n\ - }}\n" - ); - let out = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("error_code.rs"); - std::fs::write(out, source).unwrap(); -} diff --git a/components/latch-path-glob/src/lib.rs b/components/latch-path-glob/src/lib.rs index 761c260..ca707ea 100644 --- a/components/latch-path-glob/src/lib.rs +++ b/components/latch-path-glob/src/lib.rs @@ -3,13 +3,9 @@ use std::path::{Path, PathBuf}; use glob::{MatchOptions, Pattern}; -use crate::{ - componentized::filesystem::latch::{Decision::Deferred, DescriptorOperation}, - exports::componentized::filesystem::latch::{ - Decision, ErrorCode, FilesystemErrorCode, Guest as Latch, Operation, PreopensOperation, - }, - wasi::filesystem::types::{DescriptorFlags, OpenFlags}, - wasi::logging::logging::{log, Level}, +use filesystem_latch::{ + Decision, DescriptorFlags, DescriptorOperation, ErrorCode, FilesystemErrorCode, Latch, OpenFlags, + Operation, PreopensOperation, }; const LATCH_NAME: &str = "latch-path-glob"; @@ -132,22 +128,7 @@ struct Patterns { impl Patterns { /// Load the patterns from config, logging why when the config is invalid. fn load() -> Result { - wasi::config::store::get_all() - .map_err(|err| match err { - wasi::config::store::Error::Upstream(message) => { - format!("ERROR=upstream: {message}") - } - wasi::config::store::Error::Io(message) => format!("ERROR=io: {message}"), - }) - .and_then(Patterns::parse) - .map_err(|message| { - log( - Level::Critical, - "componentized-latch", - &format!("Invalid config LATCH={LATCH_NAME} {message}"), - ); - ErrorCode::InvalidConfig(LATCH_NAME.to_string()) - }) + filesystem_latch::load_config(LATCH_NAME, Patterns::parse) } fn parse(config: Vec<(String, String)>) -> Result { @@ -166,7 +147,7 @@ impl Patterns { } else if key.starts_with("readonly") { readonly.push(Pattern::new(&value).map_err(|err| invalid(err.to_string()))?); } else if key == "reason" { - reason = get_error_code(value).unwrap_or(FilesystemErrorCode::Access); + reason = filesystem_latch::reason(value).unwrap_or(FilesystemErrorCode::Access); } else if key == "default" { default = match value.as_str() { "deny" => Action::Deny, @@ -281,20 +262,20 @@ impl Latch for PathGlobLatch { } }, Operation::Descriptor(descriptor_operation) => match descriptor_operation { - DescriptorOperation::ReadViaStream(_) => Ok(Deferred), + DescriptorOperation::ReadViaStream(_) => Ok(Decision::Deferred), DescriptorOperation::WriteViaStream((_, path, _)) => { Patterns::authorize_mutation(path) } DescriptorOperation::AppendViaStream((_, path)) => { Patterns::authorize_mutation(path) } - DescriptorOperation::Advise(_) => Ok(Deferred), + DescriptorOperation::Advise(_) => Ok(Decision::Deferred), DescriptorOperation::SyncData((_, path)) => Patterns::authorize_mutation(path), - DescriptorOperation::GetFlags(_) => Ok(Deferred), - DescriptorOperation::GetType(_) => Ok(Deferred), + DescriptorOperation::GetFlags(_) => Ok(Decision::Deferred), + DescriptorOperation::GetType(_) => Ok(Decision::Deferred), DescriptorOperation::SetSize((_, path, _)) => Patterns::authorize_mutation(path), DescriptorOperation::SetTimes((_, path, _)) => Patterns::authorize_mutation(path), - DescriptorOperation::ReadDirectory(_) => Ok(Deferred), + DescriptorOperation::ReadDirectory(_) => Ok(Decision::Deferred), DescriptorOperation::ReadDirectoryEntry((_, path, entry)) => { Patterns::authorize(path, vec![entry.name], Read) } @@ -302,7 +283,7 @@ impl Latch for PathGlobLatch { DescriptorOperation::CreateDirectoryAt((_, path, args)) => { Patterns::authorize(path, vec![args.path], Mutate) } - DescriptorOperation::Stat(_) => Ok(Deferred), + DescriptorOperation::Stat(_) => Ok(Decision::Deferred), DescriptorOperation::StatAt((_, path, args)) => { Patterns::authorize(path, vec![args.path], Read) } @@ -331,7 +312,7 @@ impl Latch for PathGlobLatch { DescriptorOperation::UnlinkFileAt((_, path, args)) => { Patterns::authorize(path, vec![args.path], Mutate) } - DescriptorOperation::MetadataHash(_) => Ok(Deferred), + DescriptorOperation::MetadataHash(_) => Ok(Decision::Deferred), DescriptorOperation::MetadataHashAt((_, path, args)) => { Patterns::authorize(path, vec![args.path], Read) } @@ -357,27 +338,7 @@ impl Latch for PathGlobLatch { } } -/// The reason to deny with, `None` when the value is empty. Any value that is not the name of an -/// `error-code` case is `other` with the value as the message. -fn get_error_code(value: String) -> Option { - match value.as_str() { - "" => None, - "other" => Some(FilesystemErrorCode::Other(None)), - // error_code_from_name is defined by build.rs - name => Some(error_code_from_name(name).unwrap_or(FilesystemErrorCode::Other(Some(value)))), - } -} - -include!(concat!(env!("OUT_DIR"), "/error_code.rs")); - -wit_bindgen::generate!({ - path: "../wit", - world: "latch", - merge_structurally_equal_types: true, - generate_all -}); - -export!(PathGlobLatch); +filesystem_latch::export!(PathGlobLatch with_types_in filesystem_latch::bindings); #[cfg(test)] mod tests { @@ -676,31 +637,6 @@ mod tests { ); } - #[test] - fn every_error_code_is_parsed_by_name() { - for (name, code) in [ - ("not-permitted", FilesystemErrorCode::NotPermitted), - ("access", FilesystemErrorCode::Access), - ("cross-device", FilesystemErrorCode::CrossDevice), - ( - "illegal-byte-sequence", - FilesystemErrorCode::IllegalByteSequence, - ), - ("other", FilesystemErrorCode::Other(None)), - ( - "i-am-a-teapot", - FilesystemErrorCode::Other(Some("i-am-a-teapot".to_string())), - ), - ] { - assert_eq!( - format!("{:?}", get_error_code(name.to_string())), - format!("{:?}", Some(code)), - "{name}" - ); - } - assert!(get_error_code(String::new()).is_none()); - } - #[test] fn readonly_paths_deny_mutations_with_read_only() { let patterns = patterns(&[("readonly", "/data/**")]); diff --git a/components/wit/worlds.wit b/components/wit/worlds.wit index d21998f..aba5dfd 100644 --- a/components/wit/worlds.wit +++ b/components/wit/worlds.wit @@ -25,3 +25,7 @@ world latch-n { import latch3: componentized:filesystem/latch@0.3.0-dev; import latch4: componentized:filesystem/latch@0.3.0-dev; } + +world insecure-random { + import wasi:random/insecure@0.3.0; +} diff --git a/components/wkg.lock b/components/wkg.lock index d82b52c..b0389b6 100644 --- a/components/wkg.lock +++ b/components/wkg.lock @@ -28,3 +28,12 @@ registry = "wasi.dev" requirement = "=0.1.0-draft" version = "0.1.0-draft" digest = "sha256:09621a45b12b0a9cddc798517f778aac0e5ae4bd234077b3d70758d6cf625580" + +[[packages]] +name = "wasi:random" +registry = "wasi.dev" + +[[packages.versions]] +requirement = "=0.3.0" +version = "0.3.0" +digest = "sha256:daa3c8897a68fbfc58b95c2e1de1b7af5be8c647bbb2cfeaa54c1e5b1ba9b772" diff --git a/crates/filesystem-latch/Cargo.toml b/crates/filesystem-latch/Cargo.toml new file mode 100644 index 0000000..6bd1de0 --- /dev/null +++ b/crates/filesystem-latch/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "filesystem-latch" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +publish = false + +[dependencies] +wit-bindgen = { workspace = true } + +[build-dependencies] +heck = { workspace = true } +wit-parser = "0.259" diff --git a/crates/filesystem-latch/build.rs b/crates/filesystem-latch/build.rs new file mode 100644 index 0000000..fb2f1b7 --- /dev/null +++ b/crates/filesystem-latch/build.rs @@ -0,0 +1,121 @@ +//! Generate code from the same WIT the bindings are generated from, so a case added to the WIT is +//! handled without changing the latches: +//! +//! - `error_code_from_name`, which parses the kebab-case name of every case of +//! `wasi:filesystem/types#error-code` without a payload. +//! - `operation_name`, the name of every latch operation, e.g. `descriptor.open-at`, and +//! `INTERFACE_NAMES` and `OPERATION_NAMES`, every interface and operation name. + +use std::fmt::Write as _; +use std::path::PathBuf; + +use heck::ToUpperCamelCase; +use wit_parser::{Interface, Resolve, Type, TypeDefKind}; + +const WIT: &str = "../../components/wit"; + +fn main() { + println!("cargo:rerun-if-changed={WIT}"); + + let mut resolve = Resolve::default(); + resolve.push_path(WIT).expect("WIT must parse"); + + let out = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + std::fs::write(out.join("error_code.rs"), error_code(&resolve)).unwrap(); + std::fs::write(out.join("operation.rs"), operation(&resolve)).unwrap(); +} + +fn interface<'a>(resolve: &'a Resolve, namespace: &str, package: &str, name: &str) -> &'a Interface { + resolve + .interfaces + .iter() + .map(|(_, interface)| interface) + .find(|interface| { + interface.name.as_deref() == Some(name) + && interface.package.is_some_and(|id| { + let package_name = &resolve.packages[id].name; + package_name.namespace == namespace && package_name.name == package + }) + }) + .unwrap_or_else(|| panic!("{namespace}:{package}/{name} must be in the WIT")) +} + +fn error_code(resolve: &Resolve) -> String { + let types = interface(resolve, "wasi", "filesystem", "types"); + let error_code = types.types["error-code"]; + let TypeDefKind::Variant(variant) = &resolve.types[error_code].kind else { + panic!("wasi:filesystem/types#error-code must be a variant"); + }; + + let mut arms = String::new(); + for case in &variant.cases { + match (&case.ty, case.name.as_str()) { + (None, name) => { + // the case name as wit-bindgen generates it + let ident = name.to_upper_camel_case(); + writeln!(arms, " {name:?} => Some(FilesystemErrorCode::{ident}),").unwrap(); + } + // `other` carries a message, it is parsed by hand + (Some(_), "other") => {} + (Some(_), name) => panic!( + "error-code case `{name}` has a payload, extend build.rs to parse it" + ), + } + } + + format!( + "/// The `error-code` case without a payload with the kebab-case name, e.g. `not-permitted`.\n\ + pub fn error_code_from_name(name: &str) -> Option {{\n \ + match name {{\n{arms} _ => None,\n }}\n\ + }}\n" + ) +} + +/// The latch's `operation` variant has a case for each interface, its payload is a variant with a +/// case for each operation of the interface, e.g. `operation::descriptor(descriptor-operation::open-at)`. +fn operation(resolve: &Resolve) -> String { + let latch = interface(resolve, "componentized", "filesystem", "latch"); + let TypeDefKind::Variant(operation) = &resolve.types[latch.types["operation"]].kind else { + panic!("componentized:filesystem/latch#operation must be a variant"); + }; + + let mut interfaces = vec![]; + let mut operations = vec![]; + let mut arms = String::new(); + for interface_case in &operation.cases { + let Some(Type::Id(id)) = interface_case.ty else { + panic!("operation case `{}` must have a variant payload", interface_case.name); + }; + let typedef = &resolve.types[id]; + let TypeDefKind::Variant(variant) = &typedef.kind else { + panic!("operation case `{}` must have a variant payload", interface_case.name); + }; + let interface_name = &interface_case.name; + let interface_ident = interface_name.to_upper_camel_case(); + let variant_ident = typedef.name.as_deref().expect("a named variant").to_upper_camel_case(); + interfaces.push(format!("{interface_name:?}")); + writeln!(arms, " Operation::{interface_ident}(operation) => match operation {{").unwrap(); + for case in &variant.cases { + let name = format!("{interface_name}.{}", case.name); + let ident = case.name.to_upper_camel_case(); + let payload = if case.ty.is_some() { "(_)" } else { "" }; + writeln!(arms, " {variant_ident}::{ident}{payload} => {name:?},").unwrap(); + operations.push(format!("{name:?}")); + } + writeln!(arms, " }},").unwrap(); + } + + format!( + "/// The name of the operation, the interface and operation cases of the latch WIT, e.g.\n\ + /// `descriptor.open-at`.\n\ + pub fn operation_name(operation: &Operation) -> &'static str {{\n \ + match operation {{\n{arms} }}\n\ + }}\n\n\ + /// The name of every interface of the operations, e.g. `descriptor`.\n\ + pub const INTERFACE_NAMES: &[&str] = &[{interfaces}];\n\n\ + /// The name of every operation, e.g. `descriptor.open-at`.\n\ + pub const OPERATION_NAMES: &[&str] = &[{operations}];\n", + interfaces = interfaces.join(", "), + operations = operations.join(", "), + ) +} diff --git a/crates/filesystem-latch/src/lib.rs b/crates/filesystem-latch/src/lib.rs new file mode 100644 index 0000000..91ceb93 --- /dev/null +++ b/crates/filesystem-latch/src/lib.rs @@ -0,0 +1,140 @@ +//! Bindings and helpers for latch components, which implement the `latch` world. +//! +//! The bindings are generated once here and shared by every latch component. A latch implements +//! [`Latch`] and exports it with [`export!`]: +//! +//! ```ignore +//! struct MyLatch {} +//! +//! impl filesystem_latch::Latch for MyLatch { +//! // ... +//! } +//! +//! filesystem_latch::export!(MyLatch with_types_in filesystem_latch::bindings); +//! ``` +//! +//! Imports a latch does not use, like `wasi:config/store` or the wrapped latch, are dropped from +//! the built component. + +pub mod bindings { + wit_bindgen::generate!({ + path: "../../components/wit", + world: "latch", + pub_export_macro: true, + merge_structurally_equal_types: true, + generate_all + }); +} + +#[macro_export] +macro_rules! export { + ($($t:tt)*) => { + $crate::bindings::export!($($t)*); + }; +} + +pub use bindings::exports::componentized::filesystem::latch::{ + Decision, DescriptorLinkAtArgs, DescriptorMetadataHashAtArgs, DescriptorOpenAtArgs, + DescriptorOperation, DescriptorSetTimesAtArgs, DescriptorStatAtArgs, ErrorCode, + FilesystemErrorCode, Guest as Latch, Operation, PreopensOperation, +}; +pub use bindings::wasi::filesystem::types::{DescriptorFlags, OpenFlags, PathFlags}; + +/// Log a message from a latch with `wasi:logging`. +pub fn log(level: bindings::wasi::logging::logging::Level, message: &str) { + bindings::wasi::logging::logging::log(level, "componentized-latch", message); +} + +/// Log a critical message from a latch, formatted like `format!`. +#[macro_export] +macro_rules! critical { + ($($arg:tt)*) => { + $crate::log($crate::bindings::wasi::logging::logging::Level::Critical, &format!($($arg)*)) + }; +} + +/// Log a warning from a latch, formatted like `format!`. +#[macro_export] +macro_rules! warn { + ($($arg:tt)*) => { + $crate::log($crate::bindings::wasi::logging::logging::Level::Warn, &format!($($arg)*)) + }; +} + +/// Load the latch's config from `wasi:config/store` and parse it. +/// +/// When the config cannot be read or parsed, the cause is logged and an `invalid-config` error +/// naming the latch is returned. Parse errors describe the offending entry, e.g. +/// `KEY= VALUE= ERROR=`. +pub fn load_config( + latch_name: &str, + parse: impl FnOnce(Vec<(String, String)>) -> Result, +) -> Result { + use bindings::wasi::config::store; + + store::get_all() + .map_err(|err| match err { + store::Error::Upstream(message) => format!("ERROR=upstream: {message}"), + store::Error::Io(message) => format!("ERROR=io: {message}"), + }) + .and_then(parse) + .map_err(|message| { + critical!("Invalid config LATCH={latch_name} {message}"); + ErrorCode::InvalidConfig(latch_name.to_string()) + }) +} + +/// The reason to deny with, configured with a `reason` key, `None` when the value is empty. Any +/// value that is not the name of an `error-code` case, e.g. `access` or `no-entry`, is `other` +/// with the value as the message. +pub fn reason(value: String) -> Option { + match value.as_str() { + "" => None, + "other" => Some(FilesystemErrorCode::Other(None)), + name => Some(error_code_from_name(name).unwrap_or(FilesystemErrorCode::Other(Some(value)))), + } +} + +include!(concat!(env!("OUT_DIR"), "/error_code.rs")); +include!(concat!(env!("OUT_DIR"), "/operation.rs")); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn operations_are_named_by_interface() { + assert_eq!(INTERFACE_NAMES, ["preopens", "descriptor"]); + assert!(OPERATION_NAMES.contains(&"preopens.get-directories")); + assert!(OPERATION_NAMES.contains(&"preopens.get-directories-item")); + assert!(OPERATION_NAMES.contains(&"descriptor.open-at")); + assert!(OPERATION_NAMES.contains(&"descriptor.read-directory-entry")); + assert!(OPERATION_NAMES.iter().all(|name| { + INTERFACE_NAMES + .iter() + .any(|interface| name.starts_with(&format!("{interface}."))) + })); + } + + #[test] + fn reasons_are_parsed_by_name() { + for (name, code) in [ + ("not-permitted", FilesystemErrorCode::NotPermitted), + ("access", FilesystemErrorCode::Access), + ("cross-device", FilesystemErrorCode::CrossDevice), + ("illegal-byte-sequence", FilesystemErrorCode::IllegalByteSequence), + ("other", FilesystemErrorCode::Other(None)), + ( + "custom-reason", + FilesystemErrorCode::Other(Some("custom-reason".to_string())), + ), + ] { + assert_eq!( + format!("{:?}", reason(name.to_string())), + format!("{:?}", Some(code)), + "{name}" + ); + } + assert!(reason(String::new()).is_none()); + } +} diff --git a/crates/test-harness/src/lib.rs b/crates/test-harness/src/lib.rs index cf6efae..4b9e8d0 100644 --- a/crates/test-harness/src/lib.rs +++ b/crates/test-harness/src/lib.rs @@ -246,6 +246,11 @@ impl LogEntry { Self::log(Level::Error, context, message) } + /// A critical message logged by the test subject. + pub fn critical(context: impl Into, message: impl Into) -> Self { + Self::log(Level::Critical, context, message) + } + fn log(level: Level, context: impl Into, message: impl Into) -> Self { Self { level, From 00307ee1210c6855ee270f929697dcca5a74cca1 Mon Sep 17 00:00:00 2001 From: Scott Andrews Date: Sun, 18 Oct 2026 20:14:57 -0400 Subject: [PATCH 21/21] drop legacy tests Signed-off-by: Scott Andrews --- tests/filesystem-cli/Cargo.toml | 8 --- tests/filesystem-cli/src/main.rs | 100 ------------------------------- 2 files changed, 108 deletions(-) delete mode 100644 tests/filesystem-cli/Cargo.toml delete mode 100644 tests/filesystem-cli/src/main.rs diff --git a/tests/filesystem-cli/Cargo.toml b/tests/filesystem-cli/Cargo.toml deleted file mode 100644 index 6b2ecad..0000000 --- a/tests/filesystem-cli/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -[package] -name = "filesystem-cli" -version = "0.1.0" -edition = "2021" -license = "Apache-2.0" - -[dependencies] -clap = { version = "4.6.1", features = ["derive"] } diff --git a/tests/filesystem-cli/src/main.rs b/tests/filesystem-cli/src/main.rs deleted file mode 100644 index 1eda915..0000000 --- a/tests/filesystem-cli/src/main.rs +++ /dev/null @@ -1,100 +0,0 @@ -use std::{ - fs::{self, File, OpenOptions}, - io, - path::PathBuf, -}; - -use clap::{Parser, Subcommand}; - -/// componentized filesystem CLI -#[derive(Debug, Parser)] // requires `derive` feature -#[command(name = "filesystem")] -#[command(about = "componentized filesystem CLI", long_about = None)] -struct FilesystemCli { - #[command(subcommand)] - command: Commands, -} - -#[derive(Debug, Subcommand, Clone)] -enum Commands { - /// List items in a directory - List { - #[arg()] - path: PathBuf, - }, - - /// Read a file - #[command(arg_required_else_help = true)] - Read { - #[arg()] - path: PathBuf, - }, - - /// Write a file - #[command(arg_required_else_help = true)] - Write { - #[arg()] - path: PathBuf, - }, - - /// Append to a file - #[command(arg_required_else_help = true)] - Append { - #[arg()] - path: PathBuf, - }, - - /// Move a file - #[command(arg_required_else_help = true)] - Move { - #[arg()] - from: PathBuf, - - #[arg()] - to: PathBuf, - }, - - /// Remove a file - #[command(arg_required_else_help = true)] - Remove { - #[arg()] - path: PathBuf, - }, -} - -fn main() -> Result<(), std::io::Error> { - match FilesystemCli::parse().command { - Commands::List { path } => { - for entry in fs::read_dir(path)? { - let entry = entry?; - println!("{:?}", entry.file_name()); - } - Ok(()) - } - Commands::Read { path } => { - let mut from = OpenOptions::new().read(true).open(path)?; - let mut to = io::stdout(); - io::copy(&mut from, &mut to)?; - Ok(()) - } - Commands::Write { path } => { - let mut from = io::stdin(); - let mut to = File::create(path)?; - io::copy(&mut from, &mut to)?; - Ok(()) - } - Commands::Append { path } => { - let mut from = io::stdin(); - let mut to = OpenOptions::new().create(true).append(true).open(path)?; - io::copy(&mut from, &mut to)?; - Ok(()) - } - Commands::Move { from, to } => fs::rename(from, to), - Commands::Remove { path } => { - if path.is_dir() { - return fs::remove_dir_all(path); - } - fs::remove_file(path) - } - } -}