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:
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "vfs-files-guest"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
[workspace]
|
||||
[dependencies]
|
||||
tempfile = "3"
|
||||
rusqlite = { version = "0.38", features = ["bundled"] }
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Exercises Rust filesystem clients through the virtual WASI filesystem.
|
||||
use std::{
|
||||
fs::{self, OpenOptions},
|
||||
io::{Read, Seek, SeekFrom, Write},
|
||||
};
|
||||
fn main() {
|
||||
fs::create_dir("/work").unwrap();
|
||||
fs::write("/work/a", b"hello").unwrap();
|
||||
let mut f = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open("/work/a")
|
||||
.unwrap();
|
||||
f.seek(SeekFrom::Start(1)).unwrap();
|
||||
f.write_all(b"A").unwrap();
|
||||
f.set_len(4).unwrap();
|
||||
f.sync_all().unwrap();
|
||||
OpenOptions::new()
|
||||
.append(true)
|
||||
.open("/work/a")
|
||||
.unwrap()
|
||||
.write_all(b"!")
|
||||
.unwrap();
|
||||
assert_eq!(fs::read("/work/a").unwrap(), b"hAll!");
|
||||
assert_eq!(fs::metadata("/work/a").unwrap().len(), 5);
|
||||
fs::rename("/work/a", "/work/b").unwrap();
|
||||
assert_eq!(fs::read("/work/../work/b").unwrap(), b"hAll!");
|
||||
assert_eq!(fs::read("work/b").unwrap(), b"hAll!");
|
||||
assert_eq!(fs::read_dir("/work").unwrap().count(), 1);
|
||||
assert_eq!(
|
||||
fs::write("/proc/no", b"no").unwrap_err().kind(),
|
||||
std::io::ErrorKind::ReadOnlyFilesystem
|
||||
);
|
||||
assert_eq!(
|
||||
fs::rename("/work/b", "/run/b").unwrap_err().kind(),
|
||||
std::io::ErrorKind::CrossesDevices
|
||||
);
|
||||
let root = fs::read_dir("/")
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name())
|
||||
.collect::<Vec<_>>();
|
||||
for name in ["proc", "run", "self"] {
|
||||
assert!(root.iter().any(|n| n == name));
|
||||
}
|
||||
assert_eq!(fs::read("/proc/value").unwrap(), b"snapshot");
|
||||
fs::write("/run/test", b"memory").unwrap();
|
||||
assert_eq!(fs::read("/run/test").unwrap(), b"memory");
|
||||
fs::remove_file("/work/b").unwrap();
|
||||
fs::remove_dir("/work").unwrap();
|
||||
// WASI std does not implement env::temp_dir, so select the folder explicitly.
|
||||
let mut temp = tempfile::tempfile_in("/tmp").unwrap();
|
||||
temp.write_all(b"temporary").unwrap();
|
||||
temp.seek(SeekFrom::Start(0)).unwrap();
|
||||
let mut text = String::new();
|
||||
temp.read_to_string(&mut text).unwrap();
|
||||
assert_eq!(text, "temporary");
|
||||
let db = rusqlite::Connection::open("/database.sqlite").unwrap();
|
||||
db.execute_batch("CREATE TABLE data (value TEXT); INSERT INTO data VALUES ('row');")
|
||||
.unwrap();
|
||||
drop(db);
|
||||
let db = rusqlite::Connection::open("/database.sqlite").unwrap();
|
||||
let row: String = db
|
||||
.query_row("SELECT value FROM data", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(row, "row");
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Layer contracts, a memory model, and differential host operation sequences.
|
||||
use proptest::prelude::*;
|
||||
use sausage_vfs::*;
|
||||
use std::{
|
||||
io::ErrorKind,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
};
|
||||
fn rw() -> Open {
|
||||
Open {
|
||||
write: true,
|
||||
create: true,
|
||||
..Open::default()
|
||||
}
|
||||
}
|
||||
async fn contract(layer: &dyn Layer) {
|
||||
layer.mkdir("/dir").await.unwrap();
|
||||
let f = layer.open("/dir/a", rw()).await.unwrap();
|
||||
f.write(2, b"abc".to_vec()).await.unwrap();
|
||||
f.append(b"d".to_vec()).await.unwrap();
|
||||
assert_eq!(f.read(0, 100).await.unwrap(), b"\0\0abcd");
|
||||
f.set_size(3).await.unwrap();
|
||||
assert_eq!(f.stat().await.unwrap().size, 3);
|
||||
f.sync().await.unwrap();
|
||||
let timestamp = std::time::UNIX_EPOCH + std::time::Duration::from_secs(12345);
|
||||
f.set_times(Some(timestamp), Some(timestamp)).await.unwrap();
|
||||
assert_eq!(f.stat().await.unwrap().modified, Some(timestamp));
|
||||
assert_eq!(
|
||||
layer
|
||||
.open(
|
||||
"/dir/a",
|
||||
Open {
|
||||
exclusive: true,
|
||||
..rw()
|
||||
}
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap()
|
||||
.kind(),
|
||||
ErrorKind::AlreadyExists
|
||||
);
|
||||
assert_eq!(
|
||||
layer
|
||||
.open(
|
||||
"/dir/a",
|
||||
Open {
|
||||
directory: true,
|
||||
..Open::default()
|
||||
}
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap()
|
||||
.kind(),
|
||||
ErrorKind::NotADirectory
|
||||
);
|
||||
layer.rename("/dir/a", "/dir/b").await.unwrap();
|
||||
assert_eq!(f.read(2, 1).await.unwrap(), b"a");
|
||||
assert_eq!(layer.list("/dir").await.unwrap()[0].name, "b");
|
||||
layer.remove_file("/dir/b").await.unwrap();
|
||||
assert_eq!(f.read(2, 1).await.unwrap(), b"a");
|
||||
layer.remove_dir("/dir").await.unwrap();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn memory_contract() {
|
||||
contract(&Memory::default()).await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn host_contract_and_sandbox() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let h = Host::new(tmp.path(), Process::default()).unwrap();
|
||||
contract(&h).await;
|
||||
assert!(h.open("/../escape", rw()).await.is_err());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn shared_memory_handles_append_atomically() {
|
||||
let memory = Memory::default();
|
||||
let other = memory.clone();
|
||||
let first = memory.open("/shared", rw()).await.unwrap();
|
||||
let second = other.open("/shared", rw()).await.unwrap();
|
||||
let a = tokio::spawn(async move {
|
||||
for _ in 0..100 {
|
||||
first.append(b"a".to_vec()).await.unwrap();
|
||||
}
|
||||
});
|
||||
let b = tokio::spawn(async move {
|
||||
for _ in 0..100 {
|
||||
second.append(b"b".to_vec()).await.unwrap();
|
||||
}
|
||||
});
|
||||
a.await.unwrap();
|
||||
b.await.unwrap();
|
||||
let bytes = memory
|
||||
.open("/shared", Open::default())
|
||||
.await
|
||||
.unwrap()
|
||||
.read(0, 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bytes.len(), 200);
|
||||
assert_eq!(bytes.iter().filter(|b| **b == b'a').count(), 100);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn generated_snapshots() {
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let c = counter.clone();
|
||||
let mut g = Generated::default();
|
||||
g.insert("/nested/data", move || {
|
||||
Ok(c.fetch_add(1, Ordering::SeqCst).to_string().into_bytes())
|
||||
});
|
||||
let a = g.open("/nested/data", Open::default()).await.unwrap();
|
||||
let b = g.open("/nested/data", Open::default()).await.unwrap();
|
||||
assert_eq!(a.read(0, 99).await.unwrap(), b"0");
|
||||
assert_eq!(b.read(0, 99).await.unwrap(), b"1");
|
||||
assert!(a.read(1, 99).await.unwrap().is_empty());
|
||||
assert_eq!(g.list("/").await.unwrap()[0].name, "nested");
|
||||
assert_eq!(
|
||||
g.remove_file("/nested/data").await.unwrap_err().kind(),
|
||||
ErrorKind::ReadOnlyFilesystem
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn mounts_resolve_and_protect() {
|
||||
let mut mounts = Mounts::default();
|
||||
mounts.mount("/", Arc::new(Memory::default()), false);
|
||||
mounts.mount("/proc", Arc::new(Memory::default()), true);
|
||||
mounts.mount("/run", Arc::new(Memory::default()), false);
|
||||
assert_eq!(
|
||||
mounts
|
||||
.list("/")
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| e.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["proc", "run"]
|
||||
);
|
||||
mounts.open("/proc/../a", rw()).await.unwrap();
|
||||
assert_eq!(
|
||||
mounts.open("/proc/a", rw()).await.err().unwrap().kind(),
|
||||
ErrorKind::ReadOnlyFilesystem
|
||||
);
|
||||
assert_eq!(
|
||||
mounts.rename("/a", "/run/a").await.unwrap_err().kind(),
|
||||
ErrorKind::CrossesDevices
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn aborted_operations_keep_guard() {
|
||||
let p = Process::default();
|
||||
let q = p.clone();
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::channel();
|
||||
let operation = tokio::spawn(async move {
|
||||
q.blocking(move || {
|
||||
started_tx.send(()).unwrap();
|
||||
release_rx.recv().unwrap();
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
});
|
||||
started_rx.await.unwrap();
|
||||
operation.abort();
|
||||
let mut idle = Box::pin(p.idle());
|
||||
assert!(matches!(
|
||||
std::future::poll_fn(|cx| std::task::Poll::Ready(idle.as_mut().poll(cx))).await,
|
||||
std::task::Poll::Pending
|
||||
));
|
||||
release_tx.send(()).unwrap();
|
||||
idle.await;
|
||||
}
|
||||
proptest! {
|
||||
#[test]
|
||||
fn memory_model(ops in prop::collection::vec((0u8..3,0usize..64,prop::collection::vec(any::<u8>(),0..20)),0..80)) {
|
||||
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||
let m = Memory::default(); let f = m.open("/f",rw()).await.unwrap(); let mut model = Vec::new();
|
||||
for (op,offset,bytes) in ops {
|
||||
match op {
|
||||
0 => { f.write(offset as u64,bytes.clone()).await.unwrap(); if !bytes.is_empty() { model.resize(model.len().max(offset+bytes.len()),0); model[offset..offset+bytes.len()].copy_from_slice(&bytes); } },
|
||||
1 => { f.append(bytes.clone()).await.unwrap(); model.extend(bytes); },
|
||||
_ => { f.set_size(offset as u64).await.unwrap(); model.resize(offset,0); }
|
||||
}
|
||||
prop_assert_eq!(f.read(0,1000).await.unwrap(),model.clone());
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
#[test]
|
||||
fn host_differential(ops in prop::collection::vec((0u8..8,0u8..5,prop::collection::vec(any::<u8>(),0..30)),0..50)) {
|
||||
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
||||
let a = tempfile::tempdir().unwrap(); let b = tempfile::tempdir().unwrap(); let host = Host::new(a.path(),Process::default()).unwrap();
|
||||
for (op,index,data) in ops {
|
||||
let name = format!("f{index}"); let path = format!("/{name}"); let real = b.path().join(&name);
|
||||
let (actual,expected) = match op {
|
||||
0 => { let actual = match host.open(&path,Open { truncate:true,..rw() }).await { Ok(f) => f.write(0,data.clone()).await.map(|_|()), Err(e) => Err(e) }; (actual,std::fs::write(real,&data)) },
|
||||
1 => (host.remove_file(&path).await,std::fs::remove_file(real)),
|
||||
2 => (host.mkdir(&path).await,std::fs::create_dir(real)),
|
||||
3 => (host.remove_dir(&path).await,std::fs::remove_dir(real)),
|
||||
4 => (host.rename(&path,"/renamed").await,std::fs::rename(real,b.path().join("renamed"))),
|
||||
_ => {
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
let actual = match host.open(&path,Open { write:true,..Open::default() }).await {
|
||||
Ok(f) => match op { 5 => f.append(data.clone()).await.map(|_|()), 6 => f.set_size(data.len() as u64).await, _ => f.write(index as u64,data.clone()).await.map(|_|()) },
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
let expected = (|| {
|
||||
let mut f = std::fs::OpenOptions::new().read(true).write(true).append(op == 5).open(real)?;
|
||||
match op { 5 => f.write_all(&data), 6 => f.set_len(data.len() as u64), _ => { f.seek(SeekFrom::Start(index as u64))?; f.write_all(&data) } }
|
||||
})();
|
||||
(actual,expected)
|
||||
}
|
||||
};
|
||||
prop_assert_eq!(actual.map_err(|e|e.kind()),expected.map_err(|e|e.kind()));
|
||||
let mut expected = std::fs::read_dir(b.path()).unwrap().map(|e| { let e=e.unwrap(); Entry { name:e.file_name().to_str().unwrap().into(),directory:e.file_type().unwrap().is_dir() } }).collect::<Vec<_>>(); expected.sort_by(|a,b|a.name.cmp(&b.name));
|
||||
for entry in &expected {
|
||||
let p = format!("/{}", entry.name);
|
||||
let metadata = host.stat(&p).await.unwrap();
|
||||
prop_assert_eq!(metadata.directory, entry.directory);
|
||||
if !entry.directory {
|
||||
let bytes = std::fs::read(b.path().join(&entry.name)).unwrap();
|
||||
prop_assert_eq!(metadata.size, bytes.len() as u64);
|
||||
prop_assert_eq!(host.open(&p, Open::default()).await.unwrap().read(0, 1024).await.unwrap(), bytes);
|
||||
}
|
||||
}
|
||||
prop_assert_eq!(host.list("/").await.unwrap(),expected);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Exercises adapter streams with the shared WASI resource table.
|
||||
use sausage_vfs::{
|
||||
adapter::{Adapter, View},
|
||||
*,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use wasmtime::component::{Resource, ResourceTable};
|
||||
use wasmtime_wasi::p2::{
|
||||
StreamError,
|
||||
bindings::filesystem::{
|
||||
preopens::Host,
|
||||
types::{DescriptorFlags, HostDescriptor, OpenFlags, PathFlags},
|
||||
},
|
||||
};
|
||||
#[tokio::test]
|
||||
async fn adapter_streams() {
|
||||
let mut mounts = Mounts::default();
|
||||
mounts.mount("/", Arc::new(Memory::default()), false);
|
||||
let mut adapter = Adapter::new(mounts);
|
||||
let mut table = ResourceTable::new();
|
||||
let mut view = View {
|
||||
adapter: &mut adapter,
|
||||
table: &mut table,
|
||||
};
|
||||
let root = view.get_directories().unwrap().remove(0).0;
|
||||
let fd = view
|
||||
.open_at(
|
||||
root,
|
||||
PathFlags::empty(),
|
||||
"file".into(),
|
||||
OpenFlags::CREATE,
|
||||
DescriptorFlags::READ | DescriptorFlags::WRITE,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let output = view
|
||||
.write_via_stream(Resource::new_borrow(fd.rep()), 2)
|
||||
.unwrap();
|
||||
{
|
||||
let stream = view.table.get_mut(&output).unwrap();
|
||||
assert!(stream.check_write().unwrap() > 0);
|
||||
stream.write(b"abc".as_slice().to_vec().into()).unwrap();
|
||||
stream.flush().unwrap();
|
||||
stream.ready().await;
|
||||
assert!(stream.check_write().unwrap() > 0);
|
||||
}
|
||||
let append = view
|
||||
.append_via_stream(Resource::new_borrow(fd.rep()))
|
||||
.unwrap();
|
||||
{
|
||||
let stream = view.table.get_mut(&append).unwrap();
|
||||
stream.write(b"!".as_slice().to_vec().into()).unwrap();
|
||||
stream.ready().await;
|
||||
}
|
||||
let input = view
|
||||
.read_via_stream(Resource::new_borrow(fd.rep()), 0)
|
||||
.unwrap();
|
||||
let stream = view.table.get_mut(&input).unwrap();
|
||||
assert_eq!(
|
||||
stream.blocking_read(100).await.unwrap().as_ref(),
|
||||
b"\0\0abc!"
|
||||
);
|
||||
assert!(matches!(
|
||||
stream.blocking_read(100).await,
|
||||
Err(StreamError::Closed)
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Compiles a real WASIp2 guest and runs std, SQLite, and tempfile through the adapter.
|
||||
use sausage_vfs::{
|
||||
adapter::{self, Adapter, View},
|
||||
*,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use wasmtime::{
|
||||
Engine, Store,
|
||||
component::{Component, Linker, ResourceTable},
|
||||
};
|
||||
use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
|
||||
struct State {
|
||||
wasi: WasiCtx,
|
||||
table: ResourceTable,
|
||||
fs: Adapter,
|
||||
}
|
||||
impl WasiView for State {
|
||||
fn ctx(&mut self) -> WasiCtxView<'_> {
|
||||
WasiCtxView {
|
||||
ctx: &mut self.wasi,
|
||||
table: &mut self.table,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn rust_guest() {
|
||||
let manifest =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/guests/files/Cargo.toml");
|
||||
let target = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../target/vfs/guests");
|
||||
let status = std::process::Command::new("cargo")
|
||||
.args([
|
||||
"build",
|
||||
"--release",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
"--manifest-path",
|
||||
])
|
||||
.arg(manifest)
|
||||
.env("CARGO_TARGET_DIR", &target)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
let world = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(world.path().join("tmp")).unwrap();
|
||||
let mut mounts = Mounts::default();
|
||||
mounts.mount(
|
||||
"/",
|
||||
Arc::new(Host::new(world.path(), Process::default()).unwrap()),
|
||||
false,
|
||||
);
|
||||
mounts.mount("/run", Arc::new(Memory::default()), false);
|
||||
mounts.mount("/self", Arc::new(Memory::default()), false);
|
||||
let mut generated = Generated::default();
|
||||
generated.insert("/value", || Ok(b"snapshot".to_vec()));
|
||||
mounts.mount("/proc", Arc::new(generated), true);
|
||||
let engine = Engine::default();
|
||||
let mut linker = Linker::new(&engine);
|
||||
wasmtime_wasi::p2::add_to_linker_async(&mut linker).unwrap();
|
||||
adapter::add_to_linker(&mut linker, |s: &mut State| View {
|
||||
adapter: &mut s.fs,
|
||||
table: &mut s.table,
|
||||
})
|
||||
.unwrap();
|
||||
let component = Component::from_file(
|
||||
&engine,
|
||||
target.join("wasm32-wasip2/release/vfs-files-guest.wasm"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut store = Store::new(
|
||||
&engine,
|
||||
State {
|
||||
wasi: WasiCtxBuilder::new()
|
||||
.inherit_stdio()
|
||||
.env("TMPDIR", "/tmp")
|
||||
.build(),
|
||||
table: ResourceTable::new(),
|
||||
fs: Adapter::new(mounts),
|
||||
},
|
||||
);
|
||||
let command =
|
||||
wasmtime_wasi::p2::bindings::Command::instantiate_async(&mut store, &component, &linker)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
command
|
||||
.wasi_cli_run()
|
||||
.call_run(&mut store)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user