whyno-core 0.5.0

Permission check pipeline, fix engine, and state types
Documentation
//! Per-layer fix generators.
//!
//! Each generator produces candidate fixes for a specific check layer
//! failure. Fixes sorted by impact ascending within each layer,
//! preferring least-privilege solutions.

use std::path::Path;

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

use super::scoring;
use super::{Fix, FixAction};

/// Generates fixes for mount layer failures.
pub fn mount_fixes(state: &SystemState) -> Vec<Fix> {
    let Some(target_idx) = state.operation.target_component(state.walk.len()) else {
        return Vec::new();
    };
    let Some(mount_idx) = state.walk[target_idx].mount else {
        return Vec::new();
    };
    let Some(entry) = state.mounts.0.get(mount_idx) else {
        return Vec::new();
    };

    let mut fixes = Vec::new();
    if entry.options.read_only && is_write_op(state.operation) {
        fixes.push(make_remount_fix(&entry.mountpoint, "rw", "read-write"));
    }
    if entry.options.noexec && state.operation == Operation::Execute {
        fixes.push(make_remount_fix(&entry.mountpoint, "exec", "with exec"));
    }
    fixes
}

fn make_remount_fix(mountpoint: &std::path::Path, options: &str, desc_suffix: &str) -> Fix {
    Fix {
        layer: CoreLayer::Mount,
        action: FixAction::Remount {
            mountpoint: mountpoint.to_path_buf(),
            options: options.into(),
        },
        impact: scoring::REMOUNT,
        description: format!("remount {} {desc_suffix}", mountpoint.display()),
    }
}

pub fn fsflags_fixes(state: &SystemState) -> Vec<Fix> {
    let Some(target_idx) = state.operation.target_component(state.walk.len()) else {
        return Vec::new();
    };
    let comp = &state.walk[target_idx];
    let Probe::Known(flags) = &comp.flags else {
        return Vec::new();
    };

    let path = &comp.path;
    let mut fixes = Vec::new();
    if flags.immutable {
        fixes.push(make_chattr_fix(path, "-i", "immutable"));
    }
    if flags.append_only && !flags.immutable {
        fixes.push(make_chattr_fix(path, "-a", "append-only"));
    }
    fixes
}

fn make_chattr_fix(path: &std::path::Path, flags: &str, name: &str) -> Fix {
    Fix {
        layer: CoreLayer::FsFlags,
        action: FixAction::Chattr {
            path: path.to_path_buf(),
            flags: flags.into(),
        },
        impact: scoring::REMOVE_FS_FLAG,
        description: format!("remove {name} flag from {}", path.display()),
    }
}

/// Generates fixes for traversal layer failures.
///
/// Returns both ACL fix (lower impact) and chmod fix (higher impact)
/// for failing ancestor, ordered by impact ascending.
pub fn traversal_fixes(state: &SystemState, component_idx: usize) -> Vec<Fix> {
    let Some(comp) = state.walk.get(component_idx) else {
        return Vec::new();
    };

    let path = &comp.path;
    let uid = state.subject.uid;

    vec![
        Fix {
            layer: CoreLayer::Traversal,
            action: FixAction::SetAcl {
                path: path.clone(),
                entry: format!("u:{uid}:--x"),
            },
            impact: scoring::ACL_USER_GRANT,
            description: format!("grant execute to uid {uid} on {} via ACL", path.display()),
        },
        Fix {
            layer: CoreLayer::Traversal,
            action: FixAction::Chmod {
                path: path.clone(),
                mode_change: "o+x".into(),
            },
            impact: scoring::CHMOD_OTHER,
            description: format!("grant other-execute on {}", path.display()),
        },
    ]
}

/// Generates fixes for DAC layer failures.
///
/// Multiple options ranked by impact: ACL (most precise), group
/// chmod (if applicable), other chmod (least desirable).
pub fn dac_fixes(state: &SystemState) -> Vec<Fix> {
    let Some(target_idx) = state.operation.target_component(state.walk.len()) else {
        return Vec::new();
    };
    let comp = &state.walk[target_idx];
    let Probe::Known(stat) = &comp.stat else {
        return Vec::new();
    };

    let perm_char = op_to_perm_char(state.operation);
    let uid = state.subject.uid;
    let path = &comp.path;
    let mut fixes = Vec::new();

    push_acl_fix(&mut fixes, path, uid, perm_char);
    push_group_fix(&mut fixes, stat, state, path, perm_char);
    push_other_fix(&mut fixes, path, perm_char);

    fixes
}

pub fn acl_fixes(state: &SystemState) -> Vec<Fix> {
    let Some(target_idx) = state.operation.target_component(state.walk.len()) else {
        return Vec::new();
    };
    let comp = &state.walk[target_idx];
    let perm_char = op_to_perm_char(state.operation);
    let uid = state.subject.uid;

    vec![Fix {
        layer: CoreLayer::Acl,
        action: FixAction::SetAcl {
            path: comp.path.clone(),
            entry: format!("u:{uid}:{perm_char}"),
        },
        impact: scoring::ACL_USER_GRANT,
        description: format!(
            "grant {perm_char} to uid {uid} on {} via ACL",
            comp.path.display()
        ),
    }]
}

