The Quipu-Log Book
Part 2 · Filesystem basics

06 · The std::fs toolbox: handling files in Rust

Quipu-Log's entire storage engine runs on nothing but Rust's standard library — std::fs and std::io — with no special OS features and no external libraries. This chapter collects the standard APIs that actually appear throughout the Quipu-Log codebase and looks at them from the angle of "this is the full set of tools we have."

DB ↔ Filesystem

A DB engine provides B-tree management, a buffer pool, a lock manager, and a WAL engine in millions of lines of C code. In Quipu-Log, std::fs and std::io are the entire toolkit — and with them alone we build a WAL, indexes, locking, and snapshots. Seeing this toollist makes you realize just how far you can get without a database engine.

Opening files: File and OpenOptions

The most fundamental file operations are open and close. In Rust, std::fs::File is the file handle and OpenOptions controls how it's opened.

crates/quipu-core/src/storage/segment.rs — opening filesuse std::fs::{File, OpenOptions};

// write-capable open (segment write path)
let file = OpenOptions::new()
    .create(true).truncate(false).read(true).write(true)
    .open(path)?;

// read-only open (skim, snapshot reads)
let file = File::open(path)?;  // read-only shorthand

A File is automatically closed when dropped — no explicit close() needed, thanks to Rust's RAII.

The I/O traits: Read, Write, Seek

Three core traits do the actual data transfer.

TraitKey methodUse in Quipu-Log
std::io::Readread_exact(&mut [u8])Reading frame headers and payloads
std::io::Writewrite_all(&[u8])Writing frame headers and payloads
std::io::Seekseek(SeekFrom::Start(n))Moving to the append position, repositioning after skim for recovery
crates/quipu-core/src/storage/segment.rs — writing a frameuse std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write};

// frame header 4+4+8 = 16 bytes, then the payload
self.writer.write_all(&(payload.len() as u32).to_le_bytes())?;
self.writer.write_all(&crc.to_le_bytes())?;
self.writer.write_all(&timestamp.to_le_bytes())?;
self.writer.write_all(payload)?;

Buffered I/O: BufReader and BufWriter

Calling write() directly on a File triggers a system call every time. Frame header: 4 bytes, CRC: 4 bytes, timestamp: 8 bytes — that's three syscalls for just the header. BufWriter accumulates data in an internal buffer (256 KB) and issues a single syscall when it flushes.

Direct writes (File) write(4B) → syscall write(4B) → syscall write(8B) → syscall write(nB) → syscall 4 syscalls per record BufWriter<File> (256 KB buffer) write(4B) → accumulates in buffer write(4B) → accumulates in buffer write(8B) → accumulates in buffer write(nB) → accumulates in buffer flush() → one syscall for everything
BufWriter batches multiple write() calls and flushes them in a single syscall. This dramatically reduces syscall overhead.

The same principle applies to reads. BufReader reads a large chunk from disk at once, and read_exact() then pulls from that buffer:

crates/quipu-core/src/storage/segment.rs — skim() readslet mut reader = BufReader::with_capacity(256 * 1024, file);
let mut header = [0u8; FRAME_HEADER];
reader.read_exact(&mut header)?;   // pull 16 bytes from the buffer

File and directory operations: the full list actually used

Here are all the std::fs functions used in Quipu-Log's storage module. This is the complete toolbox:

APIWhere Quipu-Log uses it
std::fs::create_dir_all()Initializing the store root and table directories
std::fs::read_dir()Collecting the list of segment files when opening a table
std::fs::remove_file()Retention — deleting old segments (unlink)
std::fs::rename()Atomic replacement after a table rewrite
std::fs::metadata().len()Checking segment size, deciding when to roll over
File::set_len()Crash recovery — truncating the broken tail
File::try_lock()Advisory OS lock on the root directory (MSRV 1.89)
File::sync_data()SyncPolicy::Always / EveryN — fsync

File locking: File::try_lock (MSRV 1.89)

Quipu-Log enforces a single-writer principle. If a second process tries to open the same root directory as a store, the in-memory index becomes inconsistent and segment writes collide. An OS advisory file lock is what prevents that.

crates/quipu-core/src/store.rs — AuditStore::open()let lock = std::fs::OpenOptions::new()
    .create(true).truncate(false).write(true)
    .open(cfg.root.join("LOCK"))?;

lock.try_lock().map_err(|e| match e {
    std::fs::TryLockError::WouldBlock => Error::Locked(cfg.root.display().to_string()),
    std::fs::TryLockError::Error(e) => Error::Io(e),
})?;

File::try_lock() was stabilized in Rust 1.89. That's why Quipu-Log's MSRV (minimum supported Rust version) is 1.89. The reason is spelled out in Cargo.toml:

Cargo.toml# MSRV floor: the store guards its root with `std::fs::File::try_lock` and
# matches on `std::fs::TryLockError`, both stabilized in Rust 1.89.
rust-version = "1.89"

A note on advisory locks: they have no effect within a single process — they only prevent conflicts between processes. When the process dies, the OS releases the lock automatically (the fd is closed). There's something elegant about the lock file: it doesn't need even a single byte of content — just opening it and calling try_lock is enough.

Why this design?

The reason Quipu-Log uses only std::fs — no external databases, no special OS APIs like mmap or io_uring — is portability. The code comment says it plainly: "Only std::fs/std::io are used, so behaviour is identical on every OS Rust targets." The same code runs on Windows, Linux, macOS, and FreeBSD.

Check yourself

① What goes wrong if you call write() dozens of times directly on a File instead of using BufWriter? How does the behavior differ?
② Why does File::try_lock() require MSRV 1.89? What alternatives exist if you want to lower the MSRV?
③ Name one advantage and one disadvantage of using only std::fs.