whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
//! DAC check layer: unix mode bits (owner/group/other), sticky-bit
//! semantics for delete, and `CAP_DAC_OVERRIDE` capability override.

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

use super::LayerResult;

/// Checks whether unix DAC mode bits allow the requested operation.
///
/// For `Delete`/`Create`, checks write+execute on parent directory.
/// For `Stat`, always passes (no DAC check needed). Metadata ops
/// (`Chmod`, `ChownUid`, `ChownGid`, `SetXattr`) bypass mode-bit
/// checks entirely and return `Pass` immediately.
#[must_use]
pub fn check_dac(state: &SystemState) -> LayerResult {
    if state.operation.is_metadata() {
        return LayerResult::Pass {
            detail: "metadata ops bypass DAC mode-bit checks".into(),
            warnings: vec![],
        };
    }

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

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

    check_target_dac(state)
}

fn check_target_dac(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];
    let stat = match &comp.stat {
        Probe::Known(s) => s,
        Probe::Unknown => return degraded("target stat unknown"),
        Probe::Inaccessible => return degraded("target stat inaccessible"),
    };

    let needed = required_bit(state.operation);
    let has_perm = check_bit(stat, &state.subject, needed);
    format_result(stat, &state.subject, has_perm, needed)
}

fn check_parent_dac(state: &SystemState) -> LayerResult {
    let Some(parent_idx) = state.operation.target_component(state.walk.len()) else {
        return degraded("path walk too short to determine parent");
    };
    let parent = &state.walk[parent_idx];
    let stat = match &parent.stat {
        Probe::Known(s) => s,
        Probe::Unknown => return degraded("parent stat unknown"),
        Probe::Inaccessible => return degraded("parent stat inaccessible"),
    };

    let has_wx = check_bit(stat, &state.subject, 'w') && check_bit(stat, &state.subject, 'x');

    if !has_wx {
        return format_parent_fail(stat, &state.subject, state.operation);
    }

    if state.operation == Operation::Delete {
        return check_sticky_bit(state, stat, parent_idx);
    }

    LayerResult::Pass {
        detail: format!("parent has w+x for {}", class_name(stat, &state.subject)),
        warnings: vec![],
    }
}

fn check_sticky_bit(
    state: &SystemState,
    parent_stat: &StatResult,
    _parent_idx: usize,
) -> LayerResult {
    if parent_stat.mode & 0o1000 == 0 {
        return LayerResult::Pass {
            detail: format!(
                "parent has w+x for {}, no sticky bit",
                class_name(parent_stat, &state.subject)
            ),
            warnings: vec![],
        };
    }

    let target_idx = state.walk.len() - 1;
    let target = &state.walk[target_idx];
    let Probe::Known(target_stat) = &target.stat else {
        return degraded("target stat unavailable for sticky bit check");
    };

    let is_target_owner = target_stat.uid == state.subject.uid;
    let is_parent_owner = parent_stat.uid == state.subject.uid;

    if is_target_owner || is_parent_owner {
        LayerResult::Pass {
            detail: "sticky bit set, but subject owns target or parent".into(),
            warnings: vec![],
        }
    } else {
        LayerResult::Fail {
            detail: format!(
                "sticky bit set on parent, subject (uid={}) owns neither target (uid={}) nor parent (uid={})",
                state.subject.uid, target_stat.uid, parent_stat.uid
            ),
            component_index: Some(target_idx),
        }
    }
}

// metadata ops bypass mode-bit checks via early return in check_dac;
// any uncovered variant falls through to the read-like default
fn required_bit(op: Operation) -> char {
    match op {
        Operation::Read | Operation::Stat => 'r',
        Operation::Write | Operation::Delete | Operation::Create => 'w',
        Operation::Execute => 'x',
        Operation::Chmod
        | Operation::ChownUid
        | Operation::ChownGid
        | Operation::SetXattr { .. } => {
            unreachable!("is_metadata() guard must precede required_bit()")
        }
    }
}

fn check_bit(stat: &StatResult, subject: &ResolvedSubject, bit: char) -> bool {
    let shift = match bit {
        'r' => 2,
        'w' => 1,
        'x' => 0,
        other => unreachable!("check_bit called with invalid permission char: {other:?}"),
    };
    if stat.uid == subject.uid {
        (stat.mode >> (6 + shift)) & 1 != 0
    } else if subject.in_group(stat.gid) {
        (stat.mode >> (3 + shift)) & 1 != 0
    } else {
        (stat.mode >> shift) & 1 != 0
    }
}

fn format_result(
    stat: &StatResult,
    subject: &ResolvedSubject,
    has_perm: bool,
    bit: char,
) -> LayerResult {
    let class = class_name(stat, subject);
    let mode_str = format!("{:04o}", stat.mode & 0o7777);
    if has_perm {
        LayerResult::Pass {
            detail: format!("mode {mode_str}: {class} has {bit} permission"),
            warnings: vec![],
        }
    } else {
        LayerResult::Fail {
            detail: format!("mode {mode_str}: {class} has no {bit} permission"),
            component_index: None,
        }
    }
}

fn format_parent_fail(stat: &StatResult, subject: &ResolvedSubject, op: Operation) -> LayerResult {
    let class = class_name(stat, subject);
    let mode_str = format!("{:04o}", stat.mode & 0o7777);
    LayerResult::Fail {
        detail: format!("mode {mode_str}: {class} lacks w+x on parent for {op:?}"),
        component_index: None,
    }
}

fn 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(reason: &str) -> LayerResult {
    LayerResult::Degraded {
        reason: reason.to_owned(),
    }
}

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

#[cfg(test)]
#[path = "dac_caps_tests.rs"]
mod caps_tests;