whyno-cli 0.4.0

Linux permission debugger
//! `faccessat2` cross-check for correctness validation.
//!
//! Compares whyno's check result against the kernel's `faccessat2(AT_EACCESS)`
//! Answer when the subject is the calling user and the kernel is 5.8+.
//!
//! In debug builds, runs automatically. in release builds, gated behind
//! `--self-test` flag. mismatches are reported to stderr, never panic in
//! Release mode.

use std::path::Path;

use whyno_core::checks::CheckReport;
use whyno_core::operation::Operation;
use whyno_core::state::SystemState;

/// Minimum kernel version for real `faccessat2` syscall.
///
/// Glibc < 2.33 emulated `AT_EACCESS` incorrectly via `fstatat`.
/// Musl calls the raw syscall directly. gate on kernel 5.8+ to
/// Ensure the real syscall is available.
const MIN_KERNEL_MAJOR: u32 = 5;
const MIN_KERNEL_MINOR: u32 = 8;

/// Runs `faccessat2` cross-check if conditions are met.
///
/// Conditions: subject uid == calling user euid, kernel >= 5.8.
/// Mismatches are logged to stderr, never fatal.
pub fn maybe_cross_check(state: &SystemState, report: &CheckReport) {
    if !subject_is_self(state) {
        return;
    }
    if !kernel_supports_faccessat2() {
        return;
    }
    let Some(path) = target_path(state) else {
        return;
    };
    run_cross_check(path, state.operation, report);
}

/// Returns `true` if subject matches the calling user's euid.
fn subject_is_self(state: &SystemState) -> bool {
    state.subject.uid == nix::unistd::geteuid().as_raw()
}

/// Checks if kernel version is >= 5.8 (real `faccessat2` syscall).
fn kernel_supports_faccessat2() -> bool {
    let Ok(uname) = nix::sys::utsname::uname() else {
        return false;
    };
    let release = uname.release().to_string_lossy();
    parse_kernel_version(&release)
        .is_some_and(|(major, minor)| meets_minimum(major, minor))
}

/// Parses "5.8.0-foo" into (5, 8).
fn parse_kernel_version(release: &str) -> Option<(u32, u32)> {
    let mut parts = release.split(|c: char| !c.is_ascii_digit());
    let major = parts.next()?.parse::<u32>().ok()?;
    let minor = parts.next()?.parse::<u32>().ok()?;
    Some((major, minor))
}

/// Checks if version meets minimum requirement.
fn meets_minimum(major: u32, minor: u32) -> bool {
    (major, minor) >= (MIN_KERNEL_MAJOR, MIN_KERNEL_MINOR)
}

/// Extracts target path from state.
fn target_path(state: &SystemState) -> Option<&Path> {
    state.walk.last().map(|c| c.path.as_path())
}

/// Maps operation to `nix::unistd::AccessFlags`.
fn operation_to_access_flags(op: Operation) -> nix::unistd::AccessFlags {
    match op {
        Operation::Read => nix::unistd::AccessFlags::R_OK,
        Operation::Write => nix::unistd::AccessFlags::W_OK,
        Operation::Execute => nix::unistd::AccessFlags::X_OK,
        Operation::Delete | Operation::Create => {
            nix::unistd::AccessFlags::W_OK | nix::unistd::AccessFlags::X_OK
        }
        Operation::Stat | _ => nix::unistd::AccessFlags::F_OK,
    }
}

/// Runs the actual cross-check and reports mismatches.
fn run_cross_check(path: &Path, operation: Operation, report: &CheckReport) {
    let check_path = if operation.checks_parent() {
        path.parent().unwrap_or(path)
    } else {
        path
    };

    let flags = operation_to_access_flags(operation);
    let kernel_ok = faccessat_eaccess(check_path, flags);
    let whyno_ok = report.is_allowed();

    if kernel_ok != whyno_ok {
        eprintln!(
            "whyno self-test: mismatch on {} {:?} -- \
             whyno says {}, kernel says {}",
            check_path.display(),
            operation,
            verdict(whyno_ok),
            verdict(kernel_ok),
        );
    }
}

/// Calls `faccessat2` with `AT_EACCESS` to check effective access.
fn faccessat_eaccess(path: &Path, flags: nix::unistd::AccessFlags) -> bool {
    // nix::unistd::faccessat with AtFlags::AT_EACCESS uses the real
    // faccessat2 syscall on musl.
    nix::unistd::faccessat(
        None,
        path,
        flags,
        nix::fcntl::AtFlags::AT_EACCESS,
    )
    .is_ok()
}

