whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
use super::*;
use crate::state::acl::{AclEntry, AclPerms, AclTag};
use crate::state::path::StatResult;

#[test]
fn defaults_produce_root_subject_and_read_op() {
    let state = StateBuilder::new().component("/", 0, 0, 0o755).build();
    assert_eq!(state.subject.uid, 0);
    assert_eq!(state.subject.gid, 0);
    assert!(state.subject.groups.is_empty());
    assert_eq!(state.operation, Operation::Read);
}

#[test]
fn subject_overrides_defaults() {
    let state = StateBuilder::new()
        .subject(1000, 1000, vec![33, 44])
        .component("/", 0, 0, 0o755)
        .build();
    assert_eq!(state.subject.uid, 1000);
    assert_eq!(state.subject.gid, 1000);
    assert_eq!(state.subject.groups, vec![33, 44]);
}

#[test]
fn component_creates_directory_with_correct_stat() {
    let state = StateBuilder::new().component("/var", 0, 0, 0o755).build();
    let comp = &state.walk[0];
    assert_eq!(comp.path, PathBuf::from("/var"));
    let stat = comp.stat.known().expect("stat should be Known");
    assert_eq!(stat.uid, 0);
    assert_eq!(stat.gid, 0);
    assert_eq!(stat.mode, 0o755);
    assert_eq!(stat.nlink, 2);
    assert_eq!(stat.file_type, FileType::Directory);
}

#[test]
fn component_file_creates_regular_file() {
    let state = StateBuilder::new()
        .component_file("/etc/passwd", 0, 0, 0o644)
        .build();
    let comp = &state.walk[0];
    let stat = comp.stat.known().expect("stat should be Known");
    assert_eq!(stat.file_type, FileType::Regular);
    assert_eq!(stat.nlink, 1);
    assert_eq!(stat.mode, 0o644);
}

#[test]
fn component_with_acl_sets_acl() {
    let entries = vec![AclEntry {
        tag: AclTag::User,
        qualifier: Some(33),
        perms: AclPerms {
            read: true,
            write: false,
            execute: false,
        },
    }];
    let state = StateBuilder::new()
        .component_with_acl("/var", 0, 0, 0o750, entries.clone())
        .build();
    let comp = &state.walk[0];
    let acl = comp.acl.known().expect("acl should be Known");
    assert_eq!(acl.0.len(), 1);
    assert_eq!(acl.0[0].tag, AclTag::User);
    assert_eq!(acl.0[0].qualifier, Some(33));
}

#[test]
fn component_with_flags_sets_flags() {
    let flags = FsFlags {
        immutable: true,
        append_only: false,
    };
    let state = StateBuilder::new()
        .component_with_flags("/var", 0, 0, 0o755, flags)
        .build();
    let comp = &state.walk[0];
    let fs_flags = comp.flags.known().expect("flags should be Known");
    assert!(fs_flags.immutable);
    assert!(!fs_flags.append_only);
}

#[test]
fn component_inaccessible_has_inaccessible_probes() {
    let state = StateBuilder::new()
        .component_inaccessible("/secret")
        .build();
    let comp = &state.walk[0];
    assert_eq!(comp.stat, Probe::Inaccessible);
    assert_eq!(comp.acl, Probe::Inaccessible);
    assert_eq!(comp.flags, Probe::Inaccessible);
}

#[test]
fn component_unknown_has_unknown_probes() {
    let state = StateBuilder::new().component_unknown("/mystery").build();
    let comp = &state.walk[0];
    assert_eq!(comp.stat, Probe::<StatResult>::Unknown);
    assert_eq!(comp.acl, Probe::<PosixAcl>::Unknown);
    assert_eq!(comp.flags, Probe::<FsFlags>::Unknown);
}

#[test]
fn mount_autolink_component_under_var() {
    let state = StateBuilder::new()
        .mount("/var", "ext4", "rw")
        .component("/var/log", 0, 0, 0o755)
        .build();
    let comp = &state.walk[0];
    assert_eq!(comp.mount, Some(0));
    let stat = comp.stat.known().expect("stat should be Known");
    assert_eq!(stat.dev, 1); // first mount gets device 1
}

