Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

src/mimir_lens/villa.rs

//! **Lens G — Villa** (Old Norse *villa*: heresy / error / going-astray).
//!
//! Where the other lenses read cost, deadness, risk, duplication, weak tests
//! and bench gaps out of the code graph, **Villa reads IMPURITY**: every place
//! the code shells out to an external process or a raw exec/system syscall
//! instead of staying pure Rust. In the nordisk constellation that is heresy —
//! *"impure operations = heresy, på fornnordiska"* — and this lens makes the
//! shell-out audit a permanent, queryable fact rather than a one-off grep.
//!
//! **The signal is the callee, as rustc resolved it.** The code room holds a
//! CALLS edge from the calling item to `std::process::Command::new`, to a libc
//! / nix escape (`system`, `execvp`, `popen`, …) or to `std::process::exit`;
//! [`classify`] reads the callee's resolved path and node kind, so a crate's own
//! `Command::new` or a method that merely happens to be called `exec` is not a
//! finding. The binary a `Command` runs (`git`, `xorriso`, `osmium`, `podman`,
//! `cc`, `sh`) is a **string argument the graph does not capture** — so Villa
//! flags the shell-out SITE, and names the tool only when the call itself says
//! it: the string literal of `Command::new("…")` at the site, read from the
//! consumed checkout ([`binary_at`]). A binary held in a variable is not named.
//! (Until 2026-10-16 the tool came from a static per-repo list, so a `runuser`
//! and a `cargo` call in dwarves both read "git".)
//!
//! Same lens shape as the others: an [`evidence_note`] naming missing feeds (a
//! room without CALLS edges must NEVER read as "no heresy"), a `feeds` record,
//! and ranked findings each carrying its own evidence note.

use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};

use super::sources::{Feed, evidence_note};

/// The kind of impurity — the taxonomy the task asks for (`shell` / `process` /
/// `external-tool`), plus `process-control` for `process::exit`/`abort` which is
/// impure but not a shell-out.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Kind {
    /// A `Command::new("…")` whose binary is the string literal at the call site
    /// (git, xorriso, osmium, podman, cc, …): a named external tool.
    ExternalTool,
    /// A raw shell / exec escape: `libc::system`, `execvp`, `popen`, `CommandExt::exec`.
    Shell,
    /// A `std::process::Command` (or `tokio::process::Command`) construction — the
    /// archetypal shell-out, binary as-yet unattributed.
    Process,
    /// `process::exit` / `process::abort` — impure control-flow, not a shell-out.
    ProcessControl,
}

impl Kind {
    pub fn label(self) -> &'static str {
        match self {
            Kind::ExternalTool => "external-tool",
            Kind::Shell => "shell",
            Kind::Process => "process",
            Kind::ProcessControl => "process-control",
        }
    }

    /// Sort/attention weight — external-tool hits first, process-control last.
    fn weight(self) -> u8 {
        match self {
            Kind::ExternalTool => 3,
            Kind::Shell => 2,
            Kind::Process => 1,
            Kind::ProcessControl => 0,
        }
    }

    pub fn severity(self) -> &'static str {
        match self {
            Kind::ExternalTool | Kind::Shell => "high",
            Kind::Process => "medium",
            Kind::ProcessControl => "low",
        }
    }
}

/// One heresy — one impure call site in the CPG.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Heresy {
    pub repo: String,
    /// The function the impure call lives in (`caller_path`) — the lens's `symbol`.
    pub symbol: String,
    pub file: String,
    pub line: u32,
    pub kind: Kind,
    pub severity: String,
    /// The resolved callee that tripped the lens, e.g. `std::process::Command::new`.
    pub callee: String,
    /// The external binary we know this repo shells out to, when the repo is a
    /// [`KNOWN_OFFENDERS`] entry — `None` otherwise (binary is a string arg the
    /// CPG does not carry).
    pub known_tool: Option<String>,
    pub evidence_note: String,
}

impl Heresy {
    fn loc(&self) -> String {
        format!("{}:{}", self.file, self.line)
    }
}

/// The whole reading.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct VillaReport {
    pub findings: Vec<Heresy>,
    /// CALLS edges the room holds (the set the impure callees were looked up in).
    pub edges_seen: usize,
    /// Impure sites found (before truncation to the limit).
    pub heresies: usize,
    pub repos_with_heresy: usize,
    pub by_kind: BTreeMap<String, usize>,
    pub feeds: Vec<Feed>,
    pub evidence_note: String,
}

