whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
//! Path traversal check layer.
//!
//! Verifies subject has execute (+x) permission on every ancestor
//! directory in the path walk. Target component is not checked
//! here -- only directories leading to it.

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

use super::LayerResult;

/// Checks that subject can traverse every ancestor directory.
///
/// Iterates all path components except last (target). Returns `Fail`
/// at first ancestor denying execute, `Degraded` if any ancestor's
/// stat is missing, or `Pass` if all ancestors allow traversal.
#[must_use]
pub fn check_traversal(state: &SystemState) -> LayerResult {
    let ancestors = ancestor_count(&state.walk);
    if ancestors == 0 {
        return LayerResult::Pass {
            detail: "no ancestors to check (single component walk)".into(),
            warnings: vec![],
        };
    }

    for idx in 0..ancestors {
        let result = check_ancestor(&state.walk[idx], idx, &state.subject);
        if !result.is_pass() {
            return result;
        }
    }

    LayerResult::Pass {
        detail: format!("all {ancestors} ancestors allow traverse"),
        warnings: vec![],
    }
}

/// Number of ancestor components (everything except target).
fn ancestor_count(walk: &[PathComponent]) -> usize {
    walk.len().saturating_sub(1)
}

fn check_ancestor(comp: &PathComponent, idx: usize, subject: &ResolvedSubject) -> LayerResult {
    let stat = match &comp.stat {
        Probe::Known(s) => s,
        Probe::Inaccessible => return degraded_for(comp, "stat inaccessible"),
        Probe::Unknown => return degraded_for(comp, "stat unknown"),
    };

    if subject.uid == 0 {
        return LayerResult::Pass {
            detail: format!("{}: root bypasses traverse check", comp.path.display()),
            warnings: vec![],
        };
    }

    if has_extended_acl(&comp.acl) {
        return check_acl_execute(comp, stat, subject, idx);
    }

    check_mode_execute(comp, stat, subject, idx)
}

fn check_mode_execute(
    comp: &PathComponent,
    stat: &StatResult,
    subject: &ResolvedSubject,
    idx: usize,
) -> LayerResult {
    let has_x = if stat.uid == subject.uid {
        (stat.mode >> 6) & 1 != 0
    } else if subject.in_group(stat.gid) {
        (stat.mode >> 3) & 1 != 0
    } else {
        stat.mode & 1 != 0
    };

    if has_x {
        LayerResult::Pass {
            detail: format!("{}: execute permitted", comp.path.display()),
            warnings: vec![],
        }
    } else {
        let class = dac_class_name(stat, subject);
        LayerResult::Fail {
            detail: format!("{}: {class} has no execute permission", comp.path.display()),
            component_index: Some(idx),
        }
    }
}

fn check_acl_execute(
    comp: &PathComponent,
    stat: &StatResult,
    subject: &ResolvedSubject,
    idx: usize,
) -> LayerResult {
    let Probe::Known(acl) = &comp.acl else {
        return degraded_for(comp, "ACL inaccessible for traversal");
    };

    let has_x = acl_grants_execute(acl, stat, subject);
    if has_x {
        LayerResult::Pass {
            detail: format!("{}: ACL grants execute", comp.path.display()),
            warnings: vec![],
        }
    } else {
        LayerResult::Fail {
            detail: format!("{}: ACL denies execute permission", comp.path.display()),
            component_index: Some(idx),
        }
    }
}

/// Evaluates POSIX.1e ACL for execute permission.
pub(crate) fn acl_grants_execute(
    acl: &PosixAcl,
    stat: &StatResult,
    subject: &ResolvedSubject,
) -> bool {
    if stat.uid == subject.uid {
        return acl.user_obj().is_some_and(|e| e.perms.execute);
    }

    if let Some(entry) = acl.named_user(subject.uid) {
        return acl.effective_perms(entry).execute;
    }

    let group_exec = best_group_execute(acl, stat, subject);
    if let Some(has_x) = group_exec {
        return has_x;
    }

    acl.other().is_some_and(|e| e.perms.execute)
}

fn best_group_execute(
    acl: &PosixAcl,
    stat: &StatResult,
    subject: &ResolvedSubject,
) -> Option<bool> {
    let mut matched = false;
    let mut best = false;

    if subject.in_group(stat.gid) {
        if let Some(entry) = acl.group_obj() {
            let eff = acl.effective_perms(entry);
            best = best || eff.execute;
            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);
                best = best || eff.execute;
                matched = true;
            }
        }
    }

    matched.then_some(best)
}

fn has_extended_acl(acl: &Probe<PosixAcl>) -> bool {
    matches!(acl, Probe::Known(a) if a.has_extended_entries())
}

fn dac_class_name(stat: &StatResult, subject: &ResolvedSubject) -> &'static str {
    if stat.uid == subject.uid {
        "owner"
    } else if subject.in_group(stat.gid) {
        "group"
    } else {
        "other"
    }
}

fn degraded_for(comp: &PathComponent, reason: &str) -> LayerResult {
    LayerResult::Degraded {
        reason: format!("{}: {reason}", comp.path.display()),
    }
}

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

#[cfg(test)]
#[path = "traversal_edge_tests.rs"]
mod edge_tests;