whycant 0.1.0

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

pub struct NetfsLayer;

enum NetKind {
    Nfs,
    Cifs,
    Other(&'static str),
}

fn classify(fstype: &str) -> Option<NetKind> {
    match fstype {
        "nfs" | "nfs4" => Some(NetKind::Nfs),
        "cifs" | "smb3" | "smb" => Some(NetKind::Cifs),
        "fuse.sshfs" => Some(NetKind::Other("fuse.sshfs")),
        "afs" => Some(NetKind::Other("afs")),
        _ => None,
    }
}

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

fn under(target: &str, mp: &str) -> bool {
    mp == "/" || target == mp || target.starts_with(&format!("{mp}/"))
}

struct MountInfo<'a> {
    source: &'a str,
    mp: &'a str,
    fstype: &'a str,
    opts: &'a str,
}

fn parse_mount(line: &str) -> Option<MountInfo<'_>> {
    let mut f = line.split_whitespace();
    let source = f.next()?;
    let mp = f.next()?;
    let fstype = f.next()?;
    let opts = f.next()?;
    Some(MountInfo {
        source,
        mp,
        fstype,
        opts,
    })
}

fn mount_for<'a>(target: &str, mounts: &'a str) -> Option<MountInfo<'a>> {
    mounts
        .lines()
        .filter_map(parse_mount)
        .filter(|m| under(target, m.mp))
        .max_by_key(|m| m.mp.len())
}

fn has_flag(opts: &str, flag: &str) -> bool {
    opts.split(',').any(|o| o == flag)
}

fn forced_creds(opts: &str) -> Vec<&str> {
    let keys = ["uid=", "gid=", "file_mode=", "dir_mode="];
    opts.split(',')
        .filter(|o| keys.iter().any(|k| o.starts_with(k)))
        .collect()
}

fn advice(text: impl Into<String>) -> Fix {
    Fix {
        action: FixAction::Advice { text: text.into() },
        needs_root: false,
        description: "server-side check".into(),
        risk: Risk::Low,
        rationale: "server arbitrates access".into(),
    }
}

enum Level {
    Suspect,
    Info,
}

struct Finding {
    level: Level,
    detail: String,
    ev: String,
    fixes: Vec<Fix>,
}

fn analyze(m: &MountInfo, target_root: bool, euid_root: bool, op: Op) -> Option<Finding> {
    let ev = format!("{} on {} type {} ({})", m.source, m.mp, m.fstype, m.opts);
    let write = is_write(op);
    match classify(m.fstype)? {
        NetKind::Nfs => {
            let mut notes = vec![format!(
                "{} is an {} mount; NFS authorizes by numeric uid",
                m.mp, m.fstype
            )];
            let mut fixes = vec![advice("confirm the uid maps server-side (idmapd/NFSv4)")];
            let mut level = Level::Info;
            if write && (target_root || euid_root) {
                level = Level::Suspect;
                notes.push("root, so root_squash may reject this write".into());
                fixes.push(advice("check root_squash in /etc/exports"));
            }
            if write && has_flag(m.opts, "ro") {
                level = Level::Suspect;
                notes.push("ro mount; export may be read-only".into());
                fixes.push(advice("verify export is rw in /etc/exports"));
            }
            Some(Finding {
                level,
                detail: notes.join("; "),
                ev,
                fixes,
            })
        }
        NetKind::Cifs => {
            let creds = forced_creds(m.opts);
            let level = match write && !creds.is_empty() {
                true => Level::Suspect,
                false => Level::Info,
            };
            let cred_str = match creds.is_empty() {
                true => String::from("mount credentials"),
                false => creds.join(","),
            };
            Some(Finding {
                level,
                detail: format!(
                    "{} is a {} mount; mount credentials ({}) govern access",
                    m.mp, m.fstype, cred_str
                ),
                ev,
                fixes: vec![
                    advice("verify share ACL on the server"),
                    advice("remount with correct credentials"),
                ],
            })
        }
        NetKind::Other(name) => Some(Finding {
            level: Level::Info,
            detail: format!(
                "{} is a {} network mount; remote host arbitrates access",
                m.mp, name
            ),
            ev,
            fixes: vec![advice("confirm remote permissions on the server")],
        }),
    }
}

