sausage-vfs: tests per topic under WASI, nested differential tests, contract through Mounts

This commit is contained in:
2026-10-23 00:12:54 +01:00
parent 4882edf243
commit 218a718a3d
7 changed files with 570 additions and 123 deletions
+244 -53
View File
@@ -64,10 +64,12 @@ async fn contract(layer: &dyn Layer) {
assert_eq!(f.read(2, 1).await.unwrap(), b"a");
layer.remove_dir("/dir").await.unwrap();
}
/// Memory files preserve the common filesystem contract.
#[tokio::test]
async fn memory_contract() {
contract(&Memory::default()).await;
}
/// Host files preserve the contract and cannot escape the sandbox.
#[tokio::test]
async fn host_contract_and_sandbox() {
let tmp = tempfile::tempdir().unwrap();
@@ -75,6 +77,7 @@ async fn host_contract_and_sandbox() {
contract(&h).await;
assert!(h.open("/../escape", rw()).await.is_err());
}
/// Cloned memory layers append through shared file contents.
#[tokio::test]
async fn shared_memory_handles_append_atomically() {
let memory = Memory::default();
@@ -103,6 +106,7 @@ async fn shared_memory_handles_append_atomically() {
assert_eq!(bytes.len(), 200);
assert_eq!(bytes.iter().filter(|b| **b == b'a').count(), 100);
}
/// Generated files capture independent finite snapshots at each open.
#[tokio::test]
async fn generated_snapshots() {
let counter = Arc::new(AtomicUsize::new(0));
@@ -122,6 +126,7 @@ async fn generated_snapshots() {
ErrorKind::ReadOnlyFilesystem
);
}
/// Mount routing resolves traversal and rejects protected mutations.
#[tokio::test]
async fn mounts_resolve_and_protect() {
let mut mounts = Mounts::default();
@@ -148,6 +153,7 @@ async fn mounts_resolve_and_protect() {
ErrorKind::CrossesDevices
);
}
/// Cancellation retains the process guard until the blocking operation returns.
#[tokio::test]
async fn aborted_operations_keep_guard() {
let p = Process::default();
@@ -172,62 +178,247 @@ async fn aborted_operations_keep_guard() {
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); }
/// The common contract also holds through mounts with a memory root.
#[tokio::test]
async fn mounted_memory_contract() {
let mut mounts = Mounts::default();
mounts.mount("/", Arc::new(Memory::default()), false);
contract(&mounts).await;
}
/// The common contract also holds through mounts with a host root.
#[tokio::test]
async fn mounted_host_contract() {
let temp = tempfile::tempdir().unwrap();
let mut mounts = Mounts::default();
mounts.mount(
"/",
Arc::new(Host::new(temp.path(), Process::default()).unwrap()),
false,
);
contract(&mounts).await;
}
async fn check_memory_model(
ops: Vec<(u8, usize, Vec<u8>)>,
) -> std::result::Result<(), TestCaseError> {
let memory = Memory::default();
let file = memory.open("/f", rw()).await.unwrap();
let mut model = Vec::new();
for (op, offset, bytes) in ops {
match op {
0 => {
file.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);
}
prop_assert_eq!(f.read(0,1000).await.unwrap(),model.clone());
}
Ok(())
})?;
1 => {
file.append(bytes.clone()).await.unwrap();
model.extend(bytes);
}
_ => {
file.set_size(offset as u64).await.unwrap();
model.resize(offset, 0);
}
}
prop_assert_eq!(file.read(0, 1000).await.unwrap(), model.clone());
}
#[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(())
}
const PATHS: &[&str] = &[
"/a",
"/b",
"/a/child",
"/b/child",
"/a/nested",
"/b/nested",
"/a/nested/file",
"/target",
"/missing/child",
];
#[derive(Debug)]
struct Operation {
kind: u8,
from: usize,
to: usize,
bytes: Vec<u8>,
}
fn operations() -> impl Strategy<Value = Vec<Operation>> {
prop::collection::vec(
(
0u8..9,
0..PATHS.len(),
0..PATHS.len(),
prop::collection::vec(any::<u8>(), 0..30),
)
.prop_map(|(kind, from, to, bytes)| Operation {
kind,
from,
to,
bytes,
}),
0..80,
)
}
async fn apply_layer(layer: &dyn Layer, op: &Operation) -> std::io::Result<()> {
let path = PATHS[op.from];
match op.kind {
0 => layer
.open(
path,
Open {
truncate: true,
..rw()
},
)
.await?
.write(0, op.bytes.clone())
.await
.map(|_| ()),
1 => layer.remove_file(path).await,
2 => layer.mkdir(path).await,
3 => layer.remove_dir(path).await,
4 => layer.rename(path, PATHS[op.to]).await,
8 => layer.stat(path).await.map(|_| ()),
_ => {
let file = layer
.open(
path,
Open {
write: true,
..Open::default()
},
)
.await?;
match op.kind {
5 => file.append(op.bytes.clone()).await.map(|_| ()),
6 => file.set_size(op.bytes.len() as u64).await,
_ => file.write(op.to as u64, op.bytes.clone()).await.map(|_| ()),
}
Ok(())
})?;
}
}
}
fn apply_std(root: &std::path::Path, op: &Operation) -> std::io::Result<()> {
use std::io::{Seek, SeekFrom, Write};
let path = root.join(PATHS[op.from].trim_start_matches('/'));
match op.kind {
0 => std::fs::write(path, &op.bytes),
1 => std::fs::remove_file(path),
2 => std::fs::create_dir(path),
3 => std::fs::remove_dir(path),
4 => std::fs::rename(path, root.join(PATHS[op.to].trim_start_matches('/'))),
8 => std::fs::metadata(path).map(|_| ()),
_ => {
let mut file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.append(op.kind == 5)
.open(path)?;
match op.kind {
5 => file.write_all(&op.bytes),
6 => file.set_len(op.bytes.len() as u64),
_ => {
file.seek(SeekFrom::Start(op.to as u64))?;
file.write_all(&op.bytes)
}
}
}
}
}
async fn compare_tree(
host: &Host,
root: &std::path::Path,
) -> std::result::Result<(), TestCaseError> {
let mut pending = vec!["/".to_string()];
while let Some(path) = pending.pop() {
let real = root.join(path.trim_start_matches('/'));
let mut entries = std::fs::read_dir(real)
.unwrap()
.map(|entry| {
let entry = entry.unwrap();
Entry {
name: entry.file_name().to_str().unwrap().into(),
directory: entry.file_type().unwrap().is_dir(),
}
})
.collect::<Vec<_>>();
entries.sort_by(|a, b| a.name.cmp(&b.name));
prop_assert_eq!(host.list(&path).await.unwrap(), entries.clone());
for entry in entries {
let child = resolve(&path, &entry.name);
let stat = host.stat(&child).await.unwrap();
prop_assert_eq!(stat.directory, entry.directory);
if entry.directory {
pending.push(child);
} else {
let bytes = std::fs::read(root.join(child.trim_start_matches('/'))).unwrap();
prop_assert_eq!(stat.size, bytes.len() as u64);
prop_assert_eq!(
host.open(&child, Open::default())
.await
.unwrap()
.read(0, 1024)
.await
.unwrap(),
bytes
);
}
}
}
Ok(())
}
async fn check_host_differential(ops: Vec<Operation>) -> std::result::Result<(), TestCaseError> {
let actual = tempfile::tempdir().unwrap();
let expected = tempfile::tempdir().unwrap();
let host = Host::new(actual.path(), Process::default()).unwrap();
for root in [actual.path(), expected.path()] {
std::fs::create_dir_all(root.join("a/nested")).unwrap();
std::fs::create_dir_all(root.join("b/nested")).unwrap();
std::fs::write(root.join("a/child"), b"source").unwrap();
std::fs::write(root.join("target"), b"destination").unwrap();
}
for op in ops {
let actual = apply_layer(&host, &op).await.map_err(|e| e.kind());
let expected_result = apply_std(expected.path(), &op).map_err(|e| e.kind());
prop_assert_eq!(actual, expected_result, "operation: {:?}", op);
compare_tree(&host, expected.path()).await?;
}
Ok(())
}
/// Nested renames replace existing files and empty folders just like std::fs.
#[tokio::test]
async fn nested_rename_replacements() {
let rename = |from, to| Operation {
kind: 4,
from,
to,
bytes: vec![],
};
check_host_differential(vec![rename(2, 7), rename(4, 5), rename(7, 8)])
.await
.unwrap();
}
proptest! {
/// Random file operations match a byte-vector model of memory files.
#[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(check_memory_model(ops))?;
}
/// Random nested operations match std::fs results and the complete tree.
#[test]
fn host_differential(ops in operations()) {
tokio::runtime::Runtime::new().unwrap().block_on(check_host_differential(ops))?;
}
}