impl VillaReport {
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "kind": "villa",
            "edges_seen": self.edges_seen,
            "heresies": self.heresies,
            "repos_with_heresy": self.repos_with_heresy,
            "by_kind": self.by_kind,
            "evidence_note": self.evidence_note,
            "feeds": self.feeds,
            // `findings` is Villa's own name; `worst` is aliased so a pane that
            // renders every lens's ranked list from a single key stays uniform.
            "findings": self.findings.iter().map(|h| serde_json::json!({
                "repo": h.repo,
                "symbol": h.symbol,
                "loc": h.loc(),
                "file": h.file,
                "line": h.line,
                "kind": h.kind.label(),
                "severity": h.severity,
                "callee": h.callee,
                "known_tool": h.known_tool,
                "evidence_note": h.evidence_note,
            })).collect::<Vec<_>>(),
            "worst": self.findings.iter().map(|h| serde_json::json!({
                "repo": h.repo, "symbol": h.symbol, "loc": h.loc(),
                "kind": h.kind.label(), "severity": h.severity,
                "known_tool": h.known_tool,
            })).collect::<Vec<_>>(),
        })
    }
}

/// **The binary a `Command::new` call runs, read from its source line.** `lines`
/// is the file, `line` the call's 1-based line; the literal may sit on that line
/// or the next two (`Command::new(\n    "git",\n)`). `None` when the line holds
/// no `Command::new(` or its argument is not a plain string literal (a variable,
/// a `format!`, a path built at run time): then the tool is not named.
pub fn binary_at(lines: &[&str], line: u32) -> Option<String> {
    let i = (line as usize).checked_sub(1)?;
    let window: String = lines.get(i..(i + 3).min(lines.len()))?.join("\n");
    let at = window.find("Command::new(")?;
    let rest = window[at + "Command::new(".len()..].trim_start();
    let rest = rest.strip_prefix('"')?;
    let end = rest.find('"')?;
    let bin = &rest[..end];
    (!bin.is_empty() && !bin.contains('\\') && !bin.contains('{')).then(|| bin.to_string())
}

/// The item names an impure callee can have — what the room is asked for
/// before [`classify`] reads each hit's resolved path.
pub const IMPURE_NAMES: &[&str] = &["new", "exit", "abort", "system", "popen", "execvp", "execv", "execve", "execvpe", "execl", "execlp", "execle", "exec"];

/// The raw shell / exec escapes, by item name.
const EXEC_NAMES: &[&str] = &["system", "popen", "execvp", "execv", "execve", "execvpe", "execl", "execlp", "execle", "exec"];

/// Crates whose `system` / `exec*` items ARE the syscall wrappers.
const SYSCALL_CRATES: &[&str] = &["libc", "nix", "rustix"];

/// One call site whose callee the graph resolved: the feed of this lens.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CallSite {
    pub repo: String,
    /// The calling item's path (`shop::pay::charge`).
    pub caller: String,
    /// The callee's resolved path (`std::process::Command::new`).
    pub callee: String,
    /// The callee's node kind (`fn`, `method`, `ffi` …).
    pub callee_kind: String,
    pub file: String,
    pub line: u32,
    /// The binary the call runs when the site spells it as a string literal
    /// ([`binary_at`]); `None` = not read (no checkout) or not a literal.
    pub binary: Option<String>,
}

/// **Classify one callee** by its resolved path and node kind. Returns the
/// impurity kind, or `None` for a pure callee.
///
/// Villa anchors on the CONSTRUCTION / free-call items (which every shell-out
/// has exactly one of) and never on the builder methods of `Command`
/// (`.spawn()` / `.output()` / `.arg()`): one finding per shell-out.
pub fn classify(callee_path: &str, callee_kind: &str) -> Option<Kind> {
    let last = callee_path.rsplit("::").next().unwrap_or(callee_path);
    let root = callee_path.split("::").next().unwrap_or("");

    // `std::process::Command::new`, `tokio::process::Command::new` — the
    // process-spawn construction. A crate's own `cli::Command::new` is not one.
    if callee_path.ends_with("process::Command::new") {
        return Some(Kind::Process);
    }
    // A raw shell / exec escape: a foreign item (`extern "C" { fn system(…) }`),
    // a libc / nix / rustix wrapper, or `CommandExt::exec` on a process.
    if EXEC_NAMES.contains(&last) {
        let syscall = callee_kind == mimir_schema::kind::FFI || SYSCALL_CRATES.contains(&root);
        let command_exec = last == "exec" && callee_path.contains("process::");
        if syscall || command_exec {
            return Some(Kind::Shell);
        }
    }
    // `std::process::exit` / `std::process::abort` — impure control flow.
    if root == "std" && (callee_path.ends_with("process::exit") || callee_path.ends_with("process::abort")) {
        return Some(Kind::ProcessControl);
    }
    None
}

