#![cfg(unix)]
use std::collections::{BTreeMap, HashSet};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::Path;
use fstool::block::{BlockDevice, FileBackend, MemoryBackend};
use fstool::fs::{FileMeta, FileSource, Filesystem, OpenFlags, ReadSeek};
use tempfile::NamedTempFile;
struct Rng(u64);
impl Rng {
fn new(seed: u64) -> Self {
Self(if seed == 0 {
0x9E37_79B9_7F4A_7C15
} else {
seed
})
}
fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
fn range(&mut self, n: u64) -> u64 {
self.next_u64() % n
}
fn bytes(&mut self, n: usize) -> Vec<u8> {
let mut out = vec![0u8; n];
for chunk in out.chunks_mut(8) {
let v = self.next_u64().to_le_bytes();
chunk.copy_from_slice(&v[..chunk.len()]);
}
out
}
}
#[derive(Debug, Clone, Copy)]
enum Op {
Create,
Overwrite,
Append,
PatchRange,
SetLen,
Delete,
Clone,
Flush,
}
struct Caps {
max_files: usize,
max_size: usize,
allow_partial: bool,
allow_set_len: bool,
}
impl Caps {
fn mutable_small() -> Self {
Self {
max_files: 8,
max_size: 8 * 1024,
allow_partial: true,
allow_set_len: true,
}
}
fn ext4_tight() -> Self {
Self {
max_files: 4,
max_size: 4 * 1024,
allow_partial: true,
allow_set_len: true,
}
}
fn f2fs_no_set_len() -> Self {
Self {
max_files: 8,
max_size: 8 * 1024,
allow_partial: true,
allow_set_len: false,
}
}
}
fn fuzz_filesystem(
fs: &mut dyn Filesystem,
dev: &mut dyn BlockDevice,
seed: u64,
iters: usize,
caps: &Caps,
) {
let cap = fs.mutation_capability();
assert!(
cap.supports_add_remove(),
"fuzz core requires add/remove support; got {cap:?}"
);
let supports_partial = caps.allow_partial && cap.supports_partial_writes();
let shares_extents = fs.clone_capability().shares_extents();
let mut rng = Rng::new(seed);
let mut shadow: BTreeMap<String, Vec<u8>> = BTreeMap::new();
let mut frozen: HashSet<String> = HashSet::new();
for step in 0..iters {
let op = pick_op(&mut rng, &shadow, &frozen, caps, supports_partial);
apply_op(
fs,
dev,
&mut rng,
&mut shadow,
&mut frozen,
op,
caps,
shares_extents,
)
.unwrap_or_else(|e| panic!("seed={seed} step={step} op={op:?}: {e}"));
fs.flush(dev)
.unwrap_or_else(|e| panic!("seed={seed} step={step} flush before verify: {e}"));
verify_against_shadow(fs, dev, &shadow)
.unwrap_or_else(|e| panic!("seed={seed} step={step} after op={op:?}: {e}"));
if step % 16 == 15 {
probe_random_seek(fs, dev, &mut rng, &shadow)
.unwrap_or_else(|e| panic!("seed={seed} step={step} seek probe: {e}"));
}
}
fs.flush(dev)
.unwrap_or_else(|e| panic!("seed={seed}: final flush failed: {e}"));
verify_against_shadow(fs, dev, &shadow)
.unwrap_or_else(|e| panic!("seed={seed} after final flush: {e}"));
}
fn pick_op(
rng: &mut Rng,
shadow: &BTreeMap<String, Vec<u8>>,
frozen: &HashSet<String>,
caps: &Caps,
supports_partial: bool,
) -> Op {
if shadow.is_empty() {
return Op::Create;
}
let can_create = shadow.len() < caps.max_files;
let has_unfrozen = shadow.keys().any(|n| !frozen.contains(n));
let mut table: Vec<Op> = Vec::with_capacity(16);
if can_create {
table.extend([Op::Create; 4]);
}
if has_unfrozen {
table.extend([Op::Overwrite; 3]);
table.extend([Op::Append; 3]);
if supports_partial {
table.extend([Op::PatchRange; 3]);
}
if caps.allow_set_len {
table.extend([Op::SetLen; 2]);
}
table.extend([Op::Delete; 2]);
if can_create {
table.push(Op::Clone);
}
}
table.push(Op::Flush);
table[rng.range(table.len() as u64) as usize]
}
#[allow(clippy::too_many_arguments)]
fn apply_op(
fs: &mut dyn Filesystem,
dev: &mut dyn BlockDevice,
rng: &mut Rng,
shadow: &mut BTreeMap<String, Vec<u8>>,
frozen: &mut HashSet<String>,
op: Op,
caps: &Caps,
shares_extents: bool,
) -> Result<(), String> {
match op {
Op::Create => {
let name = pick_fresh_name(rng, shadow);
let path = format!("/{name}");
let len = (rng.range(caps.max_size as u64) + 1) as usize;
let body = rng.bytes(len);
create_via_reader(fs, dev, &path, &body)?;
shadow.insert(name, body);
}
Op::Overwrite => {
let Some(name) = pick_present_unfrozen(rng, shadow, frozen) else {
return Ok(());
};
let path = format!("/{name}");
let len = (rng.range(caps.max_size as u64) + 1) as usize;
let body = rng.bytes(len);
overwrite_via_rw(fs, dev, &path, &body)?;
shadow.insert(name, body);
}
Op::Append => {
let Some(name) = pick_present_unfrozen(rng, shadow, frozen) else {
return Ok(());
};
let path = format!("/{name}");
let add_len = (rng.range((caps.max_size / 2) as u64) + 1) as usize;
let chunk = rng.bytes(add_len);
append_via_rw(fs, dev, &path, &chunk)?;
shadow.get_mut(&name).unwrap().extend_from_slice(&chunk);
}
Op::PatchRange => {
let Some(name) = pick_present_unfrozen(rng, shadow, frozen) else {
return Ok(());
};
let cur = shadow.get(&name).cloned().unwrap();
if cur.is_empty() {
return Ok(());
}
let off = rng.range(cur.len() as u64) as usize;
let max_grow = caps.max_size.saturating_sub(off).max(1);
let n = (rng.range(max_grow as u64) + 1) as usize;
let chunk = rng.bytes(n);
let path = format!("/{name}");
patch_via_rw(fs, dev, &path, off as u64, &chunk)?;
let entry = shadow.get_mut(&name).unwrap();
if off + chunk.len() > entry.len() {
entry.resize(off + chunk.len(), 0);
}
entry[off..off + chunk.len()].copy_from_slice(&chunk);
}
Op::SetLen => {
let Some(name) = pick_present_unfrozen(rng, shadow, frozen) else {
return Ok(());
};
let new_len = rng.range(caps.max_size as u64);
let path = format!("/{name}");
set_len_via_rw(fs, dev, &path, new_len)?;
let entry = shadow.get_mut(&name).unwrap();
entry.resize(new_len as usize, 0);
}
Op::Delete => {
let Some(name) = pick_present_unfrozen(rng, shadow, frozen) else {
return Ok(());
};
let path = format!("/{name}");
fs.remove(dev, Path::new(&path))
.map_err(|e| format!("remove {path}: {e}"))?;
shadow.remove(&name);
}
Op::Clone => {
let Some(src_name) = pick_present_unfrozen(rng, shadow, frozen) else {
return Ok(());
};
let dst_name = pick_fresh_name(rng, shadow);
let src_path = format!("/{src_name}");
let dst_path = format!("/{dst_name}");
fs.clone_file(dev, Path::new(&src_path), Path::new(&dst_path))
.map_err(|e| format!("clone_file {src_path} → {dst_path}: {e}"))?;
let body = shadow.get(&src_name).cloned().unwrap();
shadow.insert(dst_name.clone(), body);
if shares_extents {
frozen.insert(src_name);
frozen.insert(dst_name);
}
}
Op::Flush => {
fs.flush(dev).map_err(|e| format!("flush: {e}"))?;
}
}
Ok(())
}
fn create_via_reader(
fs: &mut dyn Filesystem,
dev: &mut dyn BlockDevice,
path: &str,
body: &[u8],
) -> Result<(), String> {
let reader: Box<dyn ReadSeek + Send> = Box::new(std::io::Cursor::new(body.to_vec()));
let src = FileSource::Reader {
reader,
len: body.len() as u64,
};
fs.create_file(
dev,
Path::new(path),
src,
FileMeta {
mode: 0o644,
mtime: 1,
..Default::default()
},
)
.map_err(|e| format!("create_file {path}: {e}"))
}
fn overwrite_via_rw(
fs: &mut dyn Filesystem,
dev: &mut dyn BlockDevice,
path: &str,
body: &[u8],
) -> Result<(), String> {
let mut h = fs
.open_file_rw(
dev,
Path::new(path),
OpenFlags {
truncate: true,
..OpenFlags::default()
},
None,
)
.map_err(|e| format!("open_file_rw truncate {path}: {e}"))?;
h.write_all(body)
.map_err(|e| format!("write_all {path}: {e}"))?;
h.sync().map_err(|e| format!("sync {path}: {e}"))?;
Ok(())
}
fn append_via_rw(
fs: &mut dyn Filesystem,
dev: &mut dyn BlockDevice,
path: &str,
chunk: &[u8],
) -> Result<(), String> {
let mut h = fs
.open_file_rw(
dev,
Path::new(path),
OpenFlags {
append: true,
..OpenFlags::default()
},
None,
)
.map_err(|e| format!("open_file_rw append {path}: {e}"))?;
h.write_all(chunk)
.map_err(|e| format!("write_all append {path}: {e}"))?;
h.sync().map_err(|e| format!("sync {path}: {e}"))?;
Ok(())
}
fn patch_via_rw(
fs: &mut dyn Filesystem,
dev: &mut dyn BlockDevice,
path: &str,
off: u64,
chunk: &[u8],
) -> Result<(), String> {
let mut h = fs
.open_file_rw(dev, Path::new(path), OpenFlags::default(), None)
.map_err(|e| format!("open_file_rw {path}: {e}"))?;
h.seek(SeekFrom::Start(off))
.map_err(|e| format!("seek {off}: {e}"))?;
h.write_all(chunk)
.map_err(|e| format!("patch write {path}: {e}"))?;
h.sync().map_err(|e| format!("sync {path}: {e}"))?;
Ok(())
}
fn set_len_via_rw(
fs: &mut dyn Filesystem,
dev: &mut dyn BlockDevice,
path: &str,
new_len: u64,
) -> Result<(), String> {
let mut h = fs
.open_file_rw(dev, Path::new(path), OpenFlags::default(), None)
.map_err(|e| format!("open_file_rw {path}: {e}"))?;
h.set_len(new_len)
.map_err(|e| format!("set_len {path} -> {new_len}: {e}"))?;
h.sync().map_err(|e| format!("sync {path}: {e}"))?;
Ok(())
}
fn verify_against_shadow(
fs: &mut dyn Filesystem,
dev: &mut dyn BlockDevice,
shadow: &BTreeMap<String, Vec<u8>>,
) -> Result<(), String> {
let entries = fs
.list(dev, Path::new("/"))
.map_err(|e| format!("list /: {e}"))?;
let names: std::collections::BTreeSet<String> = entries
.iter()
.map(|e| e.name.clone())
.filter(|n| !is_fs_internal(n))
.collect();
let want: std::collections::BTreeSet<String> = shadow.keys().cloned().collect();
if names != want {
let extra: Vec<_> = names.difference(&want).collect();
let missing: Vec<_> = want.difference(&names).collect();
return Err(format!(
"root listing diverged: missing={missing:?} extra={extra:?}"
));
}
for (name, body) in shadow {
let path = format!("/{name}");
let mut r = fs
.read_file(dev, Path::new(&path))
.map_err(|e| format!("read_file {path}: {e}"))?;
let mut got = Vec::with_capacity(body.len());
r.read_to_end(&mut got)
.map_err(|e| format!("read_to_end {path}: {e}"))?;
drop(r);
if got != *body {
let diff_at = got
.iter()
.zip(body.iter())
.position(|(a, b)| a != b)
.unwrap_or(usize::min(got.len(), body.len()));
let slice_got = preview_at(&got, diff_at);
let slice_want = preview_at(body, diff_at);
return Err(format!(
"{path} mismatch (got {}B, want {}B, first diff @ {}): \
got {slice_got}, want {slice_want}",
got.len(),
body.len(),
diff_at,
));
}
}
Ok(())
}
fn probe_random_seek(
fs: &mut dyn Filesystem,
dev: &mut dyn BlockDevice,
rng: &mut Rng,
shadow: &BTreeMap<String, Vec<u8>>,
) -> Result<(), String> {
let Some(name) = pick_present(rng, shadow) else {
return Ok(());
};
let body = shadow.get(&name).unwrap();
if body.is_empty() {
return Ok(());
}
let off = rng.range(body.len() as u64);
let max_window = body.len() as u64 - off;
let n = rng.range(max_window) + 1;
let want = &body[off as usize..(off + n) as usize];
let path = format!("/{name}");
let mut h = match fs.open_file_ro(dev, Path::new(&path)) {
Ok(h) => h,
Err(fstool::Error::Unsupported(_)) => return Ok(()),
Err(e) => return Err(format!("open_file_ro {path}: {e}")),
};
h.seek(SeekFrom::Start(off))
.map_err(|e| format!("seek {off}: {e}"))?;
let mut got = vec![0u8; n as usize];
h.read_exact(&mut got)
.map_err(|e| format!("read_exact {n} @ {off}: {e}"))?;
if got != want {
return Err(format!(
"seek-read mismatch at {path}:{off}+{n}: got {:?}, want {:?}",
preview(&got),
preview(want),
));
}
Ok(())
}
fn pick_fresh_name(rng: &mut Rng, shadow: &BTreeMap<String, Vec<u8>>) -> String {
for _ in 0..32 {
let n = (rng.range(8) + 3) as usize;
let name: String = (0..n)
.map(|_| (b'a' + (rng.range(26) as u8)) as char)
.collect();
if !shadow.contains_key(&name) {
return name;
}
}
format!("file_{:x}", rng.next_u64())
}
fn pick_present(rng: &mut Rng, shadow: &BTreeMap<String, Vec<u8>>) -> Option<String> {
if shadow.is_empty() {
return None;
}
let i = rng.range(shadow.len() as u64) as usize;
shadow.keys().nth(i).cloned()
}
fn pick_present_unfrozen(
rng: &mut Rng,
shadow: &BTreeMap<String, Vec<u8>>,
frozen: &HashSet<String>,
) -> Option<String> {
let live: Vec<&String> = shadow.keys().filter(|n| !frozen.contains(*n)).collect();
if live.is_empty() {
return None;
}
let i = rng.range(live.len() as u64) as usize;
Some(live[i].clone())
}
fn is_fs_internal(name: &str) -> bool {
matches!(name, "lost+found" | "." | "..")
}
fn preview(b: &[u8]) -> String {
let head: Vec<u8> = b.iter().take(16).copied().collect();
format!("{head:02x?}")
}
fn preview_at(b: &[u8], at: usize) -> String {
let start = at.saturating_sub(4);
let end = b.len().min(at + 12);
let win: Vec<u8> = b[start..end].to_vec();
format!("[{start}..{end}]={win:02x?}")
}
const FUZZ_ITERS: usize = 200;
#[test]
fn fuzz_ext2() {
use fstool::fs::ext::{Ext, FormatOpts, FsKind};
let opts = FormatOpts {
kind: FsKind::Ext2,
inodes_count: 256,
blocks_count: 4096,
..FormatOpts::default()
};
let size = opts.blocks_count as u64 * opts.block_size as u64;
let mut dev = MemoryBackend::new(size);
let mut ext = Ext::format_with(&mut dev, &opts).expect("format ext2");
fuzz_filesystem(
&mut ext,
&mut dev,
0xE2_0202_C0DE,
FUZZ_ITERS,
&Caps::mutable_small(),
);
}
#[test]
fn fuzz_ext3() {
use fstool::fs::ext::{Ext, FormatOpts, FsKind};
let opts = FormatOpts {
kind: FsKind::Ext3,
inodes_count: 256,
blocks_count: 4096,
..FormatOpts::default()
};
let size = opts.blocks_count as u64 * opts.block_size as u64;
let mut dev = MemoryBackend::new(size);
let mut ext = Ext::format_with(&mut dev, &opts).expect("format ext3");
fuzz_filesystem(
&mut ext,
&mut dev,
0xE3_0303_C0DE,
FUZZ_ITERS,
&Caps::mutable_small(),
);
}
#[test]
fn fuzz_ext4() {
use fstool::fs::ext::{Ext, FormatOpts, FsKind};
let opts = FormatOpts {
kind: FsKind::Ext4,
inodes_count: 256,
blocks_count: 4096,
..FormatOpts::default()
};
let size = opts.blocks_count as u64 * opts.block_size as u64;
let mut dev = MemoryBackend::new(size);
let mut ext = Ext::format_with(&mut dev, &opts).expect("format ext4");
fuzz_filesystem(
&mut ext,
&mut dev,
0xE4_0404_C0DE,
FUZZ_ITERS,
&Caps::ext4_tight(),
);
}
#[test]
fn fuzz_fat32() {
use fstool::fs::fat::{Fat32, FatFormatOpts};
const TOTAL_SECTORS: u32 = 64 * 1024 * 1024 / 512;
let tmp = NamedTempFile::new().expect("tempfile");
let mut dev =
FileBackend::create(tmp.path(), TOTAL_SECTORS as u64 * 512).expect("FileBackend create");
let opts = FatFormatOpts {
total_sectors: TOTAL_SECTORS,
..FatFormatOpts::default()
};
let mut fs = Fat32::format(&mut dev, &opts).expect("format fat32");
fuzz_filesystem(
&mut fs,
&mut dev,
0xFAFA_3232_C0DE,
FUZZ_ITERS,
&Caps::mutable_small(),
);
}
#[test]
fn fuzz_exfat() {
use fstool::fs::exfat::Exfat;
use fstool::fs::exfat::format::FormatOpts;
const SIZE: u64 = 32 * 1024 * 1024;
let tmp = NamedTempFile::new().expect("tempfile");
let mut dev = FileBackend::create(tmp.path(), SIZE).expect("FileBackend create");
let opts = FormatOpts::default();
let mut fs = Exfat::format(&mut dev, &opts).expect("format exfat");
fuzz_filesystem(
&mut fs,
&mut dev,
0xEF_AAAA_C0DE,
FUZZ_ITERS,
&Caps::mutable_small(),
);
}
#[test]
fn fuzz_hfs_plus() {
use fstool::fs::hfs_plus::{FormatOpts, HfsPlus};
const SIZE: u64 = 16 * 1024 * 1024;
let tmp = NamedTempFile::new().expect("tempfile");
let mut dev = FileBackend::create(tmp.path(), SIZE).expect("FileBackend create");
let opts = FormatOpts::default();
let mut fs = HfsPlus::format(&mut dev, &opts).expect("format hfs+");
fuzz_filesystem(
&mut fs,
&mut dev,
0x4859_4653_C0DE,
FUZZ_ITERS,
&Caps::mutable_small(),
);
}
#[test]
fn fuzz_f2fs() {
use fstool::fs::f2fs::{F2fs, FormatOpts};
const SIZE: u64 = 64 * 1024 * 1024;
let tmp = NamedTempFile::new().expect("tempfile");
let mut dev = FileBackend::create(tmp.path(), SIZE).expect("FileBackend create");
let opts = FormatOpts::default();
let mut fs = F2fs::format(&mut dev, &opts).expect("format f2fs");
fuzz_filesystem(
&mut fs,
&mut dev,
0xF2F2_5555_C0DE,
FUZZ_ITERS,
&Caps::f2fs_no_set_len(),
);
}
#[test]
fn fuzz_xfs() {
use fstool::fs::xfs::{self, FormatOpts};
const SIZE: u64 = 256 * 1024 * 1024;
let tmp = NamedTempFile::new().expect("tempfile");
let mut dev = FileBackend::create(tmp.path(), SIZE).expect("FileBackend create");
let opts = FormatOpts::default();
let mut fs = xfs::format(&mut dev, &opts).expect("format xfs");
fuzz_filesystem(
&mut fs,
&mut dev,
0x5846_5300_C0DE,
FUZZ_ITERS,
&Caps::mutable_small(),
);
}
#[test]
fn fuzz_ntfs() {
use fstool::fs::ntfs::Ntfs;
use fstool::fs::ntfs::format::FormatOpts;
const SIZE: u64 = 16 * 1024 * 1024;
let tmp = NamedTempFile::new().expect("tempfile");
let mut dev = FileBackend::create(tmp.path(), SIZE).expect("FileBackend create");
let opts = FormatOpts {
volume_label: "FSTOOL-FUZZ".to_string(),
..Default::default()
};
let mut fs = Ntfs::format(&mut dev, &opts).expect("format ntfs");
fuzz_filesystem(
&mut fs,
&mut dev,
0x4E54_4653_C0DE, FUZZ_ITERS,
&Caps::mutable_small(),
);
}