whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
//! Tests for the mount check layer.
use super::*;
use crate::operation::Operation;
use crate::test_helpers::StateBuilder;

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

    let result = check_mount(&state);
    assert!(result.is_pass());
}

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

    let result = check_mount(&state);
    assert!(result.is_fail());
    if let LayerResult::Fail { detail, .. } = &result {
        assert!(detail.contains("read-only"));
        assert!(detail.contains("/mnt"));
    }
}

#[test]
fn execute_on_noexec_mount_fails() {
    let state = StateBuilder::new()
        .operation(Operation::Execute)
        .mount("/mnt", "ext4", "noexec")
        .component("/mnt", 0, 0, 0o755)
        .component_file("/mnt/script.sh", 0, 0, 0o755)
        .build();

    let result = check_mount(&state);
    assert!(result.is_fail());
    if let LayerResult::Fail { detail, .. } = &result {
        assert!(detail.contains("noexec"));
        assert!(detail.contains("/mnt"));
    }
}

#[test]
fn execute_on_rw_mount_passes() {
    let state = StateBuilder::new()
        .operation(Operation::Execute)
        .mount("/usr", "ext4", "rw")
        .component("/usr", 0, 0, 0o755)
        .component_file("/usr/bin/ls", 0, 0, 0o755)
        .build();

    let result = check_mount(&state);
    assert!(result.is_pass());
}

#[test]
fn write_on_rw_mount_passes() {
    let state = StateBuilder::new()
        .operation(Operation::Write)
        .mount("/var", "ext4", "rw")
        .component("/var", 0, 0, 0o755)
        .component_file("/var/log/app.log", 0, 0, 0o644)
        .build();

    let result = check_mount(&state);
    assert!(result.is_pass());
}

#[test]
fn delete_on_ro_mount_fails() {
    let state = StateBuilder::new()
        .operation(Operation::Delete)
        .mount("/mnt", "ext4", "ro")
        .component("/mnt", 0, 0, 0o755)
        .component_file("/mnt/old.txt", 0, 0, 0o644)
        .build();

    let result = check_mount(&state);
    assert!(result.is_fail());
    if let LayerResult::Fail { detail, .. } = &result {
        assert!(detail.contains("read-only"));
    }
}

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

    let result = check_mount(&state);
    assert!(result.is_fail());
    if let LayerResult::Fail { detail, .. } = &result {
        assert!(detail.contains("read-only"));
    }
}

#[test]
fn no_mount_info_returns_degraded() {
    let state = StateBuilder::new()
        .operation(Operation::Write)
        .component("/orphan", 0, 0, 0o755)
        .component_file("/orphan/file.txt", 0, 0, 0o644)
        .build();

    let result = check_mount(&state);
    assert!(result.is_degraded());
}

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

    let result = check_mount(&state);
    assert!(result.is_pass());
}

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

    let result = check_mount(&state);
    if let LayerResult::Fail { detail, .. } = &result {
        assert!(detail.contains("xfs"));
    } else {
        panic!("expected Fail");
    }
}

#[test]
fn execute_nosuid_suid_target_emits_warning() {
    // target has setuid bit (0o4755), mount has nosuid
    let state = StateBuilder::new()
        .operation(Operation::Execute)
        .mount("/mnt", "ext4", "nosuid")
        .component("/mnt", 0, 0, 0o755)
        .component_file("/mnt/suid_bin", 0, 0, 0o4755)
        .build();

    let result = check_mount(&state);
    assert!(result.is_pass());
    if let LayerResult::Pass { warnings, .. } = &result {
        assert_eq!(warnings.len(), 1);
        assert!(warnings[0].contains("nosuid"));
        assert!(warnings[0].contains("setuid/setgid"));
    } else {
        panic!("expected Pass");
    }
}

#[test]
fn execute_nosuid_sgid_target_emits_warning() {
    // target has setgid bit (0o2755), mount has nosuid
    let state = StateBuilder::new()
        .operation(Operation::Execute)
        .mount("/mnt", "ext4", "nosuid")
        .component("/mnt", 0, 0, 0o755)
        .component_file("/mnt/sgid_bin", 0, 0, 0o2755)
        .build();

    let result = check_mount(&state);
    assert!(result.is_pass());
    if let LayerResult::Pass { warnings, .. } = &result {
        assert_eq!(warnings.len(), 1);
        assert!(warnings[0].contains("nosuid"));
    } else {
        panic!("expected Pass");
    }
}

#[test]
fn execute_nosuid_no_suid_bits_no_warning() {
    // target has no suid/sgid bits, mount has nosuid -- no warning
    let state = StateBuilder::new()
        .operation(Operation::Execute)
        .mount("/mnt", "ext4", "nosuid")
        .component("/mnt", 0, 0, 0o755)
        .component_file("/mnt/normal_bin", 0, 0, 0o755)
        .build();

    let result = check_mount(&state);
    assert!(result.is_pass());
    if let LayerResult::Pass { warnings, .. } = &result {
        assert!(warnings.is_empty());
    } else {
        panic!("expected Pass");
    }
}

#[test]
fn execute_no_nosuid_suid_target_no_warning() {
    // target has setuid bit but mount does NOT have nosuid -- no warning
    let state = StateBuilder::new()
        .operation(Operation::Execute)
        .mount("/usr", "ext4", "rw")
        .component("/usr", 0, 0, 0o755)
        .component_file("/usr/bin/sudo", 0, 0, 0o4755)
        .build();

    let result = check_mount(&state);
    assert!(result.is_pass());
    if let LayerResult::Pass { warnings, .. } = &result {
        assert!(warnings.is_empty());
    } else {
        panic!("expected Pass");
    }
}

#[test]
fn write_nosuid_suid_target_no_warning() {
    // nosuid warning only applies to execute operations
    let state = StateBuilder::new()
        .operation(Operation::Write)
        .mount("/mnt", "ext4", "nosuid")
        .component("/mnt", 0, 0, 0o755)
        .component_file("/mnt/suid_bin", 0, 0, 0o4755)
        .build();

    let result = check_mount(&state);
    assert!(result.is_pass());
    if let LayerResult::Pass { warnings, .. } = &result {
        assert!(warnings.is_empty());
    } else {
        panic!("expected Pass");
    }
}

#[test]
fn delete_single_component_returns_degraded() {
    // Delete with single component -> target_component() returns None
    let state = StateBuilder::new()
        .operation(Operation::Delete)
        .mount("/", "ext4", "ro")
        .component("/", 0, 0, 0o755)
        .build();

    let result = check_mount(&state);
    assert!(result.is_degraded());
}