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
+1
View File
@@ -4,5 +4,6 @@ version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
wasip2 = "1"
tempfile = "3"
rusqlite = { version = "0.38", features = ["bundled"] }
+223 -32
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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");
}