whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
//! Operation types for permission queries.
//!
//! Each operation maps to a syscall class and determines which
//! permission bits are checked and whether the check targets
//! the file or its parent directory.

use serde::Serialize;

/// Namespace for `setxattr(2)` extended attribute operations.
///
/// Determines the security model for the attribute write.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub enum XattrNamespace {
    /// `user.*` attributes — owner or `CAP_FOWNER`.
    User,
    /// `trusted.*` attributes — requires `CAP_SYS_ADMIN`.
    Trusted,
    /// `security.*` attributes — requires `CAP_SYS_ADMIN`.
    Security,
    /// `system.posix_acl_access` — owner or `CAP_FOWNER`.
    SystemPosixAcl,
}

/// Operation being attempted on the target path.
///
/// Determines which permission bits to check and whether the check
/// redirects to the parent directory (for `Delete` and `Create`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub enum Operation {
    /// Read file contents or list directory entries. Requires `r`.
    Read,
    /// Write file contents, truncate, or modify. Requires `w`.
    Write,
    /// Execute a binary or traverse a directory. Requires `x`.
    Execute,
    /// Remove a file or directory. Checks `w+x` on parent.
    Delete,
    /// Create a new file or directory. Checks `w+x` on parent.
    Create,
    /// Stat/metadata read. Requires only path traversal (`+x` on ancestors).
    Stat,
    /// Change file mode bits (`chmod`). Gated by ownership or `CAP_FOWNER`.
    Chmod,
    /// Change file owner UID (`chown`). Requires `CAP_CHOWN`.
    ChownUid,
    /// Change file owner GID (`chown`). Owner-in-group or `CAP_CHOWN`.
    ChownGid,
    /// Set extended attribute (`setxattr`). Gated by namespace.
    SetXattr {
        /// Xattr namespace determines the capability required.
        namespace: XattrNamespace,
    },
}

impl Operation {
    /// Returns `true` if this operation uses the metadata check path.
    ///
    /// Metadata ops bypass DAC mode-bit checks; they evaluate ownership
    /// and capability rules directly. New variants MUST be added here —
    /// no wildcard arm permitted (`clippy::wildcard_enum_match_arm` is denied).
    #[must_use]
    pub fn is_metadata(&self) -> bool {
        matches!(
            self,
            Self::Chmod | Self::ChownUid | Self::ChownGid | Self::SetXattr { .. }
        )
    }

    /// Returns `true` if this operation checks the parent directory
    /// rather than the target itself.
    ///
    /// `Delete` and `Create` require `w+x` on parent, not target.
    #[must_use]
    pub fn checks_parent(&self) -> bool {
        matches!(self, Self::Delete | Self::Create)
    }

    /// Index into path walk of the component to check.
    ///
    /// For most operations, last component (target). for `Delete` and
    /// `Create`, second-to-last (parent directory). Returns `None` if
    /// walk is empty or too short for a parent-directed operation.
    #[must_use]
    pub fn target_component(&self, walk_len: usize) -> Option<usize> {
        if walk_len == 0 {
            return None;
        }
        if self.checks_parent() {
            walk_len.checked_sub(2)
        } else {
            Some(walk_len - 1)
        }
    }
}

/// Caller-supplied intent for metadata-change operations.
///
/// Separate from `SystemState` (OS-gathered state). Carries values the
/// caller wants to apply, not current file state. Missing required fields
/// cause the check layer to return `Degraded` rather than failing at gather time.
#[derive(Debug, Default, Clone)]
pub struct MetadataParams {
    /// Target mode bits for a `Chmod` operation. `None` causes the metadata
    /// check to return `Degraded` rather than a hard `Fail`.
    pub new_mode: Option<u32>,
    /// Target owner UID for a `ChownUid` operation. `None` causes the metadata
    /// check to return `Degraded` rather than a hard `Fail`.
    pub new_uid: Option<u32>,
    /// Target owner GID for a `ChownGid` operation. `None` causes the metadata
    /// check to return `Degraded` rather than a hard `Fail`.
    pub new_gid: Option<u32>,
}

#[cfg(test)]
#[path = "operation_metadata_tests.rs"]
mod metadata_tests;

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

    #[test]
    fn checks_parent_true_for_delete() {
        assert!(Operation::Delete.checks_parent());
    }

    #[test]
    fn checks_parent_true_for_create() {
        assert!(Operation::Create.checks_parent());
    }

    #[test]
    fn checks_parent_false_for_read() {
        assert!(!Operation::Read.checks_parent());
    }

    #[test]
    fn checks_parent_false_for_write() {
        assert!(!Operation::Write.checks_parent());
    }

    #[test]
    fn checks_parent_false_for_execute() {
        assert!(!Operation::Execute.checks_parent());
    }

    #[test]
    fn checks_parent_false_for_stat() {
        assert!(!Operation::Stat.checks_parent());
    }

    #[test]
    fn target_component_last_for_read() {
        // Walk: ["/", "/var", "/var/log"] -> index 2
        assert_eq!(Operation::Read.target_component(3), Some(2));
    }

    #[test]
    fn target_component_last_for_single_component() {
        assert_eq!(Operation::Read.target_component(1), Some(0));
    }

    #[test]
    fn target_component_second_to_last_for_delete() {
        // Walk: ["/", "/var", "/var/log"] -> parent is index 1
        assert_eq!(Operation::Delete.target_component(3), Some(1));
    }

    #[test]
    fn target_component_second_to_last_for_create() {
        assert_eq!(Operation::Create.target_component(3), Some(1));
    }

    #[test]
    fn target_component_none_for_empty_walk() {
        assert_eq!(Operation::Read.target_component(0), None);
    }

    #[test]
    fn target_component_none_for_parent_op_with_single_component() {
        // Walk: ["/"] -> no parent exists
        assert_eq!(Operation::Delete.target_component(1), None);
    }

    #[test]
    fn target_component_parent_op_with_two_components() {
        // Walk: ["/", "/file"] -> parent is index 0
        assert_eq!(Operation::Delete.target_component(2), Some(0));
    }
}