whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
//! POSIX ACL check layer.
//!
//! Evaluates POSIX.1e access ACL entries on target component.
//! When extended entries exist (named `User` or `Group`), ACL takes
//! precedence over traditional mode bits for those classes.

use crate::operation::Operation;
use crate::state::acl::{AclPerms, PosixAcl};
use crate::state::path::StatResult;
use crate::state::subject::ResolvedSubject;
use crate::state::{Probe, SystemState};

use super::LayerResult;

/// Checks whether POSIX ACL entries allow the requested operation.
///
/// Returns `Pass` when no extended ACL entries exist (base-only ACL).
/// When extended entries exist, evaluates via POSIX.1e algorithm:
/// `UserObj` -> named `User` -> groups (best match) -> `Other`.
/// Metadata ops bypass ACL checks — they are governed by `check_metadata`.
#[must_use]
pub fn check_acl(state: &SystemState) -> LayerResult {
    if state.operation.is_metadata() {
        return LayerResult::Pass {
            detail: "metadata ops bypass ACL mode-bit checks".into(),
            warnings: vec![],
        };
    }

    if state.operation == Operation::Stat {
        return LayerResult::Pass {
            detail: "stat requires only traversal, no ACL check".into(),
            warnings: vec![],
        };
    }

    if state.operation.checks_parent() {
        return check_parent_acl(state);
    }

    check_target_acl(state)
}

fn check_target_acl(state: &SystemState) -> LayerResult {
    let Some(idx) = state.operation.target_component(state.walk.len()) else {
        return degraded("path walk too short to determine target");
    };
    let comp = &state.walk[idx];
    evaluate_component_acl(&comp.acl, &comp.stat, state)
}

fn check_parent_acl(state: &SystemState) -> LayerResult {
    let Some(idx) = state.operation.target_component(state.walk.len()) else {
        return degraded("path walk too short to determine parent");
    };
    let comp = &state.walk[idx];
    evaluate_component_acl(&comp.acl, &comp.stat, state)
}

fn evaluate_component_acl(
    acl_probe: &Probe<PosixAcl>,
    stat_probe: &Probe<StatResult>,
    state: &SystemState,
) -> LayerResult {
    let acl = match acl_probe {
        Probe::Known(a) => a,
        Probe::Inaccessible => return degraded("ACL inaccessible"),
        Probe::Unknown => return degraded("ACL unknown"),
    };

    if !acl.has_extended_entries() {
        return LayerResult::Pass {
            detail: "no extended ACL entries".into(),
            warnings: vec![],
        };
    }

    let Probe::Known(stat) = stat_probe else {
        return degraded("stat unavailable for ACL evaluation");
    };

    let needed = required_perms(state.operation);
    let (has_perm, matched_entry) = evaluate_acl(acl, stat, &state.subject, &needed);
    format_acl_result(has_perm, &matched_entry, &needed)
}

/// Evaluates POSIX.1e algorithm, returns (granted, entry description).
fn evaluate_acl(
    acl: &PosixAcl,
    stat: &StatResult,
    subject: &ResolvedSubject,
    needed: &NeededPerms,
) -> (bool, String) {
    if stat.uid == subject.uid {
        let perms = acl.user_obj().map_or(AclPerms::NONE, |e| e.perms);
        let granted = check_needed(perms, needed);
        return (granted, "UserObj (file owner)".into());
    }

    if let Some(entry) = acl.named_user(subject.uid) {
        let eff = acl.effective_perms(entry);
        let granted = check_needed(eff, needed);
        return (granted, format!("named user uid={}", subject.uid));
    }

    if let Some((granted, desc)) = best_group_match(acl, stat, subject, needed) {
        return (granted, desc);
    }

    let perms = acl.other().map_or(AclPerms::NONE, |e| e.perms);
    let granted = check_needed(perms, needed);
    (granted, "Other".into())
}

fn best_group_match(
    acl: &PosixAcl,
    stat: &StatResult,
    subject: &ResolvedSubject,
    needed: &NeededPerms,
) -> Option<(bool, String)> {
    let mut best_granted = false;
    let mut matched = false;
    let mut desc = String::new();

    if subject.in_group(stat.gid) {
        if let Some(entry) = acl.group_obj() {
            let eff = acl.effective_perms(entry);
            let g = check_needed(eff, needed);
            if g || !matched {
                best_granted = g;
                desc = "GroupObj (owning group)".into();
            }
            matched = true;
        }
    }

    for entry in acl.named_groups() {
        if let Some(gid) = entry.qualifier {
            if subject.in_group(gid) {
                let eff = acl.effective_perms(entry);
                let g = check_needed(eff, needed);
                if g && !best_granted {
                    best_granted = true;
                    desc = format!("named group gid={gid}");
                }
                matched = true;
            }
        }
    }

    matched.then_some((best_granted, desc))
}

struct NeededPerms {
    read: bool,
    write: bool,
    execute: bool,
}

// metadata ops bypass ACL checks entirely — is_metadata() guard precedes this
fn required_perms(op: Operation) -> NeededPerms {
    match op {
        Operation::Read => NeededPerms {
            read: true,
            write: false,
            execute: false,
        },
        Operation::Write => NeededPerms {
            read: false,
            write: true,
            execute: false,
        },
        Operation::Execute => NeededPerms {
            read: false,
            write: false,
            execute: true,
        },
        Operation::Delete | Operation::Create => NeededPerms {
            read: false,
            write: true,
            execute: true,
        },
        Operation::Chmod
        | Operation::ChownUid
        | Operation::ChownGid
        | Operation::SetXattr { .. } => {
            unreachable!("is_metadata() guard must precede required_perms()")
        }
        Operation::Stat => NeededPerms {
            read: false,
            write: false,
            execute: false,
        },
    }
}

fn check_needed(perms: AclPerms, needed: &NeededPerms) -> bool {
    (!needed.read || perms.read)
        && (!needed.write || perms.write)
        && (!needed.execute || perms.execute)
}

fn format_acl_result(granted: bool, matched_entry: &str, needed: &NeededPerms) -> LayerResult {
    let needed_str = needed_description(needed);
    if granted {
        LayerResult::Pass {
            detail: format!("ACL {matched_entry} grants {needed_str}"),
            warnings: vec![],
        }
    } else {
        LayerResult::Fail {
            detail: format!("ACL {matched_entry} denies {needed_str}"),
            component_index: None,
        }
    }
}

fn needed_description(needed: &NeededPerms) -> String {
    let mut parts = Vec::new();
    if needed.read {
        parts.push("read");
    }
    if needed.write {
        parts.push("write");
    }
    if needed.execute {
        parts.push("execute");
    }
    if parts.is_empty() {
        "no specific permission".into()
    } else {
        parts.join("+")
    }
}

fn degraded(reason: &str) -> LayerResult {
    LayerResult::Degraded {
        reason: reason.to_owned(),
    }
}

#[cfg(test)]
#[path = "acl_tests.rs"]
mod tests;

#[cfg(test)]
#[path = "acl_mask_tests.rs"]
mod mask_tests;