#[test]
fn mount_autolink_longest_prefix_wins() {
    let state = StateBuilder::new()
        .mount("/var", "ext4", "rw")
        .mount("/var/log", "ext4", "rw,noexec")
        .component("/var/log/app.log", 0, 0, 0o644)
        .build();
    // "/var/log" is a longer prefix than "/var" => index 1
    let comp = &state.walk[0];
    assert_eq!(comp.mount, Some(1));
    let stat = comp.stat.known().expect("stat should be Known");
    assert_eq!(stat.dev, 2); // second mount gets device 2
}

#[test]
fn mount_options_parsing_ro() {
    let state = StateBuilder::new()
        .mount("/mnt", "ext4", "ro")
        .component("/mnt/data", 0, 0, 0o755)
        .build();
    let mount = &state.mounts.0[0];
    assert!(mount.options.read_only);
    assert!(!mount.options.noexec);
    assert!(!mount.options.nosuid);
}

#[test]
fn mount_options_parsing_multiple() {
    let state = StateBuilder::new()
        .mount("/mnt", "ext4", "ro,noexec,nosuid")
        .component("/mnt/data", 0, 0, 0o755)
        .build();
    let mount = &state.mounts.0[0];
    assert!(mount.options.read_only);
    assert!(mount.options.noexec);
    assert!(mount.options.nosuid);
}

#[test]
fn mount_options_parsing_rw_all_false() {
    let state = StateBuilder::new()
        .mount("/", "ext4", "rw")
        .component("/tmp", 0, 0, 0o1777)
        .build();
    let mount = &state.mounts.0[0];
    assert!(!mount.options.read_only);
    assert!(!mount.options.noexec);
    assert!(!mount.options.nosuid);
}

#[test]
fn nginx_reading_log_file_scenario() {
    let state = StateBuilder::new()
        .subject(33, 33, vec![])
        .operation(Operation::Read)
        .mount("/var", "ext4", "rw")
        .component("/", 0, 0, 0o755)
        .component("/var", 0, 0, 0o755)
        .component("/var/log", 0, 4, 0o2775)
        .component("/var/log/app", 0, 33, 0o750)
        .component_file("/var/log/app/current.log", 0, 33, 0o640)
        .build();

    assert_eq!(state.subject.uid, 33);
    assert_eq!(state.subject.gid, 33);
    assert_eq!(state.operation, Operation::Read);
    assert_eq!(state.walk.len(), 5);

    // root "/" has no mount match (our mount is "/var")
    assert!(state.walk[0].mount.is_none());

    // all components under /var get linked to mount index 0
    for comp in &state.walk[1..] {
        assert_eq!(comp.mount, Some(0));
    }

    // last component is a regular file
    let target = &state.walk[4];
    let stat = target.stat.known().expect("stat should be Known");
    assert_eq!(stat.file_type, FileType::Regular);
    assert_eq!(stat.mode, 0o640);
    assert_eq!(stat.uid, 0);
    assert_eq!(stat.gid, 33);
}

#[test]
#[should_panic(expected = "at least one path component is required")]
fn build_with_no_components_panics() {
    let _state = StateBuilder::new().build();
}

#[test]
fn operation_override_works() {
    let state = StateBuilder::new()
        .operation(Operation::Write)
        .component("/tmp/file", 0, 0, 0o644)
        .build();
    assert_eq!(state.operation, Operation::Write);
}

#[test]
fn inaccessible_component_gets_no_mount_link() {
    let state = StateBuilder::new()
        .mount("/var", "ext4", "rw")
        .component_inaccessible("/var/secret")
        .build();
    // inaccessible components still get mount-linked by path prefix
    let comp = &state.walk[0];
    assert_eq!(comp.mount, Some(0));
    // but stat remains Inaccessible (no dev to set)
    assert_eq!(comp.stat, Probe::Inaccessible);
}

#[test]
fn multiple_mounts_assign_sequential_device_ids() {
    let state = StateBuilder::new()
        .mount("/", "ext4", "rw")
        .mount("/var", "ext4", "rw")
        .mount("/tmp", "tmpfs", "rw,noexec")
        .component("/home", 0, 0, 0o755)
        .build();
    assert_eq!(state.mounts.0[0].device, 1);
    assert_eq!(state.mounts.0[1].device, 2);
    assert_eq!(state.mounts.0[2].device, 3);
}