whycant 0.1.0

CLI tool to figure out why you can't do something.
Documentation
use crate::engine::{Layer, LayerResult};
use crate::identity::Identity;
use crate::op::Op;
use crate::report::{Certainty, Evidence, EvidenceSource, Fix, FixAction, LayerId, Risk};
use std::fs::Metadata;
use std::os::unix::fs::MetadataExt;
use std::path::Path;
use uzers::{get_group_by_gid, get_user_by_uid};

pub struct TraverseLayer;

enum Class {
    Owner,
    Group,
    Other,
}

fn class_of(id: &Identity, meta: &Metadata) -> Class {
    match () {
        _ if id.uid == meta.uid() => Class::Owner,
        _ if meta.gid() == id.primary_gid || id.groups.contains(&meta.gid()) => Class::Group,
        _ => Class::Other,
    }
}

fn has_search(id: &Identity, meta: &Metadata) -> bool {
    let mode = meta.mode();
    match class_of(id, meta) {
        Class::Owner => mode & 0o100 != 0,
        Class::Group => mode & 0o010 != 0,
        Class::Other => mode & 0o001 != 0,
    }
}

fn perm_string(mode: u32, is_dir: bool) -> String {
    let bit = |m: u32, c: char| if mode & m != 0 { c } else { '-' };
    let sp = |set: u32, x: u32, lo: char, up: char| match (mode & set != 0, mode & x != 0) {
        (true, true) => lo,
        (true, false) => up,
        (false, true) => 'x',
        (false, false) => '-',
    };
    let mut s = String::with_capacity(10);
    s.push(if is_dir { 'd' } else { '-' });
    s.push(bit(0o400, 'r'));
    s.push(bit(0o200, 'w'));
    s.push(sp(0o4000, 0o100, 's', 'S'));
    s.push(bit(0o040, 'r'));
    s.push(bit(0o020, 'w'));
    s.push(sp(0o2000, 0o010, 's', 'S'));
    s.push(bit(0o004, 'r'));
    s.push(bit(0o002, 'w'));
    s.push(sp(0o1000, 0o001, 't', 'T'));
    s
}

fn ls_ld(path: &Path, meta: &Metadata) -> String {
    let owner = get_user_by_uid(meta.uid())
        .and_then(|u| u.name().to_str().map(str::to_owned))
        .unwrap_or_else(|| meta.uid().to_string());
    let group = get_group_by_gid(meta.gid())
        .and_then(|g| g.name().to_str().map(str::to_owned))
        .unwrap_or_else(|| meta.gid().to_string());
    format!(
        "{} {} {} {} {} {}",
        perm_string(meta.mode(), meta.is_dir()),
        meta.nlink(),
        owner,
        group,
        meta.len(),
        path.display()
    )
}

fn subject(id: &Identity) -> String {
    id.name.clone().unwrap_or_else(|| format!("uid {}", id.uid))
}

fn build_fix(id: &Identity, dir: &Path, meta: &Metadata) -> (Fix, String) {
    let (sym, who) = match class_of(id, meta) {
        Class::Owner => ("u+x", "owner"),
        Class::Group => ("g+x", "group"),
        Class::Other => ("o+x", "others"),
    };
    let euid = rustix::process::geteuid();
    let needs_root = !euid.is_root() && euid.as_raw() != meta.uid();
    let target = dir.display().to_string();
    let detail = format!(
        "{} not traversable by {}; missing {} execute bit",
        target,
        subject(id),
        who
    );
    let fix = Fix {
        action: FixAction::Run {
            argv: vec!["chmod".into(), sym.into(), target.clone()],
        },
        needs_root,
        description: format!("grant search (execute) on {target}"),
        risk: Risk::Low,
        rationale: format!("add {who} search bit"),
    };
    (fix, detail)
}

fn evaluate(id: &Identity, path: &Path) -> LayerResult {
    let abs = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
    let parent = match abs.parent() {
        Some(p) => p.to_path_buf(),
        None => return LayerResult::pass(Vec::new()),
    };
    let mut chain = Vec::new();
    for dir in parent.ancestors().collect::<Vec<_>>().into_iter().rev() {
        let meta = match std::fs::metadata(dir) {
            Ok(m) => m,
            Err(_) => return LayerResult::skip(),
        };
        chain.push(Evidence {
            source: EvidenceSource::LsLd,
            raw: ls_ld(dir, &meta),
            path: Some(dir.to_path_buf()),
        });
        if id.uid == 0 || has_search(id, &meta) {
            continue;
        }
        let (fix, detail) = build_fix(id, dir, &meta);
        return LayerResult::block(Certainty::Proven, detail, chain, vec![fix]);
    }
    LayerResult::pass(chain)
}

impl Layer for TraverseLayer {
    fn name(&self) -> &str {
        "traverse"
    }
    fn order(&self) -> u8 {
        2
    }
    fn id(&self) -> LayerId {
        LayerId::Traverse
    }
    fn check(&self, id: &Identity, path: &Path, _op: Op) -> LayerResult {
        evaluate(id, path)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::engine::LayerStatus;
    use std::fs;
    use std::os::unix::fs::PermissionsExt;

    fn mk(mode: u32, p: &Path) {
        fs::set_permissions(p, fs::Permissions::from_mode(mode)).unwrap();
    }

    #[test]
    fn middle_ancestor_is_the_blocker() {
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let base = std::env::temp_dir().join(format!("whycant_tv_{}_{nanos}", std::process::id()));
        let a = base.join("a");
        let b = a.join("b");
        let c = b.join("c");
        fs::create_dir_all(&c).unwrap();
        mk(0o755, &base);
        mk(0o755, &a);
        mk(0o000, &b);

        let owner = uzers::get_effective_uid();
        let id = Identity {
            uid: owner,
            primary_gid: uzers::get_current_gid(),
            groups: vec![uzers::get_current_gid()],
            name: Some("tester".into()),
            is_self: false,
        };

        let r = evaluate(&id, &c);
        if owner != 0 {
            assert!(matches!(r.status, LayerStatus::Block));
            assert!(r.certainty == Certainty::Proven);

            let blocker = r.evidence.last().unwrap();
            assert_eq!(blocker.path.as_deref(), Some(b.as_path()));
            assert!(r.detail.contains(&b.display().to_string()));

            assert!(
                r.evidence
                    .iter()
                    .any(|e| e.path.as_deref() == Some(a.as_path()))
            );
            assert!(
                r.evidence
                    .iter()
                    .all(|e| e.path.as_deref() != Some(c.as_path()))
            );

            let fix = &r.fixes[0];
            let FixAction::Run { argv } = &fix.action else {
                panic!("expected run fix");
            };
            assert_eq!(argv.last().unwrap(), &b.display().to_string());
            assert!(argv.iter().any(|s| s == "u+x"));
            assert!(
                !fix.needs_root,
                "current user owns the dir; no sudo to chmod"
            );
        }

        mk(0o755, &b);
        let _ = fs::remove_dir_all(&base);
    }
}