whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
//! Tests for the traversal check layer — basic allow/deny cases.
use super::*;
use crate::operation::Operation;
use crate::state::acl::{AclEntry, AclPerms, AclTag};
use crate::test_helpers::StateBuilder;

fn acl_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 all_ancestors_world_traversable() {
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component("/var", 0, 0, 0o755)
        .component("/var/log", 0, 0, 0o755)
        .component_file("/var/log/app.log", 0, 0, 0o644)
        .build();

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

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

    let result = check_traversal(&state);
    assert!(result.is_fail());
    if let LayerResult::Fail {
        detail,
        component_index,
    } = &result
    {
        assert!(detail.contains("/var"));
        assert!(detail.contains("other"));
        assert_eq!(*component_index, Some(1));
    }
}

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

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

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

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

#[test]
fn acl_grants_traverse_on_ancestor() {
    let acl_entries = vec![
        acl_entry(AclTag::UserObj, None, true, true, true),
        acl_entry(AclTag::User, Some(1000), true, false, true),
        acl_entry(AclTag::GroupObj, None, false, false, false),
        acl_entry(AclTag::Mask, None, true, true, true),
        acl_entry(AclTag::Other, None, false, false, false),
    ];
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_with_acl("/restricted", 0, 0, 0o700, acl_entries)
        .component_file("/restricted/file.txt", 0, 0, 0o644)
        .build();

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

#[test]
fn acl_denies_traverse_on_ancestor() {
    let acl_entries = vec![
        acl_entry(AclTag::UserObj, None, true, true, true),
        acl_entry(AclTag::User, Some(1000), true, false, true),
        acl_entry(AclTag::GroupObj, None, false, false, false),
        acl_entry(AclTag::Mask, None, true, false, false),
        acl_entry(AclTag::Other, None, false, false, false),
    ];
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_with_acl("/restricted", 0, 0, 0o700, acl_entries)
        .component_file("/restricted/file.txt", 0, 0, 0o644)
        .build();

    let result = check_traversal(&state);
    assert!(result.is_fail());
    if let LayerResult::Fail { detail, .. } = &result {
        assert!(detail.contains("/restricted"));
        assert!(detail.contains("ACL"));
    }
}

#[test]
fn acl_grants_execute_via_named_group() {
    let acl_entries = vec![
        acl_entry(AclTag::UserObj, None, true, true, false),
        acl_entry(AclTag::Group, Some(50), false, false, true),
        acl_entry(AclTag::GroupObj, None, false, false, false),
        acl_entry(AclTag::Mask, None, true, true, true),
        acl_entry(AclTag::Other, None, false, false, false),
    ];
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![50])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_with_acl("/groupdir", 0, 0, 0o700, acl_entries)
        .component_file("/groupdir/file.txt", 0, 0, 0o644)
        .build();

    let result = check_traversal(&state);
    // named group 50 grants execute; subject has supplementary group 50
    assert!(result.is_pass(), "expected pass, got: {result:?}");
}

#[test]
fn acl_mask_restricts_group_execute_to_deny() {
    let acl_entries = vec![
        acl_entry(AclTag::UserObj, None, true, true, true),
        acl_entry(AclTag::Group, Some(50), false, false, true),
        acl_entry(AclTag::GroupObj, None, false, false, false),
        acl_entry(AclTag::Mask, None, true, false, false),
        acl_entry(AclTag::Other, None, false, false, false),
    ];
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![50])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_with_acl("/masked", 0, 0, 0o700, acl_entries)
        .component_file("/masked/file.txt", 0, 0, 0o644)
        .build();

    let result = check_traversal(&state);
    assert!(result.is_fail(), "expected fail, got: {result:?}");
    if let LayerResult::Fail { detail, .. } = &result {
        assert!(detail.contains("/masked"));
    }
}

#[test]
fn acl_other_entry_grants_execute() {
    // subject has no uid/group match; falls through to Other
    let acl_entries = vec![
        acl_entry(AclTag::UserObj, None, true, true, true),
        acl_entry(AclTag::User, Some(9999), false, false, false),
        acl_entry(AclTag::GroupObj, None, false, false, false),
        acl_entry(AclTag::Mask, None, true, false, false),
        acl_entry(AclTag::Other, None, false, false, true),
    ];
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_with_acl("/world", 0, 99, 0o700, acl_entries)
        .component_file("/world/file.txt", 0, 0, 0o644)
        .build();

    let result = check_traversal(&state);
    assert!(result.is_pass(), "expected pass, got: {result:?}");
}

#[test]
fn acl_owner_uses_user_obj_path() {
    // subject is file owner -- uses UserObj, not named entries
    let acl_entries = vec![
        acl_entry(AclTag::UserObj, None, true, true, true),
        acl_entry(AclTag::User, Some(9999), false, false, false),
        acl_entry(AclTag::GroupObj, None, false, false, false),
        acl_entry(AclTag::Mask, None, false, false, false),
        acl_entry(AclTag::Other, None, false, false, false),
    ];
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_with_acl("/owned", 1000, 0, 0o700, acl_entries)
        .component_file("/owned/file.txt", 0, 0, 0o644)
        .build();

    let result = check_traversal(&state);
    // UserObj grants execute; owner should pass
    assert!(result.is_pass(), "expected pass, got: {result:?}");
}