whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
//! Tests for chattr, remount, chown, and setacl edge case apply semantics.

use std::path::PathBuf;

use super::apply::apply_fix;
use crate::checks::CoreLayer;
use crate::fix::scoring;
use crate::fix::{Fix, FixAction};
use crate::operation::Operation;
use crate::state::acl::AclTag;
use crate::state::fsflags::FsFlags;
use crate::state::Probe;
use crate::test_helpers::StateBuilder;

fn setacl_fix(path: &str, entry: &str) -> Fix {
    Fix {
        layer: CoreLayer::Dac,
        action: FixAction::SetAcl {
            path: PathBuf::from(path),
            entry: entry.to_owned(),
        },
        impact: scoring::ACL_USER_GRANT,
        description: "setacl".into(),
    }
}

fn chattr_fix(path: &str, flags: &str) -> Fix {
    Fix {
        layer: CoreLayer::FsFlags,
        action: FixAction::Chattr {
            path: PathBuf::from(path),
            flags: flags.to_owned(),
        },
        impact: scoring::REMOVE_FS_FLAG,
        description: "chattr".into(),
    }
}

fn remount_fix(mountpoint: &str, options: &str) -> Fix {
    Fix {
        layer: CoreLayer::Mount,
        action: FixAction::Remount {
            mountpoint: PathBuf::from(mountpoint),
            options: options.to_owned(),
        },
        impact: scoring::REMOUNT,
        description: "remount".into(),
    }
}

// --- SetAcl edge cases ---

#[test]
fn apply_setacl_invalid_entry_string_is_noop() {
    let mut state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component_file_with_acl("/file.txt", 0, 0, 0o600, vec![])
        .build();

    let fix = Fix {
        layer: CoreLayer::Dac,
        action: FixAction::SetAcl {
            path: PathBuf::from("/file.txt"),
            entry: "not-a-valid-acl-entry".into(),
        },
        impact: 1,
        description: "invalid".into(),
    };
    apply_fix(&fix, &mut state);

    // Should remain empty ACL
    let Probe::Known(acl) = &state.walk[0].acl else {
        panic!()
    };
    assert!(acl.0.is_empty());
}

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

    let fix = setacl_fix("/file.txt", "g:50:r");
    apply_fix(&fix, &mut state);

    let comp = state
        .walk
        .iter()
        .find(|c| c.path.as_os_str() == "/file.txt")
        .unwrap();
    let Probe::Known(acl) = &comp.acl else {
        panic!()
    };
    let group_entry = acl
        .0
        .iter()
        .find(|e| e.tag == AclTag::Group && e.qualifier == Some(50));
    assert!(group_entry.is_some());
    assert!(group_entry.unwrap().perms.read);
}

// --- Chattr apply ---

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

    let fix = chattr_fix("/locked.txt", "-i");
    apply_fix(&fix, &mut state);

    let comp = state
        .walk
        .iter()
        .find(|c| c.path.as_os_str() == "/locked.txt")
        .unwrap();
    let Probe::Known(flags) = &comp.flags else {
        panic!("flags should be known")
    };
    assert!(!flags.immutable, "immutable should be cleared");
}

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

    let fix = chattr_fix("/append.txt", "-a");
    apply_fix(&fix, &mut state);

    let comp = state
        .walk
        .iter()
        .find(|c| c.path.as_os_str() == "/append.txt")
        .unwrap();
    let Probe::Known(flags) = &comp.flags else {
        panic!()
    };
    assert!(!flags.append_only, "append_only should be cleared");
}

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

    let fix = chattr_fix("/file.txt", "-z");
    apply_fix(&fix, &mut state);

    let comp = state
        .walk
        .iter()
        .find(|c| c.path.as_os_str() == "/file.txt")
        .unwrap();
    let Probe::Known(flags) = &comp.flags else {
        panic!()
    };
    // Both should remain unchanged
    assert!(flags.immutable);
    assert!(flags.append_only);
}

#[test]
fn apply_chattr_on_unknown_flags_is_noop() {
    let mut state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Write)
        .mount("/", "ext4", "rw")
        .component_unknown("/mystery.txt")
        .build();

    let fix = chattr_fix("/mystery.txt", "-i");
    apply_fix(&fix, &mut state);

    // Should remain Unknown
    assert!(matches!(state.walk[0].flags, Probe::Unknown));
}

// --- Remount apply ---

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

    let fix = remount_fix("/mnt", "rw");
    apply_fix(&fix, &mut state);

    let mount = state
        .mounts
        .0
        .iter()
        .find(|m| m.mountpoint.as_os_str() == "/mnt")
        .unwrap();
    assert!(
        !mount.options.read_only,
        "read_only should be cleared after remount rw"
    );
}

#[test]
fn apply_remount_exec_clears_noexec() {
    let mut state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Execute)
        .mount("/mnt", "tmpfs", "noexec")
        .component("/mnt", 0, 0, 0o755)
        .component_file("/mnt/script.sh", 1000, 1000, 0o755)
        .build();

    let fix = remount_fix("/mnt", "exec");
    apply_fix(&fix, &mut state);

    let mount = state
        .mounts
        .0
        .iter()
        .find(|m| m.mountpoint.as_os_str() == "/mnt")
        .unwrap();
    assert!(
        !mount.options.noexec,
        "noexec should be cleared after remount exec"
    );
}

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

    let fix = remount_fix("/mnt", "unknown_option");
    apply_fix(&fix, &mut state);

    // read_only should remain true
    let mount = state
        .mounts
        .0
        .iter()
        .find(|m| m.mountpoint.as_os_str() == "/mnt")
        .unwrap();
    assert!(mount.options.read_only);
}

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

    let fix = remount_fix("/does-not-exist", "rw");
    apply_fix(&fix, &mut state);

    // Original /mnt mount should be unchanged
    let mount = state
        .mounts
        .0
        .iter()
        .find(|m| m.mountpoint.as_os_str() == "/mnt")
        .unwrap();
    assert!(mount.options.read_only);
}

// --- Chown apply ---

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

    let fix = Fix {
        layer: CoreLayer::Dac,
        action: FixAction::Chown {
            path: PathBuf::from("/file.txt"),
            owner: Some(1000),
            group: None,
        },
        impact: 2,
        description: "chown".into(),
    };
    apply_fix(&fix, &mut state);

    let Probe::Known(stat) = &state.walk[0].stat else {
        panic!()
    };
    assert_eq!(stat.uid, 1000, "uid should be updated to 1000");
}