whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
//! Unit tests for `check_apparmor`.
//!
//! RED phase: tests compile but fail because `check_apparmor` reads procfs and
//! the securityfs directly instead of consuming `state.mac_state`. Phase 6
//! will refactor the function to read from `mac_state`, making these pass.

use super::*;
use crate::operation::{Operation, XattrNamespace};
use crate::state::mac::AppArmorState;
use crate::state::Probe;
use crate::test_helpers::StateBuilder;

// ── metadata-aware enforce-mode tests ────────────────────────────────────────

/// Chmod in enforce mode must return Degraded with capability hint.
#[test]
fn chmod_in_enforce_mode_returns_degraded_with_capability_hint() {
    let state = StateBuilder::new()
        .operation(Operation::Chmod)
        .with_apparmor_state(AppArmorState {
            profile_label: "nginx (enforce)".into(),
        })
        .component("/", 0, 0, 0o755)
        .component_file("/var/www/html/index.html", 0, 0, 0o644)
        .build();

    let result = check_apparmor(&state);
    assert!(
        result.is_degraded(),
        "expected Degraded for Chmod in enforce mode, got {result:?}"
    );
    if let LayerResult::Degraded { reason } = &result {
        assert!(
            reason.contains("capability rules"),
            "expected 'capability rules' in reason, got: {reason}"
        );
    }
}

/// `ChownUid` in enforce mode must return Degraded with capability hint.
#[test]
fn chown_uid_in_enforce_mode_returns_degraded_with_capability_hint() {
    let state = StateBuilder::new()
        .operation(Operation::ChownUid)
        .with_apparmor_state(AppArmorState {
            profile_label: "/usr/sbin/sshd (enforce)".into(),
        })
        .component("/", 0, 0, 0o755)
        .component_file("/etc/passwd", 0, 0, 0o644)
        .build();

    let result = check_apparmor(&state);
    assert!(
        result.is_degraded(),
        "expected Degraded for ChownUid in enforce mode, got {result:?}"
    );
    if let LayerResult::Degraded { reason } = &result {
        assert!(
            reason.contains("capability rules"),
            "expected 'capability rules' in reason, got: {reason}"
        );
    }
}

/// SetXattr(Security) in enforce mode must return Degraded with capability hint.
#[test]
fn setxattr_security_in_enforce_mode_returns_degraded_with_capability_hint() {
    let state = StateBuilder::new()
        .operation(Operation::SetXattr {
            namespace: XattrNamespace::Security,
        })
        .with_apparmor_state(AppArmorState {
            profile_label: "nginx (enforce)".into(),
        })
        .component("/", 0, 0, 0o755)
        .component_file("/var/www/html/index.html", 0, 0, 0o644)
        .build();

    let result = check_apparmor(&state);
    assert!(
        result.is_degraded(),
        "expected Degraded for SetXattr in enforce mode, got {result:?}"
    );
    if let LayerResult::Degraded { reason } = &result {
        assert!(
            reason.contains("capability rules"),
            "expected 'capability rules' in reason, got: {reason}"
        );
    }
}

/// Read in enforce mode must use the existing libapparmor rebuild path, not the metadata path.
#[test]
fn read_in_enforce_mode_uses_existing_path() {
    let state = StateBuilder::new()
        .operation(Operation::Read)
        .with_apparmor_state(AppArmorState {
            profile_label: "nginx (enforce)".into(),
        })
        .component("/", 0, 0, 0o755)
        .component_file("/var/www/html/index.html", 0, 0, 0o644)
        .build();

    let result = check_apparmor(&state);
    assert!(
        result.is_degraded(),
        "expected Degraded for Read in enforce mode, got {result:?}"
    );
    if let LayerResult::Degraded { reason } = &result {
        assert!(
            !reason.contains("capability rules"),
            "Read should not mention 'capability rules', got: {reason}"
        );
        assert!(
            reason.contains("libapparmor"),
            "Read should mention 'libapparmor', got: {reason}"
        );
    }
}

// ── existing unit tests (preserved from inline mod) ─────────────────────────

#[test]
fn parse_profile_label_enforce() {
    let (name, mode) = parse_profile_label("nginx (enforce)");
    assert_eq!(name, "nginx");
    assert!(matches!(mode, ProfileMode::Enforce));
}

#[test]
fn parse_profile_label_complain() {
    let (name, mode) = parse_profile_label("/usr/sbin/sshd (complain)");
    assert_eq!(name, "/usr/sbin/sshd");
    assert!(matches!(mode, ProfileMode::Complain));
}

#[test]
fn parse_profile_label_no_suffix() {
    let (name, mode) = parse_profile_label("unconfined");
    assert_eq!(name, "unconfined");
    assert!(matches!(mode, ProfileMode::Unknown));
}

#[test]
fn parse_profile_label_unknown_mode() {
    let (name, mode) = parse_profile_label("nginx (audit)");
    assert_eq!(name, "nginx");
    assert!(matches!(mode, ProfileMode::Unknown));
}

