whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
//! Targeted tests for traversal, DAC, and ACL fix generator branches.

use super::generators;
use crate::checks::CoreLayer;
use crate::fix::scoring;
use crate::fix::FixAction;
use crate::operation::Operation;
use crate::test_helpers::StateBuilder;

// --- traversal_fixes ---

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

    let fixes = generators::traversal_fixes(&state, 1);
    assert_eq!(fixes.len(), 2);
    assert_eq!(fixes[0].impact, scoring::ACL_USER_GRANT);
    assert_eq!(fixes[1].impact, scoring::CHMOD_OTHER);
    assert_eq!(fixes[0].layer, CoreLayer::Traversal);
    assert!(matches!(&fixes[0].action, FixAction::SetAcl { entry, .. } if entry.contains("1000")));
    assert!(
        matches!(&fixes[1].action, FixAction::Chmod { mode_change, .. } if mode_change == "o+x")
    );
}

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

    // Index 99 is out of range
    let fixes = generators::traversal_fixes(&state, 99);
    assert!(fixes.is_empty());
}

// --- dac_fixes ---

#[test]
fn dac_fixes_execute_operation_uses_x_perm() {
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Execute)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/script.sh", 0, 0, 0o644)
        .build();

    let fixes = generators::dac_fixes(&state);
    // ACL fix and other-chmod, no group-chmod since gid doesn't match
    assert!(fixes.iter().any(|f| matches!(
        &f.action,
        FixAction::SetAcl { entry, .. } if entry.contains('x')
    )));
    assert!(fixes.iter().any(|f| matches!(
        &f.action,
        FixAction::Chmod { mode_change, .. } if mode_change.contains('x')
    )));
}

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

    let fixes = generators::dac_fixes(&state);
    assert!(fixes.iter().any(|f| matches!(
        &f.action,
        FixAction::SetAcl { entry, .. } if entry.contains('w')
    )));
}

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

    let fixes = generators::dac_fixes(&state);
    assert!(fixes.iter().any(|f| matches!(
        &f.action,
        FixAction::SetAcl { entry, .. } if entry.contains('w')
    )));
}

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

    let fixes = generators::dac_fixes(&state);
    assert!(fixes.iter().any(|f| matches!(
        &f.action,
        FixAction::SetAcl { entry, .. } if entry.contains('r')
    )));
}

#[test]
fn dac_fixes_no_target_returns_empty() {
    // Delete with single component has no parent (target_component returns None)
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Delete)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .build();

    let fixes = generators::dac_fixes(&state);
    assert!(fixes.is_empty());
}

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

    let fixes = generators::dac_fixes(&state);
    assert!(fixes.is_empty());
}

// --- acl_fixes ---

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

    let fixes = generators::acl_fixes(&state);
    assert_eq!(fixes.len(), 1);
    assert_eq!(fixes[0].layer, CoreLayer::Acl);
    assert_eq!(fixes[0].impact, scoring::ACL_USER_GRANT);
    assert!(matches!(
        &fixes[0].action,
        FixAction::SetAcl { entry, .. } if entry.contains("1000") && entry.contains('r')
    ));
}

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

    let fixes = generators::acl_fixes(&state);
    assert_eq!(fixes.len(), 1);
    assert!(matches!(
        &fixes[0].action,
        FixAction::SetAcl { entry, .. } if entry.contains('w')
    ));
}

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

    let fixes = generators::acl_fixes(&state);
    assert!(fixes.is_empty());
}