Skip to main content

vortex_fs/
sim.rs

1//! `SimFs` — In-memory simulated filesystem with fault injection.
2//!
3//! All file data lives in a BTreeMap keyed by normalised path. Fault injection
4//! is driven by [`FsFaultConfig`](vortex_core::FsFaultConfig) rules matched
5//! against the path and operation.
6
7use std::collections::BTreeMap;
8
9use vortex_core::{DetRng, FsError, FsFaultConfig, FsFaultRule, FsOp};
10
11use crate::traits::{FileMetadata, FileType, VortexFs, VortexFsError, VortexFsResult};
12
13/// In-memory entry — either a file with data or a directory marker.
14#[derive(Debug, Clone)]
15enum Entry {
16    File(Vec<u8>),
17    Dir,
18}
19
20/// Deterministic in-memory filesystem with fault injection.
21///
22/// Each operation checks the configured fault rules. If a rule matches, the
23/// simulation RNG decides (based on probability) whether to inject the fault.
24///
25/// ```
26/// use vortex_fs::{SimFs, VortexFs};
27///
28/// let mut fs = SimFs::new(42);
29/// fs.create_dir_all("/data/logs").unwrap();
30/// fs.write_file("/data/logs/app.log", b"hello").unwrap();
31/// assert!(fs.exists("/data/logs/app.log"));
32/// assert_eq!(fs.read_file("/data/logs/app.log").unwrap(), b"hello");
33/// ```
34pub struct SimFs {
35    rng: DetRng,
36    entries: BTreeMap<String, Entry>,
37    fault_config: FsFaultConfig,
38    /// Total bytes written (for after_bytes thresholds).
39    total_bytes_written: u64,
40}
41
42impl SimFs {
43    /// Create a new in-memory filesystem with the given seed.
44    pub fn new(seed: u64) -> Self {
45        let mut entries = BTreeMap::new();
46        entries.insert("/".to_string(), Entry::Dir);
47        Self {
48            rng: DetRng::new(seed),
49            entries,
50            fault_config: FsFaultConfig::default(),
51            total_bytes_written: 0,
52        }
53    }
54
55    /// Create with fault injection configuration.
56    pub fn with_faults(seed: u64, config: FsFaultConfig) -> Self {
57        let mut fs = Self::new(seed);
58        fs.fault_config = config;
59        fs
60    }
61
62    /// Set the fault configuration.
63    pub fn set_fault_config(&mut self, config: FsFaultConfig) {
64        self.fault_config = config;
65    }
66
67    /// Get total bytes written so far.
68    pub fn total_bytes_written(&self) -> u64 {
69        self.total_bytes_written
70    }
71
72    /// Number of entries (files + directories).
73    pub fn entry_count(&self) -> usize {
74        self.entries.len()
75    }
76
77    // --- Fault injection ---
78
79    /// Check if a fault should be injected for the given path and operation.
80    /// Returns `Some(error)` if a fault fires, `None` if the operation proceeds normally.
81    fn check_fault(&mut self, path: &str, op: FsOp, pending_bytes: u64) -> Option<VortexFsError> {
82        let effective_bytes = self.total_bytes_written + pending_bytes;
83        for rule in &self.fault_config.rules {
84            // Skip TornWrite and Corrupt — they have dedicated handling paths
85            // Skip DelayedFsync for non-fsync ops — it only applies to fsync
86            if rule.error == FsError::TornWrite
87                || rule.error == FsError::Corrupt
88                || (rule.error == FsError::DelayedFsync && op != FsOp::Fsync)
89            {
90                continue;
91            }
92            if !matches_glob(&rule.path, path) {
93                continue;
94            }
95            if rule.op != FsOp::Any && rule.op != op {
96                continue;
97            }
98            if rule.after_bytes > 0 && effective_bytes < rule.after_bytes {
99                continue;
100            }
101            if self.rng.chance(rule.probability) {
102                return Some(fault_rule_to_error(rule, path));
103            }
104        }
105        None
106    }
107
108    /// Check for torn write: succeed partially, then error.
109    fn check_torn_write(&mut self, path: &str, data: &[u8]) -> Option<(usize, VortexFsError)> {
110        for rule in &self.fault_config.rules {
111            if rule.error != FsError::TornWrite {
112                continue;
113            }
114            if !matches_glob(&rule.path, path) {
115                continue;
116            }
117            if rule.op != FsOp::Any && rule.op != FsOp::Write {
118                continue;
119            }
120            if rule.after_bytes > 0 && self.total_bytes_written < rule.after_bytes {
121                continue;
122            }
123            if self.rng.chance(rule.probability) {
124                let partial = if data.is_empty() {
125                    0
126                } else {
127                    self.rng.next_u64_below(data.len() as u64) as usize
128                };
129                return Some((
130                    partial,
131                    VortexFsError::TornWrite {
132                        path: path.to_string(),
133                        bytes_written: partial as u64,
134                        intended: data.len() as u64,
135                    },
136                ));
137            }
138        }
139        None
140    }
141
142    /// Check for byte corruption post-write.
143    fn maybe_corrupt(&mut self, path: &str, data: &mut [u8]) {
144        for rule in &self.fault_config.rules {
145            if rule.error != FsError::Corrupt {
146                continue;
147            }
148            if !matches_glob(&rule.path, path) {
149                continue;
150            }
151            if self.rng.chance(rule.probability) && !data.is_empty() {
152                let pos = self.rng.next_u64_below(data.len() as u64) as usize;
153                data[pos] ^= (self.rng.next_u64_below(255) + 1) as u8;
154            }
155        }
156    }
157}
158
159impl VortexFs for SimFs {
160    fn read_file(&self, path: &str) -> VortexFsResult<Vec<u8>> {
161        let norm = normalise(path);
162        // NOTE: fault checking for reads needs &mut self for RNG, but the trait
163        // takes &self. We skip fault injection on read for immutable access.
164        // Users who need read faults should call check_fault separately.
165        match self.entries.get(&norm) {
166            Some(Entry::File(data)) => Ok(data.clone()),
167            Some(Entry::Dir) => Err(VortexFsError::IsADirectory(norm)),
168            None => Err(VortexFsError::NotFound(norm)),
169        }
170    }
171
172    fn write_file(&mut self, path: &str, data: &[u8]) -> VortexFsResult<()> {
173        let norm = normalise(path);
174
175        // Check for standard faults (ENOSPC, EIO, EACCES)
176        if let Some(err) = self.check_fault(&norm, FsOp::Write, data.len() as u64) {
177            return Err(err);
178        }
179
180        // Check for torn write
181        if let Some((partial_bytes, err)) = self.check_torn_write(&norm, data) {
182            // Write partial data, then return error
183            self.ensure_parent_dirs(&norm)?;
184            self.entries
185                .insert(norm, Entry::File(data[..partial_bytes].to_vec()));
186            self.total_bytes_written += partial_bytes as u64;
187            return Err(err);
188        }
189
190        self.ensure_parent_dirs(&norm)?;
191        let mut file_data = data.to_vec();
192        self.maybe_corrupt(&norm, &mut file_data);
193        self.total_bytes_written += file_data.len() as u64;
194        self.entries.insert(norm, Entry::File(file_data));
195        Ok(())
196    }
197
198    fn append_file(&mut self, path: &str, data: &[u8]) -> VortexFsResult<()> {
199        let norm = normalise(path);
200        if let Some(err) = self.check_fault(&norm, FsOp::Write, data.len() as u64) {
201            return Err(err);
202        }
203        self.ensure_parent_dirs(&norm)?;
204        let entry = self
205            .entries
206            .entry(norm.clone())
207            .or_insert_with(|| Entry::File(Vec::new()));
208        match entry {
209            Entry::File(existing) => {
210                existing.extend_from_slice(data);
211                self.total_bytes_written += data.len() as u64;
212                Ok(())
213            }
214            Entry::Dir => Err(VortexFsError::IsADirectory(norm)),
215        }
216    }
217
218    fn remove_file(&mut self, path: &str) -> VortexFsResult<()> {
219        let norm = normalise(path);
220        if let Some(err) = self.check_fault(&norm, FsOp::Delete, 0) {
221            return Err(err);
222        }
223        match self.entries.get(&norm) {
224            Some(Entry::File(_)) => {
225                self.entries.remove(&norm);
226                Ok(())
227            }
228            Some(Entry::Dir) => Err(VortexFsError::IsADirectory(norm)),
229            None => Err(VortexFsError::NotFound(norm)),
230        }
231    }
232
233    fn rename(&mut self, from: &str, to: &str) -> VortexFsResult<()> {
234        let from_norm = normalise(from);
235        let to_norm = normalise(to);
236        if let Some(err) = self.check_fault(&from_norm, FsOp::Rename, 0) {
237            return Err(err);
238        }
239        match self.entries.remove(&from_norm) {
240            Some(entry) => {
241                self.entries.insert(to_norm, entry);
242                Ok(())
243            }
244            None => Err(VortexFsError::NotFound(from_norm)),
245        }
246    }
247
248    fn create_dir_all(&mut self, path: &str) -> VortexFsResult<()> {
249        let norm = normalise(path);
250        // Create all parent components
251        let parts: Vec<&str> = norm.split('/').filter(|s| !s.is_empty()).collect();
252        let mut current = String::from("/");
253        for part in parts {
254            if !current.ends_with('/') {
255                current.push('/');
256            }
257            current.push_str(part);
258            let norm_current = normalise(&current);
259            if let Some(entry) = self.entries.get(&norm_current) {
260                match entry {
261                    Entry::Dir => continue,
262                    Entry::File(_) => {
263                        return Err(VortexFsError::NotADirectory(norm_current));
264                    }
265                }
266            }
267            self.entries.insert(norm_current, Entry::Dir);
268        }
269        Ok(())
270    }
271
272    fn remove_dir(&mut self, path: &str) -> VortexFsResult<()> {
273        let norm = normalise(path);
274        match self.entries.get(&norm) {
275            Some(Entry::Dir) => {
276                // Check if directory is empty
277                let prefix = if norm.ends_with('/') {
278                    norm.clone()
279                } else {
280                    format!("{norm}/")
281                };
282                let has_children = self
283                    .entries
284                    .keys()
285                    .any(|k| k != &norm && k.starts_with(&prefix));
286                if has_children {
287                    return Err(VortexFsError::NotEmpty(norm));
288                }
289                self.entries.remove(&norm);
290                Ok(())
291            }
292            Some(Entry::File(_)) => Err(VortexFsError::NotADirectory(norm)),
293            None => Err(VortexFsError::NotFound(norm)),
294        }
295    }
296
297    fn read_dir(&self, path: &str) -> VortexFsResult<Vec<String>> {
298        let norm = normalise(path);
299        match self.entries.get(&norm) {
300            Some(Entry::Dir) => {}
301            Some(Entry::File(_)) => return Err(VortexFsError::NotADirectory(norm.clone())),
302            None => return Err(VortexFsError::NotFound(norm.clone())),
303        }
304        let prefix = if norm == "/" {
305            "/".to_string()
306        } else {
307            format!("{norm}/")
308        };
309        let mut names: Vec<String> = Vec::new();
310        for key in self.entries.keys() {
311            if key == &norm {
312                continue;
313            }
314            if let Some(rest) = key.strip_prefix(&prefix) {
315                // Only direct children (no further '/' in the rest)
316                if !rest.contains('/') && !rest.is_empty() {
317                    names.push(rest.to_string());
318                }
319            }
320        }
321        names.sort();
322        Ok(names)
323    }
324
325    fn metadata(&self, path: &str) -> VortexFsResult<FileMetadata> {
326        let norm = normalise(path);
327        match self.entries.get(&norm) {
328            Some(Entry::File(data)) => Ok(FileMetadata {
329                file_type: FileType::File,
330                size: data.len() as u64,
331            }),
332            Some(Entry::Dir) => Ok(FileMetadata {
333                file_type: FileType::Directory,
334                size: 0,
335            }),
336            None => Err(VortexFsError::NotFound(norm)),
337        }
338    }
339
340    fn exists(&self, path: &str) -> bool {
341        let norm = normalise(path);
342        self.entries.contains_key(&norm)
343    }
344
345    fn fsync(&mut self, path: &str) -> VortexFsResult<()> {
346        let norm = normalise(path);
347        if let Some(err) = self.check_fault(&norm, FsOp::Fsync, 0) {
348            return Err(err);
349        }
350        if !self.entries.contains_key(&norm) {
351            return Err(VortexFsError::NotFound(norm));
352        }
353        Ok(()) // In-memory — fsync is a no-op (unless faulted)
354    }
355}
356
357impl SimFs {
358    fn ensure_parent_dirs(&mut self, path: &str) -> VortexFsResult<()> {
359        if let Some(parent) = parent_path(path)
360            && !self.entries.contains_key(&parent)
361        {
362            self.create_dir_all(&parent)?;
363        }
364        Ok(())
365    }
366}
367
368// --- Helpers ---
369
370/// Normalise a path: collapse `//`, remove trailing `/`, ensure leading `/`.
371fn normalise(path: &str) -> String {
372    let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
373    if parts.is_empty() {
374        return "/".to_string();
375    }
376    format!("/{}", parts.join("/"))
377}
378
379/// Get the parent path of a normalised path.
380fn parent_path(path: &str) -> Option<String> {
381    let norm = normalise(path);
382    if norm == "/" {
383        return None;
384    }
385    match norm.rfind('/') {
386        Some(0) => Some("/".to_string()),
387        Some(pos) => Some(norm[..pos].to_string()),
388        None => None,
389    }
390}
391
392/// Simple glob matching: supports `*` (any characters except `/`) and `**` (any path).
393fn matches_glob(pattern: &str, path: &str) -> bool {
394    // Handle the common simple cases without full regex
395    if pattern == "*" || pattern == "**" {
396        return true;
397    }
398
399    // Check if it's a suffix match (e.g. "*.wal")
400    if let Some(suffix) = pattern.strip_prefix('*')
401        && !suffix.contains('*')
402    {
403        return path.ends_with(suffix)
404            || path
405                .rsplit('/')
406                .next()
407                .is_some_and(|name| name.ends_with(suffix));
408    }
409
410    // Check if it's a prefix match (e.g. "/data/**")
411    if let Some(prefix) = pattern.strip_suffix("/**") {
412        return path.starts_with(prefix);
413    }
414    if let Some(prefix) = pattern.strip_suffix("/*") {
415        let rest = path.strip_prefix(prefix).unwrap_or("");
416        return rest.starts_with('/') && rest.matches('/').count() <= 1;
417    }
418
419    // Exact match
420    pattern == path
421}
422
423/// Convert a fault rule to the corresponding VortexFsError.
424fn fault_rule_to_error(rule: &FsFaultRule, path: &str) -> VortexFsError {
425    match rule.error {
426        FsError::Enospc => VortexFsError::DiskFull(path.to_string()),
427        FsError::Eio => VortexFsError::IoError(path.to_string()),
428        FsError::Eacces => VortexFsError::PermissionDenied(path.to_string()),
429        FsError::TornWrite => VortexFsError::IoError(format!("torn write on {path}")),
430        FsError::Corrupt => VortexFsError::Corrupted(path.to_string()),
431        FsError::DelayedFsync => VortexFsError::IoError(format!("delayed fsync on {path}")),
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use vortex_core::{FsError, FsFaultConfig, FsFaultRule, FsOp};
439
440    #[test]
441    fn test_basic_read_write() {
442        let mut fs = SimFs::new(42);
443        fs.write_file("/hello.txt", b"world").unwrap();
444        assert_eq!(fs.read_file("/hello.txt").unwrap(), b"world");
445    }
446
447    #[test]
448    fn test_auto_create_parent_dirs() {
449        let mut fs = SimFs::new(42);
450        fs.write_file("/a/b/c/file.txt", b"data").unwrap();
451        assert!(fs.exists("/a"));
452        assert!(fs.exists("/a/b"));
453        assert!(fs.exists("/a/b/c"));
454        assert!(fs.exists("/a/b/c/file.txt"));
455    }
456
457    #[test]
458    fn test_append() {
459        let mut fs = SimFs::new(42);
460        fs.write_file("/log.txt", b"line1\n").unwrap();
461        fs.append_file("/log.txt", b"line2\n").unwrap();
462        assert_eq!(fs.read_file("/log.txt").unwrap(), b"line1\nline2\n");
463    }
464
465    #[test]
466    fn test_remove_file() {
467        let mut fs = SimFs::new(42);
468        fs.write_file("/tmp.txt", b"temp").unwrap();
469        assert!(fs.exists("/tmp.txt"));
470        fs.remove_file("/tmp.txt").unwrap();
471        assert!(!fs.exists("/tmp.txt"));
472    }
473
474    #[test]
475    fn test_remove_nonexistent_file() {
476        let mut fs = SimFs::new(42);
477        assert!(matches!(
478            fs.remove_file("/nope.txt"),
479            Err(VortexFsError::NotFound(_))
480        ));
481    }
482
483    #[test]
484    fn test_read_nonexistent() {
485        let fs = SimFs::new(42);
486        assert!(matches!(
487            fs.read_file("/nope.txt"),
488            Err(VortexFsError::NotFound(_))
489        ));
490    }
491
492    #[test]
493    fn test_rename() {
494        let mut fs = SimFs::new(42);
495        fs.write_file("/old.txt", b"data").unwrap();
496        fs.rename("/old.txt", "/new.txt").unwrap();
497        assert!(!fs.exists("/old.txt"));
498        assert_eq!(fs.read_file("/new.txt").unwrap(), b"data");
499    }
500
501    #[test]
502    fn test_read_dir() {
503        let mut fs = SimFs::new(42);
504        fs.write_file("/dir/a.txt", b"a").unwrap();
505        fs.write_file("/dir/b.txt", b"b").unwrap();
506        fs.create_dir_all("/dir/sub").unwrap();
507        let entries = fs.read_dir("/dir").unwrap();
508        assert_eq!(entries, vec!["a.txt", "b.txt", "sub"]);
509    }
510
511    #[test]
512    fn test_remove_nonempty_dir() {
513        let mut fs = SimFs::new(42);
514        fs.write_file("/dir/file.txt", b"data").unwrap();
515        assert!(matches!(
516            fs.remove_dir("/dir"),
517            Err(VortexFsError::NotEmpty(_))
518        ));
519    }
520
521    #[test]
522    fn test_metadata() {
523        let mut fs = SimFs::new(42);
524        fs.write_file("/data.bin", b"12345").unwrap();
525        let meta = fs.metadata("/data.bin").unwrap();
526        assert_eq!(meta.file_type, FileType::File);
527        assert_eq!(meta.size, 5);
528
529        fs.create_dir_all("/mydir").unwrap();
530        let meta = fs.metadata("/mydir").unwrap();
531        assert_eq!(meta.file_type, FileType::Directory);
532    }
533
534    #[test]
535    fn test_fault_enospc() {
536        let config = FsFaultConfig {
537            rules: vec![FsFaultRule {
538                path: "*.wal".into(),
539                op: FsOp::Write,
540                error: FsError::Enospc,
541                after_bytes: 0,
542                probability: 1.0, // Always trigger
543            }],
544        };
545        let mut fs = SimFs::with_faults(42, config);
546        let result = fs.write_file("/data.wal", b"WAL data");
547        assert!(matches!(result, Err(VortexFsError::DiskFull(_))));
548    }
549
550    #[test]
551    fn test_fault_eio_on_read_path() {
552        let config = FsFaultConfig {
553            rules: vec![FsFaultRule {
554                path: "/data/*".into(),
555                op: FsOp::Write,
556                error: FsError::Eio,
557                after_bytes: 0,
558                probability: 1.0,
559            }],
560        };
561        let mut fs = SimFs::with_faults(42, config);
562        // Writing to a non-matching path should succeed
563        fs.write_file("/logs/app.log", b"OK").unwrap();
564        // Writing to a matching path should fail
565        let result = fs.write_file("/data/table.sst", b"data");
566        assert!(matches!(result, Err(VortexFsError::IoError(_))));
567    }
568
569    #[test]
570    fn test_fault_after_bytes_threshold() {
571        let config = FsFaultConfig {
572            rules: vec![FsFaultRule {
573                path: "*".into(),
574                op: FsOp::Write,
575                error: FsError::Enospc,
576                after_bytes: 100, // Only trigger after 100 bytes written
577                probability: 1.0,
578            }],
579        };
580        let mut fs = SimFs::with_faults(42, config);
581        // First write: 50 bytes — should succeed (under threshold)
582        fs.write_file("/a.txt", &[0u8; 50]).unwrap();
583        // Second write: 60 bytes — total now 110 — should fail
584        let result = fs.write_file("/b.txt", &[0u8; 60]);
585        assert!(matches!(result, Err(VortexFsError::DiskFull(_))));
586    }
587
588    #[test]
589    fn test_fault_torn_write() {
590        let config = FsFaultConfig {
591            rules: vec![FsFaultRule {
592                path: "*.wal".into(),
593                op: FsOp::Write,
594                error: FsError::TornWrite,
595                after_bytes: 0,
596                probability: 1.0,
597            }],
598        };
599        let mut fs = SimFs::with_faults(42, config);
600        let data = vec![0xAB; 100];
601        let result = fs.write_file("/log.wal", &data);
602        assert!(matches!(result, Err(VortexFsError::TornWrite { .. })));
603        // The file should exist with partial data
604        assert!(fs.exists("/log.wal"));
605        let written = fs.read_file("/log.wal").unwrap();
606        assert!(written.len() < data.len());
607    }
608
609    #[test]
610    fn test_fault_corruption() {
611        let config = FsFaultConfig {
612            rules: vec![FsFaultRule {
613                path: "*".into(),
614                op: FsOp::Write,
615                error: FsError::Corrupt,
616                after_bytes: 0,
617                probability: 1.0,
618            }],
619        };
620        let original = vec![0x42; 100];
621        let mut fs = SimFs::with_faults(42, config);
622        fs.write_file("/data.bin", &original).unwrap();
623        let read_back = fs.read_file("/data.bin").unwrap();
624        // At least one byte should differ
625        assert_ne!(original, read_back, "Data should be corrupted");
626    }
627
628    #[test]
629    fn test_probability_based_faults() {
630        let config = FsFaultConfig {
631            rules: vec![FsFaultRule {
632                path: "*".into(),
633                op: FsOp::Write,
634                error: FsError::Eio,
635                after_bytes: 0,
636                probability: 0.5,
637            }],
638        };
639        let mut fs = SimFs::with_faults(42, config);
640        let mut successes = 0;
641        let mut failures = 0;
642        for i in 0..100 {
643            match fs.write_file(&format!("/file_{i}.txt"), b"data") {
644                Ok(()) => successes += 1,
645                Err(_) => failures += 1,
646            }
647        }
648        // With p=0.5, both should be non-zero
649        assert!(successes > 0, "Expected some successes");
650        assert!(failures > 0, "Expected some failures");
651    }
652
653    // --- Glob matching tests ---
654
655    #[test]
656    fn test_glob_star_suffix() {
657        assert!(matches_glob("*.wal", "/data/log.wal"));
658        assert!(matches_glob("*.wal", "log.wal"));
659        assert!(!matches_glob("*.wal", "/data/log.txt"));
660    }
661
662    #[test]
663    fn test_glob_prefix_doublestar() {
664        assert!(matches_glob("/data/**", "/data/a/b/c.txt"));
665        assert!(matches_glob("/data/**", "/data/file.txt"));
666        assert!(!matches_glob("/data/**", "/logs/file.txt"));
667    }
668
669    #[test]
670    fn test_glob_exact() {
671        assert!(matches_glob("/specific/file.txt", "/specific/file.txt"));
672        assert!(!matches_glob("/specific/file.txt", "/other/file.txt"));
673    }
674
675    #[test]
676    fn test_normalise() {
677        assert_eq!(normalise("/a/b/c"), "/a/b/c");
678        assert_eq!(normalise("a/b/c"), "/a/b/c");
679        assert_eq!(normalise("/a//b///c/"), "/a/b/c");
680        assert_eq!(normalise("/"), "/");
681        assert_eq!(normalise(""), "/");
682    }
683}