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
}
}