use std::collections::BTreeMap;
use vortex_core::{DetRng, FsError, FsFaultConfig, FsFaultRule, FsOp};
use crate::traits::{FileMetadata, FileType, VortexFs, VortexFsError, VortexFsResult};
#[derive(Debug, Clone)]
enum Entry {
File(Vec<u8>),
Dir,
}
pub struct SimFs {
rng: DetRng,
entries: BTreeMap<String, Entry>,
fault_config: FsFaultConfig,
total_bytes_written: u64,
}
impl SimFs {
pub fn new(seed: u64) -> Self {
let mut entries = BTreeMap::new();
entries.insert("/".to_string(), Entry::Dir);
Self {
rng: DetRng::new(seed),
entries,
fault_config: FsFaultConfig::default(),
total_bytes_written: 0,
}
}
pub fn with_faults(seed: u64, config: FsFaultConfig) -> Self {
let mut fs = Self::new(seed);
fs.fault_config = config;
fs
}
pub fn set_fault_config(&mut self, config: FsFaultConfig) {
self.fault_config = config;
}
pub fn total_bytes_written(&self) -> u64 {
self.total_bytes_written
}
pub fn entry_count(&self) -> usize {
self.entries.len()
}
fn check_fault(&mut self, path: &str, op: FsOp, pending_bytes: u64) -> Option<VortexFsError> {
let effective_bytes = self.total_bytes_written + pending_bytes;
for rule in &self.fault_config.rules {
if rule.error == FsError::TornWrite
|| rule.error == FsError::Corrupt
|| (rule.error == FsError::DelayedFsync && op != FsOp::Fsync)
{
continue;
}
if !matches_glob(&rule.path, path) {
continue;
}
if rule.op != FsOp::Any && rule.op != op {
continue;
}
if rule.after_bytes > 0 && effective_bytes < rule.after_bytes {
continue;
}
if self.rng.chance(rule.probability) {
return Some(fault_rule_to_error(rule, path));
}
}
None
}
fn check_torn_write(&mut self, path: &str, data: &[u8]) -> Option<(usize, VortexFsError)> {
for rule in &self.fault_config.rules {
if rule.error != FsError::TornWrite {
continue;
}
if !matches_glob(&rule.path, path) {
continue;
}
if rule.op != FsOp::Any && rule.op != FsOp::Write {
continue;
}
if rule.after_bytes > 0 && self.total_bytes_written < rule.after_bytes {
continue;
}
if self.rng.chance(rule.probability) {
let partial = if data.is_empty() {
0
} else {
self.rng.next_u64_below(data.len() as u64) as usize
};
return Some((
partial,
VortexFsError::TornWrite {
path: path.to_string(),
bytes_written: partial as u64,
intended: data.len() as u64,
},
));
}
}
None
}
fn maybe_corrupt(&mut self, path: &str, data: &mut [u8]) {
for rule in &self.fault_config.rules {
if rule.error != FsError::Corrupt {
continue;
}
if !matches_glob(&rule.path, path) {
continue;
}
if self.rng.chance(rule.probability) && !data.is_empty() {
let pos = self.rng.next_u64_below(data.len() as u64) as usize;
data[pos] ^= (self.rng.next_u64_below(255) + 1) as u8;
}
}
}
}
impl VortexFs for SimFs {
fn read_file(&self, path: &str) -> VortexFsResult<Vec<u8>> {
let norm = normalise(path);
match self.entries.get(&norm) {
Some(Entry::File(data)) => Ok(data.clone()),
Some(Entry::Dir) => Err(VortexFsError::IsADirectory(norm)),
None => Err(VortexFsError::NotFound(norm)),
}
}
fn write_file(&mut self, path: &str, data: &[u8]) -> VortexFsResult<()> {
let norm = normalise(path);
if let Some(err) = self.check_fault(&norm, FsOp::Write, data.len() as u64) {
return Err(err);
}
if let Some((partial_bytes, err)) = self.check_torn_write(&norm, data) {
self.ensure_parent_dirs(&norm)?;
self.entries
.insert(norm, Entry::File(data[..partial_bytes].to_vec()));
self.total_bytes_written += partial_bytes as u64;
return Err(err);
}
self.ensure_parent_dirs(&norm)?;
let mut file_data = data.to_vec();
self.maybe_corrupt(&norm, &mut file_data);
self.total_bytes_written += file_data.len() as u64;
self.entries.insert(norm, Entry::File(file_data));
Ok(())
}
fn append_file(&mut self, path: &str, data: &[u8]) -> VortexFsResult<()> {
let norm = normalise(path);
if let Some(err) = self.check_fault(&norm, FsOp::Write, data.len() as u64) {
return Err(err);
}
self.ensure_parent_dirs(&norm)?;
let entry = self
.entries
.entry(norm.clone())
.or_insert_with(|| Entry::File(Vec::new()));
match entry {
Entry::File(existing) => {
existing.extend_from_slice(data);
self.total_bytes_written += data.len() as u64;
Ok(())
}
Entry::Dir => Err(VortexFsError::IsADirectory(norm)),
}
}
fn remove_file(&mut self, path: &str) -> VortexFsResult<()> {
let norm = normalise(path);
if let Some(err) = self.check_fault(&norm, FsOp::Delete, 0) {
return Err(err);
}
match self.entries.get(&norm) {
Some(Entry::File(_)) => {
self.entries.remove(&norm);
Ok(())
}
Some(Entry::Dir) => Err(VortexFsError::IsADirectory(norm)),
None => Err(VortexFsError::NotFound(norm)),
}
}
fn rename(&mut self, from: &str, to: &str) -> VortexFsResult<()> {
let from_norm = normalise(from);
let to_norm = normalise(to);
if let Some(err) = self.check_fault(&from_norm, FsOp::Rename, 0) {
return Err(err);
}
match self.entries.remove(&from_norm) {
Some(entry) => {
self.entries.insert(to_norm, entry);
Ok(())
}
None => Err(VortexFsError::NotFound(from_norm)),
}
}
fn create_dir_all(&mut self, path: &str) -> VortexFsResult<()> {
let norm = normalise(path);
let parts: Vec<&str> = norm.split('/').filter(|s| !s.is_empty()).collect();
let mut current = String::from("/");
for part in parts {
if !current.ends_with('/') {
current.push('/');
}
current.push_str(part);
let norm_current = normalise(¤t);
if let Some(entry) = self.entries.get(&norm_current) {
match entry {
Entry::Dir => continue,
Entry::File(_) => {
return Err(VortexFsError::NotADirectory(norm_current));
}
}
}
self.entries.insert(norm_current, Entry::Dir);
}
Ok(())
}
fn remove_dir(&mut self, path: &str) -> VortexFsResult<()> {
let norm = normalise(path);
match self.entries.get(&norm) {
Some(Entry::Dir) => {
let prefix = if norm.ends_with('/') {
norm.clone()
} else {
format!("{norm}/")
};
let has_children = self
.entries
.keys()
.any(|k| k != &norm && k.starts_with(&prefix));
if has_children {
return Err(VortexFsError::NotEmpty(norm));
}
self.entries.remove(&norm);
Ok(())
}
Some(Entry::File(_)) => Err(VortexFsError::NotADirectory(norm)),
None => Err(VortexFsError::NotFound(norm)),
}
}
fn read_dir(&self, path: &str) -> VortexFsResult<Vec<String>> {
let norm = normalise(path);
match self.entries.get(&norm) {
Some(Entry::Dir) => {}
Some(Entry::File(_)) => return Err(VortexFsError::NotADirectory(norm.clone())),
None => return Err(VortexFsError::NotFound(norm.clone())),
}
let prefix = if norm == "/" {
"/".to_string()
} else {
format!("{norm}/")
};
let mut names: Vec<String> = Vec::new();
for key in self.entries.keys() {
if key == &norm {
continue;
}
if let Some(rest) = key.strip_prefix(&prefix) {
if !rest.contains('/') && !rest.is_empty() {
names.push(rest.to_string());
}
}
}
names.sort();
Ok(names)
}
fn metadata(&self, path: &str) -> VortexFsResult<FileMetadata> {
let norm = normalise(path);
match self.entries.get(&norm) {
Some(Entry::File(data)) => Ok(FileMetadata {
file_type: FileType::File,
size: data.len() as u64,
}),
Some(Entry::Dir) => Ok(FileMetadata {
file_type: FileType::Directory,
size: 0,
}),
None => Err(VortexFsError::NotFound(norm)),
}
}
fn exists(&self, path: &str) -> bool {
let norm = normalise(path);
self.entries.contains_key(&norm)
}
fn fsync(&mut self, path: &str) -> VortexFsResult<()> {
let norm = normalise(path);
if let Some(err) = self.check_fault(&norm, FsOp::Fsync, 0) {
return Err(err);
}
if !self.entries.contains_key(&norm) {
return Err(VortexFsError::NotFound(norm));
}
Ok(()) }
}
impl SimFs {
fn ensure_parent_dirs(&mut self, path: &str) -> VortexFsResult<()> {
if let Some(parent) = parent_path(path)
&& !self.entries.contains_key(&parent)
{
self.create_dir_all(&parent)?;
}
Ok(())
}
}
fn normalise(path: &str) -> String {
let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if parts.is_empty() {
return "/".to_string();
}
format!("/{}", parts.join("/"))
}
fn parent_path(path: &str) -> Option<String> {
let norm = normalise(path);
if norm == "/" {
return None;
}
match norm.rfind('/') {
Some(0) => Some("/".to_string()),
Some(pos) => Some(norm[..pos].to_string()),
None => None,
}
}
fn matches_glob(pattern: &str, path: &str) -> bool {
if pattern == "*" || pattern == "**" {
return true;
}
if let Some(suffix) = pattern.strip_prefix('*')
&& !suffix.contains('*')
{
return path.ends_with(suffix)
|| path
.rsplit('/')
.next()
.is_some_and(|name| name.ends_with(suffix));
}
if let Some(prefix) = pattern.strip_suffix("/**") {
return path.starts_with(prefix);
}
if let Some(prefix) = pattern.strip_suffix("/*") {
let rest = path.strip_prefix(prefix).unwrap_or("");
return rest.starts_with('/') && rest.matches('/').count() <= 1;
}
pattern == path
}
fn fault_rule_to_error(rule: &FsFaultRule, path: &str) -> VortexFsError {
match rule.error {
FsError::Enospc => VortexFsError::DiskFull(path.to_string()),
FsError::Eio => VortexFsError::IoError(path.to_string()),
FsError::Eacces => VortexFsError::PermissionDenied(path.to_string()),
FsError::TornWrite => VortexFsError::IoError(format!("torn write on {path}")),
FsError::Corrupt => VortexFsError::Corrupted(path.to_string()),
FsError::DelayedFsync => VortexFsError::IoError(format!("delayed fsync on {path}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use vortex_core::{FsError, FsFaultConfig, FsFaultRule, FsOp};
#[test]
fn test_basic_read_write() {
let mut fs = SimFs::new(42);
fs.write_file("/hello.txt", b"world").unwrap();
assert_eq!(fs.read_file("/hello.txt").unwrap(), b"world");
}
#[test]
fn test_auto_create_parent_dirs() {
let mut fs = SimFs::new(42);
fs.write_file("/a/b/c/file.txt", b"data").unwrap();
assert!(fs.exists("/a"));
assert!(fs.exists("/a/b"));
assert!(fs.exists("/a/b/c"));
assert!(fs.exists("/a/b/c/file.txt"));
}
#[test]
fn test_append() {
let mut fs = SimFs::new(42);
fs.write_file("/log.txt", b"line1\n").unwrap();
fs.append_file("/log.txt", b"line2\n").unwrap();
assert_eq!(fs.read_file("/log.txt").unwrap(), b"line1\nline2\n");
}
#[test]
fn test_remove_file() {
let mut fs = SimFs::new(42);
fs.write_file("/tmp.txt", b"temp").unwrap();
assert!(fs.exists("/tmp.txt"));
fs.remove_file("/tmp.txt").unwrap();
assert!(!fs.exists("/tmp.txt"));
}
#[test]
fn test_remove_nonexistent_file() {
let mut fs = SimFs::new(42);
assert!(matches!(
fs.remove_file("/nope.txt"),
Err(VortexFsError::NotFound(_))
));
}
#[test]
fn test_read_nonexistent() {
let fs = SimFs::new(42);
assert!(matches!(
fs.read_file("/nope.txt"),
Err(VortexFsError::NotFound(_))
));
}
#[test]
fn test_rename() {
let mut fs = SimFs::new(42);
fs.write_file("/old.txt", b"data").unwrap();
fs.rename("/old.txt", "/new.txt").unwrap();
assert!(!fs.exists("/old.txt"));
assert_eq!(fs.read_file("/new.txt").unwrap(), b"data");
}
#[test]
fn test_read_dir() {
let mut fs = SimFs::new(42);
fs.write_file("/dir/a.txt", b"a").unwrap();
fs.write_file("/dir/b.txt", b"b").unwrap();
fs.create_dir_all("/dir/sub").unwrap();
let entries = fs.read_dir("/dir").unwrap();
assert_eq!(entries, vec!["a.txt", "b.txt", "sub"]);
}
#[test]
fn test_remove_nonempty_dir() {
let mut fs = SimFs::new(42);
fs.write_file("/dir/file.txt", b"data").unwrap();
assert!(matches!(
fs.remove_dir("/dir"),
Err(VortexFsError::NotEmpty(_))
));
}
#[test]
fn test_metadata() {
let mut fs = SimFs::new(42);
fs.write_file("/data.bin", b"12345").unwrap();
let meta = fs.metadata("/data.bin").unwrap();
assert_eq!(meta.file_type, FileType::File);
assert_eq!(meta.size, 5);
fs.create_dir_all("/mydir").unwrap();
let meta = fs.metadata("/mydir").unwrap();
assert_eq!(meta.file_type, FileType::Directory);
}
#[test]
fn test_fault_enospc() {
let config = FsFaultConfig {
rules: vec![FsFaultRule {
path: "*.wal".into(),
op: FsOp::Write,
error: FsError::Enospc,
after_bytes: 0,
probability: 1.0, }],
};
let mut fs = SimFs::with_faults(42, config);
let result = fs.write_file("/data.wal", b"WAL data");
assert!(matches!(result, Err(VortexFsError::DiskFull(_))));
}
#[test]
fn test_fault_eio_on_read_path() {
let config = FsFaultConfig {
rules: vec![FsFaultRule {
path: "/data/*".into(),
op: FsOp::Write,
error: FsError::Eio,
after_bytes: 0,
probability: 1.0,
}],
};
let mut fs = SimFs::with_faults(42, config);
fs.write_file("/logs/app.log", b"OK").unwrap();
let result = fs.write_file("/data/table.sst", b"data");
assert!(matches!(result, Err(VortexFsError::IoError(_))));
}
#[test]
fn test_fault_after_bytes_threshold() {
let config = FsFaultConfig {
rules: vec![FsFaultRule {
path: "*".into(),
op: FsOp::Write,
error: FsError::Enospc,
after_bytes: 100, probability: 1.0,
}],
};
let mut fs = SimFs::with_faults(42, config);
fs.write_file("/a.txt", &[0u8; 50]).unwrap();
let result = fs.write_file("/b.txt", &[0u8; 60]);
assert!(matches!(result, Err(VortexFsError::DiskFull(_))));
}
#[test]
fn test_fault_torn_write() {
let config = FsFaultConfig {
rules: vec![FsFaultRule {
path: "*.wal".into(),
op: FsOp::Write,
error: FsError::TornWrite,
after_bytes: 0,
probability: 1.0,
}],
};
let mut fs = SimFs::with_faults(42, config);
let data = vec![0xAB; 100];
let result = fs.write_file("/log.wal", &data);
assert!(matches!(result, Err(VortexFsError::TornWrite { .. })));
assert!(fs.exists("/log.wal"));
let written = fs.read_file("/log.wal").unwrap();
assert!(written.len() < data.len());
}
#[test]
fn test_fault_corruption() {
let config = FsFaultConfig {
rules: vec![FsFaultRule {
path: "*".into(),
op: FsOp::Write,
error: FsError::Corrupt,
after_bytes: 0,
probability: 1.0,
}],
};
let original = vec![0x42; 100];
let mut fs = SimFs::with_faults(42, config);
fs.write_file("/data.bin", &original).unwrap();
let read_back = fs.read_file("/data.bin").unwrap();
assert_ne!(original, read_back, "Data should be corrupted");
}
#[test]
fn test_probability_based_faults() {
let config = FsFaultConfig {
rules: vec![FsFaultRule {
path: "*".into(),
op: FsOp::Write,
error: FsError::Eio,
after_bytes: 0,
probability: 0.5,
}],
};
let mut fs = SimFs::with_faults(42, config);
let mut successes = 0;
let mut failures = 0;
for i in 0..100 {
match fs.write_file(&format!("/file_{i}.txt"), b"data") {
Ok(()) => successes += 1,
Err(_) => failures += 1,
}
}
assert!(successes > 0, "Expected some successes");
assert!(failures > 0, "Expected some failures");
}
#[test]
fn test_glob_star_suffix() {
assert!(matches_glob("*.wal", "/data/log.wal"));
assert!(matches_glob("*.wal", "log.wal"));
assert!(!matches_glob("*.wal", "/data/log.txt"));
}
#[test]
fn test_glob_prefix_doublestar() {
assert!(matches_glob("/data/**", "/data/a/b/c.txt"));
assert!(matches_glob("/data/**", "/data/file.txt"));
assert!(!matches_glob("/data/**", "/logs/file.txt"));
}
#[test]
fn test_glob_exact() {
assert!(matches_glob("/specific/file.txt", "/specific/file.txt"));
assert!(!matches_glob("/specific/file.txt", "/other/file.txt"));
}
#[test]
fn test_normalise() {
assert_eq!(normalise("/a/b/c"), "/a/b/c");
assert_eq!(normalise("a/b/c"), "/a/b/c");
assert_eq!(normalise("/a//b///c/"), "/a/b/c");
assert_eq!(normalise("/"), "/");
assert_eq!(normalise(""), "/");
}
}