sausage-vfs: tests per topic under WASI, nested differential tests, contract through Mounts
This commit is contained in:
@@ -13,6 +13,7 @@ bytes = "1"
|
|||||||
tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
wasip2 = "1"
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
rusqlite = { version = "0.38", features = ["bundled"] }
|
rusqlite = { version = "0.38", features = ["bundled"] }
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
//! Wasmtime 49 filesystem interfaces backed exclusively by the virtual mount namespace.
|
//! 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::streams::{Reader, Writer};
|
||||||
use crate::*;
|
use crate::*;
|
||||||
use wasmtime::component::{HasData, Linker, Resource, ResourceTable};
|
use wasmtime::component::{HasData, Linker, Resource, ResourceTable};
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
[workspace]
|
[workspace]
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
wasip2 = "1"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
rusqlite = { version = "0.38", features = ["bundled"] }
|
rusqlite = { version = "0.38", features = ["bundled"] }
|
||||||
|
|||||||
@@ -1,9 +1,30 @@
|
|||||||
//! Exercises Rust filesystem clients through the virtual WASI filesystem.
|
//! Runs independently selected WASI filesystem test topics.
|
||||||
use std::{
|
use std::{
|
||||||
fs::{self, OpenOptions},
|
fs::{self, OpenOptions},
|
||||||
|
io::ErrorKind,
|
||||||
io::{Read, Seek, SeekFrom, Write},
|
io::{Read, Seek, SeekFrom, Write},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Select one topic so host failures identify the failing contract.
|
||||||
fn main() {
|
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::create_dir("/work").unwrap();
|
||||||
fs::write("/work/a", b"hello").unwrap();
|
fs::write("/work/a", b"hello").unwrap();
|
||||||
let mut f = OpenOptions::new()
|
let mut f = OpenOptions::new()
|
||||||
@@ -15,25 +36,171 @@ fn main() {
|
|||||||
f.write_all(b"A").unwrap();
|
f.write_all(b"A").unwrap();
|
||||||
f.set_len(4).unwrap();
|
f.set_len(4).unwrap();
|
||||||
f.sync_all().unwrap();
|
f.sync_all().unwrap();
|
||||||
OpenOptions::new()
|
assert_eq!(fs::read("work/../work/a").unwrap(), b"hAll");
|
||||||
.append(true)
|
assert_eq!(fs::metadata("/work/a").unwrap().len(), 4);
|
||||||
.open("/work/a")
|
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()
|
.unwrap()
|
||||||
.write_all(b"!")
|
.map(Result::unwrap)
|
||||||
.unwrap();
|
.collect::<Vec<_>>();
|
||||||
assert_eq!(fs::read("/work/a").unwrap(), b"hAll!");
|
assert_eq!(entries.len(), 2);
|
||||||
assert_eq!(fs::metadata("/work/a").unwrap().len(), 5);
|
for entry in entries {
|
||||||
fs::rename("/work/a", "/work/b").unwrap();
|
let metadata = entry.metadata().unwrap();
|
||||||
assert_eq!(fs::read("/work/../work/b").unwrap(), b"hAll!");
|
let directory = entry.file_name() == "b";
|
||||||
assert_eq!(fs::read("work/b").unwrap(), b"hAll!");
|
assert_eq!(entry.file_type().unwrap().is_dir(), directory);
|
||||||
assert_eq!(fs::read_dir("/work").unwrap().count(), 1);
|
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!(
|
assert_eq!(
|
||||||
fs::write("/proc/no", b"no").unwrap_err().kind(),
|
fs::metadata("/tree").unwrap_err().kind(),
|
||||||
std::io::ErrorKind::ReadOnlyFilesystem
|
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!(
|
assert_eq!(
|
||||||
fs::rename("/work/b", "/run/b").unwrap_err().kind(),
|
fs::write("/missing/child", b"x").unwrap_err().kind(),
|
||||||
std::io::ErrorKind::CrossesDevices
|
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("/")
|
let root = fs::read_dir("/")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -43,24 +210,48 @@ fn main() {
|
|||||||
assert!(root.iter().any(|n| n == name));
|
assert!(root.iter().any(|n| n == name));
|
||||||
}
|
}
|
||||||
assert_eq!(fs::read("/proc/value").unwrap(), b"snapshot");
|
assert_eq!(fs::read("/proc/value").unwrap(), b"snapshot");
|
||||||
fs::write("/run/test", b"memory").unwrap();
|
fs::write("/file", b"root write").unwrap();
|
||||||
assert_eq!(fs::read("/run/test").unwrap(), b"memory");
|
assert_eq!(fs::read("/proc/../file").unwrap(), b"root write");
|
||||||
fs::remove_file("/work/b").unwrap();
|
assert_eq!(
|
||||||
fs::remove_dir("/work").unwrap();
|
fs::rename("/file", "/run/file").unwrap_err().kind(),
|
||||||
// WASI std does not implement env::temp_dir, so select the folder explicitly.
|
ErrorKind::CrossesDevices
|
||||||
let mut temp = tempfile::tempfile_in("/tmp").unwrap();
|
);
|
||||||
temp.write_all(b"temporary").unwrap();
|
fs::write("/run/file", b"memory").unwrap();
|
||||||
temp.seek(SeekFrom::Start(0)).unwrap();
|
assert_eq!(fs::read("/run/file").unwrap(), b"memory");
|
||||||
let mut text = String::new();
|
}
|
||||||
temp.read_to_string(&mut text).unwrap();
|
|
||||||
assert_eq!(text, "temporary");
|
/// SQLite commits many rows in one transaction and reads them after reopen.
|
||||||
let db = rusqlite::Connection::open("/database.sqlite").unwrap();
|
fn sqlite() {
|
||||||
db.execute_batch("CREATE TABLE data (value TEXT); INSERT INTO data VALUES ('row');")
|
let mut db = rusqlite::Connection::open("/database.sqlite").unwrap();
|
||||||
|
db.execute_batch("CREATE TABLE data (value INTEGER)")
|
||||||
.unwrap();
|
.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);
|
drop(db);
|
||||||
let db = rusqlite::Connection::open("/database.sqlite").unwrap();
|
let db = rusqlite::Connection::open("/database.sqlite").unwrap();
|
||||||
let row: String = db
|
let result: (i64, i64) = db
|
||||||
.query_row("SELECT value FROM data", [], |r| r.get(0))
|
.query_row("SELECT count(*), sum(value) FROM data", [], |r| {
|
||||||
|
Ok((r.get(0)?, r.get(1)?))
|
||||||
|
})
|
||||||
.unwrap();
|
.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");
|
||||||
}
|
}
|
||||||
|
|||||||
+244
-53
@@ -64,10 +64,12 @@ async fn contract(layer: &dyn Layer) {
|
|||||||
assert_eq!(f.read(2, 1).await.unwrap(), b"a");
|
assert_eq!(f.read(2, 1).await.unwrap(), b"a");
|
||||||
layer.remove_dir("/dir").await.unwrap();
|
layer.remove_dir("/dir").await.unwrap();
|
||||||
}
|
}
|
||||||
|
/// Memory files preserve the common filesystem contract.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn memory_contract() {
|
async fn memory_contract() {
|
||||||
contract(&Memory::default()).await;
|
contract(&Memory::default()).await;
|
||||||
}
|
}
|
||||||
|
/// Host files preserve the contract and cannot escape the sandbox.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn host_contract_and_sandbox() {
|
async fn host_contract_and_sandbox() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
@@ -75,6 +77,7 @@ async fn host_contract_and_sandbox() {
|
|||||||
contract(&h).await;
|
contract(&h).await;
|
||||||
assert!(h.open("/../escape", rw()).await.is_err());
|
assert!(h.open("/../escape", rw()).await.is_err());
|
||||||
}
|
}
|
||||||
|
/// Cloned memory layers append through shared file contents.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn shared_memory_handles_append_atomically() {
|
async fn shared_memory_handles_append_atomically() {
|
||||||
let memory = Memory::default();
|
let memory = Memory::default();
|
||||||
@@ -103,6 +106,7 @@ async fn shared_memory_handles_append_atomically() {
|
|||||||
assert_eq!(bytes.len(), 200);
|
assert_eq!(bytes.len(), 200);
|
||||||
assert_eq!(bytes.iter().filter(|b| **b == b'a').count(), 100);
|
assert_eq!(bytes.iter().filter(|b| **b == b'a').count(), 100);
|
||||||
}
|
}
|
||||||
|
/// Generated files capture independent finite snapshots at each open.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn generated_snapshots() {
|
async fn generated_snapshots() {
|
||||||
let counter = Arc::new(AtomicUsize::new(0));
|
let counter = Arc::new(AtomicUsize::new(0));
|
||||||
@@ -122,6 +126,7 @@ async fn generated_snapshots() {
|
|||||||
ErrorKind::ReadOnlyFilesystem
|
ErrorKind::ReadOnlyFilesystem
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
/// Mount routing resolves traversal and rejects protected mutations.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn mounts_resolve_and_protect() {
|
async fn mounts_resolve_and_protect() {
|
||||||
let mut mounts = Mounts::default();
|
let mut mounts = Mounts::default();
|
||||||
@@ -148,6 +153,7 @@ async fn mounts_resolve_and_protect() {
|
|||||||
ErrorKind::CrossesDevices
|
ErrorKind::CrossesDevices
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
/// Cancellation retains the process guard until the blocking operation returns.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn aborted_operations_keep_guard() {
|
async fn aborted_operations_keep_guard() {
|
||||||
let p = Process::default();
|
let p = Process::default();
|
||||||
@@ -172,62 +178,247 @@ async fn aborted_operations_keep_guard() {
|
|||||||
release_tx.send(()).unwrap();
|
release_tx.send(()).unwrap();
|
||||||
idle.await;
|
idle.await;
|
||||||
}
|
}
|
||||||
proptest! {
|
|
||||||
#[test]
|
/// The common contract also holds through mounts with a memory root.
|
||||||
fn memory_model(ops in prop::collection::vec((0u8..3,0usize..64,prop::collection::vec(any::<u8>(),0..20)),0..80)) {
|
#[tokio::test]
|
||||||
tokio::runtime::Runtime::new().unwrap().block_on(async {
|
async fn mounted_memory_contract() {
|
||||||
let m = Memory::default(); let f = m.open("/f",rw()).await.unwrap(); let mut model = Vec::new();
|
let mut mounts = Mounts::default();
|
||||||
for (op,offset,bytes) in ops {
|
mounts.mount("/", Arc::new(Memory::default()), false);
|
||||||
match op {
|
contract(&mounts).await;
|
||||||
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 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]
|
Ok(())
|
||||||
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();
|
const PATHS: &[&str] = &[
|
||||||
for (op,index,data) in ops {
|
"/a",
|
||||||
let name = format!("f{index}"); let path = format!("/{name}"); let real = b.path().join(&name);
|
"/b",
|
||||||
let (actual,expected) = match op {
|
"/a/child",
|
||||||
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)) },
|
"/b/child",
|
||||||
1 => (host.remove_file(&path).await,std::fs::remove_file(real)),
|
"/a/nested",
|
||||||
2 => (host.mkdir(&path).await,std::fs::create_dir(real)),
|
"/b/nested",
|
||||||
3 => (host.remove_dir(&path).await,std::fs::remove_dir(real)),
|
"/a/nested/file",
|
||||||
4 => (host.rename(&path,"/renamed").await,std::fs::rename(real,b.path().join("renamed"))),
|
"/target",
|
||||||
_ => {
|
"/missing/child",
|
||||||
use std::io::{Seek, SeekFrom, Write};
|
];
|
||||||
let actual = match host.open(&path,Open { write:true,..Open::default() }).await {
|
#[derive(Debug)]
|
||||||
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(|_|()) },
|
struct Operation {
|
||||||
Err(e) => Err(e),
|
kind: u8,
|
||||||
};
|
from: usize,
|
||||||
let expected = (|| {
|
to: usize,
|
||||||
let mut f = std::fs::OpenOptions::new().read(true).write(true).append(op == 5).open(real)?;
|
bytes: Vec<u8>,
|
||||||
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) } }
|
}
|
||||||
})();
|
fn operations() -> impl Strategy<Value = Vec<Operation>> {
|
||||||
(actual,expected)
|
prop::collection::vec(
|
||||||
}
|
(
|
||||||
};
|
0u8..9,
|
||||||
prop_assert_eq!(actual.map_err(|e|e.kind()),expected.map_err(|e|e.kind()));
|
0..PATHS.len(),
|
||||||
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));
|
0..PATHS.len(),
|
||||||
for entry in &expected {
|
prop::collection::vec(any::<u8>(), 0..30),
|
||||||
let p = format!("/{}", entry.name);
|
)
|
||||||
let metadata = host.stat(&p).await.unwrap();
|
.prop_map(|(kind, from, to, bytes)| Operation {
|
||||||
prop_assert_eq!(metadata.directory, entry.directory);
|
kind,
|
||||||
if !entry.directory {
|
from,
|
||||||
let bytes = std::fs::read(b.path().join(&entry.name)).unwrap();
|
to,
|
||||||
prop_assert_eq!(metadata.size, bytes.len() as u64);
|
bytes,
|
||||||
prop_assert_eq!(host.open(&p, Open::default()).await.unwrap().read(0, 1024).await.unwrap(), bytes);
|
}),
|
||||||
}
|
0..80,
|
||||||
}
|
)
|
||||||
prop_assert_eq!(host.list("/").await.unwrap(),expected);
|
}
|
||||||
|
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))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use wasmtime_wasi::p2::{
|
|||||||
types::{DescriptorFlags, HostDescriptor, OpenFlags, PathFlags},
|
types::{DescriptorFlags, HostDescriptor, OpenFlags, PathFlags},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
/// Adapter streams preserve offsets, append semantics, and EOF.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn adapter_streams() {
|
async fn adapter_streams() {
|
||||||
let mut mounts = Mounts::default();
|
let mut mounts = Mounts::default();
|
||||||
|
|||||||
+96
-38
@@ -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::{
|
use sausage_vfs::{
|
||||||
adapter::{self, Adapter, View},
|
adapter::{self, Adapter, View},
|
||||||
*,
|
*,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, OnceLock};
|
||||||
use wasmtime::{
|
use wasmtime::{
|
||||||
Engine, Store,
|
Engine, Store,
|
||||||
component::{Component, Linker, ResourceTable},
|
component::{Component, Linker, ResourceTable},
|
||||||
@@ -22,26 +22,37 @@ impl WasiView for State {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
fn guest() -> &'static (Engine, Component) {
|
||||||
async fn rust_guest() {
|
static GUEST: OnceLock<(Engine, Component)> = OnceLock::new();
|
||||||
let manifest =
|
GUEST.get_or_init(|| {
|
||||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/guests/files/Cargo.toml");
|
let manifest =
|
||||||
let target = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../target/vfs/guests");
|
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/guests/files/Cargo.toml");
|
||||||
let status = std::process::Command::new("cargo")
|
let target = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../target/vfs/guests");
|
||||||
.args([
|
let status = std::process::Command::new("cargo")
|
||||||
"build",
|
.args([
|
||||||
"--release",
|
"build",
|
||||||
"--locked",
|
"--release",
|
||||||
"--offline",
|
"--offline",
|
||||||
"--target",
|
"--target",
|
||||||
"wasm32-wasip2",
|
"wasm32-wasip2",
|
||||||
"--manifest-path",
|
"--manifest-path",
|
||||||
])
|
])
|
||||||
.arg(manifest)
|
.arg(manifest)
|
||||||
.env("CARGO_TARGET_DIR", &target)
|
.env("CARGO_TARGET_DIR", &target)
|
||||||
.status()
|
.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();
|
.unwrap();
|
||||||
assert!(status.success());
|
(engine, component)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async fn run(topic: &str, shared: Arc<Memory>) {
|
||||||
|
let (engine, component) = guest();
|
||||||
let world = tempfile::tempdir().unwrap();
|
let world = tempfile::tempdir().unwrap();
|
||||||
std::fs::create_dir(world.path().join("tmp")).unwrap();
|
std::fs::create_dir(world.path().join("tmp")).unwrap();
|
||||||
let mut mounts = Mounts::default();
|
let mut mounts = Mounts::default();
|
||||||
@@ -50,45 +61,92 @@ async fn rust_guest() {
|
|||||||
Arc::new(Host::new(world.path(), Process::default()).unwrap()),
|
Arc::new(Host::new(world.path(), Process::default()).unwrap()),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
mounts.mount("/run", Arc::new(Memory::default()), false);
|
mounts.mount("/run", shared, false);
|
||||||
mounts.mount("/self", Arc::new(Memory::default()), false);
|
mounts.mount("/self", Arc::new(Memory::default()), false);
|
||||||
let mut generated = Generated::default();
|
let mut generated = Generated::default();
|
||||||
generated.insert("/value", || Ok(b"snapshot".to_vec()));
|
generated.insert("/value", || Ok(b"snapshot".to_vec()));
|
||||||
mounts.mount("/proc", Arc::new(generated), true);
|
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();
|
wasmtime_wasi::p2::add_to_linker_async(&mut linker).unwrap();
|
||||||
adapter::add_to_linker(&mut linker, |s: &mut State| View {
|
adapter::add_to_linker(&mut linker, |s: &mut State| View {
|
||||||
adapter: &mut s.fs,
|
adapter: &mut s.fs,
|
||||||
table: &mut s.table,
|
table: &mut s.table,
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let component = Component::from_file(
|
|
||||||
&engine,
|
|
||||||
target.join("wasm32-wasip2/release/vfs-files-guest.wasm"),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let mut store = Store::new(
|
let mut store = Store::new(
|
||||||
&engine,
|
engine,
|
||||||
State {
|
State {
|
||||||
wasi: WasiCtxBuilder::new()
|
wasi: WasiCtxBuilder::new()
|
||||||
.inherit_stdio()
|
.inherit_stdio()
|
||||||
.env("TMPDIR", "/tmp")
|
.env("VFS_TOPIC", topic)
|
||||||
.build(),
|
.build(),
|
||||||
table: ResourceTable::new(),
|
table: ResourceTable::new(),
|
||||||
fs: Adapter::new(mounts),
|
fs: Adapter::new(mounts),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let command =
|
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
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let result = command.wasi_cli_run().call_run(&mut store).await;
|
||||||
assert!(
|
assert!(
|
||||||
command
|
matches!(result, Ok(Ok(()))),
|
||||||
.wasi_cli_run()
|
"guest topic {topic} failed: {result:?}"
|
||||||
.call_run(&mut store)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.is_ok()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user