whycant 0.1.0

CLI tool to figure out why you can't do something.
Documentation
use crate::render::human::{Layout, render_report};
use crate::report::Report;
use crate::term::{ColorDepth, GlyphSet, TermCtx};
use anyhow::Result;

pub fn render_json(report: &Report) -> Result<String> {
    Ok(serde_json::to_string_pretty(report)?)
}

pub fn render_toml(report: &Report) -> Result<String> {
    Ok(toml::to_string_pretty(report)?)
}

pub fn render_plain(report: &Report, term: &TermCtx) -> String {
    let plain = TermCtx {
        color: ColorDepth::None,
        glyphs: GlyphSet::Ascii,
        hyperlinks: false,
        width: term.width,
    };
    render_report(report, &plain, true, Layout::Plain)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::op::Op;
    use crate::report::{
        Certainty, CrossCheck, Evidence, EvidenceSource, Fix, FixAction, IdentityReport, LayerId,
        LayerResult, LayerStatus, Mark, NodeKind, PathComponent, Risk, RunningAs, Verdict,
    };

    fn plain_term() -> TermCtx {
        TermCtx {
            color: ColorDepth::None,
            glyphs: GlyphSet::Ascii,
            hyperlinks: false,
            width: 80,
        }
    }

    fn sample() -> Report {
        Report {
            schema_version: 1,
            tool: "whycant".into(),
            identity: IdentityReport {
                target_uid: 1000,
                target_user: Some("alice".into()),
                primary_gid: 1000,
                groups: vec![1000, 27],
                group_names: vec!["alice".into(), "sudo".into()],
                running_as: RunningAs::User,
                privilege_note: "running as alice (uid 1000)".into(),
                target_is_self: true,
            },
            op: Op::Read,
            path: "/srv/secret/report.txt".into(),
            resolved_path: None,
            verdict: Verdict::Blocked,
            certainty: Certainty::Proven,
            culprit: Some("DAC denies read for alice: mode 0600".into()),
            blocking_layer: Some(LayerId::Dac),
            evidence_chain: vec![
                PathComponent {
                    name: "/".into(),
                    kind: NodeKind::Dir,
                    mark: Mark::Pass,
                    need: "x".into(),
                    evidence: None,
                    note: None,
                    layer: Some(LayerId::Traverse),
                },
                PathComponent {
                    name: "srv".into(),
                    kind: NodeKind::Dir,
                    mark: Mark::Pass,
                    need: "x".into(),
                    evidence: None,
                    note: None,
                    layer: Some(LayerId::Traverse),
                },
                PathComponent {
                    name: "secret".into(),
                    kind: NodeKind::Dir,
                    mark: Mark::Pass,
                    need: "x".into(),
                    evidence: None,
                    note: None,
                    layer: Some(LayerId::Traverse),
                },
                PathComponent {
                    name: "report.txt".into(),
                    kind: NodeKind::File,
                    mark: Mark::Block,
                    need: "r".into(),
                    evidence: None,
                    note: Some("class other lacks r".into()),
                    layer: Some(LayerId::Dac),
                },
            ],
            layer_results: vec![LayerResult {
                layer: LayerId::Dac,
                status: LayerStatus::Block,
                certainty: Certainty::Proven,
                summary: "DAC denies read".into(),
                evidence: vec![Evidence {
                    source: EvidenceSource::LsLd,
                    raw: "-rw------- 1 root root 1240 /srv/secret/report.txt".into(),
                    path: Some("/srv/secret/report.txt".into()),
                }],
            }],
            fixes: vec![Fix {
                action: FixAction::Run {
                    argv: vec![
                        "setfacl".into(),
                        "-m".into(),
                        "u:alice:r".into(),
                        "/srv/secret/report.txt".into(),
                    ],
                },
                needs_root: true,
                description: "grant alice r via named-user ACL".into(),
                risk: Risk::Low,
                rationale: "owner, group, other unchanged".into(),
            }],
            cross_check: Some(CrossCheck {
                available: true,
                kernel_allows: Some(false),
                model_allows: false,
                agree: true,
                kernel_rwx: Some([false, false, false]),
                message: None,
            }),
            warnings: vec!["audit log unreadable, re-run with sudo".into()],
        }
    }

    #[test]
    fn json_toml_describe_identical_data() {
        let r = sample();
        let j = render_json(&r).unwrap();
        let t = render_toml(&r).unwrap();
        let from_json: Report = serde_json::from_str(&j).unwrap();
        let from_toml: Report = toml::from_str(&t).unwrap();
        assert_eq!(j, serde_json::to_string_pretty(&from_json).unwrap());
        assert_eq!(j, serde_json::to_string_pretty(&from_toml).unwrap());
    }

    #[test]
    fn schema_version_in_both_formats() {
        let r = sample();
        assert!(render_json(&r).unwrap().contains("\"schema_version\": 1"));
        assert!(render_toml(&r).unwrap().contains("schema_version = 1"));
    }

    #[test]
    fn toml_file_round_trip() {
        let r = sample();
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().join("report.toml");
        std::fs::write(&p, render_toml(&r).unwrap()).unwrap();
        let back: Report = toml::from_str(&std::fs::read_to_string(&p).unwrap()).unwrap();
        assert_eq!(
            render_json(&r).unwrap(),
            render_json(&back).unwrap(),
            "toml file round-trips to identical json"
        );
    }

    #[test]
    fn plain_is_greppable_and_bare() {
        let out = render_plain(&sample(), &plain_term());
        assert!(!out.contains('\u{1b}'), "no ansi: {out}");
        assert!(out.is_ascii(), "no unicode glyphs: {out}");
        assert!(!out.contains("\n\n"), "no blank separators: {out}");
        let first = out.lines().next().unwrap();
        assert!(
            first.starts_with("verdict blocked read /srv/secret/report.txt"),
            "{first}"
        );
        assert!(out.lines().any(|l| l.starts_with("culprit ")));
        assert!(out.lines().any(|l| l.starts_with("chain ")));
        assert!(out.lines().any(|l| l.starts_with("evidence dac ls_ld ")));
        assert!(out.lines().any(|l| l.starts_with("crosscheck agree ")));
        assert!(out.lines().any(|l| l.starts_with("fix low ")));
        assert!(out.lines().any(|l| l.starts_with("fix-note ")));
        for l in out.lines() {
            assert!(!l.starts_with(' '), "fixed leading token, no indent: {l}");
        }
    }

    #[test]
    fn plain_snapshot() {
        insta::assert_snapshot!(render_plain(&sample(), &plain_term()));
    }
}