whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
//! Tests for `capability_modify` and root execute/delete override behavior.
use super::*;
use crate::checks::capability_modify;
use crate::checks::caps::CAP_DAC_OVERRIDE;
use crate::operation::Operation;
use crate::state::mac::MacState;
use crate::test_helpers::StateBuilder;

#[test]
fn root_execute_on_0744_passes_owner_has_x() {
    let state = StateBuilder::new()
        .subject(0, 0, vec![])
        .operation(Operation::Execute)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/script.sh", 1000, 1000, 0o744)
        .build();
    assert!(capability_modify(check_dac(&state), &state).is_pass());
}

#[test]
fn root_execute_on_0111_passes_all_have_x() {
    let state = StateBuilder::new()
        .subject(0, 0, vec![])
        .operation(Operation::Execute)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/bin/tool", 1000, 1000, 0o111)
        .build();
    assert!(capability_modify(check_dac(&state), &state).is_pass());
}

#[test]
fn capability_modify_preserves_pass() {
    let state = StateBuilder::new()
        .subject(0, 0, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/file.txt", 0, 0, 0o644)
        .build();
    let dac = check_dac(&state);
    assert!(dac.is_pass());
    assert!(capability_modify(dac, &state).is_pass());
}

#[test]
fn capability_modify_preserves_degraded() {
    let state = StateBuilder::new()
        .subject(0, 0, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_unknown("/mystery")
        .build();
    let dac = check_dac(&state);
    assert!(dac.is_degraded());
    assert!(capability_modify(dac, &state).is_degraded());
}

#[test]
fn root_delete_sticky_parent_mentions_sticky_in_override() {
    // parent owned by uid=1000, mode 0o1700: root (uid=0) is "other" with
    // no w+x, so check_dac fails. capability_modify overrides to Pass but
    // should mention the sticky bit being bypassed.
    let state = StateBuilder::new()
        .subject(0, 0, vec![])
        .operation(Operation::Delete)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component("/restricted", 1000, 1000, 0o1700)
        .component_file("/restricted/file.txt", 1000, 1000, 0o644)
        .build();
    let dac = check_dac(&state);
    assert!(dac.is_fail(), "expected DAC fail for root as other on 1700");
    let result = capability_modify(dac, &state);
    assert!(result.is_pass());
    if let LayerResult::Pass { detail, .. } = &result {
        assert!(
            detail.contains("sticky"),
            "expected sticky bit context: {detail}"
        );
        assert!(
            detail.contains("CAP_DAC_OVERRIDE"),
            "expected CAP_DAC_OVERRIDE: {detail}"
        );
    }
}

#[test]
fn root_delete_no_sticky_generic_override() {
    // same as above but parent has no sticky bit (mode 0o700).
    // override message should NOT mention sticky.
    let state = StateBuilder::new()
        .subject(0, 0, vec![])
        .operation(Operation::Delete)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component("/restricted", 1000, 1000, 0o700)
        .component_file("/restricted/file.txt", 1000, 1000, 0o644)
        .build();
    let dac = check_dac(&state);
    assert!(dac.is_fail());
    let result = capability_modify(dac, &state);
    assert!(result.is_pass());
    if let LayerResult::Pass { detail, .. } = &result {
        assert!(
            !detail.contains("sticky"),
            "should not mention sticky: {detail}"
        );
    }
}

// --- Probe-aware capability_modify tests ---

#[test]
fn capability_modify_known_cap_overrides_fail() {
    // non-root uid with Known(CAP_DAC_OVERRIDE): DAC fail becomes Pass.
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .with_capabilities(CAP_DAC_OVERRIDE)
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/secret.txt", 0, 0, 0o000)
        .build();
    let result = capability_modify(check_dac(&state), &state);
    assert!(result.is_pass(), "expected Pass, got {result:?}");
    if let LayerResult::Pass { detail, .. } = &result {
        assert!(detail.contains("CAP_DAC_OVERRIDE"));
    }
}

#[test]
fn capability_modify_known_no_cap_leaves_fail() {
    // non-root uid with Known(0): no capability set, fail stays fail.
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .with_capabilities(0u64)
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/secret.txt", 0, 0, 0o000)
        .build();
    let result = capability_modify(check_dac(&state), &state);
    assert!(result.is_fail(), "expected Fail when cap bitmask is 0");
}

#[test]
fn capability_modify_unknown_root_overrides() {
    // uid=0 with Probe::Unknown: backward-compat path assumes full caps.
    let state = StateBuilder::new()
        .subject(0, 0, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/secret.txt", 1000, 1000, 0o000)
        .build();
    // StateBuilder defaults to Probe::Unknown for capabilities.
    let result = capability_modify(check_dac(&state), &state);
    assert!(result.is_pass(), "uid=0 + Unknown should override DAC fail");
}

#[test]
fn capability_modify_unknown_nonroot_no_override() {
    // uid=1000 with Probe::Unknown: no cap info, no override.
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/secret.txt", 0, 0, 0o000)
        .build();
    // StateBuilder defaults to Probe::Unknown for capabilities.
    let result = capability_modify(check_dac(&state), &state);
    assert!(
        result.is_fail(),
        "uid=1000 + Unknown should not override DAC fail"
    );
}

#[test]
fn capability_modify_inaccessible_leaves_result() {
    // Probe::Inaccessible: user namespace or EPERM on /proc — return unchanged.
    use crate::state::subject::ResolvedSubject;
    use crate::state::Probe;
    use crate::state::SystemState;
    // build state manually to inject Probe::Inaccessible into capabilities
    let base = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/secret.txt", 0, 0, 0o000)
        .build();
    let state = SystemState {
        subject: ResolvedSubject {
            uid: base.subject.uid,
            gid: base.subject.gid,
            groups: base.subject.groups.clone(),
            capabilities: Probe::Inaccessible,
        },
        walk: base.walk,
        mounts: base.mounts,
        operation: base.operation,
        mac_state: MacState::default(),
    };
    let dac = check_dac(&state);
    assert!(dac.is_fail());
    let result = capability_modify(dac.clone(), &state);
    assert_eq!(
        result, dac,
        "Inaccessible must return original result unchanged"
    );
}

#[test]
fn delete_single_component_returns_degraded() {
    // Delete with single component -> target_component() returns None -> degraded
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Delete)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .build();
    let result = check_dac(&state);
    assert!(result.is_degraded());
}

#[test]
fn delete_parent_with_inaccessible_stat_returns_degraded() {
    // Delete where parent stat is Inaccessible -> check_parent_dac -> degraded
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Delete)
        .component_inaccessible("/locked_dir")
        .component_file("/locked_dir/file.txt", 1000, 1000, 0o644)
        .build();
    let result = check_dac(&state);
    assert!(result.is_degraded());
}