src/mimir_lens/data_reach.rs
//! **Lens — `Mimir.DataReach`: which code reaches which warehouse table.**
//!
//! Gemini roadmap #2 (`mimir-intelligence-roadmap-2026-07-24.md`), re-keyed to
//! the code room (Mímir v2). The facts are the room's:
//!
//! - `TOUCHES_TABLE` edges (`fn → table`, with `access` read / write / both
//! and the sites) — the code↔data edges, written by the rustc driver where
//! a table NAME meets a read or a write of the storage engine;
//! - `CALLS` — walked BACKWARDS from each fn that touches a table: who can
//! reach it (the reachers), inside the request's scope;
//! - `effective_visibility` — which reacher is part of a crate's nameable
//! API (the outer surface a table is exposed through).
//!
//! [`super::room_feed`] gathers them as [`Touch`] rows; this file is the kept
//! half: the sensitivity label, the ranking, the report.
//!
//! Sensitivity is a NAME heuristic — there is no label table yet — so a table whose
//! name matches [`SENSITIVE_PATTERNS`] (pii, secret, token, cred, password, key,
//! vault, saga, personnummer, …) is treated as sensitive. A real per-table label
//! source can override this later without changing the fold.
//!
//! One row = one `(table, fn that touches it)`: the access, the sites, the
//! reachers. Rows rank by (sensitive first, a write before a read, widest
//! reach, a public entry point present). A **leak path** is a row whose table
//! is sensitive.
//!
//! A site whose table name is not known at compile time (a runtime string) is
//! NOT a row of a table and is not dropped: it is counted and listed under
//! `unresolved`, by fn and `file:line`.
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use super::sources::{Feed, evidence_note};
/// Case-insensitive substrings that mark a table as holding sensitive data. A NAME
/// heuristic, kept as greppable data so the sensitivity policy is one obvious list.
pub const SENSITIVE_PATTERNS: &[(&str, &str)] = &[
("pii", "pii"),
("personnummer", "pii"),
("person", "pii"),
("secret", "secret"),
("token", "credential"),
("cred", "credential"),
("password", "credential"),
("passwd", "credential"),
("apikey", "credential"),
("api_key", "credential"),
("vault", "credential"),
("key_material", "credential"),
("saga", "audit-pii"),
("audit", "audit-pii"),
];
/// Reacher names one row lists (`reach_breadth` is always the full count).
pub const REACHERS_SHOWN: usize = 64;
/// Which sensitivity label a table name carries, if any (case-insensitive substring).
pub fn sensitivity_of(table: &str) -> Option<&'static str> {
let t = table.to_ascii_lowercase();
SENSITIVE_PATTERNS
.iter()
.find(|(pat, _)| t.contains(pat))
.map(|(_, label)| *label)
}
/// One fn that touches one table, as the code room holds it.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Touch {
/// The table's name; `None` = not known at compile time.
pub table: Option<String>,
/// The fn that touches it, by its readable path.
pub accessor_fn: String,
pub crate_name: String,
pub repo: String,
/// `read` / `write` / `both`.
pub access: String,
/// `file:line` of each positioned site.
pub sites: Vec<String>,
/// Sites the edge counts (≥ `sites.len()`: a site inside a macro has no line).
pub site_count: usize,
/// Every fn that reaches `accessor_fn` along CALLS inside the scope
/// (readable paths, sorted).
pub reachers: Vec<String>,
/// `accessor_fn` or a reacher is part of a crate's nameable API.
pub public_entry: bool,
}
/// One `(table, fn)` row of the report.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LeakPath {
/// The warehouse table touched.
pub table: String,
/// The sensitivity label matched (`pii` / `credential` / `secret` /
/// `audit-pii`); `""` for a table no pattern matches.
pub sensitivity: String,
/// The function that directly touches the table.
pub accessor_fn: String,
pub crate_name: String,
pub repo: String,
/// `read` / `write` / `both` (a write is the higher-risk direction).
pub access: String,
/// `file:line` of the sites.
pub sites: Vec<String>,
pub site_count: usize,
/// Transitive upstream callers that can reach `accessor_fn` — at most
/// [`REACHERS_SHOWN`] of them.
pub reachers: Vec<String>,
/// How many there are — the blast radius. A SORT KEY; read `reachers` beside it.
pub reach_breadth: usize,
/// A fn of a crate's nameable API sits on the reach path (or IS the
/// accessor) — the table is exposed through the crate's outer surface.
pub public_entry: bool,
}
impl LeakPath {
pub fn explain(&self) -> String {
format!(
"{} `{}` touches table `{}`{}; reachable from {} upstream caller(s){}",
self.access,
self.accessor_fn,
self.table,
if self.sensitivity.is_empty() { String::new() } else { format!(" (sensitive: {})", self.sensitivity) },
self.reach_breadth,
if self.public_entry { " — via a PUBLIC entry point" } else { "" }
)
}
fn writes(&self) -> bool {
self.access == "write" || self.access == "both"
}
}
/// One table, over every fn that touches it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TableReach {
pub table: String,
pub sensitivity: String,
/// Fns that read / write it directly (a fn that does both is in both).
pub readers: usize,
pub writers: usize,
pub sites: usize,
/// Distinct fns that touch it or reach one that does.
pub reach: usize,
}
/// One fn with sites whose table is not known at compile time.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnresolvedTouch {
pub accessor_fn: String,
pub crate_name: String,
pub repo: String,
pub access: String,
pub sites: Vec<String>,
pub site_count: usize,
}
/// The whole reading.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DataReachReport {
/// `(table, fn)` edges with a known table.
pub access_edges_seen: usize,
pub tables_seen: usize,
/// Every table touched, by name.
pub tables: Vec<TableReach>,
pub sensitive_tables: Vec<String>,
/// Rows whose table is sensitive.
pub leak_paths_seen: usize,
/// Fns / sites that touch a table whose name is not known at compile time.
pub unresolved_edges: usize,
pub unresolved_sites: usize,
pub unresolved: Vec<UnresolvedTouch>,
pub worst: Vec<LeakPath>,
pub feeds: Vec<Feed>,
pub evidence_note: String,
pub model_attribution: String,
}
pub const ATTRIBUTION: &str = "Composition of code-room facts: TOUCHES_TABLE (fn → table, where a \
table name meets a read / write of the storage engine, from the rustc driver) × CALLS walked \
backwards (transitive reachability) × effective_visibility (public entry surface). \
Sensitivity is a table-name heuristic (SENSITIVE_PATTERNS) until a per-table label source \
lands. Ranks WHICH table touch has the widest exposed blast radius — the schema-blast / \
leak-path question.";
impl DataReachReport {
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"kind": "data_reach",
"access_edges_seen": self.access_edges_seen,
"tables_seen": self.tables_seen,
"tables": self.tables,
"sensitive_tables": self.sensitive_tables,
"leak_paths_seen": self.leak_paths_seen,
"unresolved_edges": self.unresolved_edges,
"unresolved_sites": self.unresolved_sites,
"unresolved": self.unresolved,
"evidence_note": self.evidence_note,
"model_attribution": self.model_attribution,
"feeds": self.feeds,
"worst": self.worst.iter().map(|p| serde_json::json!({
"table": p.table,
"sensitivity": p.sensitivity,
"accessor_fn": p.accessor_fn,
"crate_name": p.crate_name,
"repo": p.repo,
"access": p.access,
"sites": p.sites,
"site_count": p.site_count,
"reachers": p.reachers,
"reach_breadth": p.reach_breadth,
"public_entry": p.public_entry,
"explain": p.explain(),
})).collect::<Vec<_>>(),
})
}
}
/// **Rank the table touches by exposed blast radius.** Pure fold — no IO.
/// `touches` = every `(fn, table)` edge of the scope with its reachers.
pub fn analyze(touches: &[Touch], feeds: Vec<Feed>, top_n: usize) -> DataReachReport {
let mut paths: Vec<LeakPath> = Vec::new();
let mut unresolved: Vec<UnresolvedTouch> = Vec::new();
// table → (readers, writers, sites, everyone who reaches it)
let mut per_table: BTreeMap<&str, (usize, usize, usize, BTreeSet<&str>)> = BTreeMap::new();
for t in touches {
let Some(table) = t.table.as_deref() else {
unresolved.push(UnresolvedTouch {
accessor_fn: t.accessor_fn.clone(),
crate_name: t.crate_name.clone(),
repo: t.repo.clone(),
access: t.access.clone(),
sites: t.sites.clone(),
site_count: t.site_count,
});
continue;
};
let slot = per_table.entry(table).or_default();
slot.0 += usize::from(t.access != "write");
slot.1 += usize::from(t.access != "read");
slot.2 += t.site_count;
slot.3.insert(&t.accessor_fn);
slot.3.extend(t.reachers.iter().map(String::as_str));
paths.push(LeakPath {
table: table.to_string(),
sensitivity: sensitivity_of(table).unwrap_or("").to_string(),
accessor_fn: t.accessor_fn.clone(),
crate_name: t.crate_name.clone(),
repo: t.repo.clone(),
access: t.access.clone(),
sites: t.sites.clone(),
site_count: t.site_count,
reachers: t.reachers.iter().take(REACHERS_SHOWN).cloned().collect(),
reach_breadth: t.reachers.len(),
public_entry: t.public_entry,
});
}
// Rank: sensitive first, a WRITE before a read, then widest blast radius,
// then a public entry present.
paths.sort_by(|a, b| {
(a.sensitivity.is_empty() as u8)
.cmp(&(b.sensitivity.is_empty() as u8))
.then_with(|| (b.writes() as u8).cmp(&(a.writes() as u8)))
.then_with(|| b.reach_breadth.cmp(&a.reach_breadth))
.then_with(|| (b.public_entry as u8).cmp(&(a.public_entry as u8)))
.then_with(|| (&a.table, &a.accessor_fn).cmp(&(&b.table, &b.accessor_fn)))
});
unresolved.sort_by(|a, b| b.site_count.cmp(&a.site_count).then_with(|| a.accessor_fn.cmp(&b.accessor_fn)));
let tables: Vec<TableReach> = per_table
.iter()
.map(|(name, (readers, writers, sites, reach))| TableReach {
table: name.to_string(),
sensitivity: sensitivity_of(name).unwrap_or("").to_string(),
readers: *readers,
writers: *writers,
sites: *sites,
reach: reach.len(),
})
.collect();
let access_edges_seen = paths.len();
let leak_paths_seen = paths.iter().filter(|p| !p.sensitivity.is_empty()).count();
let (unresolved_edges, unresolved_sites) = (unresolved.len(), unresolved.iter().map(|u| u.site_count).sum());
paths.truncate(top_n);
unresolved.truncate(top_n);
DataReachReport {
access_edges_seen,
tables_seen: tables.len(),
sensitive_tables: tables.iter().filter(|t| !t.sensitivity.is_empty()).map(|t| t.table.clone()).collect(),
tables,
leak_paths_seen,
unresolved_edges,
unresolved_sites,
unresolved,
worst: paths,
evidence_note: evidence_note(&feeds, "DataReach (code↔data reachability)"),
model_attribution: ATTRIBUTION.to_string(),
feeds,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn touch(table: &str, accessor: &str, access: &str, reachers: &[&str], public: bool) -> Touch {
Touch {
table: (!table.is_empty()).then(|| table.to_string()),
accessor_fn: accessor.to_string(),
crate_name: accessor.split("::").next().unwrap_or("").to_string(),
repo: "r".into(),
access: access.to_string(),
sites: vec!["src/db.rs:7".into()],
site_count: 1,
reachers: reachers.iter().map(|s| s.to_string()).collect(),
public_entry: public,
}
}
fn fed() -> Vec<Feed> {
vec![Feed { table: "code_edges:TOUCHES_TABLE".into(), rows: 3, root: "room".into(), missing: false }]
}
/// A write to a PII table reachable from a public entry point is the worst
/// row — and it outranks a wider read of a table no pattern matches.
#[test]
fn a_pii_write_reachable_from_a_public_entry_is_the_worst_leak() {
let t = vec![
touch("metrics_counter", "app::metrics::bump", "read", &["app::a", "app::b", "app::c"], true),
touch("user_pii", "app::db::write_user_pii", "write", &["app::web::handler", "app::web::route"], true),
];
let r = analyze(&t, fed(), 10);
assert_eq!(r.access_edges_seen, 2);
assert_eq!(r.leak_paths_seen, 1, "only the sensitive table is a leak path");
assert_eq!(r.sensitive_tables, vec!["user_pii".to_string()]);
let w = &r.worst[0];
assert_eq!((w.table.as_str(), w.sensitivity.as_str(), w.access.as_str()), ("user_pii", "pii", "write"));
assert_eq!(w.reach_breadth, 2);
assert!(w.public_entry && w.explain().contains("PUBLIC") && w.explain().contains("sensitive: pii"));
// the table no pattern matches is still a row: every touch is reported
assert_eq!(r.worst[1].table, "metrics_counter");
assert_eq!(r.worst[1].sensitivity, "");
assert_eq!(r.tables_seen, 2);
}
/// At equal sensitivity: a write (or `both`) before a read, then the wider reach.
#[test]
fn a_write_ranks_before_a_read_and_a_wider_reach_before_a_narrower() {
let t = vec![
touch("session_token", "b::reads_token", "read", &["b::x"], false),
touch("app_secret_store", "a::reads_secret", "read", &["a::c1", "a::c2", "a::c3"], false),
touch("vault_keys", "c::rotates", "both", &[], false),
];
let r = analyze(&t, fed(), 10);
assert_eq!(r.leak_paths_seen, 3);
let order: Vec<&str> = r.worst.iter().map(|p| p.accessor_fn.as_str()).collect();
assert_eq!(order, ["c::rotates", "a::reads_secret", "b::reads_token"], "red when `both` stops counting as a write, or the reach key is dropped");
assert_eq!(r.worst[1].reach_breadth, 3);
}
/// One table, several fns: the per-table line counts readers, writers and
/// the distinct fns that reach it (a fn on two paths is counted once).
#[test]
fn a_table_line_counts_readers_writers_and_distinct_reach() {
let t = vec![
touch("bench_runs", "wh::append", "write", &["cli::run", "srv::submit"], true),
touch("bench_runs", "wh::query", "read", &["cli::run", "viz::pane"], true),
touch("bench_runs", "wh::compact", "both", &[], false),
];
let r = analyze(&t, fed(), 10);
assert_eq!(r.tables, vec![TableReach { table: "bench_runs".into(), sensitivity: String::new(), readers: 2, writers: 2, sites: 3, reach: 6 }]);
}
/// A site whose table is a runtime string is not attributed to a table and
/// not dropped: counted, and named by fn and site.
#[test]
fn a_runtime_table_name_is_counted_and_named_not_dropped() {
let mut browse = touch("", "viz::browser::preview", "read", &["viz::app::draw"], true);
browse.sites = vec!["src/viz/browser.rs:40".into(), "src/viz/browser.rs:52".into()];
browse.site_count = 3;
let t = vec![browse, touch("bench_runs", "wh::query", "read", &[], false)];
let r = analyze(&t, fed(), 10);
assert_eq!((r.access_edges_seen, r.unresolved_edges, r.unresolved_sites), (1, 1, 3));
assert_eq!(r.unresolved[0].accessor_fn, "viz::browser::preview");
assert_eq!(r.unresolved[0].sites.len(), 2);
assert!(r.worst.iter().all(|p| p.table != ""), "an unresolved site is never a row of a table");
let j = r.state_json();
assert_eq!(j["unresolved_sites"], 3);
assert_eq!(j["unresolved"][0]["sites"][1], "src/viz/browser.rs:52");
}
/// More reachers than the row lists: the count stays exact.
#[test]
fn the_reacher_list_is_cut_and_the_breadth_is_not() {
let many: Vec<String> = (0..REACHERS_SHOWN + 30).map(|i| format!("a::f{i:03}")).collect();
let refs: Vec<&str> = many.iter().map(String::as_str).collect();
let r = analyze(&[touch("t", "a::touch", "read", &refs, false)], fed(), 10);
assert_eq!(r.worst[0].reachers.len(), REACHERS_SHOWN);
assert_eq!(r.worst[0].reach_breadth, REACHERS_SHOWN + 30);
}
/// Anti-silent-empty: an unfed TOUCHES_TABLE reads as a MISSING FEED, not "no touches".
#[test]
fn an_empty_touch_feed_reads_as_missing_not_no_leaks() {
let feeds = vec![Feed { table: "code_edges:TOUCHES_TABLE".into(), rows: 0, root: "room".into(), missing: true }];
let r = analyze(&[], feeds, 10);
assert_eq!(r.leak_paths_seen, 0);
assert!(r.evidence_note.contains("MISSING FEEDS") && r.evidence_note.contains("TOUCHES_TABLE"), "{}", r.evidence_note);
}
}