sausage-vfs: layers (host, memory, generated, mounts), process guards, wasi:filesystem adapter

Checkpoint. More tests and the review findings follow.
This commit is contained in:
2026-10-23 00:08:02 +01:00
parent 01712e745a
commit 0a479b5800
15 changed files with 1932 additions and 1 deletions
+470
View File
@@ -0,0 +1,470 @@
//! Wasmtime 49 filesystem interfaces backed exclusively by the virtual mount namespace.
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);
types::add_to_linker::<T, Vfs>(linker, view)?;
preopens::add_to_linker::<T, Vfs>(linker, view)?;
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,
_ => 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.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) -> Option<SystemTime> {
match t {
types::NewTimestamp::NoChange => None,
types::NewTimestamp::Now => Some(SystemTime::now()),
types::NewTimestamp::Timestamp(t) => {
Some(SystemTime::UNIX_EPOCH + std::time::Duration::new(t.seconds, t.nanoseconds))
}
}
}
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,
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.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 _ = path_flags;
let p = self.path(&fd, &path)?;
Ok(stat(self.adapter.mounts.stat(&p).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 _ = path_flags;
let p = self.path(&fd, &path)?;
self.adapter
.mounts
.open(
&p,
Open {
write: true,
..Open::default()
},
)
.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 _ = path_flags;
let path = self.path(&fd, &path)?;
let file = self
.adapter
.mounts
.open(
&path,
Open {
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 _ = (fd, path);
Err(ErrorCode::Unsupported.into())
}
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 _ = path_flags;
let p = self.path(&fd, &path)?;
self.adapter.mounts.stat(&p).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(())
}
}
+132
View File
@@ -0,0 +1,132 @@
//! Read-only generated files. Each open captures an independent finite snapshot.
use crate::*;
use std::{collections::BTreeMap, io::ErrorKind as E};
type Generator = Arc<dyn Fn() -> Result<Vec<u8>> + Send + Sync>;
/// A tree of generated files. Parent folders are implicit.
#[derive(Default)]
pub struct Generated {
files: BTreeMap<String, Generator>,
}
impl Generated {
pub fn insert(
&mut self,
path: &str,
generate: impl Fn() -> Result<Vec<u8>> + Send + Sync + 'static,
) {
self.files.insert(resolve("/", path), Arc::new(generate));
}
fn directory(&self, path: &str) -> bool {
path == "/"
|| self
.files
.keys()
.any(|p| p.starts_with(&format!("{path}/")))
}
}
struct Snapshot(Vec<u8>);
fn readonly<T>() -> Result<T> {
Err(error(E::ReadOnlyFilesystem))
}
#[async_trait]
impl Layer for Generated {
async fn stat(&self, path: &str) -> Result<Stat> {
if let Some(f) = self.files.get(path) {
return Snapshot(f()?).stat().await;
}
if !self.directory(path) {
return Err(error(E::NotFound));
}
Ok(Stat {
directory: true,
size: 0,
accessed: None,
modified: None,
})
}
async fn list(&self, path: &str) -> Result<Vec<Entry>> {
if !self.directory(path) {
return Err(error(if self.files.contains_key(path) {
E::NotADirectory
} else {
E::NotFound
}));
}
let prefix = if path == "/" {
"/".into()
} else {
format!("{path}/")
};
let mut entries = BTreeMap::new();
for p in self.files.keys() {
if let Some(rest) = p.strip_prefix(&prefix) {
let (name, directory) = rest
.split_once('/')
.map_or((rest, false), |(name, _)| (name, true));
entries.insert(name.to_string(), directory);
}
}
Ok(entries
.into_iter()
.map(|(name, directory)| Entry { name, directory })
.collect())
}
async fn open(&self, path: &str, o: Open) -> Result<Arc<dyn File>> {
if o.write || o.create || o.truncate {
return readonly();
}
if let Some(f) = self.files.get(path) {
if o.directory {
return Err(error(E::NotADirectory));
}
return Ok(Arc::new(Snapshot(f()?)));
}
if self.directory(path) {
return Memory::default().open("/", o).await;
}
Err(error(E::NotFound))
}
async fn mkdir(&self, _: &str) -> Result<()> {
readonly()
}
async fn remove_file(&self, _: &str) -> Result<()> {
readonly()
}
async fn remove_dir(&self, _: &str) -> Result<()> {
readonly()
}
async fn rename(&self, _: &str, _: &str) -> Result<()> {
readonly()
}
}
#[async_trait]
impl File for Snapshot {
async fn stat(&self) -> Result<Stat> {
Ok(Stat {
directory: false,
size: self.0.len() as u64,
accessed: None,
modified: None,
})
}
async fn read(&self, offset: u64, length: usize) -> Result<Vec<u8>> {
let start = usize::try_from(offset)
.unwrap_or(usize::MAX)
.min(self.0.len());
Ok(self.0[start..start.saturating_add(length).min(self.0.len())].to_vec())
}
async fn write(&self, _: u64, _: Vec<u8>) -> Result<usize> {
readonly()
}
async fn append(&self, _: Vec<u8>) -> Result<usize> {
readonly()
}
async fn set_size(&self, _: u64) -> Result<()> {
readonly()
}
async fn sync(&self) -> Result<()> {
Ok(())
}
async fn set_times(&self, _: Option<SystemTime>, _: Option<SystemTime>) -> Result<()> {
readonly()
}
}
+190
View File
@@ -0,0 +1,190 @@
//! Capability-sandboxed host files. Every system call runs on the blocking pool.
use crate::*;
use cap_std::fs::{Dir, OpenOptions};
use std::{
io::{Read, Seek, SeekFrom, Write},
path::Path,
sync::Mutex,
};
/// A host directory bound to one process tracker.
#[derive(Clone)]
pub struct Host {
dir: Arc<Dir>,
process: Process,
}
impl Host {
/// Open the sandbox root. Call this outside an asynchronous worker.
pub fn new(path: impl AsRef<Path>, process: Process) -> Result<Self> {
Ok(Self {
dir: Arc::new(Dir::open_ambient_dir(path, cap_std::ambient_authority())?),
process,
})
}
/// Share a sandbox with a different process tracker.
pub fn for_process(&self, process: Process) -> Self {
Self {
dir: self.dir.clone(),
process,
}
}
}
fn rel(path: &str) -> String {
let p = path.trim_start_matches('/');
if p.is_empty() { ".".into() } else { p.into() }
}
fn stat(m: cap_std::fs::Metadata) -> Stat {
Stat {
directory: m.is_dir(),
size: m.len(),
accessed: m.accessed().ok().map(|t| t.into_std()),
modified: m.modified().ok().map(|t| t.into_std()),
}
}
struct Handle {
file: Arc<Mutex<cap_std::fs::File>>,
append: Arc<Mutex<cap_std::fs::File>>,
process: Process,
}
#[async_trait]
impl Layer for Host {
async fn stat(&self, path: &str) -> Result<Stat> {
let (d, p) = (self.dir.clone(), rel(path));
self.process.blocking(move || d.metadata(p).map(stat)).await
}
async fn list(&self, path: &str) -> Result<Vec<Entry>> {
let (d, p) = (self.dir.clone(), rel(path));
self.process
.blocking(move || {
let mut entries = d
.read_dir(p)?
.map(|e| {
let e = e?;
Ok(Entry {
name: e.file_name().to_string_lossy().into_owned(),
directory: e.file_type()?.is_dir(),
})
})
.collect::<Result<Vec<_>>>()?;
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(entries)
})
.await
}
async fn open(&self, path: &str, o: Open) -> Result<Arc<dyn File>> {
let (d, p) = (self.dir.clone(), rel(path));
let (file, append) = self
.process
.blocking(move || {
let mut options = OpenOptions::new();
options
.read(true)
.write(o.write)
.create(o.create)
.create_new(o.create && o.exclusive)
.truncate(o.truncate);
let f = d.open_with(&p, &options)?;
let directory = f.metadata()?.is_dir();
if o.directory && !directory {
return Err(error(io::ErrorKind::NotADirectory));
}
let append = if o.write && !directory {
d.open_with(&p, OpenOptions::new().append(true))?
} else {
f.try_clone()?
};
Ok((f, append))
})
.await?;
Ok(Arc::new(Handle {
file: Arc::new(Mutex::new(file)),
append: Arc::new(Mutex::new(append)),
process: self.process.clone(),
}))
}
async fn mkdir(&self, path: &str) -> Result<()> {
let (d, p) = (self.dir.clone(), rel(path));
self.process.blocking(move || d.create_dir(p)).await
}
async fn remove_file(&self, path: &str) -> Result<()> {
let (d, p) = (self.dir.clone(), rel(path));
self.process.blocking(move || d.remove_file(p)).await
}
async fn remove_dir(&self, path: &str) -> Result<()> {
let (d, p) = (self.dir.clone(), rel(path));
self.process.blocking(move || d.remove_dir(p)).await
}
async fn rename(&self, from: &str, to: &str) -> Result<()> {
let (d, a, b) = (self.dir.clone(), rel(from), rel(to));
self.process.blocking(move || d.rename(a, &d, b)).await
}
}
#[async_trait]
impl File for Handle {
async fn stat(&self) -> Result<Stat> {
let f = self.file.clone();
self.process
.blocking(move || f.lock().unwrap().metadata().map(stat))
.await
}
async fn read(&self, offset: u64, length: usize) -> Result<Vec<u8>> {
let f = self.file.clone();
self.process
.blocking(move || {
let mut f = f.lock().unwrap();
f.seek(SeekFrom::Start(offset))?;
let mut bytes = Vec::new();
(&mut *f).take(length as u64).read_to_end(&mut bytes)?;
Ok(bytes)
})
.await
}
async fn write(&self, offset: u64, bytes: Vec<u8>) -> Result<usize> {
let f = self.file.clone();
self.process
.blocking(move || {
let mut f = f.lock().unwrap();
f.seek(SeekFrom::Start(offset))?;
f.write(&bytes)
})
.await
}
async fn append(&self, bytes: Vec<u8>) -> Result<usize> {
let f = self.append.clone();
self.process
.blocking(move || f.lock().unwrap().write(&bytes))
.await
}
async fn set_size(&self, size: u64) -> Result<()> {
let f = self.file.clone();
self.process
.blocking(move || f.lock().unwrap().set_len(size))
.await
}
async fn sync(&self) -> Result<()> {
let f = self.file.clone();
self.process
.blocking(move || f.lock().unwrap().sync_all())
.await
}
async fn set_times(
&self,
accessed: Option<SystemTime>,
modified: Option<SystemTime>,
) -> Result<()> {
let f = self.file.clone();
self.process
.blocking(move || {
let f = f.lock().unwrap().try_clone()?.into_std();
let mut times = std::fs::FileTimes::new();
if let Some(t) = accessed {
times = times.set_accessed(t);
}
if let Some(t) = modified {
times = times.set_modified(t);
}
f.set_times(times)
})
.await
}
}
+92 -1
View File
@@ -1 +1,92 @@
//! A composable virtual filesystem for WASI guests in Wasmtime.
//! Composable filesystem layers, open files, mount routing, and process IO guards.
pub mod adapter;
pub mod generated;
pub mod host;
pub mod memory;
pub mod mounts;
pub mod process;
mod readonly;
mod streams;
pub use async_trait::async_trait;
pub use generated::Generated;
pub use host::Host;
pub use memory::Memory;
pub use mounts::Mounts;
pub use process::Process;
use std::{io, sync::Arc, time::SystemTime};
/// A filesystem result. Error kinds preserve portable filesystem failures.
pub type Result<T> = io::Result<T>;
/// The type and timestamps of a node.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Stat {
pub directory: bool,
pub size: u64,
pub accessed: Option<SystemTime>,
pub modified: Option<SystemTime>,
}
/// One child of a folder.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry {
pub name: String,
pub directory: bool,
}
/// Options for an open operation. Exclusive applies only with create.
#[derive(Clone, Copy, Debug, Default)]
pub struct Open {
pub write: bool,
pub create: bool,
pub exclusive: bool,
pub truncate: bool,
pub directory: bool,
}
/// An open file keeps its identity after rename or unlink.
#[async_trait]
pub trait File: Send + Sync {
async fn stat(&self) -> Result<Stat>;
async fn read(&self, offset: u64, length: usize) -> Result<Vec<u8>>;
async fn write(&self, offset: u64, bytes: Vec<u8>) -> Result<usize>;
async fn append(&self, bytes: Vec<u8>) -> Result<usize>;
async fn set_size(&self, size: u64) -> Result<()>;
async fn sync(&self) -> Result<()>;
async fn set_times(
&self,
accessed: Option<SystemTime>,
modified: Option<SystemTime>,
) -> Result<()>;
}
/// One filesystem layer. Paths are normalized, absolute, and relative to its root.
/// Implementations must retain open file identity and return portable IO errors.
#[async_trait]
pub trait Layer: Send + Sync {
async fn stat(&self, path: &str) -> Result<Stat>;
async fn list(&self, path: &str) -> Result<Vec<Entry>>;
async fn open(&self, path: &str, options: Open) -> Result<Arc<dyn File>>;
async fn mkdir(&self, path: &str) -> Result<()>;
async fn remove_file(&self, path: &str) -> Result<()>;
async fn remove_dir(&self, path: &str) -> Result<()>;
async fn rename(&self, from: &str, to: &str) -> Result<()>;
}
pub(crate) fn error(kind: io::ErrorKind) -> io::Error {
kind.into()
}
/// Resolve a path in the virtual namespace, including traversal across mounts.
pub fn resolve(base: &str, path: &str) -> String {
let mut parts = Vec::new();
if !path.starts_with('/') {
parts.extend(base.split('/').filter(|s| !s.is_empty()));
}
for part in path.split('/') {
match part {
"" | "." => {}
".." => {
parts.pop();
}
p => parts.push(p),
}
}
format!("/{}", parts.join("/"))
}
+257
View File
@@ -0,0 +1,257 @@
//! Shared in-memory directories and identity-preserving open files.
use crate::*;
use std::{collections::BTreeMap, io::ErrorKind as E, sync::Mutex};
#[derive(Clone)]
enum Node {
Dir,
File(Arc<Mutex<Data>>),
}
struct Data {
bytes: Vec<u8>,
accessed: Option<SystemTime>,
modified: Option<SystemTime>,
}
/// A shareable memory filesystem. Clones refer to the same tree.
#[derive(Clone)]
pub struct Memory {
nodes: Arc<Mutex<BTreeMap<String, Node>>>,
}
impl Default for Memory {
fn default() -> Self {
Self {
nodes: Arc::new(Mutex::new(BTreeMap::from([("/".into(), Node::Dir)]))),
}
}
}
fn parent(path: &str) -> &str {
path.rsplit_once('/')
.map(|(p, _)| if p.is_empty() { "/" } else { p })
.unwrap_or("/")
}
fn directory(nodes: &BTreeMap<String, Node>, path: &str) -> Result<()> {
match nodes.get(path) {
Some(Node::Dir) => Ok(()),
Some(_) => Err(error(E::NotADirectory)),
None => Err(error(E::NotFound)),
}
}
fn metadata(node: &Node) -> Stat {
match node {
Node::Dir => Stat {
directory: true,
size: 0,
accessed: None,
modified: None,
},
Node::File(data) => {
let d = data.lock().unwrap();
Stat {
directory: false,
size: d.bytes.len() as u64,
accessed: d.accessed,
modified: d.modified,
}
}
}
}
struct Handle {
node: Node,
write: bool,
}
#[async_trait]
impl Layer for Memory {
async fn stat(&self, path: &str) -> Result<Stat> {
self.nodes
.lock()
.unwrap()
.get(path)
.map(metadata)
.ok_or_else(|| error(E::NotFound))
}
async fn list(&self, path: &str) -> Result<Vec<Entry>> {
let nodes = self.nodes.lock().unwrap();
directory(&nodes, path)?;
Ok(nodes
.iter()
.filter(|(p, _)| p.as_str() != path && parent(p) == path)
.map(|(p, n)| Entry {
name: p.rsplit('/').next().unwrap().into(),
directory: matches!(n, Node::Dir),
})
.collect())
}
async fn open(&self, path: &str, o: Open) -> Result<Arc<dyn File>> {
let mut nodes = self.nodes.lock().unwrap();
if o.truncate && !o.write {
return Err(error(E::InvalidInput));
}
if nodes.contains_key(path) && o.create && o.exclusive {
return Err(error(E::AlreadyExists));
}
if !nodes.contains_key(path) {
if !o.create || o.directory {
return Err(error(E::NotFound));
}
directory(&nodes, parent(path))?;
nodes.insert(
path.into(),
Node::File(Arc::new(Mutex::new(Data {
bytes: vec![],
accessed: None,
modified: None,
}))),
);
}
let node = nodes[path].clone();
match &node {
Node::Dir if o.write || o.truncate => return Err(error(E::IsADirectory)),
Node::File(_) if o.directory => return Err(error(E::NotADirectory)),
Node::File(d) if o.truncate => d.lock().unwrap().bytes.clear(),
_ => {}
}
Ok(Arc::new(Handle {
node,
write: o.write,
}))
}
async fn mkdir(&self, path: &str) -> Result<()> {
let mut nodes = self.nodes.lock().unwrap();
if nodes.contains_key(path) {
return Err(error(E::AlreadyExists));
}
directory(&nodes, parent(path))?;
nodes.insert(path.into(), Node::Dir);
Ok(())
}
async fn remove_file(&self, path: &str) -> Result<()> {
let mut nodes = self.nodes.lock().unwrap();
match nodes.get(path) {
None => return Err(error(E::NotFound)),
Some(Node::Dir) => return Err(error(E::IsADirectory)),
_ => {}
}
nodes.remove(path);
Ok(())
}
async fn remove_dir(&self, path: &str) -> Result<()> {
let mut nodes = self.nodes.lock().unwrap();
directory(&nodes, path)?;
if path == "/" {
return Err(error(E::ResourceBusy));
}
if nodes.keys().any(|p| p != path && parent(p) == path) {
return Err(error(E::DirectoryNotEmpty));
}
nodes.remove(path);
Ok(())
}
async fn rename(&self, from: &str, to: &str) -> Result<()> {
let mut nodes = self.nodes.lock().unwrap();
let node = nodes.get(from).cloned().ok_or_else(|| error(E::NotFound))?;
directory(&nodes, parent(to))?;
if from == to {
return Ok(());
}
if from == "/" || to == "/" {
return Err(error(E::ResourceBusy));
}
if to.starts_with(&format!("{from}/")) {
return Err(error(E::InvalidInput));
}
if let Some(target) = nodes.get(to) {
match (&node, target) {
(Node::Dir, Node::File(_)) => return Err(error(E::NotADirectory)),
(Node::File(_), Node::Dir) => return Err(error(E::IsADirectory)),
(Node::Dir, Node::Dir) if nodes.keys().any(|p| p != to && parent(p) == to) => {
return Err(error(E::DirectoryNotEmpty));
}
_ => {}
}
}
let prefix = format!("{from}/");
let moved: Vec<_> = nodes
.keys()
.filter(|p| p.as_str() == from || p.starts_with(&prefix))
.cloned()
.collect();
for p in moved {
let n = nodes.remove(&p).unwrap();
nodes.insert(format!("{to}{}", &p[from.len()..]), n);
}
Ok(())
}
}
impl Handle {
fn data(&self) -> Result<&Mutex<Data>> {
match &self.node {
Node::File(d) => Ok(d),
_ => Err(error(E::IsADirectory)),
}
}
fn writable(&self) -> Result<&Mutex<Data>> {
if !self.write {
return Err(error(E::PermissionDenied));
}
self.data()
}
}
#[async_trait]
impl File for Handle {
async fn stat(&self) -> Result<Stat> {
Ok(metadata(&self.node))
}
async fn read(&self, offset: u64, length: usize) -> Result<Vec<u8>> {
let d = self.data()?.lock().unwrap();
let start = usize::try_from(offset)
.unwrap_or(usize::MAX)
.min(d.bytes.len());
Ok(d.bytes[start..start.saturating_add(length).min(d.bytes.len())].to_vec())
}
async fn write(&self, offset: u64, bytes: Vec<u8>) -> Result<usize> {
let mut d = self.writable()?.lock().unwrap();
let start = usize::try_from(offset).map_err(|_| error(E::InvalidInput))?;
let end = start
.checked_add(bytes.len())
.ok_or_else(|| error(E::InvalidInput))?;
if !bytes.is_empty() {
if end > d.bytes.len() {
d.bytes.resize(end, 0);
}
d.bytes[start..end].copy_from_slice(&bytes);
}
Ok(bytes.len())
}
async fn append(&self, bytes: Vec<u8>) -> Result<usize> {
self.writable()?
.lock()
.unwrap()
.bytes
.extend_from_slice(&bytes);
Ok(bytes.len())
}
async fn set_size(&self, size: u64) -> Result<()> {
self.writable()?.lock().unwrap().bytes.resize(
usize::try_from(size).map_err(|_| error(E::InvalidInput))?,
0,
);
Ok(())
}
async fn sync(&self) -> Result<()> {
Ok(())
}
async fn set_times(
&self,
accessed: Option<SystemTime>,
modified: Option<SystemTime>,
) -> Result<()> {
let mut d = self.writable()?.lock().unwrap();
if accessed.is_some() {
d.accessed = accessed;
}
if modified.is_some() {
d.modified = modified;
}
Ok(())
}
}
+97
View File
@@ -0,0 +1,97 @@
//! Resolves virtual paths before routing them to mounted layers.
use crate::*;
use std::{collections::BTreeMap, io::ErrorKind as E};
struct Mount {
layer: Arc<dyn Layer>,
readonly: bool,
}
/// A per-process mount namespace. Mount the root before use.
#[derive(Default)]
pub struct Mounts {
mounts: BTreeMap<String, Mount>,
}
impl Mounts {
/// Add or replace a mount. Read-only mounts reject every mutation.
pub fn mount(&mut self, path: &str, layer: Arc<dyn Layer>, readonly: bool) {
self.mounts
.insert(resolve("/", path), Mount { layer, readonly });
}
fn route(&self, path: &str, write: bool) -> Result<(&Mount, String)> {
let path = resolve("/", path);
let (base, m) = self
.mounts
.iter()
.filter(|(p, _)| p.as_str() == "/" || path == **p || path.starts_with(&format!("{p}/")))
.max_by_key(|(p, _)| p.len())
.ok_or_else(|| error(E::NotFound))?;
if write && m.readonly {
return Err(error(E::ReadOnlyFilesystem));
}
let rest = if base == "/" {
path
} else {
path[base.len()..].into()
};
Ok((m, if rest.is_empty() { "/".into() } else { rest }))
}
}
#[async_trait]
impl Layer for Mounts {
async fn stat(&self, path: &str) -> Result<Stat> {
let (m, p) = self.route(path, false)?;
m.layer.stat(&p).await
}
async fn list(&self, path: &str) -> Result<Vec<Entry>> {
let path = resolve("/", path);
let (m, p) = self.route(&path, false)?;
let mut entries = m.layer.list(&p).await?;
let prefix = if path == "/" {
"/".into()
} else {
format!("{path}/")
};
for mount in self.mounts.keys() {
if let Some(rest) = mount.strip_prefix(&prefix)
&& !rest.is_empty()
{
let name = rest.split('/').next().unwrap();
entries.retain(|e| e.name != name);
entries.push(Entry {
name: name.into(),
directory: true,
});
}
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
Ok(entries)
}
async fn open(&self, path: &str, options: Open) -> Result<Arc<dyn File>> {
let (m, p) = self.route(path, options.write || options.create || options.truncate)?;
let file = m.layer.open(&p, options).await?;
if m.readonly {
Ok(Arc::new(crate::readonly::Readonly(file)))
} else {
Ok(file)
}
}
async fn mkdir(&self, path: &str) -> Result<()> {
let (m, p) = self.route(path, true)?;
m.layer.mkdir(&p).await
}
async fn remove_file(&self, path: &str) -> Result<()> {
let (m, p) = self.route(path, true)?;
m.layer.remove_file(&p).await
}
async fn remove_dir(&self, path: &str) -> Result<()> {
let (m, p) = self.route(path, true)?;
m.layer.remove_dir(&p).await
}
async fn rename(&self, from: &str, to: &str) -> Result<()> {
let (a, p) = self.route(from, true)?;
let (b, q) = self.route(to, true)?;
if !std::ptr::eq(a, b) {
return Err(error(E::CrossesDevices));
}
a.layer.rename(&p, &q).await
}
}
+51
View File
@@ -0,0 +1,51 @@
//! Tracks blocking operations until their system calls return, independent of cancellation.
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use tokio::sync::Notify;
#[derive(Default)]
struct State {
count: AtomicUsize,
idle: Notify,
}
/// A per-process operation tracker. Stop the process before awaiting idle.
#[derive(Clone, Default)]
pub struct Process(Arc<State>);
struct Guard(Arc<State>);
impl Drop for Guard {
fn drop(&mut self) {
if self.0.count.fetch_sub(1, Ordering::SeqCst) == 1 {
self.0.idle.notify_waiters();
}
}
}
impl Process {
/// Run a system call on the blocking pool. Cancellation does not release its guard.
pub async fn blocking<T: Send + 'static>(
&self,
operation: impl FnOnce() -> std::io::Result<T> + Send + 'static,
) -> std::io::Result<T> {
self.0.count.fetch_add(1, Ordering::SeqCst);
let guard = Guard(self.0.clone());
tokio::task::spawn_blocking(move || {
let _guard = guard;
operation()
})
.await
.map_err(std::io::Error::other)?
}
/// Wait for all outstanding system calls. The caller must prevent new operations.
pub async fn idle(&self) {
loop {
let notified = self.0.idle.notified();
tokio::pin!(notified);
notified.as_mut().enable();
if self.0.count.load(Ordering::SeqCst) == 0 {
return;
}
notified.await;
}
}
}
+30
View File
@@ -0,0 +1,30 @@
//! Preserves read-only mount errors for mutations through open descriptors.
use crate::*;
pub(crate) struct Readonly(pub Arc<dyn File>);
fn denied<T>() -> Result<T> {
Err(error(io::ErrorKind::ReadOnlyFilesystem))
}
#[async_trait]
impl File for Readonly {
async fn stat(&self) -> Result<Stat> {
self.0.stat().await
}
async fn read(&self, offset: u64, length: usize) -> Result<Vec<u8>> {
self.0.read(offset, length).await
}
async fn write(&self, _: u64, _: Vec<u8>) -> Result<usize> {
denied()
}
async fn append(&self, _: Vec<u8>) -> Result<usize> {
denied()
}
async fn set_size(&self, _: u64) -> Result<()> {
denied()
}
async fn sync(&self) -> Result<()> {
self.0.sync().await
}
async fn set_times(&self, _: Option<SystemTime>, _: Option<SystemTime>) -> Result<()> {
denied()
}
}
+134
View File
@@ -0,0 +1,134 @@
//! Nonblocking WASI streams that await layer operations through readiness.
use crate::*;
use bytes::Bytes;
use std::{future::Future, pin::Pin};
type Pending<T> = Pin<Box<dyn Future<Output = Result<T>> + Send>>;
use wasmtime_wasi::p2::{InputStream, OutputStream, Pollable, StreamError, StreamResult};
fn failure(e: io::Error) -> StreamError {
StreamError::LastOperationFailed(e.into())
}
pub(crate) struct Reader {
file: Arc<dyn File>,
offset: u64,
pending: Option<Pending<Vec<u8>>>,
data: Bytes,
eof: bool,
error: Option<io::Error>,
}
impl Reader {
pub fn new(file: Arc<dyn File>, offset: u64) -> Self {
Self {
file,
offset,
pending: None,
data: Bytes::new(),
eof: false,
error: None,
}
}
fn start(&mut self) {
if self.pending.is_none() && self.data.is_empty() && !self.eof {
let f = self.file.clone();
let offset = self.offset;
self.pending = Some(Box::pin(async move { f.read(offset, 65536).await }));
}
}
}
#[async_trait]
impl Pollable for Reader {
async fn ready(&mut self) {
self.start();
if let Some(task) = &mut self.pending {
match task.await {
Ok(data) => {
self.eof = data.is_empty();
self.offset += data.len() as u64;
self.data = data.into();
}
Err(e) => self.error = Some(e),
}
self.pending = None;
}
}
}
#[async_trait]
impl InputStream for Reader {
fn read(&mut self, size: usize) -> StreamResult<Bytes> {
if let Some(e) = self.error.take() {
return Err(failure(e));
}
if self.eof && self.data.is_empty() {
return Err(StreamError::Closed);
}
let n = size.min(self.data.len());
let data = self.data.split_to(n);
self.start();
Ok(data)
}
}
pub(crate) struct Writer {
file: Arc<dyn File>,
offset: Option<u64>,
pending: Option<Pending<usize>>,
error: Option<io::Error>,
}
impl Writer {
pub fn new(file: Arc<dyn File>, offset: Option<u64>) -> Self {
Self {
file,
offset,
pending: None,
error: None,
}
}
}
#[async_trait]
impl Pollable for Writer {
async fn ready(&mut self) {
if let Some(task) = &mut self.pending {
match task.await {
Ok(n) => {
if let Some(p) = &mut self.offset {
*p += n as u64
}
}
Err(e) => self.error = Some(e),
}
self.pending = None;
}
}
}
#[async_trait]
impl OutputStream for Writer {
fn write(&mut self, bytes: Bytes) -> StreamResult<()> {
if self.check_write()? == 0 {
return Err(StreamError::trap("write without a permit"));
}
let f = self.file.clone();
let offset = self.offset;
self.pending = Some(Box::pin(async move {
let mut done = 0;
while done < bytes.len() {
let n = match offset {
Some(p) => f.write(p + done as u64, bytes[done..].to_vec()).await?,
None => f.append(bytes[done..].to_vec()).await?,
};
if n == 0 {
return Err(error(io::ErrorKind::WriteZero));
}
done += n;
}
Ok(done)
}));
Ok(())
}
fn flush(&mut self) -> StreamResult<()> {
Ok(())
}
fn check_write(&mut self) -> StreamResult<usize> {
if let Some(e) = self.error.take() {
return Err(failure(e));
}
Ok(if self.pending.is_some() { 0 } else { 65536 })
}
}