whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
//! Tests for core DAC permission checks (owner/group/other, sticky, create/delete).
use super::*;
use crate::checks::capability_modify;
use crate::operation::Operation;
use crate::test_helpers::StateBuilder;

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

#[test]
fn group_read_on_0640_passes() {
    let state = StateBuilder::new()
        .subject(2000, 1000, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/file.txt", 1000, 1000, 0o640)
        .build();
    assert!(check_dac(&state).is_pass());
}

#[test]
fn other_read_on_0640_fails() {
    let state = StateBuilder::new()
        .subject(2000, 2000, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/file.txt", 1000, 1000, 0o640)
        .build();
    let result = check_dac(&state);
    assert!(result.is_fail());
    if let LayerResult::Fail { detail, .. } = &result {
        assert!(detail.contains("other"));
        assert!(detail.contains("no r permission"));
    }
}

#[test]
fn owner_write_on_0644_passes() {
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Write)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/file.txt", 1000, 1000, 0o644)
        .build();
    assert!(check_dac(&state).is_pass());
}

#[test]
fn other_write_on_0644_fails() {
    let state = StateBuilder::new()
        .subject(2000, 2000, vec![])
        .operation(Operation::Write)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/file.txt", 1000, 1000, 0o644)
        .build();
    assert!(check_dac(&state).is_fail());
}

#[test]
fn root_read_on_0000_passes_via_override() {
    let state = StateBuilder::new()
        .subject(0, 0, vec![])
        .operation(Operation::Read)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/secret.txt", 1000, 1000, 0o000)
        .build();
    let result = capability_modify(check_dac(&state), &state);
    assert!(result.is_pass());
    if let LayerResult::Pass { detail, .. } = &result {
        assert!(detail.contains("CAP_DAC_OVERRIDE"));
    }
}

#[test]
fn root_execute_on_0644_fails_no_x_anywhere() {
    let state = StateBuilder::new()
        .subject(0, 0, vec![])
        .operation(Operation::Execute)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/script.sh", 1000, 1000, 0o644)
        .build();
    let result = capability_modify(check_dac(&state), &state);
    assert!(result.is_fail());
    if let LayerResult::Fail { detail, .. } = &result {
        assert!(detail.contains("no execute bit"));
        assert!(detail.contains("CAP_DAC_OVERRIDE does not apply"));
    }
}

#[test]
fn delete_with_sticky_subject_owns_target_passes() {
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Delete)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component("/tmp", 0, 0, 0o1777)
        .component_file("/tmp/myfile.txt", 1000, 1000, 0o644)
        .build();
    let result = check_dac(&state);
    assert!(result.is_pass());
    if let LayerResult::Pass { detail, .. } = &result {
        assert!(detail.contains("sticky"));
    }
}

#[test]
fn delete_with_sticky_subject_owns_neither_fails() {
    let state = StateBuilder::new()
        .subject(2000, 2000, vec![])
        .operation(Operation::Delete)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component("/tmp", 0, 0, 0o1777)
        .component_file("/tmp/otherfile.txt", 1000, 1000, 0o644)
        .build();
    let result = check_dac(&state);
    assert!(result.is_fail());
    if let LayerResult::Fail { detail, .. } = &result {
        assert!(detail.contains("sticky"));
        assert!(detail.contains("uid=2000"));
    }
}

#[test]
fn create_parent_has_wx_passes() {
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Create)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component("/dir", 1000, 1000, 0o755)
        .component_file("/dir/newfile.txt", 1000, 1000, 0o644)
        .build();
    assert!(check_dac(&state).is_pass());
}

#[test]
fn create_parent_missing_write_fails() {
    let state = StateBuilder::new()
        .subject(2000, 2000, vec![])
        .operation(Operation::Create)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component("/dir", 1000, 1000, 0o755)
        .component_file("/dir/newfile.txt", 2000, 2000, 0o644)
        .build();
    let result = check_dac(&state);
    assert!(result.is_fail());
    if let LayerResult::Fail { detail, .. } = &result {
        assert!(detail.contains("w+x"));
    }
}

#[test]
fn stat_always_passes_regardless_of_mode() {
    let state = StateBuilder::new()
        .subject(2000, 2000, vec![])
        .operation(Operation::Stat)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/secret.txt", 0, 0, 0o000)
        .build();
    assert!(check_dac(&state).is_pass());
}

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

