whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
//! Check pipeline framework and layer orchestration.
//!
//! Each check layer is a pure function: `fn check_*(state: &SystemState) -> LayerResult`.
//! Pipeline runs all layers in order, collecting results into a [`CheckReport`].

mod acl;
#[cfg(feature = "apparmor")]
mod apparmor;
pub mod caps;
mod caps_modify;
mod dac;
mod fsflags;
mod metadata;
mod mount;
#[cfg(feature = "selinux")]
mod selinux;
mod traversal;

use enum_map::{Enum, EnumMap};
use serde::Serialize;

use crate::state::SystemState;

pub use crate::operation::MetadataParams;
pub use acl::check_acl;
pub use caps::{
    has_cap, CAP_CHOWN, CAP_DAC_OVERRIDE, CAP_DAC_READ_SEARCH, CAP_FOWNER, CAP_FSETID,
    CAP_LINUX_IMMUTABLE, CAP_SYS_ADMIN,
};
pub use caps_modify::capability_modify;
pub use dac::check_dac;
pub use fsflags::check_fs_flags;
pub use metadata::check_metadata;
pub use mount::check_mount;
#[cfg(feature = "selinux")]
pub use selinux::operation_to_selinux_perm;
pub use traversal::check_traversal;

/// Identifies which DAC/mount/flag check layer produced a result.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Enum, Serialize)]
#[non_exhaustive]
pub enum CoreLayer {
    /// Filesystem mount options (ro, noexec, nosuid).
    Mount,
    /// Inode flags from `ioctl(FS_IOC_GETFLAGS)` (immutable, append-only).
    FsFlags,
    /// Execute permission on every ancestor directory.
    Traversal,
    /// Discretionary access control (uid/gid/mode bits).
    Dac,
    /// POSIX ACL entries and mask interaction.
    Acl,
    /// Metadata-change ownership and capability check (chmod/chown/setxattr).
    Metadata,
}

/// Identifies which MAC check layer produced a result.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[non_exhaustive]
pub enum MacLayer {
    /// `SELinux` mandatory access control.
    SeLinux,
    /// `AppArmor` mandatory access control.
    AppArmor,
}

/// Result from a single check layer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "result")]
#[non_exhaustive]
pub enum LayerResult {
    /// Layer allows the operation.
    Pass {
        /// Human-readable explanation of why this layer passed.
        detail: String,
        /// Advisory warnings that do not block the operation.
        ///
        /// Empty vec means no warnings. Used for conditions like nosuid
        /// stripping setuid/setgid bits on exec.
        warnings: Vec<String>,
    },
    /// Layer blocks the operation.
    Fail {
        /// Human-readable explanation of blocking condition.
        detail: String,
        /// Index of path component that caused failure, if applicable.
        component_index: Option<usize>,
    },
    /// Layer could not be evaluated (missing or inaccessible state).
    Degraded {
        /// Explanation of why this layer could not be checked.
        reason: String,
    },
}

impl LayerResult {
    /// True if result is `Fail`.
    #[must_use]
    pub fn is_fail(&self) -> bool {
        matches!(self, Self::Fail { .. })
    }

    /// True if result is `Pass`.
    #[must_use]
    pub fn is_pass(&self) -> bool {
        matches!(self, Self::Pass { .. })
    }

    /// True if result is `Degraded`.
    #[must_use]
    pub fn is_degraded(&self) -> bool {
        matches!(self, Self::Degraded { .. })
    }
}

/// Aggregated results from all check layers for a single permission query.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CheckReport {
    /// Per-layer results indexed by [`CoreLayer`].
    pub core_results: EnumMap<CoreLayer, LayerResult>,
    /// MAC layer results; always contains `SeLinux` and `AppArmor` entries in that order.
    ///
    /// Entries are `Degraded` with a rebuild hint when the corresponding
    /// feature flag is absent; never empty regardless of feature flags.
    pub mac_results: Vec<(MacLayer, LayerResult)>,
}

impl CheckReport {
    /// True if no core or MAC layer produced a `Fail` result.
    #[must_use]
    pub fn is_allowed(&self) -> bool {
        let core_ok = self.core_results.values().all(|r| !r.is_fail());
        let mac_ok = self.mac_results.iter().all(|(_, r)| !r.is_fail());
        core_ok && mac_ok
    }

