ratatop day 3: disks, statvfs, and my first unsafe block Maneshwar, a developer building git-lrc, a Micro AI code reviewer, documented day three of creating a btop-style system monitor in Rust with ratatui. To display disk usage, he chose to call the statvfs syscall directly via the libc crate, using his first unsafe block, rather than shelling out to df. He highlights the importance of CString for NUL-terminated strings, the safety of mem::zeroed, and the necessity of SAFETY comments in unsafe code. Hello, I'm Maneshwar. I'm building git-lrc, a Micro AI code reviewer that runs on every commit. It is free and source-available on Github. Star git-lrc to help devs discover the project. Do give it a try and share your feedback. Day three of building a btop-style system monitor in Rust with ratatui https://ratatui.rs . Day one was the CPU box. Day two was memory, where I learned that MemFree is a number nobody should look at. Today: disks. And this one broke a rule I had set for myself on day one. My rule was simple. Read /proc and /sys , never shell out to another tool. CPU came from /proc/stat . Memory came from /proc/meminfo . Disks would come from... something. I went looking for the file. /proc/mounts tells you what is mounted. /proc/diskstats tells you how much I/O happened. /sys/block/ tells you device sizes. None of them tell you how full a filesystem is. That number does not exist as a file anywhere. It only exists behind a syscall: statvfs 2 . The kernel computes it on demand from the filesystem's own metadata, and the only way to ask is to actually make the call. So I had two options. Shell out to df and parse it, which breaks my rule and drags in output-format fragility across distros. Or make the syscall myself, which means the libc crate, which means unsafe . I picked the syscall. df itself is just a statvfs wrapper with a printf on top. Spawning a process to read a number the kernel would hand me directly is silly. Rust's standard library has no statvfs . So this is FFI territory. php fn usage mount: &str - Option< u64, u64 { let path = CString::new mount .ok ?; // SAFETY: path is a valid NUL-terminated string that outlives the call, // and vfs is fully initialised by statvfs before we read it. A failed call // is reported through the return value, which we check. let vfs = unsafe { let mut vfs: libc::statvfs = std::mem::zeroed ; if libc::statvfs path.as ptr , &mut vfs = 0 { return None; } vfs }; let frsize = vfs.f frsize as u64; let total = vfs.f blocks as u64 .saturating mul frsize ; let free = vfs.f bavail as u64 .saturating mul frsize ; Some total, free.min total } Three things worth pointing out, because they are the parts I had to actually think about. CString::new is not optional. C wants a NUL-terminated string. Rust &str is not NUL-terminated, it carries a length. If you hand C a pointer to raw Rust string bytes, it reads until it happens to find a zero byte, which could be well past the end of your string. CString does the conversion properly, and it returns a Result because a Rust string containing an interior NUL cannot be represented at all. mem::zeroed looks worse than it is. We are handing C a buffer to fill in. statvfs writes every field before returning success. Zeroing first means that even in some bizarre partial-write scenario we get zeros rather than whatever was on the stack. The SAFETY comment is not decoration. Rust convention is that every unsafe block gets a comment explaining why it is actually safe. Writing that comment is a genuinely useful exercise. It forces you to enumerate your assumptions, and if you cannot write the comment, that is your answer about whether the code is correct.The nice part is that the unsafety is contained. One function, ten lines, and a safe Option< u64, u64 comes out the other end. Everything upstream of it is ordinary safe Rust that cannot tell the difference. Now the fun part. statvfs gives you three block counts, and picking the wrong one gives you the wrong answer. f blocks : total blocks in the filesystem f bfree : blocks that are genuinely unused f bavail : blocks available Those last two are not the same, and the gap is bigger than you would guess. Real numbers from my root filesystem: total 91.1 GiB bfree 23.7 GiB bavail 19.0 GiB That is 4.67 GiB, exactly 5.1% of the disk, that is free but that I am not allowed to use. ext4 reserves 5% of the filesystem for root by default. The idea is that if a runaway process fills the disk, root still has room to log in and clean up, and the filesystem has room to avoid the fragmentation that comes with running completely full. Which number you subtract changes the headline figure: used via f bavail 79.1% used via f bfree 74.0% Five percentage points, from one field name. btop uses f bavail , and so does df . That is the honest choice for a monitor: report the space you can actually use, and treat root's reserve as spoken for. Showing 74% when df says 79% would just make the tool look broken. Next problem. /proc/self/mounts on my laptop has around 40 entries. Four of them are disks. The rest is proc , sysfs , cgroup2 , tmpfs , a dozen snap squashfs images, and a couple of FUSE mounts from the desktop session. The obvious move is a blocklist of filesystem types to skip. I started writing one, then realised the kernel already publishes exactly this information: bash $ cat /proc/filesystems nodev sysfs nodev tmpfs nodev proc nodev cgroup2 ext4 vfat Anything flagged nodev is not backed by a block device. That is the kernel telling you, authoritatively, which filesystem types cannot possibly be a disk. So instead of maintaining a list that goes stale, I read theirs: js let mut types: Vec