#[test]
fn delete_parent_with_unknown_stat_returns_degraded() {
    // Delete where parent stat is Unknown -> check_parent_dac -> degraded
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Delete)
        .component_unknown("/unknown_dir")
        .component_file("/unknown_dir/file.txt", 1000, 1000, 0o644)
        .build();
    let result = check_dac(&state);
    assert!(result.is_degraded());
}

#[test]
fn read_single_component_with_unknown_stat_returns_degraded() {
    // single component with unknown stat -> degraded
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Read)
        .component_unknown("/mystery.txt")
        .build();
    let result = check_dac(&state);
    assert!(result.is_degraded());
}

#[test]
fn sticky_bit_target_stat_unknown_returns_degraded() {
    // sticky bit set on parent (0o1777), target stat is Unknown.
    // Other has w+x so sticky check fires; target being Unknown => degraded.
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![])
        .operation(Operation::Delete)
        .component("/sticky_dir", 0, 0, 0o1777)
        .component_unknown("/sticky_dir/file.txt")
        .build();
    let result = check_dac(&state);
    assert!(result.is_degraded());
}

#[test]
fn dac_passes_for_chmod_without_consulting_mode_bits() {
    // uid=999 ≠ file owner uid=1000 — would normally fail DAC.
    // metadata ops bypass DAC entirely.
    let state = StateBuilder::new()
        .subject(999, 999, vec![])
        .operation(Operation::Chmod)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/file.txt", 1000, 1000, 0o000)
        .build();
    let result = check_dac(&state);
    assert!(result.is_pass(), "expected Pass for Chmod, got {result:?}");
}

#[test]
fn dac_passes_for_chown_uid() {
    let state = StateBuilder::new()
        .subject(999, 999, vec![])
        .operation(Operation::ChownUid)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/file.txt", 1000, 1000, 0o000)
        .build();
    assert!(check_dac(&state).is_pass());
}

#[test]
fn dac_passes_for_chown_gid() {
    let state = StateBuilder::new()
        .subject(999, 999, vec![])
        .operation(Operation::ChownGid)
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/file.txt", 1000, 1000, 0o000)
        .build();
    assert!(check_dac(&state).is_pass());
}

#[test]
fn dac_passes_for_setxattr_user() {
    use crate::operation::XattrNamespace;
    let state = StateBuilder::new()
        .subject(999, 999, vec![])
        .operation(Operation::SetXattr {
            namespace: XattrNamespace::User,
        })
        .mount("/", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component_file("/file.txt", 1000, 1000, 0o000)
        .build();
    assert!(check_dac(&state).is_pass());
}

/// Verifies all three valid permission chars produce correct results.
///
/// `check_bit` uses `unreachable!` for any char outside `'r'`, `'w'`, `'x'`.
/// This test pins the valid-char behavior and confirms the guard is
/// meaningful: a bad char would panic immediately rather than silently deny.
#[test]
fn check_bit_valid_chars_produce_correct_results() {
    use crate::state::path::{FileType, StatResult};
    use crate::state::subject::ResolvedSubject;
    use crate::state::Probe;

    // mode 0o777: all bits set; subject is owner
    let stat = StatResult {
        uid: 1000,
        gid: 1000,
        mode: 0o777,
        dev: 1,
        nlink: 1,
        file_type: FileType::Regular,
    };
    let subject = ResolvedSubject {
        uid: 1000,
        gid: 1000,
        groups: vec![],
        capabilities: Probe::Unknown,
    };
    assert!(
        check_bit(&stat, &subject, 'r'),
        "owner 'r' on 0o777 must be true"
    );
    assert!(
        check_bit(&stat, &subject, 'w'),
        "owner 'w' on 0o777 must be true"
    );
    assert!(
        check_bit(&stat, &subject, 'x'),
        "owner 'x' on 0o777 must be true"
    );

    // mode 0o000: no permissions for anyone
    let stat_zero = StatResult {
        uid: 1000,
        gid: 1000,
        mode: 0o000,
        dev: 1,
        nlink: 1,
        file_type: FileType::Regular,
    };
    assert!(
        !check_bit(&stat_zero, &subject, 'r'),
        "owner 'r' on 0o000 must be false"
    );
    assert!(
        !check_bit(&stat_zero, &subject, 'w'),
        "owner 'w' on 0o000 must be false"
    );
    assert!(
        !check_bit(&stat_zero, &subject, 'x'),
        "owner 'x' on 0o000 must be false"
    );
}