whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
//! Mount table types for filesystem-level permission checks.
//!
//! Metadata parsed from `/proc/self/mountinfo`, mount flags (`read_only`,
//! `noexec`, `nosuid`) populated from `statvfs()` by the gather layer.
//! Mount options apply per-filesystem and can block operations regardless
//! of file-level DAC permissions.

use std::path::PathBuf;

use serde::Serialize;

/// Single mount entry. Metadata from `/proc/self/mountinfo`,
/// flags from `statvfs()`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MountEntry {
    /// Kernel-assigned mount ID.
    pub mount_id: u32,
    /// Device number (`st_dev` from stat).
    pub device: u64,
    /// Absolute path where this filesystem is mounted.
    pub mountpoint: PathBuf,
    /// Filesystem type (e.g., "ext4", "tmpfs", "xfs").
    pub fs_type: String,
    /// Parsed mount options relevant to permission checks.
    pub options: MountOptions,
}

/// Mount options that affect permission checks.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MountOptions {
    /// Filesystem mounted read-only -- blocks all write operations.
    pub read_only: bool,
    /// `noexec` -- blocks execution of binaries on this filesystem.
    pub noexec: bool,
    /// `nosuid` -- disables setuid/setgid bits on this filesystem.
    pub nosuid: bool,
}

/// Collection of mount entries.
///
/// Metadata from `/proc/self/mountinfo`, flags from `statvfs()`.
/// Lookup by device number returns entry with longest mountpoint
/// for correct bind-mount resolution.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MountTable(pub Vec<MountEntry>);

impl MountTable {
    /// Finds mount entry for given device number.
    ///
    /// When multiple entries share a device (e.g., bind mounts),
    /// returns the one with longest mountpoint path, matching
    /// kernel behavior of using most specific mount.
    #[must_use]
    pub fn find_by_device(&self, dev: u64) -> Option<&MountEntry> {
        self.0
            .iter()
            .filter(|entry| entry.device == dev)
            .max_by_key(|entry| entry.mountpoint.as_os_str().len())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_entry(mount_id: u32, device: u64, mountpoint: &str) -> MountEntry {
        MountEntry {
            mount_id,
            device,
            mountpoint: PathBuf::from(mountpoint),
            fs_type: String::from("ext4"),
            options: MountOptions {
                read_only: false,
                noexec: false,
                nosuid: false,
            },
        }
    }

    #[test]
    fn find_by_device_single_match() {
        let table = MountTable(vec![make_entry(1, 100, "/"), make_entry(2, 200, "/var")]);
        let found = table.find_by_device(200);
        assert_eq!(found.map(|e| e.mount_id), Some(2));
    }

    #[test]
    fn find_by_device_longest_mountpoint_wins() {
        let table = MountTable(vec![
            make_entry(1, 100, "/"),
            make_entry(2, 100, "/var"),
            make_entry(3, 100, "/var/log"),
        ]);
        let found = table.find_by_device(100);
        assert_eq!(found.map(|e| e.mount_id), Some(3));
    }

    #[test]
    fn find_by_device_no_match_returns_none() {
        let table = MountTable(vec![make_entry(1, 100, "/")]);
        assert!(table.find_by_device(999).is_none());
    }

    #[test]
    fn find_by_device_empty_table_returns_none() {
        let table = MountTable(vec![]);
        assert!(table.find_by_device(100).is_none());
    }
}