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::LayerId;
use std::path::Path;

pub struct MacosLayer;

impl Layer for MacosLayer {
    fn name(&self) -> &str {
        "macos"
    }
    fn order(&self) -> u8 {
        9
    }
    fn id(&self) -> LayerId {
        LayerId::MacosSip
    }
    fn check(&self, id: &Identity, path: &Path, op: Op) -> LayerResult {
        let _ = (id, path, op);
        #[cfg(target_os = "macos")]
        {
            return read_and_classify(path, op);
        }
        #[allow(unreachable_code)]
        LayerResult::skip()
    }
}

#[cfg(target_os = "macos")]
fn read_and_classify(path: &Path, op: Op) -> LayerResult {
    let flags = stat_flags(path).unwrap_or(0);
    let quarantine = read_quarantine(path);
    let home = std::env::var_os("HOME")
        .map(std::path::PathBuf::from)
        .unwrap_or_default();
    pure::classify(flags, quarantine.as_deref(), path, &home, op)
}

#[cfg(target_os = "macos")]
fn stat_flags(path: &Path) -> Option<u32> {
    use std::ffi::CString;
    use std::os::unix::ffi::OsStrExt;
    let c = CString::new(path.as_os_str().as_bytes()).ok()?;
    let mut st: libc::stat = unsafe { std::mem::zeroed() };
    match unsafe { libc::stat(c.as_ptr(), &mut st) } {
        0 => Some(st.st_flags as u32),
        _ => None,
    }
}

#[cfg(target_os = "macos")]
fn read_quarantine(path: &Path) -> Option<String> {
    xattr::get(path, pure::QUARANTINE_XATTR)
        .ok()
        .flatten()
        .map(|b| String::from_utf8_lossy(&b).into_owned())
}

#[cfg(any(target_os = "macos", test))]
mod pure {
    use crate::engine::LayerResult;
    use crate::op::Op;
    use crate::report::{Certainty, Evidence, EvidenceSource, Fix, FixAction, Risk};
    use std::path::Path;

    pub const SF_RESTRICTED: u32 = 0x0008_0000;
    pub const QUARANTINE_XATTR: &str = "com.apple.quarantine";

    pub const TCC_LOCATIONS: &[&str] = &[
        "Library/Mail",
        "Library/Messages",
        "Library/Application Support/com.apple.TCC",
        "Library/Safari",
        "Library/Cookies",
        "Library/HomeKit",
        "Pictures/Photos Library.photoslibrary",
    ];

    pub fn sip_restricted(flags: u32, op: Op) -> bool {
        matches!(op, Op::Write | Op::Delete | Op::Create) && flags & SF_RESTRICTED != 0
    }

    pub fn quarantine_blocks_exec(present: bool, op: Op) -> bool {
        present && op == Op::Exec
    }

