From 218a718a3dfe42d5cc2e10d3cefc3a3327d30c05 Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 23 Oct 2026 00:12:54 +0100 Subject: [PATCH] sausage-vfs: tests per topic under WASI, nested differential tests, contract through Mounts --- vfs/Cargo.toml | 1 + vfs/src/adapter.rs | 4 + vfs/tests/guests/files/Cargo.toml | 1 + vfs/tests/guests/files/src/main.rs | 255 +++++++++++++++++++++---- vfs/tests/layers.rs | 297 ++++++++++++++++++++++++----- vfs/tests/streams.rs | 1 + vfs/tests/wasi.rs | 134 +++++++++---- 7 files changed, 570 insertions(+), 123 deletions(-) diff --git a/vfs/Cargo.toml b/vfs/Cargo.toml index 6da482b..7aea8d6 100644 --- a/vfs/Cargo.toml +++ b/vfs/Cargo.toml @@ -13,6 +13,7 @@ bytes = "1" tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros"] } [dev-dependencies] +wasip2 = "1" proptest = "1" tempfile = "3" rusqlite = { version = "0.38", features = ["bundled"] } diff --git a/vfs/src/adapter.rs b/vfs/src/adapter.rs index 98cb8b1..1fdce88 100644 --- a/vfs/src/adapter.rs +++ b/vfs/src/adapter.rs @@ -1,4 +1,8 @@ //! 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. use crate::streams::{Reader, Writer}; use crate::*; use wasmtime::component::{HasData, Linker, Resource, ResourceTable}; diff --git a/vfs/tests/guests/files/Cargo.toml b/vfs/tests/guests/files/Cargo.toml index 0ae7c0a..7b8874c 100644 --- a/vfs/tests/guests/files/Cargo.toml +++ b/vfs/tests/guests/files/Cargo.toml @@ -4,5 +4,6 @@ version = "0.1.0" edition = "2024" [workspace] [dependencies] +wasip2 = "1" tempfile = "3" rusqlite = { version = "0.38", features = ["bundled"] } diff --git a/vfs/tests/guests/files/src/main.rs b/vfs/tests/guests/files/src/main.rs index 8205b23..95055ea 100644 --- a/vfs/tests/guests/files/src/main.rs +++ b/vfs/tests/guests/files/src/main.rs @@ -1,9 +1,30 @@ -//! Exercises Rust filesystem clients through the virtual WASI filesystem. +//! Runs independently selected WASI filesystem test topics. use std::{ fs::{self, OpenOptions}, + io::ErrorKind, io::{Read, Seek, SeekFrom, Write}, }; + +/// Select one topic so host failures identify the failing contract. fn main() { + match std::env::var("VFS_TOPIC").unwrap().as_str() { + "basic" => basic(), + "trees" => trees(), + "options" => options(), + "rename" => rename(), + "sparse" => sparse(), + "streams" => streams(), + "mounts" => mounts(), + "shared_write" => fs::write("/run/shared", b"shared between guests").unwrap(), + "shared_read" => assert_eq!(fs::read("/run/shared").unwrap(), b"shared between guests"), + "sqlite" => sqlite(), + "tempfile" => temporary(), + topic => panic!("unknown topic: {topic}"), + } +} + +/// Basic operations preserve file contents, sizes, and relative path resolution. +fn basic() { fs::create_dir("/work").unwrap(); fs::write("/work/a", b"hello").unwrap(); let mut f = OpenOptions::new() @@ -15,25 +36,171 @@ fn main() { f.write_all(b"A").unwrap(); f.set_len(4).unwrap(); f.sync_all().unwrap(); - OpenOptions::new() - .append(true) - .open("/work/a") + assert_eq!(fs::read("work/../work/a").unwrap(), b"hAll"); + assert_eq!(fs::metadata("/work/a").unwrap().len(), 4); + fs::remove_file("/work/a").unwrap(); + fs::remove_dir("/work").unwrap(); +} + +/// Recursive folder operations and directory entries expose the complete tree. +fn trees() { + fs::create_dir_all("/tree/a/b").unwrap(); + fs::create_dir_all("/tree/a/b").unwrap(); + fs::write("/tree/a/file", b"contents").unwrap(); + let entries = fs::read_dir("/tree/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); + .map(Result::unwrap) + .collect::>(); + assert_eq!(entries.len(), 2); + for entry in entries { + let metadata = entry.metadata().unwrap(); + let directory = entry.file_name() == "b"; + assert_eq!(entry.file_type().unwrap().is_dir(), directory); + assert_eq!(metadata.is_dir(), directory); + assert_eq!(metadata.is_file(), !directory); + if !directory { + assert_eq!(metadata.len(), 8); + } + } + fs::remove_dir_all("/tree").unwrap(); assert_eq!( - fs::write("/proc/no", b"no").unwrap_err().kind(), - std::io::ErrorKind::ReadOnlyFilesystem + fs::metadata("/tree").unwrap_err().kind(), + ErrorKind::NotFound + ); +} + +/// Open flags and filesystem errors match Rust's filesystem contract. +fn options() { + OpenOptions::new() + .write(true) + .create_new(true) + .open("/file") + .unwrap(); + assert_eq!( + OpenOptions::new() + .write(true) + .create_new(true) + .open("/file") + .unwrap_err() + .kind(), + ErrorKind::AlreadyExists + ); + fs::write("/file", b"old").unwrap(); + OpenOptions::new() + .write(true) + .truncate(true) + .open("/file") + .unwrap(); + assert!(fs::read("/file").unwrap().is_empty()); + let mut append = OpenOptions::new().append(true).open("/file").unwrap(); + append.write_all(b"one").unwrap(); + append.seek(SeekFrom::Start(0)).unwrap(); + append.write_all(b"two").unwrap(); + assert_eq!(fs::read("/file").unwrap(), b"onetwo"); + assert_eq!( + fs::read("/missing").unwrap_err().kind(), + ErrorKind::NotFound ); assert_eq!( - fs::rename("/work/b", "/run/b").unwrap_err().kind(), - std::io::ErrorKind::CrossesDevices + fs::write("/missing/child", b"x").unwrap_err().kind(), + ErrorKind::NotFound + ); + assert_eq!( + fs::read("/file/child").unwrap_err().kind(), + ErrorKind::NotADirectory + ); + fs::create_dir("/folder").unwrap(); + fs::write("/folder/child", b"x").unwrap(); + assert_eq!( + fs::remove_dir("/folder").unwrap_err().kind(), + ErrorKind::DirectoryNotEmpty + ); +} + +/// Renames replace files and preserve the contents of moved folders. +fn rename() { + fs::write("/source", b"new").unwrap(); + fs::write("/target", b"old").unwrap(); + fs::rename("/source", "/target").unwrap(); + assert_eq!(fs::read("/target").unwrap(), b"new"); + fs::create_dir_all("/folder/nested").unwrap(); + fs::write("/folder/nested/file", b"nested").unwrap(); + fs::rename("/folder", "/moved").unwrap(); + assert_eq!(fs::read("/moved/nested/file").unwrap(), b"nested"); + assert_eq!( + fs::rename("/target", "/absent/target").unwrap_err().kind(), + ErrorKind::NotFound + ); + assert_eq!(fs::read("/target").unwrap(), b"new"); +} + +/// A write after a seek beyond EOF fills the gap with zeros. +fn sparse() { + let mut f = OpenOptions::new() + .write(true) + .create_new(true) + .open("/sparse") + .unwrap(); + f.write_all(b"start").unwrap(); + f.seek(SeekFrom::Start(8192)).unwrap(); + f.write_all(b"end").unwrap(); + let bytes = fs::read("/sparse").unwrap(); + assert_eq!(&bytes[..5], b"start"); + assert!(bytes[5..8192].iter().all(|b| *b == 0)); + assert_eq!(&bytes[8192..], b"end"); +} + +/// One write_all and one read_to_end transfer several MiB through WASI streams. +fn streams() { + use wasip2::filesystem::{ + preopens, + types::{DescriptorFlags, OpenFlags, PathFlags}, + }; + let root = preopens::get_directories().remove(0).0; + let file = root + .open_at( + PathFlags::empty(), + "large", + OpenFlags::CREATE, + DescriptorFlags::READ | DescriptorFlags::WRITE, + ) + .unwrap(); + let bytes = (0..3 * 1024 * 1024 + 137) + .map(|i| (i % 251) as u8) + .collect::>(); + let mut output = file.write_via_stream(0).unwrap(); + Write::write_all(&mut output, &bytes).unwrap(); + Write::flush(&mut output).unwrap(); + drop(output); + let mut input = file.read_via_stream(0).unwrap(); + let mut received = Vec::new(); + Read::read_to_end(&mut input, &mut received).unwrap(); + let checksum = |data: &[u8]| { + data.iter() + .fold(0u64, |sum, b| sum.wrapping_mul(31).wrapping_add(*b as u64)) + }; + assert_eq!(received.len(), bytes.len()); + assert_eq!(checksum(&received), checksum(&bytes)); + assert_eq!(received, bytes); +} + +/// Mount roots remain folders and read-only mounts reject mutations. +fn mounts() { + assert_eq!( + fs::write("/", b"x").unwrap_err().kind(), + ErrorKind::IsADirectory + ); + assert_eq!( + fs::write("/proc", b"x").unwrap_err().kind(), + ErrorKind::ReadOnlyFilesystem + ); + assert_eq!( + fs::write("/run", b"x").unwrap_err().kind(), + ErrorKind::IsADirectory + ); + assert_eq!( + fs::write("/proc/no", b"x").unwrap_err().kind(), + ErrorKind::ReadOnlyFilesystem ); let root = fs::read_dir("/") .unwrap() @@ -43,24 +210,48 @@ fn main() { 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');") + fs::write("/file", b"root write").unwrap(); + assert_eq!(fs::read("/proc/../file").unwrap(), b"root write"); + assert_eq!( + fs::rename("/file", "/run/file").unwrap_err().kind(), + ErrorKind::CrossesDevices + ); + fs::write("/run/file", b"memory").unwrap(); + assert_eq!(fs::read("/run/file").unwrap(), b"memory"); +} + +/// SQLite commits many rows in one transaction and reads them after reopen. +fn sqlite() { + let mut db = rusqlite::Connection::open("/database.sqlite").unwrap(); + db.execute_batch("CREATE TABLE data (value INTEGER)") .unwrap(); + let tx = db.transaction().unwrap(); + for i in 0..1000 { + tx.execute("INSERT INTO data VALUES (?1)", [i]).unwrap(); + } + tx.commit().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)) + let result: (i64, i64) = db + .query_row("SELECT count(*), sum(value) FROM data", [], |r| { + Ok((r.get(0)?, r.get(1)?)) + }) .unwrap(); - assert_eq!(row, "row"); + assert_eq!(result, (1000, 999 * 1000 / 2)); +} + +/// Named temporary files persist their bytes at the selected destination. +fn temporary() { + let mut unnamed = tempfile::tempfile_in("/tmp").unwrap(); + unnamed.write_all(b"unnamed").unwrap(); + unnamed.seek(SeekFrom::Start(0)).unwrap(); + let mut bytes = Vec::new(); + unnamed.read_to_end(&mut bytes).unwrap(); + assert_eq!(bytes, b"unnamed"); + let mut named = tempfile::NamedTempFile::new_in("/tmp").unwrap(); + let original = named.path().to_owned(); + named.write_all(b"persisted").unwrap(); + named.persist("/persisted").unwrap(); + assert!(!original.exists()); + assert_eq!(fs::read("/persisted").unwrap(), b"persisted"); } diff --git a/vfs/tests/layers.rs b/vfs/tests/layers.rs index 9312349..12b2d12 100644 --- a/vfs/tests/layers.rs +++ b/vfs/tests/layers.rs @@ -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::(),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)>, +) -> 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::(),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::>(); 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, +} +fn operations() -> impl Strategy> { + prop::collection::vec( + ( + 0u8..9, + 0..PATHS.len(), + 0..PATHS.len(), + prop::collection::vec(any::(), 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::>(); + 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) -> 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::(), 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))?; } } diff --git a/vfs/tests/streams.rs b/vfs/tests/streams.rs index 0221088..b132421 100644 --- a/vfs/tests/streams.rs +++ b/vfs/tests/streams.rs @@ -12,6 +12,7 @@ use wasmtime_wasi::p2::{ types::{DescriptorFlags, HostDescriptor, OpenFlags, PathFlags}, }, }; +/// Adapter streams preserve offsets, append semantics, and EOF. #[tokio::test] async fn adapter_streams() { let mut mounts = Mounts::default(); diff --git a/vfs/tests/wasi.rs b/vfs/tests/wasi.rs index 0527e3b..9fff96e 100644 --- a/vfs/tests/wasi.rs +++ b/vfs/tests/wasi.rs @@ -1,9 +1,9 @@ -//! Compiles a real WASIp2 guest and runs std, SQLite, and tempfile through the adapter. +//! Builds one WASIp2 guest and runs each filesystem topic in an isolated instance. use sausage_vfs::{ adapter::{self, Adapter, View}, *, }; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use wasmtime::{ Engine, Store, component::{Component, Linker, ResourceTable}, @@ -22,26 +22,37 @@ impl WasiView for State { } } } -#[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() +fn guest() -> &'static (Engine, Component) { + static GUEST: OnceLock<(Engine, Component)> = OnceLock::new(); + GUEST.get_or_init(|| { + 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", + "--offline", + "--target", + "wasm32-wasip2", + "--manifest-path", + ]) + .arg(manifest) + .env("CARGO_TARGET_DIR", &target) + .status() + .unwrap(); + assert!(status.success(), "guest build failed"); + let engine = Engine::default(); + let component = Component::from_file( + &engine, + target.join("wasm32-wasip2/release/vfs-files-guest.wasm"), + ) .unwrap(); - assert!(status.success()); + (engine, component) + }) +} +async fn run(topic: &str, shared: Arc) { + let (engine, component) = guest(); let world = tempfile::tempdir().unwrap(); std::fs::create_dir(world.path().join("tmp")).unwrap(); let mut mounts = Mounts::default(); @@ -50,45 +61,92 @@ async fn rust_guest() { Arc::new(Host::new(world.path(), Process::default()).unwrap()), false, ); - mounts.mount("/run", Arc::new(Memory::default()), false); + mounts.mount("/run", shared, 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); + 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, + engine, State { wasi: WasiCtxBuilder::new() .inherit_stdio() - .env("TMPDIR", "/tmp") + .env("VFS_TOPIC", topic) .build(), table: ResourceTable::new(), fs: Adapter::new(mounts), }, ); let command = - wasmtime_wasi::p2::bindings::Command::instantiate_async(&mut store, &component, &linker) + wasmtime_wasi::p2::bindings::Command::instantiate_async(&mut store, component, &linker) .await .unwrap(); + let result = command.wasi_cli_run().call_run(&mut store).await; assert!( - command - .wasi_cli_run() - .call_run(&mut store) - .await - .unwrap() - .is_ok() + matches!(result, Ok(Ok(()))), + "guest topic {topic} failed: {result:?}" ); } +async fn isolated(topic: &str) { + run(topic, Arc::new(Memory::default())).await; +} + +/// Basic filesystem operations support relative paths and metadata. +#[tokio::test(flavor = "multi_thread")] +async fn basic_files() { + isolated("basic").await; +} +/// Recursive operations and directory entries preserve nested trees. +#[tokio::test(flavor = "multi_thread")] +async fn nested_trees() { + isolated("trees").await; +} +/// Open flags and error kinds match Rust's filesystem contract. +#[tokio::test(flavor = "multi_thread")] +async fn options_and_errors() { + isolated("options").await; +} +/// Renames replace existing files and move complete folders. +#[tokio::test(flavor = "multi_thread")] +async fn renames() { + isolated("rename").await; +} +/// Sparse writes return zeros for the gap beyond EOF. +#[tokio::test(flavor = "multi_thread")] +async fn sparse_writes() { + isolated("sparse").await; +} +/// Streams transfer several MiB with a verified checksum. +#[tokio::test(flavor = "multi_thread")] +async fn large_streams() { + isolated("streams").await; +} +/// Mount listings, root writes, and read-only errors follow the namespace. +#[tokio::test(flavor = "multi_thread")] +async fn mount_boundaries() { + isolated("mounts").await; +} +/// Separate guest stores share the same memory layer. +#[tokio::test(flavor = "multi_thread")] +async fn shared_memory_between_guests() { + let memory = Arc::new(Memory::default()); + run("shared_write", memory.clone()).await; + run("shared_read", memory).await; +} +/// SQLite commits many rows and reads them after reopen. +#[tokio::test(flavor = "multi_thread")] +async fn sqlite_transactions() { + isolated("sqlite").await; +} +/// Named temporary files persist through the filesystem adapter. +#[tokio::test(flavor = "multi_thread")] +async fn named_temporary_files() { + isolated("tempfile").await; +}