fn push_acl_fix(fixes: &mut Vec<Fix>, path: &std::path::Path, uid: u32, perm: char) {
    fixes.push(Fix {
        layer: CoreLayer::Dac,
        action: FixAction::SetAcl {
            path: path.to_path_buf(),
            entry: format!("u:{uid}:{perm}"),
        },
        impact: scoring::ACL_USER_GRANT,
        description: format!("grant {perm} to uid {uid} on {} via ACL", path.display()),
    });
}

fn push_group_fix(
    fixes: &mut Vec<Fix>,
    stat: &StatResult,
    state: &SystemState,
    path: &std::path::Path,
    perm: char,
) {
    if state.subject.in_group(stat.gid) {
        fixes.push(Fix {
            layer: CoreLayer::Dac,
            action: FixAction::Chmod {
                path: path.to_path_buf(),
                mode_change: format!("g+{perm}"),
            },
            impact: scoring::CHMOD_GROUP,
            description: format!("grant group-{perm} on {}", path.display()),
        });
    }
}

fn push_other_fix(fixes: &mut Vec<Fix>, path: &std::path::Path, perm: char) {
    fixes.push(Fix {
        layer: CoreLayer::Dac,
        action: FixAction::Chmod {
            path: path.to_path_buf(),
            mode_change: format!("o+{perm}"),
        },
        impact: scoring::CHMOD_OTHER,
        description: format!("grant other-{perm} on {}", path.display()),
    });
}

/// Generates fixes for metadata-change layer failures.
///
/// Produces least-privilege suggestions per operation type:
/// `Chmod` → `Chown` (impact 4) + `GrantCap` `cap_fowner` (impact 5).
/// `ChownUid`/`ChownGid` → `GrantCap` `cap_chown` (impact 5).
/// `SetXattr` user/`posix_acl` → `Chown` (impact 4), `posix_acl` also gets `GrantCap` `cap_fowner`.
/// `SetXattr` trusted/security → `GrantCap` `cap_sys_admin` (impact 6).
pub fn metadata_fixes(state: &SystemState, _params: &MetadataParams) -> Vec<Fix> {
    let Some(target_idx) = state.operation.target_component(state.walk.len()) else {
        return Vec::new();
    };
    let target_path = state.walk[target_idx].path.clone();

    match state.operation {
        Operation::Chmod => chmod_metadata_fixes(state, &target_path),
        Operation::ChownUid | Operation::ChownGid => chown_metadata_fixes(),
        Operation::SetXattr { namespace } => {
            setxattr_metadata_fixes(state, &target_path, namespace)
        }
        Operation::Read
        | Operation::Write
        | Operation::Execute
        | Operation::Delete
        | Operation::Create
        | Operation::Stat => Vec::new(),
    }
}

fn chmod_metadata_fixes(state: &SystemState, path: &Path) -> Vec<Fix> {
    vec![
        make_chown_fix(path, state.subject.uid, 4),
        make_grant_cap_fix("cap_fowner", 5, "grant CAP_FOWNER to the process"),
    ]
}

fn chown_metadata_fixes() -> Vec<Fix> {
    vec![make_grant_cap_fix(
        "cap_chown",
        5,
        "grant CAP_CHOWN to the process",
    )]
}

fn setxattr_metadata_fixes(
    state: &SystemState,
    path: &Path,
    namespace: XattrNamespace,
) -> Vec<Fix> {
    match namespace {
        XattrNamespace::User => vec![make_chown_fix(path, state.subject.uid, 4)],
        XattrNamespace::Trusted | XattrNamespace::Security => {
            vec![make_cap_sys_admin_fix()]
        }
        XattrNamespace::SystemPosixAcl => vec![
            make_chown_fix(path, state.subject.uid, 4),
            make_grant_cap_fix("cap_fowner", 5, "grant CAP_FOWNER to the process"),
        ],
    }
}

fn make_cap_sys_admin_fix() -> Fix {
    make_grant_cap_fix(
        "cap_sys_admin",
        6,
        "grant CAP_SYS_ADMIN to the process (broad system administration capability)",
    )
}

fn make_chown_fix(path: &Path, uid: u32, impact: u8) -> Fix {
    Fix {
        layer: CoreLayer::Metadata,
        action: FixAction::Chown {
            path: path.to_path_buf(),
            owner: Some(uid),
            group: None,
        },
        impact,
        description: format!("transfer file ownership to subject (uid={uid})"),
    }
}

/// Builds a `GrantCap` fix with `path: None` (process binary unknown).
fn make_grant_cap_fix(capability: &str, impact: u8, description: &str) -> Fix {
    Fix {
        layer: CoreLayer::Metadata,
        action: FixAction::GrantCap {
            path: None,
            capability: capability.into(),
        },
        impact,
        description: description.into(),
    }
}

fn is_write_op(op: Operation) -> bool {
    matches!(op, Operation::Write | Operation::Delete | Operation::Create)
}

/// Maps operation to single permission character for fix actions.
///
/// Metadata ops short-circuit before fix generation; any uncovered
/// variant falls through to the read-like default.
fn op_to_perm_char(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 op_to_perm_char()")
        }
    }
}