whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
//! Fluent builder for constructing synthetic [`SystemState`] in tests.
//!
//! Gated behind `test-helpers` feature for cross-crate test usage:
//! ```toml
//! [dev-dependencies]
//! whyno-core = { path = "../whyno-core", features = ["test-helpers"] }
//! ```

mod internal;
mod mac;

use std::path::PathBuf;

use crate::operation::Operation;
use crate::state::acl::PosixAcl;
use crate::state::fsflags::FsFlags;
use crate::state::mac::MacState;
use crate::state::mount::{MountEntry, MountTable};
use crate::state::path::{FileType, PathComponent};
use crate::state::subject::ResolvedSubject;
use crate::state::{Probe, SystemState};

use internal::{link_mounts, make_component, parse_mount_options};

/// Fluent builder for constructing [`SystemState`] in tests.
///
/// Defaults to root (uid=0, gid=0) performing `Read`. Call `.build()`
/// to finalize; auto-links mounts to path components via
/// longest-prefix matching.
#[derive(Debug)]
pub struct StateBuilder {
    subject: ResolvedSubject,
    operation: Operation,
    components: Vec<PathComponent>,
    mounts: Vec<MountEntry>,
    next_mount_id: u32,
    next_device: u64,
    mac_state: MacState,
}

impl Default for StateBuilder {
    fn default() -> Self {
        Self::new()
    }
}

impl StateBuilder {
    /// Creates new builder with root subject and `Read` operation.
    #[must_use]
    pub fn new() -> Self {
        Self {
            subject: ResolvedSubject {
                uid: 0,
                gid: 0,
                groups: vec![],
                capabilities: Probe::Unknown,
            },
            operation: Operation::Read,
            components: Vec::new(),
            mounts: Vec::new(),
            next_mount_id: 1,
            next_device: 1,
            mac_state: MacState::default(),
        }
    }

    /// Sets subject identity (uid, gid, supplementary groups).
    ///
    /// Capabilities default to `Probe::Unknown`; use `.with_capabilities()` to set.
    #[must_use]
    pub fn subject(mut self, uid: u32, gid: u32, groups: Vec<u32>) -> Self {
        self.subject = ResolvedSubject {
            uid,
            gid,
            groups,
            capabilities: Probe::Unknown,
        };
        self
    }

    /// Sets a known capability bitmask on the subject.
    #[must_use]
    pub fn with_capabilities(mut self, bitmask: u64) -> Self {
        self.subject.capabilities = Probe::Known(bitmask);
        self
    }

    /// Sets operation being checked.
    #[must_use]
    pub fn operation(mut self, op: Operation) -> Self {
        self.operation = op;
        self
    }

    /// Appends directory path component with known stat data.
    #[must_use]
    pub fn component(mut self, path: &str, uid: u32, gid: u32, mode: u32) -> Self {
        self.components
            .push(make_component(path, uid, gid, mode, FileType::Directory, 2));
        self
    }

    /// Appends regular file path component with known stat data.
    #[must_use]
    pub fn component_file(mut self, path: &str, uid: u32, gid: u32, mode: u32) -> Self {
        self.components
            .push(make_component(path, uid, gid, mode, FileType::Regular, 1));
        self
    }

    /// Appends directory component with POSIX ACL entries.
    #[must_use]
    pub fn component_with_acl(
        mut self,
        path: &str,
        uid: u32,
        gid: u32,
        mode: u32,
        acl_entries: Vec<crate::state::acl::AclEntry>,
    ) -> Self {
        let mut comp = make_component(path, uid, gid, mode, FileType::Directory, 2);
        comp.acl = Probe::Known(PosixAcl(acl_entries));
        self.components.push(comp);
        self
    }

    /// Appends regular file component with POSIX ACL entries.
    #[must_use]
    pub fn component_file_with_acl(
        mut self,
        path: &str,
        uid: u32,
        gid: u32,
        mode: u32,
        acl_entries: Vec<crate::state::acl::AclEntry>,
    ) -> Self {
        let mut comp = make_component(path, uid, gid, mode, FileType::Regular, 1);
        comp.acl = Probe::Known(PosixAcl(acl_entries));
        self.components.push(comp);
        self
    }

    /// Appends directory component with filesystem flags.
    #[must_use]
    pub fn component_with_flags(
        mut self,
        path: &str,
        uid: u32,
        gid: u32,
        mode: u32,
        flags: FsFlags,
    ) -> Self {
        let mut comp = make_component(path, uid, gid, mode, FileType::Directory, 2);
        comp.flags = Probe::Known(flags);
        self.components.push(comp);
        self
    }

    /// Appends regular file component with filesystem flags.
    #[must_use]
    pub fn component_file_with_flags(
        mut self,
        path: &str,
        uid: u32,
        gid: u32,
        mode: u32,
        flags: FsFlags,
    ) -> Self {
        let mut comp = make_component(path, uid, gid, mode, FileType::Regular, 1);
        comp.flags = Probe::Known(flags);
        self.components.push(comp);
        self
    }

    /// Appends component where all probes returned `Inaccessible`.
    #[must_use]
    pub fn component_inaccessible(mut self, path: &str) -> Self {
        self.components.push(PathComponent {
            path: PathBuf::from(path),
            stat: Probe::Inaccessible,
            acl: Probe::Inaccessible,
            flags: Probe::Inaccessible,
            mount: None,
        });
        self
    }

    /// Appends component where all probes returned `Unknown`.
    #[must_use]
    pub fn component_unknown(mut self, path: &str) -> Self {
        self.components.push(PathComponent {
            path: PathBuf::from(path),
            stat: Probe::Unknown,
            acl: Probe::Unknown,
            flags: Probe::Unknown,
            mount: None,
        });
        self
    }

    /// Appends mount entry with given mountpoint, fs type, and options.
    ///
    /// Options are comma-separated. Recognized: `ro`, `noexec`, `nosuid`.
    /// Auto-assigns sequential mount IDs and device IDs.
    #[must_use]
    pub fn mount(mut self, mountpoint: &str, fs_type: &str, options_str: &str) -> Self {
        let options = parse_mount_options(options_str);
        let device = self.next_device;
        self.mounts.push(MountEntry {
            mount_id: self.next_mount_id,
            device,
            mountpoint: PathBuf::from(mountpoint),
            fs_type: fs_type.to_owned(),
            options,
        });
        self.next_mount_id += 1;
        self.next_device += 1;
        self
    }

    /// Builds final [`SystemState`], linking mounts to components.
    ///
    /// # Panics
    ///
    /// Panics if no components have been added (test setup error).
    #[must_use]
    pub fn build(mut self) -> SystemState {
        assert!(
            !self.components.is_empty(),
            "StateBuilder: at least one path component is required"
        );
        link_mounts(&mut self.components, &self.mounts);
        SystemState {
            subject: self.subject,
            walk: self.components,
            mounts: MountTable(self.mounts),
            operation: self.operation,
            mac_state: self.mac_state,
        }
    }
}

#[cfg(test)]
mod tests;