Files
sausage/vfs/src/adapter.rs
T
surma 151550242b sausage-vfs: review fixes (fallible growth, symlink flags, stream cancel, sized reads, timestamps, error kinds, linker shadowing)
API: Layer::stat(path, follow), Layer::readlink, Open::follow,
Stat::symlink and Entry::symlink. Process::idle documents the shutdown
order: abort, await the task, await idle, release locks.
2026-10-23 00:49:46 +01:00

518 lines
17 KiB
Rust

//! Wasmtime 49 filesystem interfaces backed exclusively by the virtual mount namespace.
//!
//! Directory descriptors retain their absolute virtual path after a directory rename.
//! Relative operations through those descriptors use the old path, not the moved folder.
//! Reopen the moved folder before further relative operations.
//! Object comparisons and metadata hashes use paths, not inode identity (review F12).
//! Hard links remain unsupported, including tempfile persist_noclobber.
use crate::streams::{Reader, Writer};
use crate::*;
use wasmtime::component::{HasData, Linker, Resource, ResourceTable};
use wasmtime_wasi::p2::{
DynInputStream, DynOutputStream, FsError, FsResult,
bindings::filesystem::{
preopens,
types::{
self, DescriptorFlags, DescriptorType, DirectoryEntry, ErrorCode, HostDescriptor,
HostDirectoryEntryStream, OpenFlags,
},
},
};
/// Store-owned filesystem namespace.
pub struct Adapter {
pub mounts: Arc<Mounts>,
}
impl Adapter {
pub fn new(mounts: Mounts) -> Self {
Self {
mounts: Arc::new(mounts),
}
}
}
/// The filesystem and shared WASI resource table borrowed from store state.
pub struct View<'a> {
pub adapter: &'a mut Adapter,
pub table: &'a mut ResourceTable,
}
pub struct Vfs;
impl HasData for Vfs {
type Data<'a> = View<'a>;
}
/// Replace filesystem imports after adding the standard asynchronous WASI linker interfaces.
pub fn add_to_linker<T: Send + 'static>(
linker: &mut Linker<T>,
view: fn(&mut T) -> View<'_>,
) -> wasmtime::Result<()> {
linker.allow_shadowing(true);
let result = (|| {
types::add_to_linker::<T, Vfs>(linker, view)?;
preopens::add_to_linker::<T, Vfs>(linker, view)
})();
linker.allow_shadowing(false);
result?;
Ok(())
}
#[derive(Clone)]
struct Descriptor {
path: String,
file: Arc<dyn File>,
flags: DescriptorFlags,
}
struct Entries(std::vec::IntoIter<DirectoryEntry>);
fn code(e: &io::Error) -> ErrorCode {
use io::ErrorKind::*;
match e.kind() {
NotADirectory => ErrorCode::NotDirectory,
IsADirectory => ErrorCode::IsDirectory,
DirectoryNotEmpty => ErrorCode::NotEmpty,
ReadOnlyFilesystem => ErrorCode::ReadOnly,
CrossesDevices => ErrorCode::CrossDevice,
Unsupported => ErrorCode::Unsupported,
FileTooLarge => ErrorCode::FileTooLarge,
ResourceBusy => ErrorCode::Busy,
_ => ErrorCode::from(e),
}
}
fn errno(e: io::Error) -> FsError {
code(&e).into()
}
fn stat(s: Stat) -> types::DescriptorStat {
fn timestamp(t: SystemTime) -> Option<types::Datetime> {
let d = t.duration_since(SystemTime::UNIX_EPOCH).ok()?;
Some(types::Datetime {
seconds: d.as_secs(),
nanoseconds: d.subsec_nanos(),
})
}
types::DescriptorStat {
type_: if s.symlink {
types::DescriptorType::SymbolicLink
} else if s.directory {
types::DescriptorType::Directory
} else {
types::DescriptorType::RegularFile
},
link_count: 1,
size: s.size,
data_access_timestamp: s.accessed.and_then(timestamp),
data_modification_timestamp: s.modified.and_then(timestamp),
status_change_timestamp: None,
}
}
fn time(t: types::NewTimestamp) -> FsResult<Option<SystemTime>> {
match t {
types::NewTimestamp::NoChange => Ok(None),
types::NewTimestamp::Now => Ok(Some(SystemTime::now())),
types::NewTimestamp::Timestamp(t) => {
let duration = std::time::Duration::from_secs(t.seconds)
.checked_add(std::time::Duration::from_nanos(t.nanoseconds as u64))
.ok_or(ErrorCode::Overflow)?;
Ok(Some(
SystemTime::UNIX_EPOCH
.checked_add(duration)
.ok_or(ErrorCode::Overflow)?,
))
}
}
}
fn hash(path: &str) -> types::MetadataHashValue {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
path.hash(&mut h);
types::MetadataHashValue {
lower: h.finish(),
upper: 0,
}
}
impl View<'_> {
fn fd(&self, fd: &Resource<types::Descriptor>) -> FsResult<Descriptor> {
Ok(self
.table
.get(&Resource::<Descriptor>::new_borrow(fd.rep()))?
.clone())
}
fn path(&self, fd: &Resource<types::Descriptor>, path: &str) -> FsResult<String> {
Ok(resolve(&self.fd(fd)?.path, path))
}
fn push(&mut self, descriptor: Descriptor) -> FsResult<Resource<types::Descriptor>> {
Ok(Resource::new_own(self.table.push(descriptor)?.rep()))
}
}
impl preopens::Host for View<'_> {
fn get_directories(&mut self) -> wasmtime::Result<Vec<(Resource<types::Descriptor>, String)>> {
// The root descriptor needs no IO: directory operations use its virtual path.
let file: Arc<dyn File> = Arc::new(Root);
let fd = self.push(Descriptor {
path: "/".into(),
file,
flags: DescriptorFlags::READ | DescriptorFlags::MUTATE_DIRECTORY,
})?;
Ok(vec![(fd, "/".into())])
}
}
struct Root;
#[async_trait]
impl File for Root {
async fn stat(&self) -> Result<Stat> {
Ok(Stat {
directory: true,
symlink: false,
size: 0,
accessed: None,
modified: None,
})
}
async fn read(&self, _: u64, _: usize) -> Result<Vec<u8>> {
Err(error(io::ErrorKind::IsADirectory))
}
async fn write(&self, _: u64, _: Vec<u8>) -> Result<usize> {
Err(error(io::ErrorKind::IsADirectory))
}
async fn append(&self, _: Vec<u8>) -> Result<usize> {
Err(error(io::ErrorKind::IsADirectory))
}
async fn set_size(&self, _: u64) -> Result<()> {
Err(error(io::ErrorKind::IsADirectory))
}
async fn sync(&self) -> Result<()> {
Ok(())
}
async fn set_times(&self, _: Option<SystemTime>, _: Option<SystemTime>) -> Result<()> {
Err(error(io::ErrorKind::Unsupported))
}
}
impl types::Host for View<'_> {
fn convert_error_code(&mut self, err: FsError) -> wasmtime::Result<ErrorCode> {
err.downcast()
}
fn filesystem_error_code(
&mut self,
err: Resource<wasmtime::Error>,
) -> wasmtime::Result<Option<ErrorCode>> {
Ok(self.table.get(&err)?.downcast_ref::<io::Error>().map(code))
}
}
impl HostDescriptor for View<'_> {
async fn advise(
&mut self,
fd: Resource<types::Descriptor>,
offset: types::Filesize,
len: types::Filesize,
advice: types::Advice,
) -> FsResult<()> {
let _ = (fd, offset, len, advice);
Ok(())
}
async fn sync_data(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
self.fd(&fd)?.file.sync().await.map_err(errno)
}
async fn get_flags(&mut self, fd: Resource<types::Descriptor>) -> FsResult<DescriptorFlags> {
Ok(self.fd(&fd)?.flags)
}
async fn get_type(&mut self, fd: Resource<types::Descriptor>) -> FsResult<DescriptorType> {
Ok(stat(self.fd(&fd)?.file.stat().await.map_err(errno)?).type_)
}
async fn set_size(
&mut self,
fd: Resource<types::Descriptor>,
size: types::Filesize,
) -> FsResult<()> {
self.fd(&fd)?.file.set_size(size).await.map_err(errno)
}
async fn set_times(
&mut self,
fd: Resource<types::Descriptor>,
atim: types::NewTimestamp,
mtim: types::NewTimestamp,
) -> FsResult<()> {
self.fd(&fd)?
.file
.set_times(time(atim)?, time(mtim)?)
.await
.map_err(errno)
}
async fn read(
&mut self,
fd: Resource<types::Descriptor>,
len: types::Filesize,
offset: types::Filesize,
) -> FsResult<(Vec<u8>, bool)> {
let bytes = self
.fd(&fd)?
.file
.read(offset, len as usize)
.await
.map_err(errno)?;
let eof = bytes.is_empty();
Ok((bytes, eof))
}
async fn write(
&mut self,
fd: Resource<types::Descriptor>,
buf: Vec<u8>,
offset: types::Filesize,
) -> FsResult<types::Filesize> {
self.fd(&fd)?
.file
.write(offset, buf)
.await
.map(|n| n as u64)
.map_err(errno)
}
async fn read_directory(
&mut self,
fd: Resource<types::Descriptor>,
) -> FsResult<Resource<types::DirectoryEntryStream>> {
let d = self.fd(&fd)?;
let entries = self
.adapter
.mounts
.list(&d.path)
.await
.map_err(errno)?
.into_iter()
.map(|e| DirectoryEntry {
name: e.name,
type_: if e.symlink {
types::DescriptorType::SymbolicLink
} else if e.directory {
types::DescriptorType::Directory
} else {
types::DescriptorType::RegularFile
},
})
.collect::<Vec<_>>();
Ok(Resource::new_own(
self.table.push(Entries(entries.into_iter()))?.rep(),
))
}
async fn sync(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
self.fd(&fd)?.file.sync().await.map_err(errno)
}
async fn create_directory_at(
&mut self,
fd: Resource<types::Descriptor>,
path: String,
) -> FsResult<()> {
let p = self.path(&fd, &path)?;
self.adapter.mounts.mkdir(&p).await.map_err(errno)
}
async fn stat(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::DescriptorStat> {
Ok(stat(self.fd(&fd)?.file.stat().await.map_err(errno)?))
}
async fn stat_at(
&mut self,
fd: Resource<types::Descriptor>,
path_flags: types::PathFlags,
path: String,
) -> FsResult<types::DescriptorStat> {
let follow = path_flags.contains(types::PathFlags::SYMLINK_FOLLOW);
let p = self.path(&fd, &path)?;
Ok(stat(
self.adapter.mounts.stat(&p, follow).await.map_err(errno)?,
))
}
async fn set_times_at(
&mut self,
fd: Resource<types::Descriptor>,
path_flags: types::PathFlags,
path: String,
atim: types::NewTimestamp,
mtim: types::NewTimestamp,
) -> FsResult<()> {
let follow = path_flags.contains(types::PathFlags::SYMLINK_FOLLOW);
let p = self.path(&fd, &path)?;
self.adapter
.mounts
.metadata_file(&p, follow)
.await
.map_err(errno)?
.set_times(time(atim)?, time(mtim)?)
.await
.map_err(errno)
}
async fn link_at(
&mut self,
fd: Resource<types::Descriptor>,
old_path_flags: types::PathFlags,
old_path: String,
new_descriptor: Resource<types::Descriptor>,
new_path: String,
) -> FsResult<()> {
let _ = (fd, old_path_flags, old_path, new_descriptor, new_path);
Err(ErrorCode::Unsupported.into())
}
async fn open_at(
&mut self,
fd: Resource<types::Descriptor>,
path_flags: types::PathFlags,
path: String,
oflags: OpenFlags,
flags: DescriptorFlags,
) -> FsResult<Resource<types::Descriptor>> {
let follow = path_flags.contains(types::PathFlags::SYMLINK_FOLLOW);
let path = self.path(&fd, &path)?;
let file = self
.adapter
.mounts
.open(
&path,
Open {
follow,
write: flags.contains(DescriptorFlags::WRITE),
create: oflags.contains(OpenFlags::CREATE),
exclusive: oflags.contains(OpenFlags::EXCLUSIVE),
truncate: oflags.contains(OpenFlags::TRUNCATE),
directory: oflags.contains(OpenFlags::DIRECTORY),
},
)
.await
.map_err(errno)?;
self.push(Descriptor { path, file, flags })
}
fn drop(&mut self, fd: Resource<types::Descriptor>) -> wasmtime::Result<()> {
self.table
.delete(Resource::<Descriptor>::new_own(fd.rep()))?;
Ok(())
}
async fn readlink_at(
&mut self,
fd: Resource<types::Descriptor>,
path: String,
) -> FsResult<String> {
let p = self.path(&fd, &path)?;
self.adapter.mounts.readlink(&p).await.map_err(errno)
}
async fn remove_directory_at(
&mut self,
fd: Resource<types::Descriptor>,
path: String,
) -> FsResult<()> {
let p = self.path(&fd, &path)?;
self.adapter.mounts.remove_dir(&p).await.map_err(errno)
}
async fn rename_at(
&mut self,
fd: Resource<types::Descriptor>,
old_path: String,
new_fd: Resource<types::Descriptor>,
new_path: String,
) -> FsResult<()> {
let a = self.path(&fd, &old_path)?;
let b = self.path(&new_fd, &new_path)?;
self.adapter.mounts.rename(&a, &b).await.map_err(errno)
}
async fn symlink_at(
&mut self,
fd: Resource<types::Descriptor>,
src_path: String,
dest_path: String,
) -> FsResult<()> {
let _ = (fd, src_path, dest_path);
Err(ErrorCode::Unsupported.into())
}
async fn unlink_file_at(
&mut self,
fd: Resource<types::Descriptor>,
path: String,
) -> FsResult<()> {
let p = self.path(&fd, &path)?;
self.adapter.mounts.remove_file(&p).await.map_err(errno)
}
fn read_via_stream(
&mut self,
fd: Resource<types::Descriptor>,
offset: types::Filesize,
) -> FsResult<Resource<DynInputStream>> {
let d = self.fd(&fd)?;
let stream: DynInputStream = Box::new(Reader::new(d.file, offset));
Ok(self.table.push(stream)?)
}
fn write_via_stream(
&mut self,
fd: Resource<types::Descriptor>,
offset: types::Filesize,
) -> FsResult<Resource<DynOutputStream>> {
let d = self.fd(&fd)?;
let stream: DynOutputStream = Box::new(Writer::new(d.file, Some(offset)));
Ok(self.table.push(stream)?)
}
fn append_via_stream(
&mut self,
fd: Resource<types::Descriptor>,
) -> FsResult<Resource<DynOutputStream>> {
let d = self.fd(&fd)?;
let stream: DynOutputStream = Box::new(Writer::new(d.file, None));
Ok(self.table.push(stream)?)
}
async fn is_same_object(
&mut self,
a: Resource<types::Descriptor>,
b: Resource<types::Descriptor>,
) -> wasmtime::Result<bool> {
Ok(self.fd(&a)?.path == self.fd(&b)?.path)
}
async fn metadata_hash(
&mut self,
fd: Resource<types::Descriptor>,
) -> FsResult<types::MetadataHashValue> {
Ok(hash(&self.fd(&fd)?.path))
}
async fn metadata_hash_at(
&mut self,
fd: Resource<types::Descriptor>,
path_flags: types::PathFlags,
path: String,
) -> FsResult<types::MetadataHashValue> {
let follow = path_flags.contains(types::PathFlags::SYMLINK_FOLLOW);
let p = self.path(&fd, &path)?;
self.adapter.mounts.stat(&p, follow).await.map_err(errno)?;
Ok(hash(&p))
}
}
impl HostDirectoryEntryStream for View<'_> {
async fn read_directory_entry(
&mut self,
stream: Resource<types::DirectoryEntryStream>,
) -> FsResult<Option<DirectoryEntry>> {
Ok(self
.table
.get_mut(&Resource::<Entries>::new_borrow(stream.rep()))?
.0
.next())
}
fn drop(&mut self, stream: Resource<types::DirectoryEntryStream>) -> wasmtime::Result<()> {
self.table
.delete(Resource::<Entries>::new_own(stream.rep()))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Timestamp arithmetic returns overflow instead of panicking on either addition.
#[test]
fn timestamp_overflow() {
for nanoseconds in [0, u32::MAX] {
let error = time(types::NewTimestamp::Timestamp(types::Datetime {
seconds: u64::MAX,
nanoseconds,
}))
.unwrap_err();
assert_eq!(error.downcast().unwrap(), ErrorCode::Overflow);
}
}
/// Portable memory errors preserve their WASI error codes.
#[test]
fn portable_error_codes() {
assert_eq!(
code(&error(io::ErrorKind::FileTooLarge)),
ErrorCode::FileTooLarge
);
assert_eq!(code(&error(io::ErrorKind::ResourceBusy)), ErrorCode::Busy);
}
}