/// **Find the heresies.** Folds the resolved call sites into ranked impure-call
/// findings. `edges_seen` is the number of CALLS edges the sites were drawn
/// from; `feeds` is the record of what was read.
pub fn analyze(sites: &[CallSite], edges_seen: usize, feeds: Vec<Feed>, repo_filter: &str, top_n: usize) -> VillaReport {
    let mut findings: Vec<Heresy> = Vec::new();
    let mut repos: BTreeSet<String> = BTreeSet::new();
    let mut by_kind: BTreeMap<String, usize> = BTreeMap::new();

    for site in sites {
        let repo = site.repo.as_str();
        if !repo_filter.is_empty() && repo != repo_filter {
            continue;
        }
        let callee = site.callee.as_str();
        let Some(mut kind) = classify(callee, &site.callee_kind) else {
            continue;
        };

        // A process construction whose binary the call itself spells is a NAMED
        // external-tool heresy; without the literal it stays a process.
        let known_tool = match (&kind, &site.binary) {
            (Kind::Process, Some(bin)) => {
                kind = Kind::ExternalTool;
                Some(bin.clone())
            }
            _ => None,
        };

        let evidence_note = match (&kind, &known_tool) {
            (Kind::ExternalTool, Some(tool)) => format!(
                "`{callee}` in {repo} — runs the external tool `{tool}` (the string literal at \
                 the call site); replace it with a pure-Rust path."
            ),
            (Kind::Process, _) => format!(
                "`{callee}` — spawns an external process (impure). The call spells no binary \
                 as a string literal (a variable or a path built at run time, or no checkout \
                 to read): inspect the site to see which."
            ),
            (Kind::Shell, _) => format!(
                "`{callee}` — a raw shell/exec escape (impure); replace with a pure-Rust path."
            ),
            (Kind::ProcessControl, _) => format!(
                "`{callee}` — impure process control (exit/abort); return a Result instead."
            ),
            (Kind::ExternalTool, None) => format!("`{callee}` — external-tool shell-out."),
        };

        repos.insert(repo.to_string());
        *by_kind.entry(kind.label().to_string()).or_default() += 1;
        findings.push(Heresy {
            repo: repo.to_string(),
            symbol: site.caller.clone(),
            file: site.file.clone(),
            line: site.line,
            severity: kind.severity().to_string(),
            kind,
            callee: callee.to_string(),
            known_tool,
            evidence_note,
        });
    }

    let heresies = findings.len();
    // Most severe first; within a kind, group by repo then file:line for a stable read.
    findings.sort_by(|a, b| {
        b.kind
            .weight()
            .cmp(&a.kind.weight())
            .then_with(|| a.repo.cmp(&b.repo))
            .then_with(|| a.file.cmp(&b.file))
            .then_with(|| a.line.cmp(&b.line))
            .then_with(|| a.symbol.cmp(&b.symbol))
    });
    if top_n > 0 {
        findings.truncate(top_n);
    }

    let note = evidence_note(&feeds, "villa (impure / shell-out heresy)");
    VillaReport {
        findings,
        edges_seen,
        heresies,
        repos_with_heresy: repos.len(),
        by_kind,
        feeds,
        evidence_note: note,
    }
}

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

    fn site(repo: &str, caller: &str, callee: &str, kind: &str, file: &str, line: u32) -> CallSite {
        CallSite { repo: repo.into(), caller: caller.into(), callee: callee.into(), callee_kind: kind.into(), file: file.into(), line, binary: None }
    }

    fn running(bin: &str, mut s: CallSite) -> CallSite {
        s.binary = Some(bin.into());
        s
    }

    fn calls(n: usize) -> Vec<Feed> {
        vec![Feed { table: "code_edges:CALLS".into(), rows: n, root: "code room".into(), missing: n == 0 }]
    }

    #[test]
    fn command_new_is_process_heresy_and_builder_methods_are_not() {
        assert_eq!(classify("std::process::Command::new", "method"), Some(Kind::Process));
        assert_eq!(classify("tokio::process::Command::new", "method"), Some(Kind::Process));
        // one finding per shell-out: the builder methods are not anchors
        assert_eq!(classify("std::process::Command::spawn", "method"), None);
        assert_eq!(classify("std::process::Command::output", "method"), None);
        assert_eq!(classify("std::process::Command::arg", "method"), None);
        // resolved, so a crate's own `Command` is not the process one
        assert_eq!(classify("clap::builder::Command::new", "method"), None);
        assert_eq!(classify("shop::cli::Command::new", "method"), None);
    }

    #[test]
    fn raw_exec_and_process_exit_are_classified() {
        assert_eq!(classify("libc::unix::system", "ffi"), Some(Kind::Shell));
        assert_eq!(classify("nix::unistd::execvp", "fn"), Some(Kind::Shell));
        assert_eq!(classify("shop::pay::system", "ffi"), Some(Kind::Shell), "an extern block of the repo's own");
        assert_eq!(classify("std::os::unix::process::Command::exec", "method"), Some(Kind::Shell));
        assert_eq!(classify("std::process::exit", "fn"), Some(Kind::ProcessControl));
        assert_eq!(classify("std::process::abort", "fn"), Some(Kind::ProcessControl));
        // a pure item that only shares the name
        assert_eq!(classify("sqlx::query::Query::exec", "method"), None);
        assert_eq!(classify("shop::pay::system", "fn"), None);
        assert_eq!(classify("shop::process::exit", "fn"), None);
        assert_eq!(classify("shop::pure_fn", "fn"), None);
    }

    #[test]
    fn every_classified_name_is_one_the_room_is_asked_for() {
        for path in ["std::process::Command::new", "libc::system", "nix::unistd::execvp", "std::process::exit", "std::process::abort"] {
            let last = path.rsplit("::").next().unwrap();
            assert!(IMPURE_NAMES.contains(&last), "{path}");
        }
        for n in EXEC_NAMES {
            assert!(IMPURE_NAMES.contains(n), "{n}");
        }
    }

    #[test]
    fn a_command_whose_call_spells_the_binary_is_a_named_external_tool() {
        let s = [running("osmium", site("znippy", "znippy::osm::run", "std::process::Command::new", "method", "osm.rs", 42))];
        let r = analyze(&s, 1, calls(1), "", 20);
        assert_eq!(r.heresies, 1);
        let h = &r.findings[0];
        assert_eq!(h.kind, Kind::ExternalTool);
        assert_eq!(h.known_tool.as_deref(), Some("osmium"));
        assert_eq!(h.severity, "high");
        assert!(h.evidence_note.contains("`osmium`"), "{}", h.evidence_note);
        assert_eq!(h.loc(), "osm.rs:42");
    }

    /// The repo names no tool: a `runuser` call in dwarves read "git" from a
    /// static per-repo list until 2026-10-16.
    #[test]
    fn a_command_without_a_literal_is_a_plain_process_in_any_repo() {
        for repo in ["korp", "dwarves", "nornir"] {
            let s = [site(repo, "x::f", "std::process::Command::new", "method", "x.rs", 9)];
            let r = analyze(&s, 1, calls(1), "", 20);
            assert_eq!(r.findings[0].kind, Kind::Process, "{repo}");
            assert!(r.findings[0].known_tool.is_none(), "{repo}");
        }
    }

    #[test]
    fn the_binary_is_the_literal_at_the_call_or_nothing() {
        let src = [
            r#"let a = std::process::Command::new("runuser").arg("-u");"#,
            r#"let b = Command::new(cargo_bin());"#,
            r#"let c = Command::new("#,
            r#"    "xorriso","#,
            r#");"#,
            r#"let d = Command::new(format!("{x}"));"#,
            r#"// nothing here"#,
        ];
        assert_eq!(binary_at(&src, 1).as_deref(), Some("runuser"));
        assert_eq!(binary_at(&src, 2), None, "a variable names no binary");
        assert_eq!(binary_at(&src, 3).as_deref(), Some("xorriso"), "the literal on the next line");
        assert_eq!(binary_at(&src, 6), None);
        assert_eq!(binary_at(&src, 7), None);
        assert_eq!(binary_at(&src, 0), None);
        assert_eq!(binary_at(&src, 99), None);
    }

    #[test]
    fn repo_filter_scopes_the_fold() {
        let s = [
            site("znippy", "a", "std::process::Command::new", "method", "a.rs", 1),
            running("podman", site("skade", "b", "std::process::Command::new", "method", "b.rs", 2)),
        ];
        let r = analyze(&s, 2, calls(2), "skade", 20);
        assert_eq!(r.heresies, 1);
        assert_eq!(r.findings[0].repo, "skade");
        assert_eq!(r.findings[0].known_tool.as_deref(), Some("podman"));
    }

    #[test]
    fn a_room_without_calls_reads_as_missing_feed_not_no_heresy() {
        let r = analyze(&[], 0, calls(0), "", 20);
        assert_eq!(r.heresies, 0);
        assert!(r.evidence_note.contains("MISSING FEEDS"), "{}", r.evidence_note);
        assert!(r.evidence_note.contains("code_edges:CALLS"), "{}", r.evidence_note);
    }

    #[test]
    fn severity_orders_external_tool_before_process_before_control() {
        let s = [
            site("korp", "c", "std::process::exit", "fn", "c.rs", 3),
            site("korp", "a", "std::process::Command::new", "method", "a.rs", 1),
            running("git", site("nornir", "b", "std::process::Command::new", "method", "b.rs", 2)),
        ];
        let r = analyze(&s, 3, calls(3), "", 20);
        assert_eq!(r.findings[0].kind, Kind::ExternalTool); // the call runs `git`
        assert_eq!(r.findings[1].kind, Kind::Process); // korp Command
        assert_eq!(r.findings[2].kind, Kind::ProcessControl); // process::exit
    }
}