Writing a mockable Filesystem trait in Rust without RefCell
I’ve been working on the CLI for my smart contract fuzzer. Its main job is to find contract files, compile them and generate Rust bindings. This involves a lot of reading and writing to the disk.
Testing this is annoying. I didn’t want to mess with temp files or clean up directories after every test. I wanted to mock the filesystem to run everything in memory. My first attempt worked but the code was hard to read. Here is how I refactored it to be cleaner.
To do this I defined a Filesystem trait. This allows me to swap between a
RealFilesystem that uses std::fs for the actual app and a TestFilesystem
that uses a HashMap for my tests.
My first attempt at this abstraction worked but it felt wrong. It forced me to write code that was hard to reason about.
Here is how I initially defined the trait.
pub trait Filesystem {
fn read_to_string(&self, path: &Path) -> std::io::Result<String>;
fn write_string(&self, path: &Path, content: &str) -> std::io::Result<()>;
}At a glance this looks fine. But if you look closely at write_string it takes
&self. This means the method promises not to change the state of the object.
For the RealFilesystem this is true because std::fs is stateless from the
perspective of the struct. It just makes system calls. But for my
TestFilesystem this was a problem. I needed to update the internal HashMap
when writing a file.
To make this work I had to use “interior mutability”. I wrapped my data in an
Rc and RefCell.
#[derive(Debug, Default, Clone)]
pub struct TestFilesystem {
files: Rc<RefCell<HashMap<PathBuf, String>>>,
}The RefCell let me mutate the map even when I only had an immutable reference
and the Rc let me clone the filesystem so I could pass it to different parts
of the builder.
This made the code messy. In my build command I had to clone the filesystem constantly.
pub fn run_with_fs<F: Filesystem + Clone>(args: BuildArgs, fs: F) -> Result<()> {
// ...
// I had to clone fs here to pass it to the builder
let builder = SolidityBuilder::new(args.files, fs.clone());
// ...
// And I still needed fs here to write the output
fs.write_string(&args.output, &generated)?;
Ok(())
}There are two big problems here.
First is the clarity of fs.clone(). In Rust clone usually implies a deep
copy. But here I was just incrementing a reference counter. The builder and
the run_with_fs function were sharing the exact same data. If the builder
decided to modify the filesystem it would affect the outer scope too. It is
spooky action at a distance which is often a source of bugs.
Second is that the type system was lying. Writing to a file is a mutation. It
changes the state of the world. By using &self I was hiding that fact.
I decided to refactor this to be more idiomatic. I changed the trait to be honest about mutation.
pub trait Filesystem {
fn read_to_string(&self, path: &Path) -> std::io::Result<String>;
// Now this requires a mutable reference
fn write_string(&mut self, path: &Path, content: &str) -> std::io::Result<()>;
}This small change triggered a massive cleanup in the implementation.
I was able to remove the Rc and RefCell entirely from TestFilesystem. It
is now just a wrapper around a HashMap.
#[derive(Debug, Default, Clone)]
pub struct TestFilesystem {
files: HashMap<PathBuf, String>,
}The usage in the build command became much clearer. I no longer pass ownership of the filesystem to the builders. Instead I pass them a reference.
pub struct SolidityBuilder<'a, F: Filesystem> {
files: Vec<PathBuf>,
fs: &'a F, // The builder borrows the FS
}
impl<'a, F: Filesystem> Builder for SolidityBuilder<'a, F> {
fn build(&self) -> Result<String> {
// The builder can readbut the compiler prevents it from writing!
let src = self.fs.read_to_string(&path)?;
// ...
}
}This is the power of the borrow checker. By giving the builder an immutable
reference &F I guarantee at compile time that the builder cannot write to the
disk. It can only read the source files.
The main run function now looks like this.
pub fn run_with_fs<F: Filesystem>(args: BuildArgs, fs: &mut F) -> Result<()> {
// We pass an immutable reference to the builder
let generated = SolidityBuilder::new(args.files, fs).build()?;
// We use our mutable reference to write the output
fs.write_string(&args.output, &generated)?;
Ok(())
}In the tests I don’t need to do any complex setup anymore. I just create the struct and pass a mutable reference.
#[test]
fn accepts_contract_and_writes_output() {
let mut fs = TestFilesystem::new();
fs.add_file(&p1, "contract A {}");
// Pass mutable reference
run_with_fs(args, &mut fs).unwrap();
// Check the result
let s = fs.read_to_string(&outp).unwrap();
assert!(s.contains("Generated by hades"));
}This refactor taught me a lot about trusting Rust’s ownership model. My first
attempt tried to bypass the rules using RefCell because I thought it would be
easier to share the object. But it just made the code confusing.
By accepting the rules and using &mut self I gained “truth” via Rust
typesystem. The function signatures now tell me exactly what is happening. The
builder reads and the command writes. There is no hidden state and no need for
reference counting. It is simple, safe and fast.
| Tags | rust , fuzzing |
|---|