whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
//! Metadata-change check layer: chmod, chown, setxattr.
//!
//! Implements kernel `setattr_prepare` semantics from `fs/attr.c`.
//! All rules are ownership + capability checks, not mode-bit checks.

use crate::operation::{MetadataParams, Operation, XattrNamespace};
use crate::state::path::StatResult;
use crate::state::{Probe, SystemState};

use super::caps::{has_cap, CAP_CHOWN, CAP_FOWNER, CAP_FSETID, CAP_SYS_ADMIN};
use super::LayerResult;

/// Checks metadata-change permission for the requested operation.
///
/// Dispatches to per-operation sub-checks. Returns `Pass` immediately
/// for non-metadata operations (they are handled by other layers).
#[must_use]
pub fn check_metadata(state: &SystemState, params: &MetadataParams) -> LayerResult {
    match state.operation {
        Operation::Chmod => check_chmod(state, params),
        Operation::ChownUid => check_chown_uid(state),
        Operation::ChownGid => check_chown_gid(state, params),
        Operation::SetXattr { namespace } => check_setxattr(state, namespace),
        Operation::Read
        | Operation::Write
        | Operation::Execute
        | Operation::Delete
        | Operation::Create
        | Operation::Stat => LayerResult::Pass {
            detail: "not a metadata operation".into(),
            warnings: vec![],
        },
    }
}

fn target_stat(state: &SystemState) -> Result<&StatResult, LayerResult> {
    match state.walk.last().map(|c| &c.stat) {
        Some(Probe::Known(s)) => Ok(s),
        Some(Probe::Inaccessible) => Err(LayerResult::Degraded {
            reason: "target stat inaccessible".into(),
        }),
        _ => Err(LayerResult::Degraded {
            reason: "target stat unknown".into(),
        }),
    }
}

/// Resolves capability bitmask; treats `Unknown`/`Inaccessible` as zero.
fn effective_caps(state: &SystemState) -> u64 {
    match state.subject.capabilities {
        Probe::Known(bitmask) => bitmask,
        Probe::Unknown | Probe::Inaccessible => 0,
    }
}

/// chmod check: owner or `CAP_FOWNER`.
///
/// Mirrors `inode_owner_or_capable()` in `fs/attr.c`.
fn check_chmod(state: &SystemState, _params: &MetadataParams) -> LayerResult {
    let stat = match target_stat(state) {
        Ok(s) => s,
        Err(d) => return d,
    };
    let caps = effective_caps(state);
    if stat.uid == state.subject.uid {
        return LayerResult::Pass {
            detail: "subject is file owner".into(),
            warnings: vec![],
        };
    }
    if has_cap(caps, CAP_FOWNER) {
        return LayerResult::Pass {
            detail: "CAP_FOWNER bypasses ownership check".into(),
            warnings: fowner_setgid_warning(state, stat, caps),
        };
    }
    LayerResult::Fail {
        detail: format!(
            "subject (uid={}) is not the file owner (uid={}) and lacks CAP_FOWNER",
            state.subject.uid, stat.uid
        ),
        component_index: None,
    }
}

/// Warning text emitted when `CAP_FOWNER` is used without `CAP_FSETID`.
///
/// Kernel strips the setgid bit when the caller is not in the file's
/// group and does not hold `CAP_FSETID`. See `inode_init_owner()`.
fn fowner_setgid_warning(state: &SystemState, stat: &StatResult, caps: u64) -> Vec<String> {
    if has_cap(caps, CAP_FSETID) || state.subject.in_group(stat.gid) {
        return vec![];
    }
    vec![
        "CAP_FOWNER bypasses ownership check; kernel will strip setgid bit \
         if caller lacks CAP_FSETID and is not in file's group"
            .into(),
    ]
}

/// `chown_uid` check: requires `CAP_CHOWN`.
///
/// Changing UID unconditionally requires `CAP_CHOWN` per `POSIX.1` and
/// Linux `security_inode_setattr()`.
fn check_chown_uid(state: &SystemState) -> LayerResult {
    let caps = effective_caps(state);
    if has_cap(caps, CAP_CHOWN) {
        return LayerResult::Pass {
            detail: "CAP_CHOWN permits UID change".into(),
            warnings: vec![],
        };
    }
    LayerResult::Fail {
        detail: "changing file UID requires CAP_CHOWN".into(),
        component_index: None,
    }
}

/// `chown_gid` check: `CAP_CHOWN`, or owner-in-target-group.
fn check_chown_gid(state: &SystemState, params: &MetadataParams) -> LayerResult {
    let Some(new_gid) = params.new_gid else {
        return LayerResult::Degraded {
            reason: "new_gid required for ChownGid check — pass --new-gid".into(),
        };
    };
    let caps = effective_caps(state);
    if has_cap(caps, CAP_CHOWN) {
        return LayerResult::Pass {
            detail: "CAP_CHOWN permits GID change".into(),
            warnings: vec![],
        };
    }
    let stat = match target_stat(state) {
        Ok(s) => s,
        Err(d) => return d,
    };
    if stat.uid != state.subject.uid {
        return LayerResult::Fail {
            detail: format!(
                "subject (uid={}) is not the file owner (uid={}) and lacks CAP_CHOWN",
                state.subject.uid, stat.uid
            ),
            component_index: None,
        };
    }
    if state.subject.in_group(new_gid) {
        return LayerResult::Pass {
            detail: format!("subject is file owner and member of target group (gid={new_gid})"),
            warnings: vec![],
        };
    }
    LayerResult::Fail {
        detail: format!(
            "subject is file owner but is not a member of group (gid={new_gid}) and lacks CAP_CHOWN"
        ),
        component_index: None,
    }
}

fn check_setxattr(state: &SystemState, namespace: XattrNamespace) -> LayerResult {
    match namespace {
        XattrNamespace::User | XattrNamespace::SystemPosixAcl => check_owner_or_fowner(state),
        XattrNamespace::Trusted | XattrNamespace::Security => {
            check_cap_required(state, CAP_SYS_ADMIN, "CAP_SYS_ADMIN")
        }
    }
}

fn check_owner_or_fowner(state: &SystemState) -> LayerResult {
    let stat = match target_stat(state) {
        Ok(s) => s,
        Err(d) => return d,
    };
    let caps = effective_caps(state);
    if stat.uid == state.subject.uid || has_cap(caps, CAP_FOWNER) {
        LayerResult::Pass {
            detail: "subject is file owner or holds CAP_FOWNER".into(),
            warnings: vec![],
        }
    } else {
        LayerResult::Fail {
            detail: format!(
                "subject (uid={}) is not the file owner (uid={}) and lacks CAP_FOWNER",
                state.subject.uid, stat.uid
            ),
            component_index: None,
        }
    }
}

fn check_cap_required(state: &SystemState, cap: u64, cap_name: &str) -> LayerResult {
    let caps = effective_caps(state);
    if has_cap(caps, cap) {
        LayerResult::Pass {
            detail: format!("{cap_name} permits xattr write"),
            warnings: vec![],
        }
    } else {
        LayerResult::Fail {
            detail: format!("xattr namespace requires {cap_name}"),
            component_index: None,
        }
    }
}

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