    /// Core layers that produced `Fail` results.
    ///
    /// MAC callers should iterate `mac_results` directly.
    #[must_use]
    pub fn failed_layers(&self) -> Vec<CoreLayer> {
        self.core_results
            .iter()
            .filter(|(_, r)| r.is_fail())
            .map(|(l, _)| l)
            .collect()
    }
}

/// Runs all check layers against gathered state.
///
/// Layers run in order: mount, `fs_flags`, traversal, dac, acl, metadata,
/// then MAC layers (`SELinux`, `AppArmor`). `capability_modify()` models
/// `CAP_DAC_OVERRIDE` semantics from `CapEff` bitmask, or uid==0
/// heuristic when capabilities are unknown. MAC entries are `Degraded`
/// with rebuild hints when the corresponding feature flag is absent.
///
/// `params` carries caller-supplied intent for metadata operations (`Chmod`,
/// `ChownUid`, `ChownGid`, `SetXattr`). Only the metadata layer reads it;
/// all other layers ignore it. Pass `MetadataParams::default()` for
/// non-metadata operations.
#[must_use]
pub fn run_checks(state: &SystemState, params: &MetadataParams) -> CheckReport {
    let core_results = EnumMap::from_array([
        check_mount(state),
        check_fs_flags(state),
        check_traversal(state),
        run_dac_with_caps(state),
        check_acl(state),
        check_metadata(state, params),
    ]);

    let mac_results = build_mac_results(state);

    CheckReport {
        core_results,
        mac_results,
    }
}

/// Collects results from all MAC check layers.
///
/// Always produces exactly two entries: `SeLinux` then `AppArmor`.
/// Each entry is either a live check (feature-gated) or a `Degraded`
/// stub with a rebuild hint when the feature is absent.
// vec_init_then_push: cfg-gated pushes cannot be expressed with vec![]
#[allow(clippy::vec_init_then_push)]
fn build_mac_results(state: &SystemState) -> Vec<(MacLayer, LayerResult)> {
    let mut results: Vec<(MacLayer, LayerResult)> = Vec::with_capacity(2);

    #[cfg(feature = "selinux")]
    results.push((MacLayer::SeLinux, selinux::check_selinux(state)));

    #[cfg(not(feature = "selinux"))]
    results.push((MacLayer::SeLinux, selinux_stub(state)));

    #[cfg(feature = "apparmor")]
    results.push((MacLayer::AppArmor, apparmor::check_apparmor(state)));

    #[cfg(not(feature = "apparmor"))]
    results.push((MacLayer::AppArmor, apparmor_stub(state)));

    results
}

/// `SELinux` result when the selinux feature is not compiled in.
///
/// Reads `mac_state.selinux` probe — if gathered, reports accordingly;
/// if not gathered, reports that state is unavailable.
#[cfg(not(feature = "selinux"))]
fn selinux_stub(state: &SystemState) -> LayerResult {
    use crate::state::Probe;
    match &state.mac_state.selinux {
        Probe::Unknown => LayerResult::Degraded {
            reason: "SELinux state not gathered".into(),
        },
        Probe::Inaccessible => LayerResult::Degraded {
            reason: "SELinux state inaccessible".into(),
        },
        Probe::Known(_) => LayerResult::Degraded {
            reason: "SELinux — not compiled in (rebuild with --features selinux)".into(),
        },
    }
}

/// `AppArmor` result when the apparmor feature is not compiled in.
///
/// Reads `mac_state.apparmor` probe — if gathered, reports accordingly;
/// if not gathered, reports that state is unavailable.
#[cfg(not(feature = "apparmor"))]
fn apparmor_stub(state: &SystemState) -> LayerResult {
    use crate::state::Probe;
    match &state.mac_state.apparmor {
        Probe::Unknown => LayerResult::Degraded {
            reason: "AppArmor state not gathered".into(),
        },
        Probe::Inaccessible => LayerResult::Degraded {
            reason: "AppArmor state inaccessible".into(),
        },
        Probe::Known(_) => LayerResult::Degraded {
            reason: "AppArmor — not compiled in (rebuild with --features apparmor)".into(),
        },
    }
}

/// Runs DAC check then applies capability override via `capability_modify`.
fn run_dac_with_caps(state: &SystemState) -> LayerResult {
    let result = check_dac(state);
    capability_modify(result, state)
}

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