whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
//! Mount options check layer.
//!
//! Checks whether filesystem-level mount options (read-only, noexec)
//! block the requested operation. Mount options apply unconditionally --
//! even root cannot write to a read-only filesystem without remounting.

use crate::operation::Operation;
use crate::state::mount::MountEntry;
use crate::state::SystemState;

use super::LayerResult;

/// Checks whether mount options block the requested operation.
///
/// Finds mount entry for target component, evaluates `read_only`
/// and `noexec` against operation type. Read and stat are never
/// blocked by mount options. On execute, warns if nosuid strips
/// setuid/setgid bits from the target.
#[must_use]
pub fn check_mount(state: &SystemState) -> LayerResult {
    let Some(target_idx) = state.operation.target_component(state.walk.len()) else {
        return LayerResult::Degraded {
            reason: "path walk too short to determine target component".into(),
        };
    };

    let component = &state.walk[target_idx];
    let Some(mount_idx) = component.mount else {
        return LayerResult::Degraded {
            reason: format!("no mount information for {}", component.path.display()),
        };
    };

    let Some(entry) = state.mounts.0.get(mount_idx) else {
        return LayerResult::Degraded {
            reason: format!("mount index {mount_idx} out of range"),
        };
    };

    let suid_bits = target_suid_bits(state, target_idx);
    check_operation_against_mount(state.operation, entry, target_idx, suid_bits)
}

#[derive(Clone, Copy)]
struct SuidBits {
    setuid: bool,
    setgid: bool,
}

fn target_suid_bits(state: &SystemState, target_idx: usize) -> SuidBits {
    let mode = state.walk[target_idx].stat.known().map_or(0, |s| s.mode);
    SuidBits {
        setuid: mode & 0o4000 != 0,
        setgid: mode & 0o2000 != 0,
    }
}

fn nosuid_warnings(entry: &MountEntry, suid: SuidBits) -> Vec<String> {
    if !entry.options.nosuid || (!suid.setuid && !suid.setgid) {
        return vec![];
    }
    vec!["nosuid: setuid/setgid bits on target will be ignored at exec".into()]
}

fn check_operation_against_mount(
    op: Operation,
    entry: &MountEntry,
    component_idx: usize,
    suid: SuidBits,
) -> LayerResult {
    let mp = entry.mountpoint.display();
    let fs = &entry.fs_type;

    match op {
        Operation::Read | Operation::Stat => LayerResult::Pass {
            detail: format!("mount at {mp} ({fs}) allows {op:?}"),
            warnings: vec![],
        },
        Operation::Write
        | Operation::Delete
        | Operation::Create
        | Operation::Chmod
        | Operation::ChownUid
        | Operation::ChownGid
        | Operation::SetXattr { .. } => {
            if entry.options.read_only {
                LayerResult::Fail {
                    detail: format!("filesystem mounted read-only at {mp} ({fs})"),
                    component_index: Some(component_idx),
                }
            } else {
                LayerResult::Pass {
                    detail: format!("mount at {mp} ({fs}) is read-write"),
                    warnings: vec![],
                }
            }
        }
        Operation::Execute => {
            if entry.options.noexec {
                LayerResult::Fail {
                    detail: format!("filesystem mounted noexec at {mp} ({fs})"),
                    component_index: Some(component_idx),
                }
            } else {
                let warnings = nosuid_warnings(entry, suid);
                LayerResult::Pass {
                    detail: format!("mount at {mp} ({fs}) allows execution"),
                    warnings,
                }
            }
        }
    }
}

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