API: Layer::stat(path, follow), Layer::readlink, Open::follow, Stat::symlink and Entry::symlink. Process::idle documents the shutdown order: abort, await the task, await idle, release locks.
460 lines
14 KiB
Rust
460 lines
14 KiB
Rust
//! 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::<Vec<_>>(),
|
|
["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<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);
|
|
}
|
|
}
|
|
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<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, 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::<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, 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<Operation>,
|
|
memory: bool,
|
|
) -> std::result::Result<(), TestCaseError> {
|
|
let actual = tempfile::tempdir().unwrap();
|
|
let expected = tempfile::tempdir().unwrap();
|
|
let host: Arc<dyn Layer> = 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<Operation>) -> 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::<u8>(), 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))?;
|
|
}
|
|
}
|