{"slug": "ratatop-day-3-disks-statvfs-and-my-first-unsafe-block", "title": "ratatop day 3: disks, statvfs, and my first unsafe block", "summary": "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.", "body_md": "*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.*\n\nDay three of building a btop-style system monitor in Rust with [ratatui](https://ratatui.rs).\n\nDay one was the CPU box.\n\nDay two was memory, where I learned that `MemFree`\n\nis a number nobody should look at.\n\nToday: disks.\n\nAnd this one broke a rule I had set for myself on day one.\n\nMy rule was simple.\n\nRead `/proc`\n\nand `/sys`\n\n, never shell out to another tool.\n\nCPU came from `/proc/stat`\n\n.\n\nMemory came from `/proc/meminfo`\n\n. Disks would come from... something.\n\nI went looking for the file. `/proc/mounts`\n\ntells you what is mounted.\n\n`/proc/diskstats`\n\ntells you how much I/O happened.\n\n`/sys/block/*`\n\ntells you device sizes.\n\nNone of them tell you how full a filesystem is.\n\nThat number does not exist as a file anywhere. It only exists behind a syscall: `statvfs(2)`\n\n.\n\nThe kernel computes it on demand from the filesystem's own metadata, and the only way to ask is to actually make the call.\n\nSo I had two options.\n\nShell out to `df`\n\nand parse it, which breaks my rule and drags in output-format fragility across distros.\n\nOr make the syscall myself, which means the `libc`\n\ncrate, which means `unsafe`\n\n.\n\nI picked the syscall.\n\n`df`\n\nitself is just a `statvfs`\n\nwrapper with a printf on top.\n\nSpawning a process to read a number the kernel would hand me directly is silly.\n\nRust's standard library has no `statvfs`\n\n.\n\nSo this is FFI territory.\n\n``` php\nfn usage(mount: &str) -> Option<(u64, u64)> {\n    let path = CString::new(mount).ok()?;\n    // SAFETY: `path` is a valid NUL-terminated string that outlives the call,\n    // and `vfs` is fully initialised by statvfs before we read it. A failed call\n    // is reported through the return value, which we check.\n    let vfs = unsafe {\n        let mut vfs: libc::statvfs = std::mem::zeroed();\n        if libc::statvfs(path.as_ptr(), &mut vfs) != 0 {\n            return None;\n        }\n        vfs\n    };\n\n    let frsize = vfs.f_frsize as u64;\n    let total = (vfs.f_blocks as u64).saturating_mul(frsize);\n    let free = (vfs.f_bavail as u64).saturating_mul(frsize);\n    Some((total, free.min(total)))\n}\n```\n\nThree things worth pointing out, because they are the parts I had to actually think about.\n\n** CString::new is not optional.** C wants a NUL-terminated string. Rust\n\n`&str`\n\nis 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`\n\ndoes the conversion properly, and it returns a `Result`\n\nbecause 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.\n\n`statvfs`\n\nwrites 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\n\n`unsafe`\n\nblock 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.\n\nOne function, ten lines, and a safe `Option<(u64, u64)>`\n\ncomes out the other end. Everything upstream of it is ordinary safe Rust that cannot tell the difference.\n\nNow the fun part.\n\n`statvfs`\n\ngives you three block counts, and picking the wrong one gives you the wrong answer.\n\n`f_blocks`\n\n: total blocks in the filesystem`f_bfree`\n\n: blocks that are genuinely unused`f_bavail`\n\n: blocks available Those last two are not the same, and the gap is bigger than you would guess. Real numbers from my root filesystem:\n\n```\ntotal   91.1 GiB\nbfree   23.7 GiB\nbavail  19.0 GiB\n```\n\nThat is 4.67 GiB, exactly 5.1% of the disk, that is free but that I am not allowed to use.\n\next4 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.\n\nWhich number you subtract changes the headline figure:\n\n```\nused via f_bavail   79.1%\nused via f_bfree    74.0%\n```\n\nFive percentage points, from one field name.\n\nbtop uses `f_bavail`\n\n, and so does `df`\n\n.\n\nThat is the honest choice for a monitor: report the space you can actually use, and treat root's reserve as spoken for.\n\nShowing 74% when `df`\n\nsays 79% would just make the tool look broken.\n\nNext problem. `/proc/self/mounts`\n\non my laptop has around 40 entries.\n\nFour of them are disks.\n\nThe rest is `proc`\n\n, `sysfs`\n\n, `cgroup2`\n\n, `tmpfs`\n\n, a dozen snap squashfs images, and a couple of FUSE mounts from the desktop session.\n\nThe obvious move is a blocklist of filesystem types to skip.\n\nI started writing one, then realised the kernel already publishes exactly this information:\n\n``` bash\n$ cat /proc/filesystems\nnodev   sysfs\nnodev   tmpfs\nnodev   proc\nnodev   cgroup2\n    ext4\n    vfat\n```\n\nAnything flagged `nodev`\n\nis not backed by a block device.\n\nThat is the kernel telling you, authoritatively, which filesystem types cannot possibly be a disk.\n\nSo instead of maintaining a list that goes stale, I read theirs:\n\n``` js\nlet mut types: Vec<String> = raw\n    .lines()\n    .filter_map(|line| line.strip_prefix(\"nodev\"))\n    .map(|name| name.trim().to_string())\n    .collect();\n```\n\nNew pseudo-filesystem lands in a future kernel? Excluded automatically, no code change.\n\nThere is a second filter that does a lot of work for one line: the mount's device has to start with `/`\n\n. A real partition is `/dev/nvme0n1p2`\n\n.\n\nPseudo-filesystems put a bare name there instead, like `proc`\n\nor `gvfsd-fuse`\n\n. No slash, not a disk.\n\nSmall one, but it would have been a confusing bug.\n\nMount a disk at a path with a space in it, and `/proc/self/mounts`\n\ndoes not give you the space:\n\n```\n/dev/sdb1 /mnt/my\\040disk ext4 rw,relatime 0 0\n```\n\n`\\040`\n\nis octal for a space. The kernel escapes spaces, tabs, newlines and backslashes this way, because the file is whitespace-separated and an unescaped space would make the line unparseable.\n\nIf you pass that raw string to `statvfs`\n\n, it fails. No such path exists. The disk silently vanishes from your monitor and you have no idea why.\n\nSo you decode it before using it. Four characters starting with a backslash, parse the next three as octal, emit the byte.\n\nUsage is a syscall. Throughput is back to reading files.\n\nbtop reads `/sys/block/<device>/stat`\n\n, which gives you a device's cumulative counters:\n\n```\n234345 84965 16346864 796898 486240 189824 21513416 23255863 0 2406558 24197776\n```\n\nThe fields I care about are index 2 (sectors read), index 6 (sectors written), and index 9 (`io_ticks`\n\n, milliseconds the device spent with a request in flight).\n\nTwo traps here.\n\n**A sector is always 512 bytes in this file.** Not the device's real block size. My NVMe drive uses 4096-byte blocks, but these counters are still expressed in 512-byte units, for backwards compatibility with software written when that was universal. Multiply by 4096 and your throughput numbers come out eight times too high.\n\n**Partitions are nested.** `/sys/block/nvme0n1/stat`\n\nexists for the whole drive. The partition I actually mounted lives at `/sys/block/nvme0n1/nvme0n1p2/stat`\n\n, one level deeper. So the lookup checks the top level first, then scans one level down.\n\n`io_ticks`\n\nis the nice one. It is busy-milliseconds, so dividing by the elapsed wall-clock milliseconds gives you utilisation directly:\n\n``` js\nlet busy_ms = counters.io_ticks.saturating_sub(previous.io_ticks) as f64;\ndisk.io_util = (busy_ms / (seconds * 1000.0) * 100.0).clamp(0.0, 100.0);\n```\n\nSame delta pattern as the CPU box on day one. Third time I have written `saturating_sub`\n\non a monotonic counter in this project, and it has earned its keep every time: devices get unplugged and re-added, and counters reset to zero when they do.\n\nbtop lists swap in the disks box, which surprised me until it made sense. You care about how full it is, in the same way and for the same reasons.\n\nBut it has no filesystem, so `statvfs`\n\nis meaningless. And it may be a file, a partition, or several of both, so there is no single block device to read I/O from.\n\nIt comes from `/proc/meminfo`\n\ninstead:\n\n```\nSwapTotal:      21092348 kB\nSwapFree:       19857688 kB\n```\n\nSo swap is a row that shares the layout but skips two of the three data sources.\n\nIn my code it is a `Disk`\n\nwith `is_swap: true`\n\n, and the render function checks that flag before drawing the I/O graph.\n\nDrawing an empty graph there would imply a measurement of zero, when the truth is there is no measurement at all.\n\nThe distinction between \"zero\" and \"unknown\" is one of those things that seems pedantic until your tool confidently reports the wrong thing.\n\nGenuinely almost nothing to say here, which is the point.\n\nThree rows per disk, laid out with the same `Layout`\n\ncalls as the other boxes:\n\n```\nlet [name_row, io_row, used_row] = Layout::vertical([\n    Constraint::Length(1),\n    Constraint::Length(1),\n    Constraint::Length(1),\n])\n.areas(area);\n```\n\nThe `Meter`\n\nwidget from day one and the `BrailleGraph`\n\nfrom day one both dropped straight in with zero changes. Third box, same two widgets. That is the payoff for writing them as plain structs with one `render`\n\nmethod.\n\nOne layout decision worth mentioning: the box only draws disks that completely fit.\n\n``` js\nlet capacity = (inner.height / ROWS_PER_DISK) as usize;\n```\n\nInteger division, so a disk with room for only two of its three rows is not drawn at all. A half-rendered disk looks like a bug, whereas a missing one just looks like a small window.\n\nNetwork. `/proc/net/dev`\n\nis another counter file, so the delta machinery is already there, but auto-scaling units across five orders of magnitude will be new.\n\nThen processes, which is the big one.\n\nReading `/proc/[pid]/stat`\n\nfor every process, every tick, and sorting it.\n\nCode so far:\n\nA reimplementation of [btop](https://github.com/aristocratos/btop) in Rust, built on\n[ratatui](https://ratatui.rs).\n\nThe goal is a resource monitor that feels like btop: the four-box layout, braille graphs with gradient fills, mouse-driven navigation, and a themable process manager, but written as a modern Rust TUI.\n\n**The CPU box works.** It samples real data and renders btop's layout:\n\nPer-core braille history graphs, a gradient load bar with sub-cell resolution frequency, package temperature, battery, and load averages.\n\nEverything else, the memory, network, and process boxes, is still to come.\n\n`/proc`\n\nand `/sys`\n\non Linux first, read directly rather than\nshelling out. btop's `src/linux/btop_collect.cpp`\n\nis the reference for what to\nread and how often; the BSD/macOS collectors are out of scope for v1.Today's lesson, if there is one: the rule I set myself (\"only read files, never shell out\") was a good rule that had exactly one legitimate exception, and finding out *why* it was an exception taught me more than following it would have.\n\nSometimes the file you are looking for does not exist because the number was never a file.\n\nAI agents write code fast. They also silently remove logic, change behavior, and introduce bugs — without telling you. You often find out in production.\n\ngit-lrc fixes this. It hooks into git commit and reviews every diff before it lands. 60-second setup. Completely free.\n\nAny feedback or contributors are welcome! It's online, source-available, and ready for anyone to use.\n\n⭐ Star it on GitHub:\n\n| [🇩🇰 Dansk](https://github.com/HexmosTech/git-lrc/readme/README.da.md) | [🇪🇸 Español](https://github.com/HexmosTech/git-lrc/readme/README.es.md) | [🇮🇷 Farsi](https://github.com/HexmosTech/git-lrc/readme/README.fa.md) | [🇫🇮 Suomi](https://github.com/HexmosTech/git-lrc/readme/README.fi.md) | [🇯🇵 日本語](https://github.com/HexmosTech/git-lrc/readme/README.ja.md) | [🇳🇴 Norsk](https://github.com/HexmosTech/git-lrc/readme/README.nn.md) | [🇵🇹 Português](https://github.com/HexmosTech/git-lrc/readme/README.pt.md) | [🇷🇺 Русский](https://github.com/HexmosTech/git-lrc/readme/README.ru.md) | [🇦🇱 Shqip](https://github.com/HexmosTech/git-lrc/readme/README.sq.md) | [🇨🇳 中文](https://github.com/HexmosTech/git-lrc/readme/README.zh.md) | [🇮🇳 हिन्दी](https://github.com/HexmosTech/git-lrc/readme/README.hi.md) |\n\nGenAI today is a **race car without brakes**. It accelerates fast -- you describe something, and large blocks of code appear instantly. But AI agents *silently break things*: they remove logic, relax constraints, introduce expensive cloud calls, leak credentials, and change behavior -- without telling you. You often find out in production.\n\n** git-lrc is your braking system.** It hooks into\n\n`git commit`\n\nand runs an AI review on every diff In short, git-lrc helps **Prevent Outages, Breaches, and Technical Debt Before They Happen**\n\n**At a glance:** [10 risk categories](https://github.com/HexmosTech/git-lrc#what-git-lrc-checks-for) · [100+ failure patterns tracked](https://github.com/HexmosTech/git-lrc#what-git-lrc-checks-for) · every commit…", "url": "https://wpnews.pro/news/ratatop-day-3-disks-statvfs-and-my-first-unsafe-block", "canonical_source": "https://dev.to/lovestaco/ratatop-day-3-disks-statvfs-and-my-first-unsafe-block-496e", "published_at": "2026-07-31 10:17:57+00:00", "updated_at": "2026-07-31 10:36:07.626644+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Maneshwar", "git-lrc", "ratatui", "Rust", "libc"], "alternates": {"html": "https://wpnews.pro/news/ratatop-day-3-disks-statvfs-and-my-first-unsafe-block", "markdown": "https://wpnews.pro/news/ratatop-day-3-disks-statvfs-and-my-first-unsafe-block.md", "text": "https://wpnews.pro/news/ratatop-day-3-disks-statvfs-and-my-first-unsafe-block.txt", "jsonld": "https://wpnews.pro/news/ratatop-day-3-disks-statvfs-and-my-first-unsafe-block.jsonld"}}