crates/disk-snapshot/src/lib.rs
//! # disk-snapshot — what an appliance's disks hold, read the cheap way
//!
//! An operator's admin view asks one question about storage — *"what is on
//! every disk and volume, and how full is it?"* — and it has four halves:
//!
//! * [`parse_mounts`] / [`read_mounts`] — every mount the kernel lists
//! (`/proc/self/mounts`), octal escapes decoded, a later mount at the same
//! path shadowing the earlier one, and [`is_pseudo_fs`] naming the
//! filesystems that hold no storage of their own (proc, sysfs, cgroup, …).
//! * [`statvfs`] — ONE `statvfs(3)` per mount, turned into bytes by
//! [`Figures::from_counts`] (pure, so the arithmetic is tested without a
//! filesystem): total, free (`f_bfree`), available to an unprivileged writer
//! (`f_bavail`), used, root-reserved, inodes. A fragment size of 0 is a
//! refusal by name, never a zero — a zero reads as "no storage", which is the
//! one lie a disk view must not tell.
//! * [`Floor`] — the space that exists and must never be sold: `max(percent of
//! the total, a minimum)`, capped at the total. [`Floor::DEFAULT`] is 5 % or
//! 2 GiB, whichever is larger (gunnar's `--disk-floor` rule). A product that
//! enforces no floor passes [`Floor::NONE`] and says so.
//! * [`read_block_devices`] — every block device under `/sys/block`: size,
//! rotational, removable, read-only, its partitions, what holds it (an md
//! array, a device-mapper target) and, for an array, its level and members.
//!
//! [`take`] puts them together: the product names the paths it cares about as
//! [`Volume`]s (`system` = `/`, `data` = its data root, a cache directory, a
//! repository's storage), each is resolved onto the mount it sits on, and the
//! [`Snapshot`] lists every storage mount once with the labels that sit on it.
//! A path that cannot be resolved is listed in [`Snapshot::unresolved`] with the
//! reason, never dropped.
//!
//! Nothing here names a product, a port or a role: holger's admin view is the
//! first reader, gunnar's is expected to be the second.
pub mod speed;
use std::path::{Component, Path, PathBuf};
/// Where the kernel lists this process's mounts.
pub const PROC_MOUNTS: &str = "/proc/self/mounts";
/// Where the kernel lists block devices.
pub const SYS_BLOCK: &str = "/sys/block";
/// Filesystem types that are kernel interfaces, not storage. `tmpfs` and
/// `ramfs` are NOT here: they hold bytes (a cache, `/run`), and a RAM-backed
/// volume filling up is exactly what an operator must see.
pub const PSEUDO_FS: &[&str] = &[
"proc",
"sysfs",
"devtmpfs",
"devpts",
"securityfs",
"cgroup",
"cgroup2",
"pstore",
"bpf",
"debugfs",
"tracefs",
"configfs",
"fusectl",
"mqueue",
"hugetlbfs",
"autofs",
"binfmt_misc",
"efivarfs",
"nsfs",
"rpc_pipefs",
"selinuxfs",
];
/// True for a filesystem type that holds no storage of its own.
pub fn is_pseudo_fs(fstype: &str) -> bool {
PSEUDO_FS.contains(&fstype)
}
/// One line of the mount table.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Mount {
/// `/dev/md127`, `/dev/vda2`, `tmpfs`, `overlay`, …
pub source: String,
/// Where it is mounted.
pub mount_point: PathBuf,
/// `xfs`, `ext4`, `vfat`, `tmpfs`, …
pub fstype: String,
/// Mounted read-only (`ro` among the options).
pub read_only: bool,
}
/// **Every line of a `/proc/mounts`-shaped table**, pure. A later mount at the
/// same mount point shadows the earlier one (only the later is kept, in its
/// place in the order). Octal escapes (`\040` for a space) are decoded. A line
/// with fewer than three fields is skipped.
pub fn parse_mounts(text: &str) -> Vec<Mount> {
let mut out: Vec<Mount> = Vec::new();
for line in text.lines() {
let mut f = line.split_ascii_whitespace();
let (Some(source), Some(target), Some(fstype)) = (f.next(), f.next(), f.next()) else {
continue;
};
let options = f.next().unwrap_or("");
let mount = Mount {
source: unescape(source),
mount_point: PathBuf::from(unescape(target)),
fstype: fstype.to_owned(),
read_only: options.split(',').any(|o| o == "ro"),
};
out.retain(|m| m.mount_point != mount.mount_point);
out.push(mount);
}
out
}
/// Read and parse a mount table (normally [`PROC_MOUNTS`]).
pub fn read_mounts(path: &Path) -> Result<Vec<Mount>, String> {
std::fs::read_to_string(path)
.map(|t| parse_mounts(&t))
.map_err(|e| format!("{}: {e} — the mount table could not be read", path.display()))
}
fn unescape(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'\\' && i + 3 < b.len() && b[i + 1..i + 4].iter().all(|c| (b'0'..=b'7').contains(c)) {
out.push((b[i + 1] - b'0') * 64 + (b[i + 2] - b'0') * 8 + (b[i + 3] - b'0'));
i += 4;
} else {
out.push(b[i]);
i += 1;
}
}
String::from_utf8_lossy(&out).into_owned()
}
/// **The mount `path` sits on**: the one whose mount point is the longest
/// component-wise prefix of `path`. `path` must be absolute and should be
/// canonical (symlinks resolved) — [`take`] canonicalizes before it asks.
/// `None` when nothing covers it (no `/` in the table).
pub fn mount_of<'a>(mounts: &'a [Mount], path: &Path) -> Option<&'a Mount> {
mounts
.iter()
.filter(|m| path.starts_with(&m.mount_point))
.max_by_key(|m| m.mount_point.components().filter(|c| !matches!(c, Component::RootDir)).count())
}
/// What one `statvfs(3)` says, in bytes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Figures {
/// `f_blocks × f_frsize` — the filesystem's size.
pub total_bytes: u64,
/// `f_bfree × f_frsize` — free, the root-reserved blocks included.
pub free_bytes: u64,
/// `f_bavail × f_frsize` — what an UNPRIVILEGED writer may still put there.
pub available_bytes: u64,
/// `(f_blocks − f_bfree) × f_frsize`.
pub used_bytes: u64,
/// `(f_bfree − f_bavail) × f_frsize` — the blocks only root may write.
pub reserved_bytes: u64,
/// `f_files`, `f_ffree` — 0 on a filesystem that has no fixed inode table.
pub inodes_total: u64,
pub inodes_free: u64,
/// `f_frsize`, the unit every block count is in.
pub block_size: u64,
}
impl Figures {
/// **The arithmetic, pure.** A fragment size of 0 cannot be turned into
/// bytes and is refused by name rather than multiplied into "no storage".
pub fn from_counts(blocks: u64, bfree: u64, bavail: u64, frsize: u64, files: u64, ffree: u64) -> Result<Figures, String> {
if frsize == 0 {
return Err("the filesystem reported a fragment size of 0, so its block counts cannot be turned into bytes".into());
}
Ok(Figures {
total_bytes: blocks.saturating_mul(frsize),
free_bytes: bfree.saturating_mul(frsize),
available_bytes: bavail.saturating_mul(frsize),
used_bytes: blocks.saturating_sub(bfree).saturating_mul(frsize),
reserved_bytes: bfree.saturating_sub(bavail).saturating_mul(frsize),
inodes_total: files,
inodes_free: ffree,
block_size: frsize,
})
}
/// Used as a share of the total, in per mille (0 on an empty filesystem).
pub fn used_permille(&self) -> u32 {
if self.total_bytes == 0 {
return 0;
}
((u128::from(self.used_bytes) * 1000) / u128::from(self.total_bytes)) as u32
}
}
/// **One `statvfs(3)` over `path`**, or the reason it could not be taken (the
/// path and the errno).
pub fn statvfs(path: &Path) -> Result<Figures, String> {
let f = rustix::fs::statvfs(path).map_err(|e| format!("statvfs({}) failed: {}", path.display(), std::io::Error::from(e)))?;
Figures::from_counts(f.f_blocks, f.f_bfree, f.f_bavail, f.f_frsize, f.f_files, f.f_ffree).map_err(|why| format!("statvfs({}): {why}", path.display()))
}
/// **The disk floor**: space that exists and must never be sold — what lets a
/// box compact, rewrite its control files and log after it stops accepting
/// writes. `max(percent × total, min_bytes)`, never more than the total.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Floor {
pub percent: u8,
pub min_bytes: u64,
}
impl Floor {
/// 5 % or 2 GiB, whichever is larger.
pub const DEFAULT: Floor = Floor { percent: 5, min_bytes: 2 << 30 };
/// No floor: a product that enforces none says so with this.
pub const NONE: Floor = Floor { percent: 0, min_bytes: 0 };
/// The floor on a filesystem of `total` bytes.
pub fn bytes(&self, total: u64) -> u64 {
let pct = (u128::from(total) * u128::from(self.percent.min(100)) / 100) as u64;
pct.max(self.min_bytes).min(total)
}
}
/// A path a product cares about, and what it calls it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Volume {
/// `system`, `data`, `cache`, `storage:<repo>`, … — the product's word.
pub label: String,
pub path: PathBuf,
}
impl Volume {
pub fn new(label: impl Into<String>, path: impl Into<PathBuf>) -> Volume {
Volume { label: label.into(), path: path.into() }
}
}
/// One storage mount, measured.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiskEntry {
pub mount: Mount,
/// The product's labels that sit on this mount (empty: nothing named it).
pub labels: Vec<String>,
/// The paths behind those labels, as given.
pub paths: Vec<PathBuf>,
/// The measurement, or why there is none.
pub figures: Result<Figures, String>,
/// [`Floor::bytes`] of the total (0 when unmeasured).
pub floor_bytes: u64,
/// `available − floor`: what may actually be handed out (0 when unmeasured).
pub servable_bytes: u64,
}
/// A labelled path that could not be put on a mount.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unresolved {
pub label: String,
pub path: PathBuf,
pub why: String,
}
/// Everything, at once.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Snapshot {
/// Every storage mount, in mount-table order, plus any pseudo mount a
/// labelled path sits on.
pub disks: Vec<DiskEntry>,
pub unresolved: Vec<Unresolved>,
pub block_devices: Vec<BlockDevice>,
/// Why the mount table or sysfs could not be read, if either could not.
pub problems: Vec<String>,
}
/// **Take a snapshot of this machine**: [`PROC_MOUNTS`], [`SYS_BLOCK`].
pub fn take(volumes: &[Volume], floor: Floor) -> Snapshot {
take_at(Path::new(PROC_MOUNTS), Path::new(SYS_BLOCK), volumes, floor)
}
/// [`take`] over a given mount table and sysfs block directory — the seam a
/// test feeds a recorded table and tree through. Every mount is measured with
/// the real [`statvfs`].
pub fn take_at(mounts_file: &Path, sys_block: &Path, volumes: &[Volume], floor: Floor) -> Snapshot {
let mut snap = Snapshot::default();
let mounts = match read_mounts(mounts_file) {
Ok(m) => m,
Err(why) => {
snap.problems.push(why);
Vec::new()
}
};
// Which mount each labelled path sits on.
let mut placed: Vec<(usize, &Volume)> = Vec::new();
for v in volumes {
let canonical = match std::fs::canonicalize(&v.path) {
Ok(p) => p,
Err(e) => {
snap.unresolved.push(Unresolved { label: v.label.clone(), path: v.path.clone(), why: format!("{}: {e}", v.path.display()) });
continue;
}
};
match mount_of(&mounts, &canonical) {
Some(m) => {
let i = mounts.iter().position(|x| x == m).unwrap_or_default();
placed.push((i, v));
}
None => snap.unresolved.push(Unresolved {
label: v.label.clone(),
path: v.path.clone(),
why: format!("no mount in the table covers {}", canonical.display()),
}),
}
}
for (i, m) in mounts.iter().enumerate() {
let on_it: Vec<&Volume> = placed.iter().filter(|(j, _)| *j == i).map(|(_, v)| *v).collect();
if is_pseudo_fs(&m.fstype) && on_it.is_empty() {
continue;
}
let figures = statvfs(&m.mount_point);
let (floor_bytes, servable_bytes) = match &figures {
Ok(f) => {
let fl = floor.bytes(f.total_bytes);
(fl, f.available_bytes.saturating_sub(fl))
}
Err(_) => (0, 0),
};
snap.disks.push(DiskEntry {
mount: m.clone(),
labels: on_it.iter().map(|v| v.label.clone()).collect(),
paths: on_it.iter().map(|v| v.path.clone()).collect(),
figures,
floor_bytes,
servable_bytes,
});
}
match read_block_devices(sys_block) {
Ok(b) => snap.block_devices = b,
Err(why) => snap.problems.push(why),
}
snap
}
/// One block device, as sysfs describes it.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct BlockDevice {
/// `vda`, `nvme0n1`, `md127`, `dm-0`, …
pub name: String,
/// `size` (512-byte sectors) × 512.
pub size_bytes: u64,
/// `queue/rotational` = 1.
pub rotational: bool,
/// `removable` = 1.
pub removable: bool,
/// `ro` = 1.
pub read_only: bool,
/// Its partitions: `(name, bytes)`, in name order.
pub partitions: Vec<(String, u64)>,
/// What holds this device or one of its partitions (`md127`, `dm-0`), in
/// name order, without repeats.
pub holders: Vec<String>,
/// What this device is built from (`slaves/`: an array's members), in name order.
pub members: Vec<String>,
/// `md/level` for an md array (`raid0`, `raid1`, …); empty otherwise.
pub raid_level: String,
}
/// **Every block device under `sys_block`** (normally [`SYS_BLOCK`]), in name
/// order. A zero-sized loop or ram device (unused) is left out; any other
/// device is listed even when a field cannot be read (the field reads as its
/// default). `Err` only when the directory itself cannot be listed.
pub fn read_block_devices(sys_block: &Path) -> Result<Vec<BlockDevice>, String> {
let entries = std::fs::read_dir(sys_block).map_err(|e| format!("{}: {e} — the block devices could not be listed", sys_block.display()))?;
let mut out = Vec::new();
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
let dir = sys_block.join(&name);
let size_bytes = read_u64(&dir.join("size")).unwrap_or(0).saturating_mul(512);
if size_bytes == 0 && (name.starts_with("loop") || name.starts_with("ram")) {
continue;
}
let mut partitions = Vec::new();
let mut holders = names_in(&dir.join("holders"));
if let Ok(sub) = std::fs::read_dir(&dir) {
for s in sub.flatten() {
let p = s.path();
if p.join("partition").is_file() {
let pname = s.file_name().to_string_lossy().into_owned();
partitions.push((pname, read_u64(&p.join("size")).unwrap_or(0).saturating_mul(512)));
holders.extend(names_in(&p.join("holders")));
}
}
}
partitions.sort();
holders.sort();
holders.dedup();
out.push(BlockDevice {
size_bytes,
rotational: read_u64(&dir.join("queue/rotational")) == Some(1),
removable: read_u64(&dir.join("removable")) == Some(1),
read_only: read_u64(&dir.join("ro")) == Some(1),
partitions,
holders,
members: names_in(&dir.join("slaves")),
raid_level: std::fs::read_to_string(dir.join("md/level")).map(|s| s.trim().to_owned()).unwrap_or_default(),
name,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
fn read_u64(p: &Path) -> Option<u64> {
std::fs::read_to_string(p).ok()?.trim().parse().ok()
}
fn names_in(dir: &Path) -> Vec<String> {
let mut v: Vec<String> = std::fs::read_dir(dir).map(|r| r.flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect()).unwrap_or_default();
v.sort();
v
}
#[cfg(test)]
mod tests {
use super::*;
const TABLE: &str = "\
/dev/vda2 / ext4 rw,relatime 0 0
proc /proc proc rw,nosuid 0 0
sysfs /sys sysfs rw 0 0
tmpfs /run tmpfs rw,nosuid 0 0
/dev/vda1 /boot vfat ro,relatime 0 0
/dev/md127 /data xfs rw,noatime 0 0
/dev/vdb1 /mnt/with\\040space ext4 rw 0 0
/dev/md126 /data xfs rw,noatime 0 0
";
#[test]
fn every_line_is_read_escapes_decoded_and_a_later_mount_shadows() {
let m = parse_mounts(TABLE);
let points: Vec<_> = m.iter().map(|x| x.mount_point.display().to_string()).collect();
assert_eq!(points, ["/", "/proc", "/sys", "/run", "/boot", "/mnt/with space", "/data"]);
let data = m.iter().find(|x| x.mount_point == Path::new("/data")).unwrap();
assert_eq!(data.source, "/dev/md126", "the later mount at /data shadows the earlier");
assert!(m.iter().find(|x| x.mount_point == Path::new("/boot")).unwrap().read_only);
assert!(!data.read_only);
}
#[test]
fn a_path_sits_on_its_longest_mount_prefix_component_wise() {
let m = parse_mounts(TABLE);
assert_eq!(mount_of(&m, Path::new("/data/holger/repo")).unwrap().source, "/dev/md126");
assert_eq!(mount_of(&m, Path::new("/data")).unwrap().source, "/dev/md126");
assert_eq!(mount_of(&m, Path::new("/database")).unwrap().source, "/dev/vda2", "a string prefix is not a path prefix");
assert_eq!(mount_of(&m, Path::new("/")).unwrap().source, "/dev/vda2");
assert!(mount_of(&parse_mounts("tmpfs /run tmpfs rw 0 0\n"), Path::new("/etc")).is_none());
}
#[test]
fn pseudo_filesystems_are_named_and_ram_backed_storage_is_not_one() {
assert!(is_pseudo_fs("proc") && is_pseudo_fs("cgroup2") && is_pseudo_fs("sysfs"));
assert!(!is_pseudo_fs("tmpfs") && !is_pseudo_fs("xfs") && !is_pseudo_fs("ramfs"));
}
#[test]
fn the_arithmetic_and_a_zero_fragment_refused_by_name() {
let f = Figures::from_counts(1000, 400, 350, 4096, 64, 10).unwrap();
assert_eq!(f.total_bytes, 4_096_000);
assert_eq!(f.free_bytes, 1_638_400);
assert_eq!(f.available_bytes, 1_433_600);
assert_eq!(f.used_bytes, 2_457_600);
assert_eq!(f.reserved_bytes, 204_800);
assert_eq!(f.used_permille(), 600);
assert!(Figures::from_counts(1000, 400, 350, 0, 0, 0).unwrap_err().contains("fragment size of 0"));
}
#[test]
fn the_floor_is_the_larger_of_the_share_and_the_minimum_and_never_the_disk() {
let gib = 1u64 << 30;
assert_eq!(Floor::DEFAULT.bytes(100 * gib), 5 * gib, "5 % of 100 GiB");
assert_eq!(Floor::DEFAULT.bytes(10 * gib), 2 * gib, "the 2 GiB minimum");
assert_eq!(Floor::DEFAULT.bytes(gib), gib, "never more than the disk");
assert_eq!(Floor::NONE.bytes(100 * gib), 0);
}
}