whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
//! Tests for POSIX ACL mask interaction, edge cases, and degraded paths.
use super::*;
use crate::operation::Operation;
use crate::state::acl::{AclEntry, AclPerms, AclTag};
use crate::state::mac::MacState;
use crate::test_helpers::StateBuilder;

fn entry(tag: AclTag, qualifier: Option<u32>, r: bool, w: bool, x: bool) -> AclEntry {
    AclEntry {
        tag,
        qualifier,
        perms: AclPerms {
            read: r,
            write: w,
            execute: x,
        },
    }
}

#[test]
fn delete_operation_checks_parent_acl() {
    // Delete redirects check to parent component (second-to-last in walk)
    // Parent has extended ACL that denies write+execute
    let parent_acl = vec![
        entry(AclTag::UserObj, None, true, true, true),
        entry(AclTag::User, Some(2000), false, false, false),
        entry(AclTag::GroupObj, None, false, false, false),
        entry(AclTag::Mask, None, true, false, false),
        entry(AclTag::Other, None, false, false, false),
    ];
    let state = StateBuilder::new()
        .subject(2000, 2000, vec![])
        .operation(Operation::Delete)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_with_acl("/dir", 0, 0, 0o755, parent_acl)
        .component_file("/dir/file.txt", 2000, 2000, 0o644)
        .build();

    let result = check_acl(&state);
    assert!(result.is_fail());
    if let LayerResult::Fail { detail, .. } = &result {
        assert!(detail.contains("named user"));
    }
}

#[test]
fn create_operation_checks_parent_acl_grants() {
    // Create redirects check to parent; parent ACL grants write+execute to named user
    let parent_acl = vec![
        entry(AclTag::UserObj, None, true, true, true),
        entry(AclTag::User, Some(2000), true, true, true),
        entry(AclTag::GroupObj, None, false, false, false),
        entry(AclTag::Mask, None, true, true, true),
        entry(AclTag::Other, None, false, false, false),
    ];
    let state = StateBuilder::new()
        .subject(2000, 2000, vec![])
        .operation(Operation::Create)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_with_acl("/dir", 0, 0, 0o755, parent_acl)
        .component_file("/dir/newfile.txt", 2000, 2000, 0o644)
        .build();

    let result = check_acl(&state);
    assert!(result.is_pass());
    if let LayerResult::Pass { detail, .. } = &result {
        assert!(detail.contains("named user"));
    }
}

#[test]
fn named_user_acl_blocked_by_mask() {
    let acl = vec![
        entry(AclTag::UserObj, None, true, true, true),
        entry(AclTag::User, Some(2000), true, true, true),
        entry(AclTag::GroupObj, None, true, false, false),
        entry(AclTag::Mask, None, true, false, false),
        entry(AclTag::Other, None, false, false, false),
    ];
    let state = StateBuilder::new()
        .subject(2000, 2000, vec![])
        .operation(Operation::Write)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file_with_acl("/file.txt", 1000, 1000, 0o640, acl)
        .build();

    let result = check_acl(&state);
    assert!(result.is_fail());
    if let LayerResult::Fail { detail, .. } = &result {
        assert!(detail.contains("named user"));
        assert!(detail.contains("write"));
    }
}

#[test]
fn file_owner_uses_user_obj_not_affected_by_mask() {
    let acl = vec![
        entry(AclTag::UserObj, None, true, true, true),
        entry(AclTag::User, Some(9999), false, false, false),
        entry(AclTag::GroupObj, None, false, false, false),
        entry(AclTag::Mask, None, false, false, false),
        entry(AclTag::Other, None, false, false, false),
    ];
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Write)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file_with_acl("/file.txt", 1000, 1000, 0o700, acl)
        .build();

    let result = check_acl(&state);
    assert!(result.is_pass());
    if let LayerResult::Pass { detail, .. } = &result {
        assert!(detail.contains("UserObj"));
    }
}

#[test]
fn other_entry_not_affected_by_mask() {
    let acl = vec![
        entry(AclTag::UserObj, None, true, true, false),
        entry(AclTag::User, Some(9999), false, false, false),
        entry(AclTag::GroupObj, None, false, false, false),
        entry(AclTag::Mask, None, false, false, false),
        entry(AclTag::Other, None, true, false, false),
    ];
    let state = StateBuilder::new()
        .subject(3000, 3000, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file_with_acl("/file.txt", 1000, 1000, 0o604, acl)
        .build();

    let result = check_acl(&state);
    assert!(result.is_pass());
    if let LayerResult::Pass { detail, .. } = &result {
        assert!(detail.contains("Other"));
    }
}

#[test]
fn supplementary_group_match_mask_restricts() {
    // named group grants write, but mask denies it
    let acl = vec![
        entry(AclTag::UserObj, None, true, true, false),
        entry(AclTag::Group, Some(50), true, true, false),
        entry(AclTag::GroupObj, None, false, false, false),
        entry(AclTag::Mask, None, true, false, false),
        entry(AclTag::Other, None, false, false, false),
    ];
    let state = StateBuilder::new()
        .subject(2000, 2000, vec![50])
        .operation(Operation::Write)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file_with_acl("/file.txt", 1000, 1000, 0o600, acl)
        .build();

    let result = check_acl(&state);
    // best group match has write denied by mask
    assert!(result.is_fail());
}

#[test]
fn stat_unavailable_for_acl_evaluation_returns_degraded() {
    // acl is Known with extended entries, but stat is Unknown
    // build component manually to create this state
    use crate::state::acl::PosixAcl;
    use crate::state::mount::MountTable;
    use crate::state::path::PathComponent;
    use crate::state::subject::ResolvedSubject;
    use crate::state::{Probe, SystemState};
    use std::path::PathBuf;

    let acl_entries = vec![
        entry(AclTag::UserObj, None, true, true, false),
        entry(AclTag::User, Some(2000), true, false, false),
        entry(AclTag::GroupObj, None, false, false, false),
        entry(AclTag::Mask, None, true, false, false),
        entry(AclTag::Other, None, false, false, false),
    ];
    let comp = PathComponent {
        path: PathBuf::from("/file.txt"),
        stat: Probe::Unknown,
        acl: Probe::Known(PosixAcl(acl_entries)),
        flags: Probe::Unknown,
        mount: None,
    };
    let state = SystemState {
        subject: ResolvedSubject {
            uid: 2000,
            gid: 2000,
            groups: vec![],
            capabilities: Probe::Unknown,
        },
        walk: vec![comp],
        mounts: MountTable(vec![]),
        operation: Operation::Read,
        mac_state: MacState::default(),
    };

    let result = check_acl(&state);
    assert!(result.is_degraded());
    if let LayerResult::Degraded { reason } = &result {
        assert!(reason.contains("stat unavailable"));
    }
}

#[test]
fn check_acl_target_walk_too_short_returns_degraded() {
    // Read with 0 components after target_component returns None
    // Build state with "/" for a Read op:
    // Walk len=1, target_component for Read = Some(0) -- returns index 0, not None.
    // For the None case we need Delete/Create with only one component (no parent).
    // Stat short-circuits, and Delete/Create go to check_parent_acl.
    // Test Delete single component -> check_parent_acl -> None
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Delete)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .build();

    let result = check_acl(&state);
    assert!(result.is_degraded());
    if let LayerResult::Degraded { reason } = &result {
        assert!(reason.contains("parent"));
    }
}

#[test]
fn check_acl_create_walk_too_short_returns_degraded() {
    // Create with single component -> check_parent_acl -> target_component returns None
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Create)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .build();

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