//! Filesystem layer with commit-on-close semantics. //! //! The layer wraps the wasmtime-wasi `wasi:filesystem` implementation. For //! mounts with `commit_on_close`, an open for write goes to a hidden temporary //! file next to the target. When the guest drops the descriptor (close), the //! layer renames the temporary file into place. Until then, no other reader //! sees the change. If the instance traps or is killed, the changes are lost. use std::collections::HashMap; use std::path::{Component, Path, PathBuf}; use wasmtime::component::{HasData, Resource}; use wasmtime_wasi::filesystem::{Descriptor, WasiFilesystemCtxView}; use wasmtime_wasi::p2::bindings::filesystem::types::{ self, ErrorCode, HostDescriptor, HostDirectoryEntryStream, }; use wasmtime_wasi::p2::bindings::filesystem::preopens; use wasmtime_wasi::p2::{DynInputStream, DynOutputStream, FsError, FsResult}; /// Name prefix of uncommitted files. The layer hides these names from guests. pub const TMP_PREFIX: &str = ".sausage-tmp-"; #[derive(Clone, Debug)] pub struct Mount { pub guest: String, pub host: PathBuf, pub writable: bool, } #[derive(Clone)] struct DirInfo { host: PathBuf, root: PathBuf, commit: bool, } struct Pending { tmp: PathBuf, target: PathBuf, } /// Per-instance state of the layer. pub struct FsExt { mounts: Vec, dirs: HashMap, pending: HashMap, counter: u64, } impl FsExt { pub fn new(mounts: Vec) -> Self { Self { mounts, dirs: HashMap::new(), pending: HashMap::new(), counter: 0 } } /// Commit every open write. Use this when the guest exits normally, /// because process exit closes all files. pub fn commit_all(&mut self) { for (_, p) in self.pending.drain() { commit(p); } } } impl Drop for FsExt { /// Discard writes that were not committed (trap, kill). fn drop(&mut self) { for (_, p) in self.pending.drain() { let _ = std::fs::remove_file(&p.tmp); } } } fn commit(p: Pending) { if let Err(e) = std::fs::rename(&p.tmp, &p.target) { eprintln!("sausage: commit of {} failed: {e}", p.target.display()); let _ = std::fs::remove_file(&p.tmp); } } fn is_tmp_name(path: &str) -> bool { path.rsplit('/').next().is_some_and(|n| n.starts_with(TMP_PREFIX)) } /// Join `rel` to the directory `dir` lexically. Return `None` if the result /// leaves the mount root. fn resolve(dir: &DirInfo, rel: &str) -> Option { let mut out = dir.host.clone(); for c in Path::new(rel).components() { match c { Component::Normal(n) => out.push(n), Component::CurDir => {} Component::ParentDir => { if !out.pop() { return None; } } Component::RootDir | Component::Prefix(_) => return None, } } out.starts_with(&dir.root).then_some(out) } fn io_err(e: std::io::Error) -> FsError { ErrorCode::from(e).into() } pub struct ActorFs<'a> { pub inner: WasiFilesystemCtxView<'a>, pub ext: &'a mut FsExt, } pub struct ActorFsData; impl HasData for ActorFsData { type Data<'a> = ActorFs<'a>; } impl preopens::Host for ActorFs<'_> { fn get_directories(&mut self) -> wasmtime::Result, String)>> { let dirs = preopens::Host::get_directories(&mut self.inner)?; for (r, name) in &dirs { if let Some(m) = self.ext.mounts.iter().find(|m| &m.guest == name) { self.ext.dirs.insert( r.rep(), DirInfo { host: m.host.clone(), root: m.host.clone(), commit: m.writable }, ); } } Ok(dirs) } } impl types::Host for ActorFs<'_> { fn convert_error_code(&mut self, err: FsError) -> wasmtime::Result { types::Host::convert_error_code(&mut self.inner, err) } fn filesystem_error_code( &mut self, err: Resource, ) -> wasmtime::Result> { types::Host::filesystem_error_code(&mut self.inner, err) } } impl HostDescriptor for ActorFs<'_> { async fn advise( &mut self, fd: Resource, offset: types::Filesize, len: types::Filesize, advice: types::Advice, ) -> FsResult<()> { HostDescriptor::advise(&mut self.inner, fd, offset, len, advice).await } async fn sync_data(&mut self, fd: Resource) -> FsResult<()> { HostDescriptor::sync_data(&mut self.inner, fd).await } async fn get_flags(&mut self, fd: Resource) -> FsResult { HostDescriptor::get_flags(&mut self.inner, fd).await } async fn get_type(&mut self, fd: Resource) -> FsResult { HostDescriptor::get_type(&mut self.inner, fd).await } async fn set_size(&mut self, fd: Resource, size: types::Filesize) -> FsResult<()> { HostDescriptor::set_size(&mut self.inner, fd, size).await } async fn set_times( &mut self, fd: Resource, atim: types::NewTimestamp, mtim: types::NewTimestamp, ) -> FsResult<()> { HostDescriptor::set_times(&mut self.inner, fd, atim, mtim).await } async fn read( &mut self, fd: Resource, len: types::Filesize, offset: types::Filesize, ) -> FsResult<(Vec, bool)> { HostDescriptor::read(&mut self.inner, fd, len, offset).await } async fn write( &mut self, fd: Resource, buf: Vec, offset: types::Filesize, ) -> FsResult { HostDescriptor::write(&mut self.inner, fd, buf, offset).await } async fn read_directory( &mut self, fd: Resource, ) -> FsResult> { HostDescriptor::read_directory(&mut self.inner, fd).await } async fn sync(&mut self, fd: Resource) -> FsResult<()> { HostDescriptor::sync(&mut self.inner, fd).await } async fn create_directory_at(&mut self, fd: Resource, path: String) -> FsResult<()> { HostDescriptor::create_directory_at(&mut self.inner, fd, path).await } async fn stat(&mut self, fd: Resource) -> FsResult { HostDescriptor::stat(&mut self.inner, fd).await } async fn stat_at( &mut self, fd: Resource, path_flags: types::PathFlags, path: String, ) -> FsResult { if is_tmp_name(&path) { return Err(ErrorCode::NoEntry.into()); } HostDescriptor::stat_at(&mut self.inner, fd, path_flags, path).await } async fn set_times_at( &mut self, fd: Resource, path_flags: types::PathFlags, path: String, atim: types::NewTimestamp, mtim: types::NewTimestamp, ) -> FsResult<()> { HostDescriptor::set_times_at(&mut self.inner, fd, path_flags, path, atim, mtim).await } async fn link_at( &mut self, fd: Resource, old_path_flags: types::PathFlags, old_path: String, new_descriptor: Resource, new_path: String, ) -> FsResult<()> { HostDescriptor::link_at(&mut self.inner, fd, old_path_flags, old_path, new_descriptor, new_path).await } async fn open_at( &mut self, fd: Resource, path_flags: types::PathFlags, path: String, oflags: types::OpenFlags, flags: types::DescriptorFlags, ) -> FsResult> { if is_tmp_name(&path) { return Err(ErrorCode::NoEntry.into()); } let parent = self.ext.dirs.get(&fd.rep()).cloned(); // Commit-on-close: redirect writes to a hidden temporary file. if let Some(dir) = parent.as_ref().filter(|d| d.commit) && flags.contains(types::DescriptorFlags::WRITE) && !oflags.contains(types::OpenFlags::DIRECTORY) { let target = resolve(dir, &path).ok_or(FsError::from(ErrorCode::NotPermitted))?; let meta = std::fs::metadata(&target).ok(); if meta.as_ref().is_some_and(|m| m.is_dir()) { return Err(ErrorCode::IsDirectory.into()); } let exists = meta.is_some(); if exists && oflags.contains(types::OpenFlags::EXCLUSIVE) { return Err(ErrorCode::Exist.into()); } if !exists && !oflags.contains(types::OpenFlags::CREATE) { return Err(ErrorCode::NoEntry.into()); } let target_dir = target.parent().ok_or(FsError::from(ErrorCode::NotPermitted))?; if !target_dir.is_dir() { return Err(ErrorCode::NoEntry.into()); } self.ext.counter += 1; let tmp_name = format!("{TMP_PREFIX}{}-{}", std::process::id(), self.ext.counter); let tmp_rel = match path.rsplit_once('/') { Some((d, _)) => format!("{d}/{tmp_name}"), None => tmp_name.clone(), }; let tmp = target_dir.join(&tmp_name); let truncate = oflags.contains(types::OpenFlags::TRUNCATE); if exists && !truncate { std::fs::copy(&target, &tmp).map_err(io_err)?; } let oflags = (oflags | types::OpenFlags::CREATE) & !(types::OpenFlags::EXCLUSIVE | types::OpenFlags::TRUNCATE); return match HostDescriptor::open_at(&mut self.inner, fd, path_flags, tmp_rel, oflags, flags).await { Ok(r) => { self.ext.pending.insert(r.rep(), Pending { tmp, target }); Ok(r) } Err(e) => { let _ = std::fs::remove_file(&tmp); Err(e) } }; } let r = HostDescriptor::open_at(&mut self.inner, fd, path_flags, path.clone(), oflags, flags).await?; if let Some(dir) = parent && matches!(self.inner.table.get(&r)?, Descriptor::Dir(_)) && let Some(host) = resolve(&dir, &path) { self.ext.dirs.insert(r.rep(), DirInfo { host, root: dir.root, commit: dir.commit }); } Ok(r) } fn drop(&mut self, fd: Resource) -> wasmtime::Result<()> { let rep = fd.rep(); HostDescriptor::drop(&mut self.inner, fd)?; self.ext.dirs.remove(&rep); if let Some(p) = self.ext.pending.remove(&rep) { commit(p); } Ok(()) } async fn readlink_at(&mut self, fd: Resource, path: String) -> FsResult { HostDescriptor::readlink_at(&mut self.inner, fd, path).await } async fn remove_directory_at(&mut self, fd: Resource, path: String) -> FsResult<()> { HostDescriptor::remove_directory_at(&mut self.inner, fd, path).await } async fn rename_at( &mut self, fd: Resource, old_path: String, new_fd: Resource, new_path: String, ) -> FsResult<()> { if is_tmp_name(&old_path) || is_tmp_name(&new_path) { return Err(ErrorCode::NoEntry.into()); } HostDescriptor::rename_at(&mut self.inner, fd, old_path, new_fd, new_path).await } async fn symlink_at( &mut self, _fd: Resource, _src_path: String, _dest_path: String, ) -> FsResult<()> { // Symlinks would break the lexical path tracking of commit-on-close. Err(ErrorCode::NotPermitted.into()) } async fn unlink_file_at(&mut self, fd: Resource, path: String) -> FsResult<()> { if is_tmp_name(&path) { return Err(ErrorCode::NoEntry.into()); } HostDescriptor::unlink_file_at(&mut self.inner, fd, path).await } fn read_via_stream( &mut self, fd: Resource, offset: types::Filesize, ) -> FsResult> { HostDescriptor::read_via_stream(&mut self.inner, fd, offset) } fn write_via_stream( &mut self, fd: Resource, offset: types::Filesize, ) -> FsResult> { HostDescriptor::write_via_stream(&mut self.inner, fd, offset) } fn append_via_stream(&mut self, fd: Resource) -> FsResult> { HostDescriptor::append_via_stream(&mut self.inner, fd) } async fn is_same_object( &mut self, a: Resource, b: Resource, ) -> wasmtime::Result { HostDescriptor::is_same_object(&mut self.inner, a, b).await } async fn metadata_hash(&mut self, fd: Resource) -> FsResult { HostDescriptor::metadata_hash(&mut self.inner, fd).await } async fn metadata_hash_at( &mut self, fd: Resource, path_flags: types::PathFlags, path: String, ) -> FsResult { HostDescriptor::metadata_hash_at(&mut self.inner, fd, path_flags, path).await } } impl HostDirectoryEntryStream for ActorFs<'_> { async fn read_directory_entry( &mut self, stream: Resource, ) -> FsResult> { let rep = stream.rep(); loop { let entry = HostDirectoryEntryStream::read_directory_entry( &mut self.inner, Resource::new_borrow(rep), ) .await?; match entry { Some(e) if e.name.starts_with(TMP_PREFIX) => continue, other => return Ok(other), } } } fn drop(&mut self, stream: Resource) -> wasmtime::Result<()> { HostDirectoryEntryStream::drop(&mut self.inner, stream) } }