whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
use super::*;
use crate::checks::caps::CAP_DAC_OVERRIDE;
use crate::operation::{MetadataParams, Operation};
use crate::test_helpers::StateBuilder;

#[cfg(not(feature = "selinux"))]
#[test]
fn run_checks_includes_selinux_degraded_when_feature_absent() {
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/tmp/test", 1000, 1000, 0o644)
        .build();
    let report = run_checks(&state, &MetadataParams::default());
    // mac_results always has 2 entries (SeLinux + AppArmor)
    assert_eq!(report.mac_results.len(), 2);
    assert!(
        matches!(
            &report.mac_results[0],
            (MacLayer::SeLinux, LayerResult::Degraded { .. })
        ),
        "expected (SeLinux, Degraded), got {:?}",
        report.mac_results[0]
    );
    if let LayerResult::Degraded { reason } = &report.mac_results[0].1 {
        assert!(
            reason.to_ascii_lowercase().contains("selinux"),
            "reason should mention SELinux: {reason}"
        );
    }
}

#[test]
fn run_checks_mac_results_always_has_both_entries() {
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/tmp/test", 1000, 1000, 0o644)
        .build();
    let report = run_checks(&state, &MetadataParams::default());
    // mac_results always has exactly 2 entries (SeLinux, AppArmor)
    // regardless of feature flags, both are present (as Degraded when not compiled in)
    assert_eq!(report.mac_results.len(), 2);
    assert!(matches!(report.mac_results[0].0, MacLayer::SeLinux));
    assert!(matches!(report.mac_results[1].0, MacLayer::AppArmor));
}

#[cfg(not(feature = "apparmor"))]
#[test]
fn run_checks_includes_apparmor_degraded_when_feature_absent() {
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/tmp/test", 1000, 1000, 0o644)
        .build();
    let report = run_checks(&state, &MetadataParams::default());
    assert!(
        matches!(
            &report.mac_results[1],
            (MacLayer::AppArmor, LayerResult::Degraded { .. })
        ),
        "expected (AppArmor, Degraded), got {:?}",
        report.mac_results[1]
    );
    if let LayerResult::Degraded { reason } = &report.mac_results[1].1 {
        assert!(
            reason.to_ascii_lowercase().contains("apparmor"),
            "reason should mention AppArmor: {reason}"
        );
    }
}

#[test]
fn ro_mount_causes_write_fail_in_pipeline() {
    let state = StateBuilder::new()
        .operation(Operation::Write)
        .mount("/var", "ext4", "ro")
        .component("/var", 0, 0, 0o755)
        .component_file("/var/data.txt", 0, 0, 0o644)
        .build();

    let report = run_checks(&state, &MetadataParams::default());
    assert!(report.core_results[CoreLayer::Mount].is_fail());
    assert!(!report.is_allowed());
    assert!(report.failed_layers().contains(&CoreLayer::Mount));
}

#[test]
fn rw_mount_all_layers_pass_for_root() {
    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 report = run_checks(&state, &MetadataParams::default());
    assert!(report.core_results[CoreLayer::Mount].is_pass());
    assert!(report.core_results[CoreLayer::Traversal].is_pass());
    assert!(report.core_results[CoreLayer::Dac].is_pass());
    assert!(report.is_allowed());
    assert!(report.failed_layers().is_empty());
}

#[test]
fn failed_layers_returns_empty_when_all_pass_or_degraded() {
    let state = StateBuilder::new()
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .build();

    let report = run_checks(&state, &MetadataParams::default());
    assert!(report.failed_layers().is_empty());
}

#[test]
fn is_allowed_false_when_any_layer_fails() {
    let core_results = EnumMap::from_array([
        LayerResult::Pass {
            detail: String::new(),
            warnings: vec![],
        },
        LayerResult::Fail {
            detail: "blocked".into(),
            component_index: Some(0),
        },
        LayerResult::Degraded {
            reason: "stub".into(),
        },
        LayerResult::Degraded {
            reason: "stub".into(),
        },
        LayerResult::Degraded {
            reason: "stub".into(),
        },
        LayerResult::Degraded {
            reason: "stub".into(),
        },
    ]);
    let report = CheckReport {
        core_results,
        mac_results: vec![],
    };
    assert!(!report.is_allowed());
    assert_eq!(report.failed_layers(), vec![CoreLayer::FsFlags]);
}