#[test]
fn parse_profile_label_full_path_enforce() {
    let (name, mode) = parse_profile_label("/usr/bin/man (enforce)");
    assert_eq!(name, "/usr/bin/man");
    assert!(matches!(mode, ProfileMode::Enforce));
}

// ── RED: check_apparmor state-driven tests ───────────────────────────────────
//
// Each test injects synthetic AppArmorState via StateBuilder::with_apparmor_state.
// All fail at runtime because check_apparmor ignores state.mac_state and reads
// /sys/kernel/security/apparmor and /proc/self/attr/apparmor/current directly.
// On this host AppArmor is not active, so the function returns Degraded
// ("AppArmor not active on this system") instead of the expected result.

/// Unconfined profile means no MAC restrictions — must pass without warnings.
// RED: check_apparmor ignores state.mac_state and checks the live filesystem.
#[test]
fn apparmor_unconfined_returns_pass() {
    let state = StateBuilder::new()
        .operation(Operation::Read)
        .with_apparmor_state(AppArmorState {
            profile_label: "unconfined".into(),
        })
        .component("/", 0, 0, 0o755)
        .component_file("/etc/hosts", 0, 0, 0o644)
        .build();

    let result = check_apparmor(&state);
    assert!(
        result.is_pass(),
        "expected Pass for unconfined profile, got {result:?}"
    );
    if let LayerResult::Pass { warnings, .. } = &result {
        assert!(warnings.is_empty(), "unconfined should carry no warnings");
    }
}

/// Complain-mode profile must pass but carry warnings about non-enforcement.
// RED: check_apparmor ignores state.mac_state and checks the live filesystem.
#[test]
fn apparmor_complain_mode_returns_pass_with_warnings() {
    let state = StateBuilder::new()
        .operation(Operation::Read)
        .with_apparmor_state(AppArmorState {
            profile_label: "nginx (complain)".into(),
        })
        .component("/", 0, 0, 0o755)
        .component_file("/var/www/html/index.html", 0, 0, 0o644)
        .build();

    let result = check_apparmor(&state);
    assert!(
        result.is_pass(),
        "expected Pass for complain-mode profile, got {result:?}"
    );
    if let LayerResult::Pass { warnings, .. } = &result {
        assert!(
            !warnings.is_empty(),
            "complain mode must carry at least one warning"
        );
    }
}

/// Enforce-mode profile without libapparmor cannot query policy — must be Degraded.
// RED: check_apparmor ignores state.mac_state and checks the live filesystem.
#[test]
fn apparmor_enforce_mode_returns_degraded() {
    let state = StateBuilder::new()
        .operation(Operation::Write)
        .with_apparmor_state(AppArmorState {
            profile_label: "nginx (enforce)".into(),
        })
        .component("/", 0, 0, 0o755)
        .component_file("/var/www/html/index.html", 0, 0, 0o644)
        .build();

    let result = check_apparmor(&state);
    assert!(
        result.is_degraded(),
        "expected Degraded for enforce-mode without libapparmor, got {result:?}"
    );
}

/// Unknown enforcement mode in label cannot be interpreted — must be Degraded.
// RED: check_apparmor ignores state.mac_state and checks the live filesystem.
#[test]
fn apparmor_unknown_mode_returns_degraded() {
    let state = StateBuilder::new()
        .operation(Operation::Read)
        .with_apparmor_state(AppArmorState {
            profile_label: "nginx (audit)".into(),
        })
        .component("/", 0, 0, 0o755)
        .component_file("/var/log/nginx/access.log", 0, 0, 0o640)
        .build();

    let result = check_apparmor(&state);
    assert!(
        result.is_degraded(),
        "expected Degraded for unknown mode label, got {result:?}"
    );
}

/// Unknown probe means gathering failed — must be Degraded, never false-green.
// RED: check_apparmor ignores state.mac_state and checks the live filesystem.
#[test]
fn apparmor_probe_unknown_returns_degraded() {
    // StateBuilder defaults mac_state.apparmor to Probe::Unknown — no with_apparmor_state call.
    let state = StateBuilder::new()
        .operation(Operation::Read)
        .component("/", 0, 0, 0o755)
        .component_file("/etc/shadow", 0, 0, 0o640)
        .build();

    assert_eq!(state.mac_state.apparmor, Probe::Unknown);

    let result = check_apparmor(&state);
    assert!(
        result.is_degraded(),
        "expected Degraded for unknown probe, got {result:?}"
    );
}

/// Inaccessible probe means EACCES during gathering — must be Degraded.
// RED: check_apparmor ignores state.mac_state and checks the live filesystem.
#[test]
fn apparmor_probe_inaccessible_returns_degraded() {
    let mut state = StateBuilder::new()
        .operation(Operation::Read)
        .component("/", 0, 0, 0o755)
        .component_file("/etc/shadow", 0, 0, 0o640)
        .build();

    // Directly set inaccessible — no builder method exists for this variant.
    state.mac_state.apparmor = Probe::Inaccessible;

    let result = check_apparmor(&state);
    assert!(
        result.is_degraded(),
        "expected Degraded for inaccessible probe, got {result:?}"
    );
}