whyno-cli 0.5.0

Linux permission debugger
//! `faccessat2(AT_EACCESS)` cross-check against whyno's result.
//!
//! Runs when subject == calling user and kernel >= 5.8. Debug builds run
//! automatically; release mode requires `--self-test`. Mismatches go to stderr.

use std::path::Path;

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

// glibc < 2.33 emulated AT_EACCESS incorrectly via fstatat; 5.8+ guarantees the real syscall
const MIN_KERNEL_MAJOR: u32 = 5;
const MIN_KERNEL_MINOR: u32 = 8;

/// Runs cross-check if subject == calling euid and kernel >= 5.8. Mismatches go to stderr.
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);
}

fn subject_is_self(state: &SystemState) -> bool {
    state.subject.uid == nix::unistd::geteuid().as_raw()
}

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))
}

fn meets_minimum(major: u32, minor: u32) -> bool {
    (major, minor) >= (MIN_KERNEL_MAJOR, MIN_KERNEL_MINOR)
}

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

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
        }
        // Stat, Chmod, ChownUid, ChownGid, SetXattr — and any future variants added to
        // the #[non_exhaustive] Operation enum — map to F_OK (existence check).
        // F_OK is conservative: it never produces a false "allowed" cross-check result.
        _ => nix::unistd::AccessFlags::F_OK,
    }
}

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),
        );
    }
}

fn faccessat_eaccess(path: &Path, flags: nix::unistd::AccessFlags) -> bool {
    // musl calls the raw faccessat2 syscall directly; glibc >= 2.33 does too
    nix::unistd::faccessat(None, path, flags, nix::fcntl::AtFlags::AT_EACCESS).is_ok()
}

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));
    }

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

    #[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));
    }

    #[test]
    fn operation_to_flags_stat() {
        let f = operation_to_access_flags(Operation::Stat);
        assert!(f.contains(nix::unistd::AccessFlags::F_OK));
    }

    #[test]
    fn meets_minimum_minor_boundary() {
        assert!(meets_minimum(5, 9));
        assert!(!meets_minimum(5, 7));
    }

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

    #[test]
    fn meets_minimum_major_4_fails() {
        assert!(!meets_minimum(4, 99));
    }

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

    #[test]
    fn parse_kernel_version_empty_string() {
        assert_eq!(parse_kernel_version(""), None);
    }

    #[test]
    fn parse_kernel_version_non_numeric() {
        assert_eq!(parse_kernel_version("not.a.version"), None);
    }

    #[test]
    fn verdict_allowed_returns_allowed() {
        assert_eq!(verdict(true), "allowed");
    }

    #[test]
    fn verdict_denied_returns_denied() {
        assert_eq!(verdict(false), "denied");
    }

    #[test]
    fn target_path_returns_last_component() {
        use whyno_core::operation::Operation;
        use whyno_core::test_helpers::StateBuilder;
        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");
    }

    #[test]
    fn subject_is_self_matching_uid() {
        use whyno_core::operation::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_file("/file.txt", euid, euid, 0o644)
            .build();
        assert!(subject_is_self(&state));
    }

    #[test]
    fn subject_is_self_different_uid() {
        use whyno_core::operation::Operation;
        use whyno_core::test_helpers::StateBuilder;
        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));
    }

    #[test]
    fn kernel_supports_faccessat2_returns_bool() {
        // Result depends on kernel version; verify no panic
        let _result = kernel_supports_faccessat2();
    }

    #[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);
    }

    #[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);
    }

    #[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);
    }

    #[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);
    }
}