crates/veilvoice-setup/src/space.rs

what this file is for · veilvoice-setup · 171 lines · the same file on GitHub

The file as it is in the tree, in the colours you chose. A line number is a link, and so is every box in this file’s diagram: it opens here with the function it names marked.


// SPDX-License-Identifier: GPL-3.0-or-later
//! How much room is actually free where VeilVoice keeps things.
//!
//! # Why this is measured rather than assumed
//!
//! Decoy vaults are only worth making if there is somewhere to put them, and
//! how many to make is a question with a real answer on each machine. A number
//! picked here would be a guess dressed as advice: nine decoys is careless on a
//! nearly full laptop and timid on a four-terabyte disk.
//!
//! So this asks the operating system, and where the operating system will not
//! say, it returns [`None`] and the interface says the count is a suggestion
//! rather than a measurement. **Not measuring and not saying so** is the answer
//! this module exists to avoid.
//!
//! # No `unsafe`, and no new dependency
//!
//! The free-space call is `statvfs` on Unix and `GetDiskFreeSpaceEx` on
//! Windows, and reaching either from Rust means an `unsafe` block or a crate.
//! This workspace forbids the first everywhere and weighs the second against a
//! dependency graph people are invited to read, and neither is worth paying for
//! one integer.
//!
//! So it runs the tool the platform already ships. On Unix that is `df` with
//! `-Pk`, whose output format is specified by POSIX rather than left to the
//! implementation, which is the whole reason that flag is there: the columns
//! are fixed, the block size is 1024, and one line describes the filesystem
//! asked about.
//!
//! # In plain words
//!
//! Asks the system how much space is free where VeilVoice is putting files, so
//! it can suggest a sensible number of decoy vaults instead of inventing one.
//! If the system will not say, it says so rather than guessing.

use std::path::Path;
use std::process::Command;


/// Free space at `path`, in bytes, or `None` if the platform would not say.
///
/// `None` is a real answer and callers must treat it as one. It happens on a
/// system with no `df`, in a sandbox that refuses to start processes, and on
/// any platform this has no branch for. Every one of those is "we do not know",
/// which is different from zero and must not be shown as a measurement.
pub fn free_bytes(path: &Path) -> Option<u64> {
    #[cfg(unix)]
    {
        unix_free(path)
    }
    #[cfg(windows)]
    {
        windows_free(path)
    }
    #[cfg(not(any(unix, windows)))]
    {
        let _ = path;
        None
    }
}



/// `df -Pk`, parsed from the format POSIX specifies.
#[cfg(unix)]
fn unix_free(path: &Path) -> Option<u64> {
    // Absolute, and never a bare name. `Command` resolves a bare program
    // through the platform search order, and this project has a rule about
    // that: a file called `df` in the working directory must not become the
    // thing that runs. Both locations are tried because the BSDs put it in
    // `/bin` and some Linux layouts only have `/usr/bin`.
    let program = ["/bin/df", "/usr/bin/df"]
        .into_iter()
        .find(|p| Path::new(p).is_file())?;

    let output = Command::new(program).arg("-Pk").arg(path).output().ok()?;
    if !output.status.success() {
        return None;
    }
    let text = String::from_utf8_lossy(&output.stdout);
    parse_df(&text)
}



/// The available column of a `df -Pk` report.
///
/// Split out so it can be tested against real output from each platform
/// without running anything, which is the only way to check a parser against
/// systems this is not running on.
///
/// POSIX fixes the columns as filesystem, 1024-blocks, used, available,
/// capacity, mount point. The **fourth** field is the one wanted, and it is
/// counted from the left rather than the right because a mount point may
/// contain spaces and a device name may not.
pub fn parse_df(text: &str) -> Option<u64> {
    // The last data line, not the second: `df` reporting on a path may print a
    // header and exactly one row, but some implementations wrap a long device
    // name onto its own line, leaving the numbers on the next.
    for line in text.lines().rev() {
        let fields: Vec<&str> = line.split_whitespace().collect();
        if fields.len() < 6 {
            continue;
        }
        // A header line has no digits where the blocks go.
        let Ok(available) = fields[3].parse::<u64>() else {
            continue;
        };
        // `-k` means 1024-byte blocks. Saturating rather than wrapping: a
        // filesystem reporting an absurd block count should give a huge number
        // rather than a small one, because a small one would suggest there is
        // no room when there is.
        return Some(available.saturating_mul(1024));
    }
    None
}



/// `fsutil volume diskfree`, which is on every Windows since Vista.
#[cfg(windows)]
fn windows_free(path: &Path) -> Option<u64> {
    let root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string());
    let program = [
        format!(r"{root}\System32\fsutil.exe"),
        format!(r"{root}\Sysnative\fsutil.exe"),
    ]
    .into_iter()
    .find(|p| Path::new(p).is_file())?;

    let output = Command::new(program)
        .arg("volume")
        .arg("diskfree")
        .arg(path)
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    parse_fsutil(&String::from_utf8_lossy(&output.stdout))
}



/// The free-bytes figure from `fsutil volume diskfree`.
///
/// # Why this reads the numbers rather than the labels
///
/// `fsutil` translates its output, so an English machine says "Total # of free
/// bytes" and a German one does not. Matching the label would work on the
/// machine it was written on and nowhere else, which is a worse failure than
/// not measuring at all because it looks like it worked.
///
/// The three figures it prints are free bytes, total bytes and available bytes,
/// and free is never larger than total. Taking the **smallest** of the numbers
/// found is therefore the conservative reading in any language: it can suggest
/// fewer decoys than there is room for, and never more.
#[cfg(windows)]
pub fn parse_fsutil(text: &str) -> Option<u64> {
    let mut smallest: Option<u64> = None;
    for line in text.lines() {
        for word in line.split(|c: char| !c.is_ascii_digit()) {
            if word.is_empty() {
                continue;
            }
            if let Ok(n) = word.parse::<u64>() {
                // Below a megabyte it is a version number or a column width
                // rather than a disk figure.
                if n >= 1024 * 1024 {
                    smallest = Some(smallest.map_or(n, |s: u64| s.min(n)));
                }
            }
        }
    }
    smallest
}


#[cfg(test)]
#[path = "space/tests.rs"]
mod tests;