#[test]
fn ro_mount_and_restrictive_mode_both_fail() {
    let state = StateBuilder::new()
        .subject(2000, 2000, vec![])
        .operation(Operation::Write)
        .mount("/mnt", "ext4", "ro")
        .component("/mnt", 0, 0, 0o755)
        .component_file("/mnt/secret.txt", 0, 0, 0o600)
        .build();

    let report = run_checks(&state, &MetadataParams::default());
    assert!(report.core_results[CoreLayer::Mount].is_fail());
    assert!(report.core_results[CoreLayer::Dac].is_fail());
    let failed = report.failed_layers();
    assert!(failed.contains(&CoreLayer::Mount));
    assert!(failed.contains(&CoreLayer::Dac));
}

#[test]
fn all_pass_for_owner_on_rw_mount() {
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component("/home", 0, 0, 0o755)
        .component_file("/home/file.txt", 1000, 1000, 0o644)
        .build();

    let report = run_checks(&state, &MetadataParams::default());
    assert!(report.core_results[CoreLayer::Mount].is_pass());
    assert!(report.core_results[CoreLayer::Traversal].is_pass());
    assert!(report.core_results[CoreLayer::Dac].is_pass());
    assert!(report.is_allowed());
}

#[test]
fn root_bypasses_dac_but_not_ro_mount() {
    let state = StateBuilder::new()
        .subject(0, 0, vec![])
        .operation(Operation::Write)
        .mount("/mnt", "ext4", "ro")
        .component("/mnt", 0, 0, 0o755)
        .component_file("/mnt/data.txt", 1000, 1000, 0o000)
        .build();

    let report = run_checks(&state, &MetadataParams::default());
    assert!(report.core_results[CoreLayer::Mount].is_fail());
    assert!(report.core_results[CoreLayer::Dac].is_pass());
    if let LayerResult::Pass { detail, .. } = &report.core_results[CoreLayer::Dac] {
        assert!(detail.contains("CAP_DAC_OVERRIDE"));
    }
}

#[test]
fn nonroot_with_known_cap_dac_override_bypasses_dac_in_pipeline() {
    // non-root uid=1000 with Known(CAP_DAC_OVERRIDE) in CapEff:
    // pipeline DAC layer shows the cap override, not a DAC fail.
    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 report = run_checks(&state, &MetadataParams::default());
    assert!(report.core_results[CoreLayer::Dac].is_pass());
    if let LayerResult::Pass { detail, .. } = &report.core_results[CoreLayer::Dac] {
        assert!(
            detail.contains("CAP_DAC_OVERRIDE"),
            "expected CAP_DAC_OVERRIDE in detail: {detail}"
        );
    }
    assert!(report.is_allowed());
}

#[test]
fn core_layer_metadata_variant_exists() {
    // CoreLayer::Metadata must be constructible.
    let layer = CoreLayer::Metadata;
    assert!(matches!(layer, CoreLayer::Metadata));
}

#[test]
fn run_checks_chmod_produces_metadata_layer_result() {
    use crate::operation::MetadataParams;
    let params = MetadataParams::default();
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Chmod)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/file.txt", 1000, 1000, 0o644)
        .build();
    let report = run_checks(&state, &params);
    // Metadata layer should not report "not a metadata operation"
    let result = &report.core_results[CoreLayer::Metadata];
    if let LayerResult::Degraded { reason } = result {
        assert!(
            !reason.contains("not a metadata operation"),
            "Chmod should not produce a passthrough Degraded: {reason}"
        );
    }
}

#[test]
fn run_checks_read_metadata_layer_is_pass_skip() {
    use crate::operation::MetadataParams;
    let params = MetadataParams::default();
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/file.txt", 1000, 1000, 0o644)
        .build();
    let report = run_checks(&state, &params);
    let result = &report.core_results[CoreLayer::Metadata];
    if let LayerResult::Pass { detail, .. } = result {
        assert!(
            detail.contains("not a metadata operation"),
            "Read op metadata layer detail should contain 'not a metadata operation': {detail}"
        );
    } else {
        panic!("expected Pass for Read op metadata layer, got {result:?}");
    }
}

#[test]
fn run_checks_chown_uid_no_cap_metadata_layer_fails() {
    use crate::operation::MetadataParams;
    let params = MetadataParams::default();
    // non-root subject, no CAP_CHOWN, op=ChownUid
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::ChownUid)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/file.txt", 0, 0, 0o644)
        .build();
    let report = run_checks(&state, &params);
    assert!(
        report.core_results[CoreLayer::Metadata].is_fail(),
        "expected Metadata layer Fail for ChownUid without CAP_CHOWN, got {:?}",
        report.core_results[CoreLayer::Metadata]
    );
}