/// Human label for allowed/denied.
fn verdict(allowed: bool) -> &'static str {
    if allowed { "allowed" } else { "denied" }
}

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

    #[test]
    fn parse_kernel_version_standard() {
        assert_eq!(parse_kernel_version("5.8.0"), Some((5, 8)));
    }

    #[test]
    fn parse_kernel_version_with_suffix() {
        assert_eq!(parse_kernel_version("6.8.12-17-pve"), Some((6, 8)));
    }

    #[test]
    fn parse_kernel_version_old() {
        assert_eq!(parse_kernel_version("4.19.0-20-amd64"), Some((4, 19)));
    }

    #[test]
    fn meets_minimum_exact() {
        assert!(meets_minimum(5, 8));
    }

    #[test]
    fn meets_minimum_higher_major() {
        assert!(meets_minimum(6, 0));
    }

    #[test]
    fn meets_minimum_lower() {
        assert!(!meets_minimum(5, 7));
        assert!(!meets_minimum(4, 19));
    }

    #[test]
    fn operation_to_flags_read() {
        let f = operation_to_access_flags(Operation::Read);
        assert!(f.contains(nix::unistd::AccessFlags::R_OK));
    }

    #[test]
    fn operation_to_flags_execute() {
        let f = operation_to_access_flags(Operation::Execute);
        assert!(f.contains(nix::unistd::AccessFlags::X_OK));
    }

    #[test]
    fn operation_to_flags_delete() {
        let f = operation_to_access_flags(Operation::Delete);
        assert!(f.contains(nix::unistd::AccessFlags::W_OK));
        assert!(f.contains(nix::unistd::AccessFlags::X_OK));
    }

    /// Verifies that Write maps to `W_OK`.
    #[test]
    fn operation_to_flags_write() {
        let f = operation_to_access_flags(Operation::Write);
        assert!(f.contains(nix::unistd::AccessFlags::W_OK));
    }

    /// Verifies that Create maps to `W_OK` | `X_OK`, matching the parent-write-plus-exec requirement.
    #[test]
    fn operation_to_flags_create() {
        let f = operation_to_access_flags(Operation::Create);
        assert!(f.contains(nix::unistd::AccessFlags::W_OK));
        assert!(f.contains(nix::unistd::AccessFlags::X_OK));
    }

    /// Verifies that Stat maps to `F_OK` (existence check only).
    #[test]
    fn operation_to_flags_stat() {
        let f = operation_to_access_flags(Operation::Stat);
        assert!(f.contains(nix::unistd::AccessFlags::F_OK));
    }

    /// Verifies that minor version boundaries are compared correctly within major 5.
    #[test]
    fn meets_minimum_minor_boundary() {
        assert!(meets_minimum(5, 9));
        assert!(!meets_minimum(5, 7));
    }

    /// Verifies that any minor version passes when the major is above the minimum.
    #[test]
    fn meets_minimum_major_6_any_minor() {
        assert!(meets_minimum(6, 0));
        assert!(meets_minimum(6, 8));
    }

    /// Verifies that major version 4 fails regardless of minor version.
    #[test]
    fn meets_minimum_major_4_fails() {
        assert!(!meets_minimum(4, 99));
    }

    /// Verifies that a version string with only major.minor (no patch) parses correctly.
    #[test]
    fn parse_kernel_version_two_parts_only() {
        assert_eq!(parse_kernel_version("5.8"), Some((5, 8)));
    }

    /// Verifies that an empty release string returns None rather than panicking.
    #[test]
    fn parse_kernel_version_empty_string() {
        assert_eq!(parse_kernel_version(""), None);
    }

    /// Verifies that a release string with no leading digits returns None.
    #[test]
    fn parse_kernel_version_non_numeric() {
        assert_eq!(parse_kernel_version("not.a.version"), None);
    }

    /// Verifies that `true` renders as "allowed".
    #[test]
    fn verdict_allowed_returns_allowed() {
        assert_eq!(verdict(true), "allowed");
    }

    /// Verifies that `false` renders as "denied".
    #[test]
    fn verdict_denied_returns_denied() {
        assert_eq!(verdict(false), "denied");
    }

    /// Verifies that `target_path` returns the path of the last walk component.
    #[test]
    fn target_path_returns_last_component() {
        use whyno_core::test_helpers::StateBuilder;
        use whyno_core::operation::Operation;
        let state = StateBuilder::new()
            .subject(1000, 1000, vec![])
            .operation(Operation::Read)
            .component("/", 0, 0, 0o755)
            .component_file("/file.txt", 1000, 1000, 0o644)
            .mount("/", "ext4", "rw")
            .build();
        let path = target_path(&state).unwrap();
        assert_eq!(path.to_str().unwrap(), "/file.txt");
    }

    /// Verifies that a subject whose uid equals the calling process euid is recognised as self.
    #[test]
    fn subject_is_self_matching_uid() {
        use whyno_core::test_helpers::StateBuilder;
        use whyno_core::operation::Operation;
        let euid = nix::unistd::geteuid().as_raw();
        let state = StateBuilder::new()
            .subject(euid, euid, vec![])
            .operation(Operation::Read)
            .component_file("/file.txt", euid, euid, 0o644)
            .build();
        assert!(subject_is_self(&state));
    }

    /// Verifies that a subject whose uid differs from the calling process euid is not self.
    #[test]
    fn subject_is_self_different_uid() {
        use whyno_core::test_helpers::StateBuilder;
        use whyno_core::operation::Operation;
        let euid = nix::unistd::geteuid().as_raw();
        // use a different uid (flip to 0 or to euid+1)
        let other_uid = if euid == 0 { 1000 } else { 0 };
        let state = StateBuilder::new()
            .subject(other_uid, other_uid, vec![])
            .operation(Operation::Read)
            .component_file("/file.txt", other_uid, other_uid, 0o644)
            .build();
        assert!(!subject_is_self(&state));
    }

    /// Verifies that `kernel_supports_faccessat2` returns a result without panicking.
    #[test]
    fn kernel_supports_faccessat2_returns_bool() {
        // Result depends on kernel version; verify no panic
        let _result = kernel_supports_faccessat2();
    }

    /// Verifies that `F_OK` on /tmp (always present) returns true.
    #[test]
    fn faccessat_eaccess_existing_file_returns_true() {
        // /tmp always exists and is readable
        let result = faccessat_eaccess(
            std::path::Path::new("/tmp"),
            nix::unistd::AccessFlags::F_OK,
        );
        assert!(result);
    }

    /// Verifies that `F_OK` on a path that does not exist returns false.
    #[test]
    fn faccessat_eaccess_nonexistent_path_returns_false() {
        let result = faccessat_eaccess(
            std::path::Path::new("/definitely_does_not_exist_whyno_test"),
            nix::unistd::AccessFlags::F_OK,
        );
        assert!(!result);
    }

    /// Verifies that `run_cross_check` completes without panicking even when a mismatch may occur.
    #[test]
    fn run_cross_check_on_real_path_does_not_panic() {
        use whyno_core::checks::run_checks;
        use whyno_core::operation::{MetadataParams, Operation};
        use whyno_core::test_helpers::StateBuilder;
        let euid = nix::unistd::geteuid().as_raw();
        let state = StateBuilder::new()
            .subject(euid, euid, vec![])
            .operation(Operation::Read)
            .component("/", 0, 0, 0o755)
            .component_file("/tmp", 0, 0, 0o1777)
            .mount("/", "ext4", "rw")
            .build();
        let report = run_checks(&state, &MetadataParams::default());
        // should not panic regardless of mismatch
        run_cross_check(std::path::Path::new("/tmp"), Operation::Read, &report);
    }

    /// Verifies that `maybe_cross_check` runs the full cross-check path without panicking when the subject is the calling user.
    #[test]
    fn maybe_cross_check_with_self_subject_does_not_panic() {
        use whyno_core::checks::run_checks;
        use whyno_core::operation::{MetadataParams, Operation};
        use whyno_core::test_helpers::StateBuilder;
        let euid = nix::unistd::geteuid().as_raw();
        let effective_gid = nix::unistd::getegid().as_raw();
        let state = StateBuilder::new()
            .subject(euid, effective_gid, vec![])
            .operation(Operation::Read)
            .component("/", 0, 0, 0o755)
            .component_file("/tmp", 0, 0, 0o1777)
            .mount("/", "ext4", "rw")
            .build();
        let report = run_checks(&state, &MetadataParams::default());
        // exercises maybe_cross_check through to run_cross_check
        maybe_cross_check(&state, &report);
    }
}