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/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);
    }
}