whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
//! Tests for the POSIX ACL 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 entry(tag: AclTag, qualifier: Option<u32>, r: bool, w: bool, x: bool) -> AclEntry {
    AclEntry {
        tag,
        qualifier,
        perms: AclPerms {
            read: r,
            write: w,
            execute: x,
        },
    }
}

fn base_acl(extras: Vec<AclEntry>) -> Vec<AclEntry> {
    let mut entries = vec![
        entry(AclTag::UserObj, None, true, true, false),
        entry(AclTag::GroupObj, None, true, false, false),
        entry(AclTag::Mask, None, true, true, true),
        entry(AclTag::Other, None, false, false, false),
    ];
    entries.extend(extras);
    entries
}

/// full ACL with all base entries + one named user for triggering extended mode.
fn full_acl(named_uid: u32, r: bool, w: bool, x: bool) -> Vec<AclEntry> {
    base_acl(vec![entry(AclTag::User, Some(named_uid), r, w, x)])
}

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

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

#[test]
fn named_user_acl_grants_read() {
    let state = StateBuilder::new()
        .subject(2000, 2000, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file_with_acl(
            "/file.txt",
            1000,
            1000,
            0o640,
            full_acl(2000, true, false, false),
        )
        .build();

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

#[test]
fn group_acl_entry_grants_access() {
    let acl = base_acl(vec![entry(AclTag::Group, Some(50), true, true, false)]);
    let state = StateBuilder::new()
        .subject(2000, 2000, vec![50])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file_with_acl("/file.txt", 1000, 1000, 0o600, acl)
        .build();

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

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

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

#[test]
fn stat_operation_with_acl_always_passes() {
    let state = StateBuilder::new()
        .subject(2000, 2000, vec![])
        .operation(Operation::Stat)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file_with_acl(
            "/file.txt",
            1000,
            1000,
            0o000,
            full_acl(2000, false, false, false),
        )
        .build();

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

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

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

#[test]
fn no_group_match_falls_through_to_other() {
    // subject's gid and groups don't match any named group or group_obj
    // so evaluation falls through to the Other entry
    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(2000, 2000, 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 group_match_via_supplementary_group() {
    // subject's primary gid=2000 doesn't match file's group,
    // but supplementary group 50 matches a named group entry
    let acl = base_acl(vec![entry(AclTag::Group, Some(50), true, true, 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);
    assert!(result.is_pass());
    if let LayerResult::Pass { detail, .. } = &result {
        assert!(detail.contains("named group"));
    }
}

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

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

#[test]
fn group_obj_matches_primary_gid_grants_access() {
    // subject's primary gid matches file's gid, GroupObj entry grants read
    let acl = vec![
        entry(AclTag::UserObj, None, false, false, false),
        entry(AclTag::User, Some(9999), false, false, false),
        entry(AclTag::GroupObj, None, true, false, false),
        entry(AclTag::Mask, None, true, true, true),
        entry(AclTag::Other, None, false, false, false),
    ];
    let state = StateBuilder::new()
        .subject(2000, 50, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file_with_acl("/file.txt", 1000, 50, 0o040, acl)
        .build();

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

#[test]
fn acl_passes_for_chmod_without_consulting_acl_entries() {
    // ACL denies subject 2000, but Chmod bypasses ACL.
    let acl = full_acl(2000, false, false, false);
    let state = StateBuilder::new()
        .subject(2000, 2000, vec![])
        .operation(Operation::Chmod)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file_with_acl("/file.txt", 1000, 1000, 0o644, acl)
        .build();
    assert!(check_acl(&state).is_pass());
}

#[test]
fn acl_passes_for_chown_uid() {
    use crate::state::acl::{AclEntry, AclPerms, AclTag};
    let deny_all = vec![
        AclEntry {
            tag: AclTag::UserObj,
            qualifier: None,
            perms: AclPerms {
                read: false,
                write: false,
                execute: false,
            },
        },
        AclEntry {
            tag: AclTag::GroupObj,
            qualifier: None,
            perms: AclPerms {
                read: false,
                write: false,
                execute: false,
            },
        },
        AclEntry {
            tag: AclTag::Other,
            qualifier: None,
            perms: AclPerms {
                read: false,
                write: false,
                execute: false,
            },
        },
        AclEntry {
            tag: AclTag::User,
            qualifier: Some(1000),
            perms: AclPerms {
                read: false,
                write: false,
                execute: false,
            },
        },
        AclEntry {
            tag: AclTag::Mask,
            qualifier: None,
            perms: AclPerms {
                read: false,
                write: false,
                execute: false,
            },
        },
    ];
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::ChownUid)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file_with_acl("/file.txt", 1000, 1000, 0o000, deny_all)
        .build();
    assert!(check_acl(&state).is_pass());
}

#[test]
fn acl_passes_for_chown_gid() {
    let acl = full_acl(1000, false, false, false);
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::ChownGid)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file_with_acl("/file.txt", 1000, 1000, 0o644, acl)
        .build();
    assert!(check_acl(&state).is_pass());
}

#[test]
fn acl_passes_for_setxattr_user() {
    use crate::operation::XattrNamespace;
    let acl = full_acl(1000, false, false, false);
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::SetXattr {
            namespace: XattrNamespace::User,
        })
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file_with_acl("/file.txt", 1000, 1000, 0o644, acl)
        .build();
    assert!(check_acl(&state).is_pass());
}