impl Layer for NetfsLayer {
    fn name(&self) -> &str {
        "netfs"
    }
    fn order(&self) -> u8 {
        10
    }
    fn id(&self) -> LayerId {
        LayerId::NetworkFs
    }
    fn check(&self, id: &Identity, path: &Path, op: Op) -> LayerResult {
        let abs = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
        let mounts = match std::fs::read_to_string("/proc/mounts") {
            Ok(s) => s,
            Err(_) => return LayerResult::skip(),
        };
        let target = abs.to_string_lossy();
        let Some(m) = mount_for(&target, &mounts) else {
            return LayerResult::skip();
        };
        let euid_root = uzers::get_effective_uid() == 0;
        match analyze(&m, id.uid == 0, euid_root, op) {
            None => LayerResult::skip(),
            Some(f) => {
                let evidence = vec![Evidence {
                    source: EvidenceSource::MountOpts,
                    raw: f.ev,
                    path: Some(abs.to_path_buf()),
                }];
                match f.level {
                    Level::Suspect => LayerResult::suspect(f.detail, evidence, f.fixes),
                    Level::Info => LayerResult {
                        status: LayerStatus::Skip,
                        certainty: Certainty::Proven,
                        evidence,
                        fixes: f.fixes,
                        detail: f.detail,
                    },
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn mi<'a>(fstype: &'a str, opts: &'a str) -> MountInfo<'a> {
        MountInfo {
            source: "s:/e",
            mp: "/mnt",
            fstype,
            opts,
        }
    }

    #[test]
    fn parses_mount_line() {
        let m = parse_mount("server:/export /mnt/nfs nfs4 rw,relatime,vers=4.2 0 0").unwrap();
        assert_eq!(m.source, "server:/export");
        assert_eq!(m.mp, "/mnt/nfs");
        assert_eq!(m.fstype, "nfs4");
        assert_eq!(m.opts, "rw,relatime,vers=4.2");
    }

    #[test]
    fn nfs_read_normal_is_informational() {
        let m = mi("nfs", "rw,relatime");
        let f = analyze(&m, false, false, Op::Read).unwrap();
        assert!(matches!(f.level, Level::Info));
    }

    #[test]
    fn nfs_root_write_is_root_squash() {
        let m = mi("nfs", "rw");
        let f = analyze(&m, true, false, Op::Write).unwrap();
        assert!(matches!(f.level, Level::Suspect));
        assert!(f.detail.contains("root_squash"));
    }

    #[test]
    fn nfs_ro_option_notes_export() {
        let m = mi("nfs", "ro,relatime");
        let f = analyze(&m, false, false, Op::Write).unwrap();
        assert!(matches!(f.level, Level::Suspect));
        assert!(f.detail.contains("read-only"));
    }

    #[test]
    fn cifs_write_uid_is_credential_suspect() {
        let m = mi("cifs", "rw,uid=1000,gid=1000,file_mode=0755");
        let f = analyze(&m, false, false, Op::Write).unwrap();
        assert!(matches!(f.level, Level::Suspect));
        assert!(f.detail.contains("credentials"));
        assert!(f.ev.contains("uid=1000"));
    }

    #[test]
    fn cifs_read_is_informational() {
        let m = mi("cifs", "rw,uid=1000,gid=1000");
        let f = analyze(&m, false, false, Op::Read).unwrap();
        assert!(matches!(f.level, Level::Info));
    }

    #[test]
    fn ext4_is_skipped() {
        let m = mi("ext4", "rw");
        assert!(analyze(&m, true, true, Op::Write).is_none());
    }
}