Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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);
    }
}