    pub fn is_tcc_protected(path: &Path, home: &Path) -> Option<&'static str> {
        TCC_LOCATIONS
            .iter()
            .copied()
            .find(|rel| path.starts_with(home.join(rel)))
    }

    pub fn classify(
        flags: u32,
        quarantine: Option<&str>,
        path: &Path,
        home: &Path,
        op: Op,
    ) -> LayerResult {
        if sip_restricted(flags, op) {
            return sip_block(path, op);
        }
        if quarantine_blocks_exec(quarantine.is_some(), op) {
            return quarantine_suspect(path, quarantine.unwrap_or_default());
        }
        match is_tcc_protected(path, home) {
            Some(loc) => tcc_suspect(path, loc),
            None => LayerResult::skip(),
        }
    }

    fn op_word(op: Op) -> &'static str {
        match op {
            Op::Read => "read",
            Op::Write => "write",
            Op::Exec => "exec",
            Op::Traverse => "traverse",
            Op::Delete => "delete",
            Op::Create => "create",
        }
    }

    fn sip_block(path: &Path, op: Op) -> LayerResult {
        let detail = format!(
            "SIP restricts {} on {}: SF_RESTRICTED set",
            op_word(op),
            path.display()
        );
        let evidence = Evidence {
            source: EvidenceSource::Statflags,
            raw: format!("restricted {}", path.display()),
            path: Some(path.to_path_buf()),
        };
        LayerResult::block(Certainty::Proven, detail, vec![evidence], vec![sip_fix()])
    }

    fn sip_fix() -> Fix {
        Fix {
            action: FixAction::Advice {
                text: "SIP-protected; disable SIP from recovery".into(),
            },
            needs_root: false,
            description: "SIP guards this path".into(),
            risk: Risk::High,
            rationale: "csrutil disable from recovery".into(),
        }
    }

    fn quarantine_suspect(path: &Path, value: &str) -> LayerResult {
        let detail = format!(
            "Gatekeeper may block first run of {}: com.apple.quarantine set",
            path.display()
        );
        let evidence = Evidence {
            source: EvidenceSource::Xattr,
            raw: format!("{QUARANTINE_XATTR}={value} {}", path.display()),
            path: Some(path.to_path_buf()),
        };
        LayerResult::suspect(detail, vec![evidence], vec![quarantine_fix(path)])
    }

    fn quarantine_fix(path: &Path) -> Fix {
        Fix {
            action: FixAction::Advice {
                text: format!(
                    "xattr -d com.apple.quarantine {} or approve in System Settings > Privacy & Security",
                    path.display()
                ),
            },
            needs_root: false,
            description: "clear the Gatekeeper quarantine attribute".into(),
            risk: Risk::Low,
            rationale: "skips Gatekeeper first-run check".into(),
        }
    }

    fn tcc_suspect(path: &Path, loc: &str) -> LayerResult {
        let detail = "TCC-protected; terminal may lack Full Disk Access".to_string();
        let evidence = Evidence {
            source: EvidenceSource::Xattr,
            raw: format!("{} under TCC-protected location ~/{loc}", path.display()),
            path: Some(path.to_path_buf()),
        };
        LayerResult::suspect(detail, vec![evidence], vec![tcc_fix()])
    }

    fn tcc_fix() -> Fix {
        Fix {
            action: FixAction::Advice {
                text: "grant your terminal Full Disk Access in System Settings > Privacy & Security > Full Disk Access, then retry".into(),
            },
            needs_root: false,
            description: "grant Full Disk Access to the terminal".into(),
            risk: Risk::Medium,
            rationale: "grant Full Disk Access".into(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::pure::*;
    use crate::engine::LayerStatus;
    use crate::op::Op;
    use crate::report::{Certainty, FixAction};
    use std::path::{Path, PathBuf};

    fn home() -> PathBuf {
        PathBuf::from("/Users/tester")
    }

    #[test]
    fn sip_restricted_blocks_write_delete_create() {
        for op in [Op::Write, Op::Delete, Op::Create] {
            assert!(sip_restricted(SF_RESTRICTED, op));
        }
    }

    #[test]
    fn sip_ignores_read_exec_traverse() {
        for op in [Op::Read, Op::Exec, Op::Traverse] {
            assert!(!sip_restricted(SF_RESTRICTED, op));
        }
    }

    #[test]
    fn sip_absent_never_blocks() {
        for op in [Op::Write, Op::Delete, Op::Create] {
            assert!(!sip_restricted(0, op));
        }
    }

    #[test]
    fn sip_write_is_a_proven_block_with_advice() {
        let r = classify(
            SF_RESTRICTED,
            None,
            Path::new("/System/x"),
            &home(),
            Op::Write,
        );
        assert!(matches!(r.status, LayerStatus::Block));
        assert!(matches!(r.certainty, Certainty::Proven));
        assert!(matches!(r.fixes[0].action, FixAction::Advice { .. }));
        assert!(!r.fixes[0].needs_root);
    }

    #[test]
    fn quarantine_exec_is_a_suspect_note() {
        let r = classify(
            0,
            Some("0081;deadbeef;Safari;"),
            Path::new("/Users/tester/Downloads/tool"),
            &home(),
            Op::Exec,
        );
        assert!(matches!(r.status, LayerStatus::Suspect));
        assert!(matches!(r.certainty, Certainty::Suspected));
    }

    #[test]
    fn quarantine_only_relevant_to_exec() {
        for op in [Op::Read, Op::Write, Op::Delete, Op::Create, Op::Traverse] {
            assert!(!quarantine_blocks_exec(true, op));
        }
        assert!(quarantine_blocks_exec(true, Op::Exec));
        assert!(!quarantine_blocks_exec(false, Op::Exec));
    }

    #[test]
    fn tcc_location_matches_mail_and_reports_suspect() {
        let p = home().join("Library/Mail/V10/box.mbox");
        assert!(is_tcc_protected(&p, &home()).is_some());
        let r = classify(0, None, &p, &home(), Op::Read);
        assert!(matches!(r.status, LayerStatus::Suspect));
        assert!(matches!(r.certainty, Certainty::Suspected));
    }

    #[test]
    fn tcc_covers_tcc_db_with_spaced_path() {
        let p = home().join("Library/Application Support/com.apple.TCC/TCC.db");
        assert!(is_tcc_protected(&p, &home()).is_some());
    }

    #[test]
    fn ordinary_tmp_path_is_not_tcc() {
        assert!(is_tcc_protected(Path::new("/tmp/scratch"), &home()).is_none());
        let r = classify(0, None, Path::new("/tmp/scratch"), &home(), Op::Read);
        assert!(matches!(r.status, LayerStatus::Skip));
    }
}