whyno-core 0.5.0

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

#[test]
fn all_pass_report_yields_empty_plan() {
    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 = crate::checks::run_checks(&state, &MetadataParams::default());
    assert!(report.is_allowed());

    let plan = generate_fixes(&report, &state, &MetadataParams::default());
    assert!(plan.fixes.is_empty());
    assert!(plan.warnings.is_empty());
}

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

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

    let mount_fixes: Vec<_> = plan
        .fixes
        .iter()
        .filter(|f| f.layer == CoreLayer::Mount)
        .collect();
    assert_eq!(mount_fixes.len(), 1);
    assert_eq!(mount_fixes[0].impact, scoring::REMOUNT);
    assert!(!plan.warnings.is_empty());
}

#[test]
fn immutable_file_generates_chattr_fix_with_warning() {
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Write)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file_with_flags(
            "/data.txt",
            1000,
            1000,
            0o644,
            FsFlags {
                immutable: true,
                append_only: false,
            },
        )
        .build();

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

    let flag_fixes: Vec<_> = plan
        .fixes
        .iter()
        .filter(|f| f.layer == CoreLayer::FsFlags)
        .collect();
    assert_eq!(flag_fixes.len(), 1);
    assert_eq!(flag_fixes[0].impact, scoring::REMOVE_FS_FLAG);
    assert!(plan
        .warnings
        .iter()
        .any(|w| w.contains("removes filesystem protection")));
}

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

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

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

    // ACL fix (impact 1) should come before chmod fix (impact 4)
    assert!(trav_fixes.len() >= 2);
    assert!(trav_fixes[0].impact < trav_fixes[1].impact);
    assert_eq!(trav_fixes[0].impact, scoring::ACL_USER_GRANT);
    assert_eq!(trav_fixes[1].impact, scoring::CHMOD_OTHER);
}

#[test]
fn dac_fail_generates_multiple_ranked_fixes() {
    // uid=1000 primary gid=100, file owned by root:100 mode 0600
    // group has no read, subject is in group but lacks permission
    let state = StateBuilder::new()
        .subject(1000, 100, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/secret.txt", 0, 100, 0o600)
        .build();

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

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

    // should have: ACL (1), group chmod (3), other chmod (4)
    assert_eq!(dac_fixes.len(), 3);
    assert_eq!(dac_fixes[0].impact, scoring::ACL_USER_GRANT);
    assert_eq!(dac_fixes[1].impact, scoring::CHMOD_GROUP);
    assert_eq!(dac_fixes[2].impact, scoring::CHMOD_OTHER);
}

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

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

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

    // no group fix since uid 1000 is not in group 0
    assert_eq!(dac_fixes.len(), 2);
    assert_eq!(dac_fixes[0].impact, scoring::ACL_USER_GRANT);
    assert_eq!(dac_fixes[1].impact, scoring::CHMOD_OTHER);
}

#[test]
fn grant_cap_variant_constructable_without_path() {
    let action = FixAction::GrantCap {
        path: None,
        capability: "cap_fowner".into(),
    };
    assert!(matches!(action, FixAction::GrantCap { .. }));
}

#[test]
fn grant_cap_variant_constructable_with_path() {
    let action = FixAction::GrantCap {
        path: Some(std::path::PathBuf::from("/usr/bin/myapp")),
        capability: "cap_chown".into(),
    };
    assert!(matches!(action, FixAction::GrantCap { .. }));
}

/// Verify `collect_mount_fixes` generates exactly one warning from newly
/// added fixes, not from pre-existing fixes of other layers.
///
/// Calls collectors directly to observe their isolated effect on warnings.
#[test]
fn collect_mount_fixes_warns_only_for_its_own_new_fixes() {
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Write)
        .mount("/mnt", "ext4", "ro")
        .component("/mnt", 0, 0, 0o755)
        .component_file_with_flags(
            "/mnt/data.txt",
            1000,
            1000,
            0o644,
            FsFlags {
                immutable: true,
                append_only: false,
            },
        )
        .build();

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

    let mut fixes: Vec<Fix> = Vec::new();
    let mut warnings: Vec<String> = Vec::new();

    // Pre-populate fixes with a FsFlags fix to simulate a prior collector run.
    collect_fsflags_fixes(&report, &state, &mut fixes, &mut warnings);
    let fsflags_warning_count = warnings.len();

    // Now run the Mount collector; it must add exactly one more warning.
    collect_mount_fixes(&report, &state, &mut fixes, &mut warnings);

    let mount_warnings: Vec<_> = warnings[fsflags_warning_count..].to_vec();
    assert_eq!(
        mount_warnings.len(),
        1,
        "collect_mount_fixes must produce exactly one warning, got {mount_warnings:?}"
    );
    assert!(
        mount_warnings[0].contains("affects entire filesystem"),
        "mount warning text must contain 'affects entire filesystem': {mount_warnings:?}"
    );
}

/// Verify `collect_fsflags_fixes` generates exactly one warning from newly
/// added fixes, not from pre-existing fixes of other layers.
#[test]
fn collect_fsflags_fixes_warns_only_for_its_own_new_fixes() {
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Write)
        .mount("/mnt", "ext4", "ro")
        .component("/mnt", 0, 0, 0o755)
        .component_file_with_flags(
            "/mnt/data.txt",
            1000,
            1000,
            0o644,
            FsFlags {
                immutable: true,
                append_only: false,
            },
        )
        .build();

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

    let mut fixes: Vec<Fix> = Vec::new();
    let mut warnings: Vec<String> = Vec::new();

    // Pre-populate fixes with a Mount fix to simulate a prior collector run.
    collect_mount_fixes(&report, &state, &mut fixes, &mut warnings);
    let mount_warning_count = warnings.len();

    // Now run the FsFlags collector; it must add exactly one more warning.
    collect_fsflags_fixes(&report, &state, &mut fixes, &mut warnings);

    let fsflags_warnings: Vec<_> = warnings[mount_warning_count..].to_vec();
    assert_eq!(
        fsflags_warnings.len(),
        1,
        "collect_fsflags_fixes must produce exactly one warning, got {fsflags_warnings:?}"
    );
    assert!(
        fsflags_warnings[0].contains("removes filesystem protection"),
        "fsflags warning text must contain 'removes filesystem protection': {fsflags_warnings:?}"
    );
}

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

    let report = crate::checks::run_checks(&state, &MetadataParams::default());
    // root bypasses DAC via CAP_DAC_OVERRIDE (except execute without any +x)
    assert!(report.core_results[CoreLayer::Dac].is_pass());

    let plan = generate_fixes(&report, &state, &MetadataParams::default());
    let dac_fixes: Vec<_> = plan
        .fixes
        .iter()
        .filter(|f| f.layer == CoreLayer::Dac)
        .collect();
    assert!(dac_fixes.is_empty());
}