whyno-core 0.5.0

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

#[test]
fn nginx_reading_log_traversal_and_dac_fail() {
    // nginx (uid=33, gid=33) reading /var/log/app/current.log
    // /var/log/app has mode 0700 (root:root) -> traversal fail
    // current.log has mode 0640 (root:root) -> DAC fail for other
    let state = StateBuilder::new()
        .subject(33, 33, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component("/var", 0, 0, 0o755)
        .component("/var/log", 0, 0, 0o755)
        .component("/var/log/app", 0, 0, 0o700)
        .component_file("/var/log/app/current.log", 0, 0, 0o640)
        .build();

    let report = crate::checks::run_checks(&state, &MetadataParams::default());
    assert!(report.core_results[CoreLayer::Traversal].is_fail());
    assert!(report.core_results[CoreLayer::Dac].is_fail());

    let plan = generate_fixes(&report, &state, &MetadataParams::default());

    let trav_fixes: Vec<_> = plan
        .fixes
        .iter()
        .filter(|f| f.layer == CoreLayer::Traversal)
        .collect();
    let dac_fixes: Vec<_> = plan
        .fixes
        .iter()
        .filter(|f| f.layer == CoreLayer::Dac)
        .collect();

    assert!(trav_fixes.len() >= 2);
    assert!(dac_fixes.len() >= 2);

    // layer ordering: all traversal fixes before DAC fixes
    let last_trav_pos = plan
        .fixes
        .iter()
        .rposition(|f| f.layer == CoreLayer::Traversal)
        .unwrap();
    let first_dac_pos = plan
        .fixes
        .iter()
        .position(|f| f.layer == CoreLayer::Dac)
        .unwrap();
    assert!(last_trav_pos < first_dac_pos);

    // within traversal: ACL fix (impact 1) before chmod (impact 4)
    assert_eq!(trav_fixes[0].impact, scoring::ACL_USER_GRANT);

    // within DAC: ACL fix (impact 1) before other chmod (impact 4)
    assert_eq!(dac_fixes[0].impact, scoring::ACL_USER_GRANT);
}

#[test]
fn fixes_ordered_by_layer_position() {
    // multiple layers fail — fixes should follow layer order
    let state = StateBuilder::new()
        .subject(2000, 2000, vec![])
        .operation(Operation::Write)
        .mount("/mnt", "ext4", "ro")
        .component("/mnt", 0, 0, 0o755)
        .component_file("/mnt/file.txt", 0, 0, 0o600)
        .build();

    let report = crate::checks::run_checks(&state, &MetadataParams::default());
    let plan = generate_fixes(&report, &state, &MetadataParams::default());

    let layers: Vec<CoreLayer> = plan.fixes.iter().map(|f| f.layer).collect();
    // mount fixes should come before DAC fixes
    if let Some(mount_pos) = layers.iter().position(|l| *l == CoreLayer::Mount) {
        if let Some(dac_pos) = layers.iter().position(|l| *l == CoreLayer::Dac) {
            assert!(mount_pos < dac_pos);
        }
    }
}

#[test]
fn chmod_failure_fix_plan_contains_chown_and_grant_cap_fowner() {
    // uid=1000, file owned by root, Chmod op, no capabilities -> Metadata fail
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .with_capabilities(0)
        .operation(Operation::Chmod)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/file.txt", 0, 0, 0o644)
        .build();

    let report = crate::checks::run_checks(&state, &MetadataParams::default());
    assert!(report.core_results[CoreLayer::Metadata].is_fail());

    let plan = generate_fixes(&report, &state, &MetadataParams::default());
    let meta_fixes: Vec<_> = plan
        .fixes
        .iter()
        .filter(|f| f.layer == CoreLayer::Metadata)
        .collect();

    assert!(
        meta_fixes.len() >= 2,
        "expected at least Chown and GrantCap fixes"
    );

    let chown_pos = meta_fixes
        .iter()
        .position(|f| matches!(f.action, FixAction::Chown { .. }));
    let grant_pos = meta_fixes.iter().position(|f| {
        matches!(&f.action, FixAction::GrantCap { capability, .. } if capability == "cap_fowner")
    });

    assert!(chown_pos.is_some(), "expected Chown fix (impact 4)");
    assert!(
        grant_pos.is_some(),
        "expected GrantCap cap_fowner fix (impact 5)"
    );

    let chown_fix = meta_fixes[chown_pos.unwrap()];
    let grant_fix = meta_fixes[grant_pos.unwrap()];
    assert_eq!(chown_fix.impact, 4);
    assert_eq!(grant_fix.impact, 5);

    // Chown (impact 4) should come before GrantCap (impact 5)
    assert!(
        chown_pos.unwrap() < grant_pos.unwrap(),
        "Chown fix should precede GrantCap in plan"
    );
}