//! 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(); } /// 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(); let h = Host::new(tmp.path(), Process::default()).unwrap(); 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(); 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); } /// Generated files capture independent finite snapshots at each open. #[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 ); } /// Mount routing resolves traversal and rejects protected mutations. #[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::>(), ["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 ); } /// Cancellation retains the process guard until the blocking operation returns. #[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; } /// 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); } } 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()); } 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, true).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(|_| ()), } } } } 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: &dyn Layer, 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(), symlink: false, } }) .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, true).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_differential( ops: Vec, memory: bool, ) -> std::result::Result<(), TestCaseError> { let actual = tempfile::tempdir().unwrap(); let expected = tempfile::tempdir().unwrap(); let host: Arc = if memory { Arc::new(Memory::default()) } else { Arc::new(Host::new(actual.path(), Process::default()).unwrap()) }; { let root = 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(); } host.mkdir("/a").await.unwrap(); host.mkdir("/a/nested").await.unwrap(); host.mkdir("/b").await.unwrap(); host.mkdir("/b/nested").await.unwrap(); host.open("/a/child", rw()) .await .unwrap() .write(0, b"source".to_vec()) .await .unwrap(); host.open("/target", rw()) .await .unwrap() .write(0, b"destination".to_vec()) .await .unwrap(); for op in ops { let actual = apply_layer(host.as_ref(), &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.as_ref(), expected.path()).await?; } Ok(()) } async fn check_host_differential(ops: Vec) -> std::result::Result<(), TestCaseError> { check_differential(ops, false).await } /// 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 memory namespace operations match std::fs and the complete tree. #[test] fn memory_differential(ops in operations()) { tokio::runtime::Runtime::new().unwrap().block_on(check_differential(ops, true))?; } /// 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))?; } }