candor_classify/lib.rs
1//! candor-classify — the curated effect classifier (crate+path -> effect), extracted to a STABLE
2//! crate so both the nightly `rustc_private` lint AND a stable backend share ONE source of truth
3//! (no drift). Pure string logic; no rustc internals. The effect vocabulary lives in candor-report.
4
5use candor_report::EFFECTS;
6
7/// The canonical CANDOR_POLICY DSL parser (SPEC §6.2), shared by the nightly gate and candor-query.
8pub mod policy;
9#[cfg(test)]
10mod policy_props;
11
12/// The SURPRISE heuristic (the cold-repo hook) — SHARED so candor-scan's scan-time note and
13/// candor-query's `tour` verb can't drift. Generic over the effect element type.
14pub mod surface;
15
16/// The transitive least fixed point over a call graph — SHARED so the scanner's gate-side reason-class
17/// accumulator and candor-query's `unverified --class` filter resolve over the SAME reach.
18pub mod propagate;
19
20/// ⟨0.24⟩ The §6.2 GATE over an already-accumulated signature — SHARED so `candor-scan --policy` and
21/// `candor-query gate --report` (SPEC §3.1) are the same gate reached by two routes, not two gates.
22pub mod gate;
23
24/// ⟨0.41⟩ `accept` lines for known policy violations (SPEC §6.2) — matched over a whole run's rows.
25pub mod accept;
26
27/// Project-supplied rules, consulted only when the built-in `classify` returns None.
28pub fn classify_extra(
29 crate_name: &str,
30 path: &str,
31 extra: &[(&'static str, bool, String)],
32) -> Option<&'static str> {
33 for (eff, is_crate, prefix) in extra {
34 let hit = if *is_crate { crate_name.starts_with(prefix.as_str()) } else { path.starts_with(prefix.as_str()) };
35 if hit {
36 return Some(eff);
37 }
38 }
39 None
40}
41
42/// The exact third-party crates `classify` has effect rules for, and the crate-name
43/// PREFIXES it recognizes. This is the single source of truth for "what candor knows":
44/// it is emitted beside the JSON report (`<prefix>.calibrated.json`) so the Claude Code
45/// receipt's coverage check reads candor's real coverage instead of a hand-copied list.
46/// Keep in lockstep with `classify` below — the `db_crates_are_calibrated` and
47/// `calibrated_crates_are_live` tests (in this crate's `tests` module) enforce both directions.
48pub const CALIBRATED_CRATES: [&str; 82] = [
49 // network (aws_config resolves credentials over the network on `.load()`;
50 // git2 remote ops — fetch/push/connect — contact the network; async_net is smol's net layer;
51 // pnet is raw L2/L3 packet capture)
52 "reqwest", "isahc", "ureq", "curl", "aws_config", "git2", "tokio_tcp", "tokio_udp", "async_net",
53 "async_nats", "lapin", "lettre", "tungstenite", "elasticsearch", "tonic", "rdkafka", "pnet",
54 // directory traversal (ignore = gitignore-aware walker, powers ripgrep/fd; its walk executors are Fs)
55 // + filesystem watching (notify = inotify/FSEvents/kqueue wrapper; powers watchexec/cargo-watch)
56 "ignore", "notify",
57 // database (see DB_CRATES in classify)
58 "sqlx", "rusqlite", "postgres", "tokio_postgres", "diesel", "redis", "mongodb",
59 "mysql", "mysql_async", "sea_orm", "deadpool_postgres",
60 // filesystem (async_fs = smol; fs_err = std::fs wrapper; tempfile; glob) / entropy /
61 // subprocess (async_process = smol; duct) / env (dotenvy/dotenv) / clock (time) / log / clipboard
62 "memmap2", "fs_err", "async_fs", "tempfile", "glob",
63 "rand", "getrandom", "fastrand",
64 // entropy: the password-hashing tier (salt mints + bcrypt's internal salt) + the OsRng source
65 "argon2", "bcrypt", "scrypt", "pbkdf2", "password_hash", "rand_core",
66 "portable_pty", "async_process", "duct",
67 "dotenvy", "dotenv",
68 "chrono", "time", "tracing", "log", "arboard",
69 // compiler diagnostic emission (a dylint lint's output) — see the Log rules in classify
70 "rustc_lint", "rustc_errors",
71 // raw syscalls via FFI — the syscall-name table that lights up the FFI-thin tier (nix is routed
72 // through the same table by leaf name, so a consumer of nix is covered without nix's own source)
73 "libc", "nix", "rustix",
74 // coverage-differential additions (verb-keyed; see the per-crate rules near the end of classify):
75 // sync TLS core + native-tls variants (Net); env/dir resolution + argv + LS_COLORS (Env);
76 // sqlx-core execution terminals (Net/Db); directory walk + timestamp mutation + same-file (Fs);
77 // process-spawn helpers (Exec); signal handler + interactive-tty prompts (Ipc); env_logger (Log);
78 // jiff/backoff clock reads (Clock).
79 "rustls", "native_tls_crate", "tokio_native_tls",
80 "etcetera", "wild", "lscolors",
81 "sqlx_core", "walkdir", "filetime", "clircle",
82 "execute", "ctrlc", "clap", "jiff", "env_logger",
83 "dialoguer", "console", "terminal_colorsaurus", "backoff", "grep_cli",
84 // TUI: the terminal is a user dialogue channel (Ipc), exactly as dialoguer/console already rule.
85 // crossterm does the tty I/O; ratatui renders to a Buffer and drives a backend that does.
86 "crossterm", "ratatui",
87 // tracing_subscriber: the fmt INIT terminals write program output (Log); the EnvFilter constructors
88 // read RUST_LOG (Env). Everything else — layers, formatters, filter types — is a builder.
89 "tracing_subscriber",
90];
91
92pub const CALIBRATED_PREFIXES: [&str; 3] = ["aws_sdk_", "aws_smithy", "cap_"];
93
94/// Crates `classify` matches by PATH prefix rather than crate-name equality (their effectful modules
95/// are recognised, e.g. `tokio::net::`/`async_std::fs::`/`mio::net::`), so they're absent from
96/// `CALIBRATED_CRATES` (which the liveness test probes by crate name). The coverage check must still
97/// treat them as *covered* — otherwise it would mislabel the most common async crates as blind spots.
98pub const PATH_CALIBRATED_CRATES: [&str; 3] = ["tokio", "async_std", "mio"];
99
100/// The FFI-thin syscall crates whose `CALIBRATED_CRATES` membership is INCOMPLETE BY DESIGN.
101///
102/// For every OTHER calibrated crate, "classify returned None" is a REVIEWED verdict — the table covers
103/// the crate's effectful surface, so a miss means the call really is pure, and candor-scan's coverage
104/// ledger exempts all of `CALIBRATED_CRATES` from blind-spot disclosure on exactly that theory (a
105/// calibrated crate's silence is informative). That does not hold for `libc`/`nix`/`rustix`: the table
106/// above DELIBERATELY skips their generic fd verbs (`read`/`write`/`close`/`lseek`/`dup`/`fcntl`/...)
107/// because a fixed label would mis-categorise an ambiguous fd (file? socket? pipe?) as often as it
108/// helps — an honest no-classify, not a purity finding. Real programs that touch these crates go
109/// through those exact verbs constantly (`libc::read(fd, ..)` IS the effect, not a builder step), so the
110/// blanket exemption converted "no rule here" into a confident, silent purity claim: `fn drain(fd: i32)
111/// { libc::read(fd, buf, 64); }` reported zero effects AND zero disclosure (R59, SOUNDNESS.md) — worse
112/// than an uncalibrated dependency, which at least discloses `invisible`.
113///
114/// Consumed ONLY by candor-scan's coverage-ledger filter (the same one-consumer site
115/// `coverage_has_exactly_one_anchor_and_exactly_one_consumer` enforces), to carve these three OUT of the
116/// `CALIBRATED_CRATES` exemption there — an unclassified call into them still joins the ledger and
117/// disclosed `invisible`, exactly like a call into an uncalibrated dependency, while their CLASSIFIED
118/// calls (`open`/`close`/`socket`/...) are untouched (they never reach the ledger at all — see
119/// `blind_direct`'s `classified.is_none()` gate). Deliberately NOT removed from `CALIBRATED_CRATES`
120/// itself: that list means "classify has SOME live rules here" (true — the FS/NET/EXEC/IPC/ENV/CLOCK/
121/// RAND tables all fire for these three), and stays the honest `.calibrated.json` sidecar contract.
122pub const CALIBRATED_BUT_PARTIAL_CRATES: [&str; 3] = ["libc", "nix", "rustix"];
123
124/// SOUNDNESS R311 — the version each calibrated crate's rules were actually WRITTEN AGAINST.
125///
126/// `CALIBRATED_CRATES` converts "no rule matched" into "reviewed, and pure", with no disclosure. That
127/// claim is evidence about SOURCE SOMEONE READ, and it was being granted on the crate NAME alone — so
128/// an upstream release extended it, silently, to code that did not exist when the review happened.
129/// Measured 2026-09-07: the sea_orm rule block says in terms that it was "verified against sea-orm
130/// 1.1.20 source"; the calibrated fixture resolved 2.0.2, whose `DatabaseExecutor`,
131/// `RestrictedConnection`, `RestrictedTransaction`, `RbacContext` and `RusqliteSharedConnection` carry
132/// 43 public entry points that no rule can name and every one of which read PURE.
133///
134/// A crate resolving ABOVE its ceiling is downgraded to `CALIBRATED_BUT_PARTIAL_CRATES` behaviour:
135/// matched calls keep their rules (they are still right), unmatched calls rejoin the ledger and
136/// disclose `invisible` instead of reading as reviewed-pure. Fail-closed AT THE POINT THE CLAIM IS
137/// MADE — the coverage gate can only ever notify us afterwards.
138///
139/// EXACT, not major-only, and the direction is deliberate: an exact ceiling fails NOISY (a minor bump
140/// discloses until someone re-reviews and moves the line) while major-only fails SILENT — and the
141/// minor direction is not hypothetical, since redis 1.6.0 -> 1.7.0 and mongodb 3.8.1 -> 3.9.0 both
142/// produced real uncovered entry points in the same measurement. Per the family's denylist rule, start
143/// strict and relax against measured disclosure volume, never the reverse.
144///
145/// A crate ABSENT from this table keeps its unconditional exemption: the ceiling is a denylist of
146/// versions known to be beyond the review, not an allowlist of versions blessed by it. Adding a crate
147/// here is what makes its claim honest, so the table is expected to grow toward `CALIBRATED_CRATES`.
148// SOUNDNESS R328 — this table is MERGED, never rewritten. `bbd7615` replaced it wholesale with a
149// freshly-computed dict and silently dropped NINE entries (dialoguer, dotenv, dotenvy, grep_cli, isahc,
150// lettre, portable_pty, tempfile, postgres) while its own commit message claimed "four crates are
151// deliberately absent, each with a stated reason in the code". Eight had no stated reason and every one
152// of them cites a version in its own rules — they are exactly the crates the ceiling exists to protect,
153// and the protection would have been gone the moment any of them published. Effect classes at stake:
154// Net (isahc), SMTP (lettre), Exec (portable_pty), Fs (tempfile), Env (dotenv/dotenvy), Ipc (dialoguer).
155//
156// DELIBERATELY ABSENT, and this is the complete list:
157// postgres — R313. Its recorded 0.7.10 was TOKIO-postgres's version, lifted by a regex from a comment
158// about a different crate; postgres ships 0.19.x. No verified baseline exists and inventing
159// one is the claim-without-review this table exists to stop.
160// async_fs, getrandom — their rules match on CRATE NAME alone, so no path can go stale.
161// rand_core — R320/R333 unresolved.
162pub const CALIBRATED_CEILINGS: [(&str, &str); 70] = [
163 ("arboard", "3.6.1"),
164 ("argon2", "0.6.0"),
165 ("async_nats", "0.35.1"),
166 ("async_process", "2.5.0"),
167 ("aws_config", "1.12.0"),
168 ("backoff", "0.4.0"),
169 ("bcrypt", "0.19.3"),
170 ("chrono", "0.4.45"),
171 ("clap", "4.6.6"),
172 ("clircle", "0.6.1"),
173 ("console", "0.15.11"),
174 ("crossterm", "0.28.1"),
175 ("ctrlc", "3.5.2"),
176 ("curl", "0.4.50"),
177 ("deadpool_postgres", "0.14.2"),
178 ("dialoguer", "0.12.0"),
179 ("diesel", "2.3.13"),
180 ("dotenv", "0.15.0"),
181 ("dotenvy", "0.15.7"),
182 ("duct", "1.1.2"),
183 ("elasticsearch", "8.19.0-alpha.1"),
184 ("env_logger", "0.11.11"),
185 ("etcetera", "0.11.0"),
186 ("execute", "0.3.0"),
187 ("fastrand", "2.5.0"),
188 ("filetime", "0.2.29"),
189 ("fs_err", "3.3.1"),
190 ("git2", "0.20.4"),
191 ("glob", "0.3.4"),
192 ("grep_cli", "0.1.12"),
193 ("ignore", "0.4.33"),
194 ("isahc", "2.0.1"),
195 ("jiff", "0.2.35"),
196 ("lapin", "4.10.0"),
197 ("lettre", "0.11.23"),
198 ("log", "0.4.34"),
199 ("lscolors", "0.21.0"),
200 ("memmap2", "0.9.11"),
201 ("mongodb", "3.8.1"),
202 ("mysql", "28.0.0"),
203 ("mysql_async", "0.37.0"),
204 ("native_tls_crate", "0.2.18"),
205 ("notify", "8.2.0"),
206 ("password_hash", "0.6.1"),
207 ("pbkdf2", "0.13.0"),
208 ("pnet", "0.35.0"),
209 ("portable_pty", "0.9.0"),
210 ("rand", "0.10.2"),
211 ("ratatui", "0.29.0"),
212 ("rdkafka", "0.39.0"),
213 ("redis", "1.6.0"),
214 ("reqwest", "0.13.4"),
215 ("rusqlite", "0.39"),
216 ("rustls", "0.23.43"),
217 ("scrypt", "0.12.0"),
218 ("sea_orm", "1.1.20"),
219 ("sqlx", "0.9.0"),
220 ("sqlx_core", "0.9.0"),
221 ("tempfile", "3.27.0"),
222 ("terminal_colorsaurus", "1.0.3"),
223 ("time", "0.3.55"),
224 ("tokio_native_tls", "0.3.1"),
225 ("tokio_postgres", "0.7.18"),
226 ("tonic", "0.12.3"),
227 ("tracing", "0.1.44"),
228 ("tracing_subscriber", "0.3.23"),
229 ("tungstenite", "0.30.0"),
230 ("ureq", "2.12.1"),
231 ("walkdir", "2.5.0"),
232 ("wild", "2.2.1"),
233];
234
235/// `true` when `resolved` is a version this crate's rules were NOT written against — i.e. strictly
236/// newer than its ceiling. Unknown crate, unparseable version, or an exact match: `false`, keep the
237/// exemption. Comparison is numeric per dotted component so `1.10.0 > 1.9.0`; a pre-release or build
238/// suffix on the resolved version is treated as ABOVE the release it decorates, which is the
239/// charging direction.
240/// Version-only comparison: is `a` strictly newer than `b`? Shared with `candor-scan`'s lockfile
241/// parser so "which of two versions of one crate do we keep" is answered by the SAME comparator that
242/// decides whether a version is beyond review — one authority, not two spellings that drift.
243pub fn calibration_exceeded_raw(a: &str, b: &str) -> bool {
244 let parts = |v: &str| -> Vec<u64> {
245 v.split(['+', '-']).next().unwrap_or("")
246 .split('.')
247 .map(|p| p.parse::<u64>().unwrap_or(0))
248 .collect()
249 };
250 let (x, y) = (parts(a), parts(b));
251 for i in 0..x.len().max(y.len()) {
252 let (p, q) = (x.get(i).copied().unwrap_or(0), y.get(i).copied().unwrap_or(0));
253 if p != q {
254 return p > q;
255 }
256 }
257 a != b
258}
259
260pub fn calibration_exceeded(crate_name: &str, resolved: &str) -> bool {
261 let Some((_, ceiling)) = CALIBRATED_CEILINGS.iter().find(|(c, _)| *c == crate_name) else {
262 return false;
263 };
264 let parts = |v: &str| -> Vec<u64> {
265 v.split(['+', '-']).next().unwrap_or("")
266 .split('.')
267 .map(|p| p.parse::<u64>().unwrap_or(0))
268 .collect()
269 };
270 let (r, c) = (parts(resolved), parts(ceiling));
271 if r.is_empty() {
272 return false;
273 }
274 for i in 0..r.len().max(c.len()) {
275 let (a, b) = (r.get(i).copied().unwrap_or(0), c.get(i).copied().unwrap_or(0));
276 if a != b {
277 return a > b;
278 }
279 }
280 // Equal numerically: a pre-release/build suffix means it is not the reviewed artifact.
281 resolved != *ceiling
282}
283
284
285/// Crates REVIEWED AND FOUND TO PERFORM NO EFFECT OF THEIR OWN — the κ ledger treats them as covered, so
286/// their calls stop being disclosed blind spots.
287///
288/// SEPARATE FROM `CALIBRATED_CRATES` BY NECESSITY, not taste. That list means "classify has effect rules
289/// here", and `calibrated_crates_are_live` fails any entry no rule matches — "a dead entry would silently
290/// suppress a real coverage warning". A genuinely pure crate has no rule to be live, so it cannot go
291/// there; without this list the only way to silence its noise would be to invent a rule, which is worse.
292///
293/// **THIS LIST MANUFACTURES PURITY CLAIMS, so an entry needs evidence and not a reputation.** A crate here
294/// stops being disclosed and starts being believed. Each of these was checked against its source in the
295/// local cargo registry for `std::{fs,net,process,env}` and stdio use, and every apparent hit was a DOC
296/// COMMENT (serde_json's `/// [`File`]: std::fs::File`, serde_yml's `/// io::stdout()`):
297///
298/// serde_json 1.0.151, serde_yml 0.0.12, toml 1.1.3, regex 1.13.1, sha2 0.11.0
299///
300/// `color_eyre` was on the same filing and is NOT here — FETCHED AND CHECKED 2026-08-03, and it is not
301/// pure. It reads `RUST_BACKTRACE` / `RUST_LIB_BACKTRACE` / `RUST_SPANTRACE` / `COLORBT_SHOW_HIDDEN`
302/// (Env, `config.rs:939..1175`) and **opens source files to render code snippets** (Fs,
303/// `config.rs:248`). It is also not CALIBRATED, deliberately: the `File::open` sits inside
304/// `impl fmt::Display for SourceSection`, reached when a report is RENDERED rather than through any named
305/// verb a caller invokes — so there is no path for a rule to match, and calibrating the crate would turn
306/// that render path into an unmatched path, i.e. a PURITY CLAIM over the file read. Its calls therefore
307/// stay disclosed as a blind spot. The noise is real; it is also honest, and that is the right trade.
308///
309/// THE SERIALIZER CAVEAT, worth stating because it is the one that looks wrong: `serde_json::from_reader`
310/// and `to_writer` do move bytes — but through a handle the CALLER had to obtain, and obtaining it (a
311/// `File::open`, a `TcpStream::connect`) is already classified on the caller. The crate performs no
312/// syscall of its own, so charging it would double-count an effect the caller already carries.
313pub const REVIEWED_PURE_CRATES: [&str; 5] = ["serde_json", "serde_yml", "toml", "regex", "sha2"];
314
315/// The completeness gate's escape hatch (`eval/coverage-gate/`, `tests/coverage_gate.rs`): exact
316/// (crate, consumer-facing path) pairs a human has read and found genuinely pure, despite `candor-scan`
317/// self-scanning the crate's own vendored source finding a reachable Fs/Net/Db/Exec effect there. SEPARATE
318/// from `REVIEWED_PURE_CRATES` — that list exempts a WHOLE crate from the coverage LEDGER's crate-level
319/// disclosure; this one exempts a single ENTRY from the completeness GATE's per-function differential.
320/// The gate's generator run (2026-08-27) found 669 self-scan-confirmed entries `classify()` already
321/// recognizes (checked in as `covered.tsv`, asserted every push) and 251 it did not yet recognize under
322/// any guessed spelling (checked in as `open.tsv`, a ratchet — NOT individually hand-verified, see that
323/// file's header). An `open.tsv` row belongs here ONLY once read against the crate's real source and
324/// confirmed to perform no effect (the SAME evidence bar `REVIEWED_PURE_CRATES` documents) — until then
325/// it stays in the ratchet as an open question, not a silent assumption. The five below were the first
326/// batch, from the 2026-08-27 coverage-gate triage:
327///
328/// - `curl::Multi::timeout` — this file's own `curl` rule (above) already documents WHY: `Easy::
329/// timeout` is a pure `CURLOPT_TIMEOUT` setter sharing the `::timeout` leaf, and an under-report on
330/// the rare event-loop kick beats mis-tagging every consumer that sets a timeout.
331/// - `execute::command` / `execute::shell` — this file's own `execute` rule (above) already documents
332/// WHY: both free functions only BUILD a `std::process::Command` (no `.spawn()`/`.output()`/
333/// `.status()` call in either body) and return it to the caller, who spawns it themselves — the
334/// caller's own `Command::spawn()` carries the effect. Self-scan's raw "Exec" signal comes from
335/// `std::process::Command::new`'s coarse whole-type rule (constructing a `Command` is Exec by
336/// default, narrowed only for crates like `async_process` that document their own pure-setter
337/// surface), not from an actual subprocess launch in either function.
338/// - `elasticsearch::Response::content_type` — a pure accessor reading a header off an ALREADY-received
339/// response (`self.response.headers().get(..)`, http/response.rs:61) — no I/O of its own. Self-scan's
340/// raw "Net" signal is the underlying HTTP client response type's coarse whole-type propagation, not
341/// a second network round-trip.
342/// - `rusqlite::Context::get_connection` — this file's own rusqlite rule (above) already documents WHY:
343/// it hands back a `ConnectionRef` BORROWED from the ALREADY-established connection a running SQL
344/// function executes within (`ffi::sqlite3_context_db_handle`, not a syscall) — the same "handle
345/// accessor, not a syscall" shape as `TcpStream::local_addr`.
346///
347/// The next two (2026-08-28, the widened-CORE audit's `Ipc` batch — see `eval/coverage-gate/generate.py`
348/// for why `Ipc` only entered the trigger set that day) are a DIFFERENT reason than the five above: not
349/// "no effect", but genuinely UNREACHABLE — the item really does perform the effect self-scan found, but
350/// no external consumer can ever name it, because the module it lives in is declared with no `pub` at
351/// all and neither the module nor the item is re-exported anywhere in the crate root (checked against
352/// real source, both ways):
353/// - `dialoguer::Paging::render_prompt` — `dialoguer::paging` is a bare `mod paging;` (dialoguer
354/// 0.12.0, lib.rs:62: `use paging::Paging;`, not `pub use`), so `pub struct Paging` and its `pub fn
355/// render_prompt` (which DOES call `Term::flush`, a real Ipc effect per this file's own `console`
356/// rule) are internal machinery every `Select`/`MultiSelect`-style prompt uses to render its own
357/// paginated view — the effect is already charged at THOSE public entry points' own `::interact*`
358/// rule (above), not double-counted here.
359/// - `mysql::Stream::connect_socket` — `mysql::io` is a bare `mod io;` (mysql 28.0.0, lib.rs:897, no
360/// `pub use` of `io::*` or `Stream` anywhere), so `pub enum Stream` and its `pub fn connect_socket`
361/// (a real Unix-domain-socket `connect()`, Ipc) are reachable only from within the crate's own
362/// connection-establishment code — a real consumer can't name `mysql::Stream` at all, let alone call
363/// a bare constructor on it.
364pub const REVIEWED_PURE_ENTRIES: &[(&str, &str)] = &[
365 ("curl", "curl::Multi::timeout"),
366 ("execute", "execute::command"),
367 ("execute", "execute::shell"),
368 ("elasticsearch", "elasticsearch::Response::content_type"),
369 ("rusqlite", "rusqlite::Context::get_connection"),
370 ("dialoguer", "dialoguer::Paging::render_prompt"),
371 ("mysql", "mysql::Stream::connect_socket"),
372];
373
374/// Representative path tails (each appended to a crate name) that the `calibrated_crates_are_live`
375/// liveness test probes: at least one must match for every `CALIBRATED_CRATES` entry, else the entry is
376/// dead. Exported as ONE source of truth because the nightly lint crate (`src/lib.rs`) runs the SAME
377/// liveness test — when the two probe lists were duplicated they drifted, and a rule keyed on a
378/// distinctive tail (pnet `::datalink::channel`, ignore `::WalkBuilder::build_parallel`, notify
379/// `::RecommendedWatcher::new`) added to only one list silently broke the other crate's `cargo test`.
380pub const CALIBRATION_PROBE_TAILS: &[&str] = &[
381 "::X::send", "::X::execute", "::X::call", "::X::query", "::X::fetch_one", "::Remote::fetch",
382 "::datalink::channel", "::WalkBuilder::build_parallel", "::RecommendedWatcher::new",
383 "::X::connect", "::Utc::now", "::X::load", "::__private_api::log", "::tempfile", "::glob",
384 "::X::run", "::dotenv", "::random", "::emit", "::X::emit_span_lint", "::X::anything",
385 "::X::draw",
386 "::SaltString::generate", "::hash", "::OsRng::fill_bytes",
387 // verb-precise crates whose whole-crate rules were narrowed to the effectful surface (the pure
388 // accessors/ctors/data-types now return None), so the liveness probe must name an EFFECTFUL path:
389 "::Mmap::map", "::event", "::u32", "::Clipboard::get_text", "::spawn_command",
390 // coverage-differential crates (each needs ≥1 effectful tail; existing tails already cover
391 // native_tls_crate/tokio_native_tls/sqlx_core via ::X::connect, execute via ::X::execute, jiff via ::now):
392 "::read_tls", "::home_dir", "::args", "::from_env", "::IntoIter::next", "::set_file_mtime",
393 "::surely_conflicts_with", "::set_handler", "::get_matches", "::init", "::interact",
394 "::write_line", "::background_color", "::retry", "::build",
395];
396
397/// Database client crates whose execution verbs are I/O (see the DB branch in `classify`).
398/// Module-level so `db_crates_are_calibrated` can enforce `DB_CRATES ⊆ CALIBRATED_CRATES`.
399pub const DB_CRATES: [&str; 11] = [
400 "sqlx", "rusqlite", "postgres", "tokio_postgres", "diesel", "redis", "mongodb",
401 "mysql", "mysql_async", "sea_orm", "deadpool_postgres",
402];
403
404/// Pure file-descriptor *ownership-transfer* leaves. These ADOPT an already-open descriptor
405/// (`from_raw_fd`/`from_raw_socket`/`from_raw_handle`), EXTRACT/BORROW one
406/// (`into_raw_fd`/`into_raw_socket`/`into_raw_handle`, `as_raw_fd`/`as_raw_socket`/`as_raw_handle`),
407/// or UNWRAP an async wrapper back to its std type (`into_std`) — none of them issue a syscall or
408/// perform I/O. calling a PURE function effectful is a FABRICATION — the precision failure (candor's cardinal sin is the opposite direction, the silent under-report) — and these collide with the
409/// coarse std-type PREFIX rules (`std::net::TcpStream`/`std::fs::File`/`std::os::unix::net` → Net/Fs/Ipc)
410/// even though the descriptor was opened ELSEWHERE. The portable_pty/async_process Exec rule already
411/// exempts `from_raw_fd`; this generalises the same carve-out across the net/fs/ipc prefix rules.
412/// (Found by a real-world sweep of tokio: `TcpStream::into_std`, `*::from_raw_fd`, `*::as_raw_fd` all
413/// fabricated Net/Fs/Ipc.)
414const PURE_FD_TRANSFER: &[&str] = &[
415 "from_raw_fd", "from_raw_socket", "from_raw_handle",
416 "into_raw_fd", "into_raw_socket", "into_raw_handle",
417 "as_raw_fd", "as_raw_socket", "as_raw_handle",
418 "into_std",
419 // `SocketAddr::from_pathname` (std/async-std unix net) builds an address STRUCT from a path —
420 // it opens no socket. The `std::os::unix::net` prefix rule below would otherwise fabricate Ipc
421 // on it. (Found sweeping socket2: `SockAddr::as_unix` → `from_pathname` reported Ipc.)
422 "from_pathname",
423];
424
425/// The std I/O **handle** types whose methods the scanner's receiver inference may route into
426/// `classify` as `Type::method`.
427///
428/// WHY A LIST AND NOT "std". The std rules in `classify` are a MIXTURE: a few are type-precise with
429/// reviewed pure-accessor carve-outs (`std::net::TcpStream` → Net minus `local_addr`/`peer_addr`/…;
430/// `std::process::Command` → Exec minus `get_program`/`get_args`/…), but most are coarse MODULE
431/// prefixes (`std::fs::` → Fs) written for free functions and constructors. Applied to arbitrary
432/// method calls, a coarse prefix charges an effect for reading a struct field: `std::fs::Metadata`,
433/// `DirEntry`, `Permissions` and `FileType` all sit under `std::fs::` and are pure DATA. So the
434/// scanner used to skip EVERY std-rooted receiver — which shut the door on the pure data types and on
435/// the real handles alike, and `fn run(cmd: &mut Command) { cmd.spawn(); }` certified PURE while it
436/// spawned a process (a silent false all-clear; the `cc` crate's `command_helpers::spawn` was a live
437/// instance). This list re-opens the door for the handles ONLY.
438///
439/// MEMBERSHIP RULE, and the reason it is safe to apply a whole-type rule to an inferred method call:
440/// a type belongs here only when EVERY method on it is either the effect itself or an already-carved-
441/// out pure read-back. That holds for an open descriptor's handle — a `File`/`TcpStream`/`Child` does
442/// nothing but I/O on the thing it owns — and the `PURE_FD_TRANSFER` guard at the top of `classify`
443/// exempts the `as_raw_fd`/`into_std` family for all of them before any prefix rule runs.
444///
445/// THE OPTION-BUILDERS, `OpenOptions` and `DirBuilder`, were absent for exactly this rule and now
446/// satisfy it: each has a type-keyed carve-out in `classify` subtracting its pure setters, so only its
447/// TERMINAL VERB (`open` / `create`) is Fs. The exclusion was written as "a narrow residual, not the
448/// reported hole" and MEASURED otherwise — `fn load(o: &OpenOptions, p: &Path) { o.open(p) }` opened a
449/// file and reported NOTHING, the same silent false all-clear as the `Command` parameter, while a
450/// setter-only `OpenOptions::new().read(true)` reported `Fs` for opening nothing. The order the old
451/// comment prescribed is the order taken: the verb-precise rules went in FIRST, and this list widened
452/// onto them. `ReadDir` stays out — its `next` IS a syscall, but nothing distinguishes it from any
453/// other iterator's `next`, so no verb-precise rule can be written and the honest miss stands.
454///
455/// `std::path::Path`/`PathBuf` are NOT here: they are pure data with an effectful STAT sub-surface —
456/// the exact inverse shape — and they route through their own verb-precise carve-out in the scanner.
457const STD_EFFECT_HANDLES: &[&str] = &[
458 // Exec — the whole type is the subprocess boundary (`new` names a program, `arg`/`env` build the
459 // invocation, `spawn`/`output`/`status` run it); the read-back getters are already carved out.
460 "std::process::Command",
461 "std::process::Child",
462 // Net — an open socket. `connect`/`accept`/`read`/`write`/`shutdown`/`set_*` are socket syscalls;
463 // `local_addr`/`peer_addr`/`nodelay`/`ttl`/`take_error` are carved out.
464 "std::net::TcpStream",
465 "std::net::TcpListener",
466 "std::net::UdpSocket",
467 // Ipc — a Unix-domain socket, the same shape as the TCP/UDP handles above.
468 "std::os::unix::net::UnixStream",
469 "std::os::unix::net::UnixListener",
470 "std::os::unix::net::UnixDatagram",
471 // Fs — an open file. Every method reads, writes, syncs, truncates or stats it; the descriptor
472 // conversions are carved out by `PURE_FD_TRANSFER`.
473 "std::fs::File",
474 // Fs — the option-builders, whose PURE setters are subtracted by the type-keyed carve-outs in
475 // `classify` so that only the terminal verb (`OpenOptions::open`, `DirBuilder::create`) routes to Fs.
476 "std::fs::OpenOptions",
477 "std::fs::DirBuilder",
478];
479
480/// Is `ty` a std I/O handle type whose methods may be routed into `classify` by the scanner's
481/// receiver inference? See `STD_EFFECT_HANDLES` for the membership rule and the named exclusions.
482pub fn is_std_effect_handle(ty: &str) -> bool {
483 STD_EFFECT_HANDLES.contains(&ty)
484}
485
486/// Classify a resolved callee by the crate it belongs to and its full path.
487pub fn classify(crate_name: &str, path: &str) -> Option<&'static str> {
488 // Pure fd ownership-transfer/extraction leaves are never an effect, regardless of which std I/O
489 // type they hang off — exempt them BEFORE the coarse prefix rules can fabricate Net/Fs/Ipc.
490 if PURE_FD_TRANSFER.contains(&path.rsplit("::").next().unwrap_or(path)) {
491 return None;
492 }
493 if crate_name.starts_with("aws_sdk_") || crate_name.starts_with("aws_smithy") {
494 // Only request dispatch is network I/O; builder setters/accessors are pure.
495 if path.ends_with("::send") || path.ends_with("::send_with") {
496 return Some("Net");
497 }
498 return None;
499 }
500 // aws-config resolves credentials/region on `.load()` — it reaches the IMDS metadata
501 // endpoint / STS over the network (and reads ~/.aws + env). Builders (`defaults()`,
502 // `SdkConfig::builder()`, `BehaviorVersion::latest()`) are pure; the `load` is the I/O.
503 // (Found hardening on a real app, ebman: `builder.load().await` was classified pure.)
504 //
505 // THE FIX: `load_from_env()` (lib.rs:170/188, both the `behavior-version-latest` and deprecated
506 // forms) is `from_env().load().await` / `load_defaults(latest).await` one call down — the crate's OWN
507 // "convenience wrapper" (its own doc comment's phrase) around the already-modelled `.load()` — but
508 // `"load_from_env".ends_with("::load")` is false, so the wrapper read pure. Same vein as
509 // `ignore::Walk::new`.
510 if crate_name == "aws_config" {
511 if path.ends_with("::load") || path.ends_with("::load_defaults") || path.ends_with("::load_from_env") {
512 return Some("Net");
513 }
514 return None;
515 }
516 // git2 (libgit2 FFI): remote operations contact the network; everything else is local
517 // to the .git directory. Match the remote verbs precisely — NOT bare `::clone`, which is
518 // the `Clone`-trait dup of a `Remote` handle (pure), not `Repository::clone`. (Found
519 // hardening on gitui: `remote.fetch`/`remote.push` were classified network-free — a git
520 // client reporting it makes no network calls.)
521 //
522 // `Repository::clone`/`clone_recurse`/`clone_local`/`init` (via `RepoBuilder::clone`) IS the
523 // thing the comment above named and then excluded anyway: the bare `::clone` denylist meant
524 // to keep out `Remote::clone` (the derived trait dup) also swallowed `Repository::clone` —
525 // libgit2's actual network clone, and arguably git2's single most common entry point. A
526 // fabrication corpus round caught it: `git2::Repository::clone(url, path)` reported ZERO
527 // effects and passed `deny Net` at exit 0. FQN-exact (like the `reqwest::get` disambiguation
528 // a few lines up) so the fix doesn't just re-widen `::clone` and reintroduce the very
529 // over-charge the comment was written to prevent: `Remote::clone` (and `Repository::clone`'s
530 // own `Clone` derive, if it has one) must still read pure.
531 if crate_name == "git2" {
532 if path.ends_with("::fetch")
533 || path.ends_with("::push")
534 || path.ends_with("::download")
535 || path.ends_with("::connect")
536 || path.ends_with("::connect_auth")
537 || path.ends_with("::ls")
538 || path.ends_with("::upload")
539 || path == "git2::Repository::clone"
540 || path == "git2::Repository::clone_recurse"
541 || path == "git2::build::RepoBuilder::clone"
542 // A FOURTH+FIFTH true positive in the same gap the `Repository::clone` fix left open:
543 // `Submodule::clone`/`Submodule::update` (submodule.rs:39,234) call `raw::git_submodule_clone`/
544 // `git_submodule_update` DIRECTLY — the exact FFI leaves already in this file's own
545 // FFI-tier NET table below, but only when a caller names the raw `ffi`/`raw::` leaf itself.
546 // git2's documented, standard submodule-init idiom (`sub.clone(None)` /
547 // `sub.update(true, None)`) never does that — it calls the safe wrapper, which carried no
548 // rule of its own and read pure under `deny Net`. FQN-exact, matching the `Repository::clone`
549 // fix's own discipline: a bare `::update`/`::clone` substring would sweep in git2's many pure
550 // `update_*` setters (`CheckoutBuilder::update_only`, `DiffOptions::update_index`, …) and the
551 // derive-`Clone` dup on every other git2 type.
552 || path == "git2::Submodule::clone"
553 || path == "git2::Submodule::update"
554 // `Remote::list`/`RemoteConnection::list` (remote.rs:378,755) call `raw::git_remote_ls`
555 // directly — the reference-advertisement fetch already in the FFI-tier NET table below,
556 // reached the same way `Submodule::clone`/`update` were: the safe wrapper's OWN name
557 // (`list`, not `ls`) never matched the `::ls` suffix this block already carried (which, on
558 // git2 0.20, matches no real method at all — dead weight kept for whatever older API it once
559 // fit). FQN-exact so a `list` on some other git2 type (none exist today, but the same
560 // discipline as `Submodule::clone` above) can't be swept in by a bare suffix later.
561 || path == "git2::Remote::list"
562 || path == "git2::RemoteConnection::list"
563 {
564 return Some("Net");
565 }
566 // THE COVERAGE-GATE SWEEP (2026-08-27): every one of these is a public entry point whose body
567 // calls a `raw::git_*` FFI leaf ALREADY in this file's own FS FFI-tier table below (config.rs/
568 // index.rs/odb.rs/packbuilder.rs/reference.rs/repo.rs/treebuilder.rs, verified against git2
569 // 0.20.4 source) — but that table is UNREACHABLE for a real consumer's call, because it only
570 // fires when self-scan resolves the FFI leaf's OWN crate (`libgit2_sys`, via git2's internal
571 // `use libgit2_sys as raw`), never for `crate_name == "git2"`, which returns from THIS block
572 // first. A consumer never spells `raw::git_repository_open`; they call `git2::Repository::open`,
573 // which fell through this entire branch to `None` — the exact `ignore::Walk::new` shape (a
574 // lower-level rule the top-level entry point never reaches) but affecting git2's single most
575 // common local operations (`Repository::open`/`init`), not an edge case. FQN-exact, not a
576 // `::open`/`::write`/`::read` suffix — those verbs are common enough elsewhere (`Odb::reader`
577 // isn't `::read`, `TreeBuilder`/`Index`/`PackBuilder::write` share the name with plenty of pure
578 // builder setters on other types) that a bare suffix would either miss these or over-charge.
579 if path == "git2::Config::add_file"
580 || path == "git2::Config::open"
581 || path == "git2::Config::open_default"
582 || path == "git2::Index::add_all"
583 || path == "git2::Index::add_path"
584 || path == "git2::Index::read"
585 || path == "git2::Index::write"
586 || path == "git2::Index::write_tree"
587 || path == "git2::Index::write_tree_to"
588 || path == "git2::Odb::read"
589 || path == "git2::Odb::reader"
590 || path == "git2::Odb::write"
591 || path == "git2::Odb::writer"
592 || path == "git2::PackBuilder::write"
593 || path == "git2::Reference::delete"
594 || path == "git2::Reference::set_target"
595 || path == "git2::Repository::blob_path"
596 || path == "git2::Repository::checkout_head"
597 || path == "git2::Repository::checkout_index"
598 || path == "git2::Repository::checkout_tree"
599 || path == "git2::Repository::commit"
600 || path == "git2::Repository::discover"
601 || path == "git2::Repository::discover_path"
602 || path == "git2::Repository::init"
603 || path == "git2::Repository::init_bare"
604 || path == "git2::Repository::init_opts"
605 || path == "git2::Repository::open"
606 || path == "git2::Repository::open_bare"
607 || path == "git2::Repository::open_ext"
608 || path == "git2::Repository::open_from_env"
609 || path == "git2::Repository::reference"
610 || path == "git2::Repository::tag"
611 || path == "git2::TreeBuilder::write"
612 {
613 return Some("Fs");
614 }
615 // `Cred::credential_helper` (cred.rs:121) calls `CredentialHelper::execute` (cred.rs:326),
616 // which — on every code path — spawns a REAL `sh -c "<helper> get"` (or, if `sh` itself fails to
617 // spawn, the helper binary directly) via `std::process::Command::spawn` to resolve
618 // `credential.helper`-configured auth (cred.rs:370-390): a real subprocess launch a caller
619 // authenticating against a private remote is one call away from (`Cred::credential_helper(cfg,
620 // url, user)` is the crate's own documented way to honor `credential.helper`), not a lower-level
621 // implementation detail. FQN-exact: `execute` alone is far too generic to key on blindly.
622 if path == "git2::Cred::credential_helper" || path == "git2::CredentialHelper::execute" {
623 return Some("Exec");
624 }
625 return None;
626 }
627 // libc — raw syscalls via FFI. The FFI-thin tier (nix, and the syscall layer beneath rusqlite/git2)
628 // is invisible to a name classifier unless we model libc directly: a 35-crate calibration
629 // (eval/calibration) showed nix reporting ZERO library effects because every wrapper bottoms out in
630 // an unrecognised `libc::*` call. Classify by syscall name, but ONLY the UNAMBIGUOUS ones — the
631 // socket family is Net, path/dir syscalls are Fs, spawn/exec/wait is Exec, SysV/pipe IPC is Ipc,
632 // env/clock/entropy each their own. We deliberately SKIP the generic file-descriptor ops
633 // (read/write/close/lseek/dup/fcntl/ioctl/poll/select/epoll*/mmap): they operate on ANY fd — file,
634 // socket, or pipe — so a fixed label would mis-categorise as often as it helps. An honest
635 // no-classify (under-report) beats emitting the WRONG effect. Pure conversions (htons/inet_pton/
636 // gmtime) are also skipped.
637 //
638 // `nix` (the idiomatic SAFE libc wrapper, in ~every Rust systems/CLI crate) is routed through the
639 // SAME table: its functions keep the syscall leaf name (`nix::fcntl::open`, `nix::sys::socket::connect`,
640 // `nix::unistd::execvp`). Without this, a CONSUMER of nix analysed without nix's own source (the
641 // stable scanner, single-crate) sees `nix::*` cross-crate and under-reports — serialport-rs opens its
642 // device via `nix::fcntl::open` and reported ZERO Fs. The nightly lint reaches `libc::*` THROUGH nix's
643 // body; this gives the scanner the same coverage directly. (Found sweeping serialport-rs.)
644 // `rustix` is the same shape as nix but does RAW syscalls (no libc underneath), so its functions MUST
645 // be classified directly. Its leaf names are the syscall names too (`rustix::time::clock_settime`,
646 // `rustix::fs::mkfifoat`/`symlink`/`stat`, `rustix::net::connect`) — route it through the same table.
647 // The rustix-specific `*at`/variant leaves it doesn't share with libc just under-report (the safe
648 // direction). VALIDATED, not speculative: coreutils' `date` reads/sets the clock via
649 // `rustix::time::clock_getres`/`clock_settime` and reported Clock=0; the file I/O that goes through
650 // std::fs was already correct, which is why only the rustix-only effects (Clock/Ipc) were missing.
651 if crate_name == "libc" || crate_name == "nix" || crate_name == "rustix" {
652 let f = path.rsplit("::").next().unwrap_or(path);
653 // path / directory / metadata syscalls (incl. *64 and *at variants) — `LIBC_FS`, hoisted to module
654 // level so the AS-EFF-008 masking guard reads THE SAME TABLE (SOUNDNESS R806); see `masks_locator`.
655 const FS: &[&str] = LIBC_FS;
656 // socket family — these operate only on sockets, so Net is unambiguous (AF_UNIX domain isn't
657 // visible at the call, so a Unix socket reads as Net rather than Ipc; acceptable over-general).
658 const NET: &[&str] = LIBC_NET;
659 // process creation / replacement / reaping
660 const EXEC: &[&str] = LIBC_EXEC;
661 // pipes / FIFOs / SysV + POSIX message queues, semaphores, shared memory; socketpair (AF_UNIX)
662 const IPC: &[&str] = &[
663 "pipe", "pipe2", "mkfifo", "mkfifoat", "socketpair", "msgget", "msgsnd", "msgrcv", "msgctl",
664 "semget", "semop", "semtimedop", "semctl", "shmget", "shmat", "shmdt", "shmctl", "mq_open",
665 "mq_send", "mq_receive", "mq_timedsend", "mq_timedreceive", "mq_close", "mq_unlink",
666 ];
667 const ENV: &[&str] = &["getenv", "secure_getenv", "setenv", "putenv", "unsetenv", "clearenv"];
668 const CLOCK: &[&str] = &[
669 "time", "gettimeofday", "clock_gettime", "clock_getres", "nanosleep", "clock_nanosleep",
670 // SETTING the system clock is a clock effect too (was unclassified — found on coreutils `date`,
671 // which sets it via `clock_settime`).
672 "clock_settime", "settimeofday", "stime", "adjtime", "adjtimex", "clock_adjtime",
673 ];
674 const RAND: &[&str] = &["getrandom", "getentropy", "arc4random", "arc4random_buf", "arc4random_uniform"];
675 if FS.contains(&f) {
676 return Some("Fs");
677 }
678 if NET.contains(&f) {
679 return Some("Net");
680 }
681 if EXEC.contains(&f) {
682 return Some("Exec");
683 }
684 if IPC.contains(&f) {
685 return Some("Ipc");
686 }
687 if ENV.contains(&f) {
688 return Some("Env");
689 }
690 if CLOCK.contains(&f) {
691 return Some("Clock");
692 }
693 if RAND.contains(&f) {
694 return Some("Rand");
695 }
696 return None;
697 }
698 // C-library FFI bindings: libsqlite3 (under rusqlite) and libgit2 (under git2). Like the libc tier,
699 // these crates are thin Rust over a C library, so their real I/O is invisible until the C entry
700 // points are named. Match by the DISTINCTIVE C function name (`sqlite3_*` / `git_*`) via the call's
701 // LEAF — independent of the binding crate's alias: rusqlite calls `ffi::sqlite3_step`, git2 calls
702 // `raw::git_remote_fetch`, and the nightly lint resolves the same to `libsqlite3_sys`/`libgit2_sys`;
703 // all spellings share the leaf. Only the I/O-performing entry points are listed — the in-memory
704 // accessors (`sqlite3_bind_*`/`sqlite3_column_*`, `git_*_oid`/strarray/options builders) stay pure,
705 // so a non-listed `sqlite3_`/`git_` leaf returns None (under-report, never a wrong effect). Calibrated
706 // + validated against rusqlite 0.39 / git2 0.20 source (eval/calibration).
707 {
708 let leaf = path.rsplit("::").next().unwrap_or(path);
709 if let Some(rest) = leaf.strip_prefix("sqlite3_") {
710 let _ = rest;
711 // SQLite C API operations that touch the database (open/exec/step/prepare/backup/blob/wal).
712 //
713 // SOUNDNESS R166 — MEMBERSHIP CRITERION, stated because the list was extended and the next
714 // reader needs to know what "already audited" covers. A leaf belongs here when it either
715 // (a) reads or writes DATABASE CONTENT (disk or the in-memory image), or (b) mutates the
716 // registry of code the engine will later RUN — the extension/auto-extension registry, whose
717 // effect is not local to the calling frame. The audit boundary was the WHOLE `sqlite3_*`
718 // surface rusqlite 0.40.2 mentions (185 distinct symbols diffed against this list), not the
719 // one name the row was filed for: `sqlite3_auto_extension` arrived with its `_cancel_`/
720 // `_reset_` siblings and with four content-I/O leaves nobody had looked at.
721 //
722 // DELIBERATELY STILL ABSENT, so this is not read as "the surface is covered": the
723 // CALLBACK-INSTALLING entry points (`sqlite3_create_function[_v2]`,
724 // `sqlite3_create_window_function`, `sqlite3_create_collation_v2`, `sqlite3_create_module_v2`,
725 // `sqlite3_*_hook`, `sqlite3_set_authorizer`, `sqlite3_progress_handler`, `sqlite3_trace_v2`).
726 // Those register a UDF/hook on ONE connection for the life of that connection; they perform no
727 // database I/O themselves, and the opaque-callback boundary is already disclosed by the
728 // scanner's own `Unknown` at the call site that hands the function pointer across. The
729 // auto-extension registry is the case that differs: it is PROCESS-GLOBAL and outlives every
730 // connection, so its consequence cannot be attributed to a caller's own frame at all.
731 // Unexamined and named as such: the libgit2 (`git_*`) table below got no equivalent sweep.
732 const DB: &[&str] = &[
733 "sqlite3_open", "sqlite3_open_v2", "sqlite3_open16", "sqlite3_close", "sqlite3_close_v2",
734 "sqlite3_exec", "sqlite3_step", "sqlite3_prepare", "sqlite3_prepare_v2",
735 "sqlite3_prepare_v3", "sqlite3_prepare16", "sqlite3_prepare16_v2", "sqlite3_prepare16_v3",
736 "sqlite3_get_table", "sqlite3_backup_init", "sqlite3_backup_step", "sqlite3_backup_finish",
737 "sqlite3_blob_open", "sqlite3_blob_read", "sqlite3_blob_write", "sqlite3_blob_reopen",
738 "sqlite3_load_extension", "sqlite3_wal_checkpoint", "sqlite3_wal_checkpoint_v2",
739 // (b) the PROCESS-GLOBAL extension registry — every one of these changes what a LATER
740 // `sqlite3_open` on any connection will execute, and `sqlite3_load_extension` (already
741 // above) is the same capability one call along.
742 "sqlite3_auto_extension", "sqlite3_cancel_auto_extension", "sqlite3_reset_auto_extension",
743 "sqlite3_enable_load_extension",
744 // (a) further DATABASE-CONTENT I/O the original list missed: whole-image load/save,
745 // a forced page-cache flush to disk, a direct VFS file operation, and arming the
746 // automatic WAL checkpoint writes whose manual twin is already listed.
747 "sqlite3_deserialize", "sqlite3_serialize", "sqlite3_db_cacheflush",
748 "sqlite3_file_control", "sqlite3_wal_autocheckpoint",
749 ];
750 return DB.contains(&leaf).then_some("Db");
751 }
752 if leaf.starts_with("git_") {
753 // libgit2: remote/transport operations contact the network … (incl. submodule clone/update,
754 // which `git_clone`/fetch the subrepo over its remote — `allow_fetch` defaults on; an A/B on
755 // git2 0.20 caught `Submodule::update`/`clone` reporting no `Net`).
756 const NET: &[&str] = &[
757 "git_clone", "git_remote_connect", "git_remote_connect_ext", "git_remote_fetch",
758 "git_remote_download", "git_remote_upload", "git_remote_push", "git_remote_ls",
759 "git_submodule_clone", "git_submodule_update",
760 ];
761 // … and repository/index/odb/checkout/ref/config operations touch the on-disk .git store.
762 const FS: &[&str] = &[
763 "git_repository_open", "git_repository_open_ext", "git_repository_open_bare",
764 "git_repository_init", "git_repository_init_ext", "git_repository_discover",
765 "git_checkout_tree", "git_checkout_head", "git_checkout_index", "git_index_read",
766 "git_index_write", "git_index_write_tree", "git_index_write_tree_to",
767 "git_index_add_bypath", "git_index_add_all", "git_odb_open", "git_odb_read",
768 "git_odb_write", "git_odb_open_wstream", "git_odb_open_rstream",
769 "git_blob_create_fromdisk", "git_blob_create_fromworkdir", "git_blob_create_from_disk",
770 "git_blob_create_from_workdir", "git_blob_create_from_stream", "git_commit_create",
771 "git_commit_create_v", "git_reference_create", "git_reference_set_target",
772 "git_reference_delete", "git_config_open_default", "git_config_open_ondisk",
773 "git_config_add_file_ondisk", "git_tag_create", "git_treebuilder_write",
774 "git_packbuilder_write",
775 ];
776 if NET.contains(&leaf) {
777 return Some("Net");
778 }
779 if FS.contains(&leaf) {
780 return Some("Fs");
781 }
782 return None;
783 }
784 if leaf.starts_with("curl_") {
785 // libcurl (under the `curl` crate, called `curl_sys::curl_*`). Only the entry points that
786 // PERFORM network I/O: the blocking transfer (`curl_easy_perform`), raw socket send/recv,
787 // the HTTP/2 keepalive PING (`upkeep`), and the multi-interface transfer pumps. The large
788 // pure surface (setopt/init/cleanup/reset/getinfo/escape/multi_add_handle/fdset/info_read)
789 // stays unclassified, as do `curl_multi_wait`/`poll` (readiness WAIT on sockets, no payload —
790 // the loop's `perform` is the tagged boundary, per the I/O-boundary principle). An A/B on
791 // curl 0.4 caught the whole crate reporting ZERO Net (`Easy::perform` read as pure).
792 const NET: &[&str] = &[
793 "curl_easy_perform", "curl_easy_send", "curl_easy_recv", "curl_easy_upkeep",
794 "curl_multi_perform", "curl_multi_socket_action",
795 ];
796 return NET.contains(&leaf).then_some("Net");
797 }
798 if let Some(op) = leaf.strip_prefix("SSL_") {
799 // OpenSSL (libssl, under the `openssl`/`native-tls` crates, called `ffi::SSL_*`). The TLS
800 // handshake and record I/O run over the peer socket -> Net. Unlike libc read/write, an SSL_*
801 // op is ~always over a network BIO (the rare memory-BIO/sans-IO case is the honest exception
802 // we accept). The crypto surface (EVP_*/SHA*/AES*) and pure setup (SSL_CTX_new/SSL_set_fd) are
803 // NOT here; `BIO_*` is skipped (a BIO may be memory or socket). Validated vs openssl 0.9 source.
804 const SSL_NET: &[&str] = &[
805 "connect", "accept", "do_handshake", "read", "read_ex", "write", "write_ex", "peek",
806 "peek_ex", "shutdown",
807 ];
808 return SSL_NET.contains(&op).then_some("Net");
809 }
810 }
811 // HTTP clients use the same builder pattern as the AWS SDK: only the dispatch is
812 // I/O. (Found by the eval: ebman's reqwest calls to the Anthropic API + webhooks
813 // were silently classified network-free because reqwest wasn't recognized.)
814 if crate_name == "reqwest" || crate_name == "isahc" {
815 // The dispatch (`::send`/`::execute`) is the I/O. PLUS the one-shot CONVENIENCE functions
816 // `reqwest::get` / `reqwest::blocking::get` / `isahc::get`, which send immediately — they're
817 // an EXACT match (not `Client::get`, the builder) to avoid false-positiving the builder path.
818 // (Found running on `xh`: a one-shot `reqwest::get(url)` was classified network-free.)
819 if path.ends_with("::send")
820 || path.ends_with("::execute")
821 || path == "reqwest::get"
822 || path == "reqwest::blocking::get"
823 || path == "isahc::get"
824 {
825 return Some("Net");
826 }
827 // THE URL-BEARING BUILDER METHODS: `Client::{get,post,put,delete,patch,head,request}(URL)`.
828 // Real code almost never uses `reqwest::get(url)`; the DOMINANT idiom is the builder chain
829 // `Client::new().post(url).send()` / `Client::builder().build()?.post(url).send()`. The `.send()`
830 // already classifies `Net` — but the URL literal rides the `.post(url)` call, NOT `.send()`, so
831 // without classifying the URL-naming step `Net` the endpoint is NEVER captured and the `Llm`
832 // host refinement can't fire (ebman's `api.anthropic.com` call read as bare Net, undisclosed as
833 // Llm — the dogfood silent under-report). Classifying these `Net` (idempotent with the eventual
834 // `.send()`) makes the scanner capture the URL from their string arg. `request(method, url)`'s
835 // url is its SECOND arg — the scanner's first-string-literal capture still gets it when the
836 // method is a literal string, and misses it (honest under-report) when the method is an
837 // expression. The pure builder surface (`::header`, `::json`, `::body`, `::query`, …) stays None.
838 if path.ends_with("::get")
839 || path.ends_with("::post")
840 || path.ends_with("::put")
841 || path.ends_with("::delete")
842 || path.ends_with("::patch")
843 || path.ends_with("::head")
844 || path.ends_with("::request")
845 {
846 return Some("Net");
847 }
848 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against isahc 2.0.1 — isahc-only, since
849 // reqwest has no equivalent free-function async family:
850 //
851 // `get_async`/`head_async`/`post_async`/`put_async`/`delete_async`/`send_async` (lib.rs:357-500)
852 // are the crate's async one-shot convenience functions — `HttpClient::shared().get_async(uri)`
853 // etc., the exact async mirror of the already-covered `isahc::get`, dispatching immediately.
854 // `HttpClient::new`/`HttpClientBuilder::build` (client.rs:629,444) are genuinely NOT lazy like
855 // reqwest's `Client::new`: `build()` unconditionally calls `agent_builder.spawn()`
856 // (agent/mod.rs:63), which spawns a background OS thread running `AgentContext::run` — curl's
857 // multi-handle event loop that DOES perform the real socket reads/writes for every request later
858 // sent through this client. Confirmed via self-scan on isahc's own source: `AgentBuilder::spawn`
859 // reaches `Net` through that same call chain, not a guess.
860 if crate_name == "isahc"
861 && (path == "isahc::get_async"
862 || path == "isahc::head_async"
863 || path == "isahc::post_async"
864 || path == "isahc::put_async"
865 || path == "isahc::delete_async"
866 || path == "isahc::send_async"
867 || path.ends_with("HttpClient::new")
868 || path.ends_with("HttpClientBuilder::build"))
869 {
870 return Some("Net");
871 }
872 // `reqwest::multipart::Part::file` (blocking/multipart.rs:223) opens the given path directly
873 // (`std::fs::File::open`) to attach it as a form part — genuinely Fs, not Net (the eventual
874 // upload is charged at `.send()`, already covered above).
875 //
876 // NOT modelled: `h3_client::pool::PoolClient::send_request` (async_impl/h3_client/pool.rs:207)
877 // — `mod async_impl;` is PRIVATE in reqwest's lib.rs (only `self::async_impl::multipart` is
878 // selectively re-exported), so `PoolClient` is unreachable from outside the crate; the real
879 // HTTP/3 dispatch a consumer can actually reach is the already-covered `::send`/`::execute`.
880 // Removed from open.tsv rather than guessed.
881 // SOUNDNESS R337 — THE WRAPPER, not the wrapped. candor already charges
882 // `native_tls_crate::Identity::from_pkcs8`/`from_pkcs12` `Fs` (the clause below), and the reason
883 // it does is concrete rather than defensive: on the security-framework backend those create a
884 // real `tempfile::TempDir` and write a keychain into it —
885 // `native-tls-0.2.18/src/imp/security_framework.rs:93` (`identity.keychain`, `from_pkcs8`) and
886 // `:167` (`tmp.keychain`, `from_pkcs12`). But candor classifies the call a CONSUMER writes, not
887 // the dependency body behind it, so a consumer who reaches that code through its idiomatic
888 // wrapper matched no rule at all — and in a CALIBRATED crate an unmatched path is a claim of
889 // reviewed purity with no disclosure. A call taking only BYTES writes a file on macOS, and it
890 // read pure.
891 //
892 // Swept for the CLASS rather than the two rows the coverage gate handed me: every caller of
893 // `native_tls{,_crate}::Identity::from_pkcs8|from_pkcs12` in the local registry is reqwest
894 // (0.11/0.12/0.13, all three spelling these the same way), lettre, and `tcp_stream` — which is
895 // NOT calibrated, so its calls disclose rather than claim purity and it needs no rule.
896 //
897 // Suffix-matched, not exact-path, because all three reqwest majors carry the same names; and
898 // narrow on purpose. The SIBLINGS are the fabrication control and they must stay pure:
899 // `reqwest::tls::Identity::from_pem` is rustls-only, a `Cursor` over a buffer (tls.rs:373), and
900 // lettre's `Certificate::from_der`/`from_pem` reach `native_tls::Certificate::*`, which imports
901 // items with no keychain and no temp dir (security_framework.rs:206,212).
902 // SOUNDNESS R342 — THE RESPONSE BODY IS READ FROM THE SOCKET, and `resp.text().await` is one of
903 // the most-written lines in Rust networking. `send()` is charged; the methods that DRAIN what it
904 // returned were not, so a helper that takes a `Response` and reads it — an extremely common
905 // shape — was a positive purity claim on a CALIBRATED crate. Executed at reqwest 0.13.4:
906 // `r.text()`, `r.json()` and `r.bytes()` all ABSENT while `c.get(..).send()` was `['Net']`.
907 //
908 // Verified in the source rather than assumed: `text` -> `text_with_charset` -> `bytes` ->
909 // `do_bytes`, which is `BodyExt::collect(self.res.into_body()).await`
910 // (`async_impl/response.rs:433`) — it drains the body off the wire. `chunk` and `bytes_stream`
911 // are the streaming spellings of the same read; charging the stream CONSTRUCTOR follows this
912 // file's own `Command::new` convention, where the effect is attributed at the point the caller
913 // asks for it rather than at the poll nobody can see.
914 //
915 // Scoped by `Response::` and not by the bare verb: `::text` and `::json` are far too common a
916 // leaf to match crate-wide. Found by the coverage gate the moment R185 taught the scanner to
917 // see through `Option`/`Result` unwraps — the same "closing a silence makes consumers' gaps
918 // visible" shape recorded on R302 and R337.
919 if crate_name == "reqwest"
920 && path.contains("Response::")
921 && (path.ends_with("::text")
922 || path.ends_with("::text_with_charset")
923 || path.ends_with("::json")
924 || path.ends_with("::bytes")
925 || path.ends_with("::chunk")
926 || path.ends_with("::bytes_stream"))
927 {
928 return Some("Net");
929 }
930 if crate_name == "reqwest"
931 && (path.ends_with("::Identity::from_pkcs12_der")
932 || path.ends_with("::Identity::from_pkcs8_pem"))
933 {
934 return Some("Fs");
935 }
936 if crate_name == "reqwest" && path.ends_with("::Part::file") {
937 return Some("Fs");
938 }
939 return None;
940 }
941 if crate_name == "ureq" && path.ends_with("::call") {
942 return Some("Net");
943 }
944 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against ureq 2.12.1 (the version this ratchet's
945 // rows were generated from — 3.x, also cached locally, rewrote this surface beyond recognition and
946 // is a different question for a future pass). `::call` above is the no-body dispatch terminal;
947 // these are its siblings:
948 //
949 // `Request::send`/`send_bytes`/`send_form`/`send_json`/`send_string` (request.rs:78-296) are the
950 // WITH-BODY dispatch terminals — `do_call(Payload::..)`, the exact same terminal `call` reaches with
951 // an empty payload. Missing them left every `agent.post(url).send_json(&body)` (the dominant idiom
952 // for a JSON API call) silently pure.
953 // `ureq::agent`/`request`/`request_url` (lib.rs:513,539,561) are real gaps in a stranger way: each
954 // one's own body contains a REACHABLE (if `is_test(true)` was ever called — itself a `pub fn` a
955 // consumer or another dependency could invoke) call to `testserver::test_agent()`, which binds a
956 // real listening `TcpStream` (see `TestServer::new` below) — self-scan's whole-body reachability
957 // treats that as real, and per this project's over-approximate-rather-than-miss discipline (a
958 // toggleable global flag routing traffic through an internal listener is exactly the kind of path a
959 // security-conscious caller wants disclosed, not silently pure), so does this rule. `agent()` is also
960 // the crate's OWN base for `request`/`request_url` (`agent().request(..)`), and `request`/
961 // `request_url` additionally carry the URL argument — matching the reqwest/isahc precedent above of
962 // classifying the URL-bearing constructor `Net` too (idempotent with the eventual `.call()`/`.send()`)
963 // so the destination is captured even if a caller never re-touches the returned `Request`.
964 if crate_name == "ureq"
965 && (path.ends_with("::send")
966 || path.ends_with("::send_bytes")
967 || path.ends_with("::send_form")
968 || path.ends_with("::send_json")
969 || path.ends_with("::send_string")
970 || path == "ureq::agent"
971 || path == "ureq::request"
972 || path == "ureq::request_url"
973 || path == "ureq::unversioned::transport::RustlsConnector::connect"
974 || path == "ureq::tls::rustls::RustlsConnector::connect"
975 || path == "ureq::RustlsConnector::connect")
976 {
977 return Some("Net");
978 }
979 // NOT modelled (both removed from open.tsv, not guessed):
980 // `ureq::Arc::connect` — the ratchet's generator strips generic arguments from an impl header, so
981 // `impl TlsConnector for Arc<rustls::ClientConfig>` (rtls.rs:88) produced the type name "Arc" — a
982 // bogus path (`ureq::Arc` names nothing; `Arc` is std's, not ureq's) that no consumer could ever
983 // write. The trait method itself is real (ureq's own internal TLS dispatch, invoked by `.call()`,
984 // already covered), but no FQN a real caller would spell resolves through this guess.
985 // `ureq::TestServer::new` — `TestServer` (testserver.rs:62) lives in `mod testserver;` (lib.rs:444),
986 // PRIVATE with no re-export, so `ureq::TestServer` is unreachable from outside the crate (the
987 // listener it binds is real, but only `agent()`'s internal `is_test` branch above can reach it).
988 // The `curl` crate (libcurl's safe binding — cargo's own HTTP client): the dispatch verbs are
989 // `perform` (Easy/Easy2/Transfer/Multi), raw-socket `send`/`recv`, the keepalive `upkeep`, and the
990 // multi-interface `action` (socket_action). The big setopt-style builder surface stays pure.
991 // `Multi::timeout` is deliberately NOT matched: `Easy::timeout` is a pure CURLOPT_TIMEOUT setter
992 // sharing the leaf — an under-report on the rare event-loop kick beats mis-tagging every consumer
993 // that sets a timeout. (Consumer-side companion to the curl_* FFI tier, same A/B finding.)
994 if crate_name == "curl"
995 && (path.ends_with("::perform")
996 || path.ends_with("::send")
997 || path.ends_with("::recv")
998 || path.ends_with("::upkeep")
999 || path.ends_with("::action"))
1000 {
1001 return Some("Net");
1002 }
1003 // The modern async-HTTP / TLS / QUIC / DNS stack — the LAYER reqwest/ureq/isahc build on, and that
1004 // crates use DIRECTLY. Found by the independent-method differential on `oha` (2026-06-17): candor
1005 // honestly DISCLOSED these as blind but never CLASSIFIED them, leaving real Net reaches uncovered.
1006 // Verb-keyed (the pure type/builder/codec surface stays None) and CRATE-GATED, so generic verbs
1007 // (request/connect/get/read/write/accept) never fabricate across unrelated crates. Same precision
1008 // discipline as the reqwest/curl rules above; complements the scan_builder_entry_effect entries.
1009 match crate_name {
1010 // hyper 1.x client connection I/O (the builder/Body/Request types stay pure).
1011 "hyper" if path.ends_with("::send_request") || path.ends_with("::handshake") => return Some("Net"),
1012 // hyper-util's pooled legacy Client + its TCP connectors.
1013 "hyper_util" if path.ends_with("::request") || path.ends_with("::connect") => return Some("Net"),
1014 // hickory (trust-dns) resolver — issues DNS queries over the network.
1015 "hickory_resolver"
1016 if path.ends_with("::lookup_ip") || path.ends_with("::lookup") || path.ends_with("_lookup")
1017 || path.ends_with("::resolve") => return Some("Net"),
1018 // HTTP/3 over QUIC.
1019 "h3" if path.ends_with("::send_request") || path.ends_with("::recv_data")
1020 || path.ends_with("::recv_response") || path.ends_with("::send_data") => return Some("Net"),
1021 // QUIC transport (UDP socket send/recv): connection setup, datagrams, AND the stream byte I/O
1022 // (`RecvStream::read*` / `SendStream::write*` / `finish`). Opening a stream is caught above, but a
1023 // fn that only HOLDS a stream and reads/writes it would otherwise read silent-pure (review: a Net
1024 // under-report). Crate-gated to quinn, where these verbs are unambiguously the socket I/O.
1025 "quinn" if path.ends_with("::connect") || path.ends_with("::accept") || path.ends_with("::open_bi")
1026 || path.ends_with("::open_uni") || path.ends_with("::accept_bi") || path.ends_with("::accept_uni")
1027 || path.ends_with("::send_datagram") || path.ends_with("::read_datagram")
1028 || path.ends_with("::read") || path.ends_with("::read_chunk") || path.ends_with("::read_chunks")
1029 || path.ends_with("::read_to_end") || path.ends_with("::write") || path.ends_with("::write_all")
1030 || path.ends_with("::write_chunk") || path.ends_with("::write_chunks")
1031 || path.ends_with("::finish") => return Some("Net"),
1032 // TLS-over-TCP stream adapters — the actual socket handshake/I/O (the config/cert types stay pure).
1033 "tokio_rustls" | "native_tls"
1034 if path.ends_with("::connect") || path.ends_with("::accept") || path.ends_with("::handshake") =>
1035 return Some("Net"),
1036 // AF_VSOCK host<->guest sockets — inter-process / VM comms.
1037 "tokio_vsock" if path.ends_with("::connect") || path.ends_with("::bind") || path.ends_with("::accept") =>
1038 return Some("Ipc"),
1039 // Loads the OS trust store from disk (cert files / keychain).
1040 "rustls_native_certs" if path.ends_with("::load_native_certs") => return Some("Fs"),
1041 // `rlimit` reads/mutates the process's kernel resource limits — the closest bucket is Env (host/
1042 // process config); no dedicated process-state bucket exists, so getrlimit (read) and setrlimit
1043 // (mutate) share it. NOTE: `num_cpus::get`/`get_physical` are deliberately NOT modeled — asking the
1044 // OS for the CPU count is a near-pure topology query, and std's equivalent `thread::
1045 // available_parallelism` classifies pure; modeling it as Env would spray Env over every thread-pool
1046 // constructor (review: a high-noise over-report) for no capability a reviewer cares about.
1047 "rlimit" if path.ends_with("::getrlimit") || path.ends_with("::setrlimit")
1048 || path.ends_with("::increase_nofile_limit") => return Some("Env"),
1049 // rustls — the SYNC TLS core (tokio_rustls/native_tls above are the async/system adapters). The
1050 // record-layer I/O is `read_tls`/`write_tls` (pull/push raw bytes through a held `io::Read`/`Write`)
1051 // and `complete_io` (loops them until the handshake/buffers drain). The config/cert/builder types
1052 // (`ClientConfig`/`ServerConfig`/`ConfigBuilder`) are PURE. `process_new_packets` is deliberately
1053 // EXCLUDED — it only decrypts ALREADY-buffered bytes (no socket touch; docs say call it AFTER
1054 // read_tls), so flagging it would over-report Net on the pure decrypt step.
1055 "rustls" if path.ends_with("::read_tls") || path.ends_with("::write_tls")
1056 || path.ends_with("::complete_io") => return Some("Net"),
1057 // native-tls under its alternate crate name + the tokio async wrapper (the `native_tls` arm above
1058 // is the common name). The TLS handshake over a TcpStream is Net; the builder/cert types are pure.
1059 "native_tls_crate" | "tokio_native_tls"
1060 if path.ends_with("::connect") || path.ends_with("::accept")
1061 || path.ends_with("::handshake") => return Some("Net"),
1062 _ => {}
1063 }
1064 // THE COVERAGE-GATE SWEEP (2026-08-27) — two crate-root CONSTRUCTORS the verb-keyed match above
1065 // never reached because neither ends in connect/accept/handshake, verified against their real
1066 // per-platform impl (native-tls 0.2.18, rustls 0.23.43):
1067 //
1068 // `native_tls_crate::TlsConnector::new` (lib.rs:481, the PUBLIC newtype wrapper around the private
1069 // per-platform `imp::TlsConnector`) reads the system trust store — on the openssl backend it loads
1070 // the probed cert file/dir from disk before any socket exists. `Identity::from_pkcs8` (lib.rs:178,
1071 // wrapping `imp::Identity::from_pkcs8`) on the security-framework (macOS) backend creates a real
1072 // temporary keychain file on disk to import the PKCS8 key into.
1073 // `rustls::KeyLogFile::new` (key_log_file.rs:88) reads `$SSLKEYLOGFILE` and, if set, opens (creating
1074 // if needed) that file in append mode — a real, if opt-in, disk write path independent of the sync
1075 // TLS record-layer I/O the `rustls` arm above already covers.
1076 // SOUNDNESS R331 — …and `TlsConnector::builder`, which is the SAME trust-store read reached by the
1077 // other spelling. `TlsConnector::new` is literally `TlsConnector::builder().build()`
1078 // (`native-tls-0.2.18/src/lib.rs:481`), and `TlsConnectorBuilder::build` (`:451`) is where
1079 // `imp::TlsConnector::new` — the openssl-backend cert-file/dir load — actually runs. The builder
1080 // spelling is the DOMINANT idiom the moment any option is set, and it is what `reqwest` itself
1081 // writes (`reqwest-0.12.28/src/async_impl/client.rs:529`).
1082 //
1083 // **KEYED ON `builder`, NOT ON `build`, AND THAT IS THE WHOLE FIX.** The first attempt added
1084 // `ends_with("TlsConnectorBuilder::build")` and moved nothing, so the row was closed with the
1085 // conclusion that the scanner "never types `TlsConnector::builder()`" and the repair had to be
1086 // receiver typing. **That conclusion was wrong.** `.build()` is a method on a foreign-returned
1087 // receiver and cannot be typed without a return index for a foreign crate — but `TlsConnector::
1088 // builder()` is a PATH call, recorded and classified exactly like `TlsConnector::new` beside it.
1089 // Charging the constructor rather than the terminal is also this file's own convention
1090 // (`Command::new`). Over-charge direction: a `builder()` whose `.build()` never happens, which is
1091 // not a shape anyone writes.
1092 //
1093 // WHY IT LOOKED LIKE NOTHING WAS RECORDED: under the calibration exemption a call that classifies
1094 // `None` is DROPPED from the report, so "no calls recorded" is what a correct scan of an exempt
1095 // crate looks like — not evidence that the receiver went untyped. Measured: under the PLAIN
1096 // `native_tls::` spelling (not calibrated) the same body is PRESENT with `invisible: ["native_tls"]`,
1097 // a disclosure; under `native_tls_crate` (the calibrated rename reqwest and tungstenite use) it is
1098 // ABSENT with nothing hidden. The rename is what turns a disclosure into a purity claim.
1099 if matches!(crate_name, "native_tls" | "native_tls_crate")
1100 && (path.ends_with("TlsConnector::new")
1101 || path.ends_with("TlsConnector::builder")
1102 || path.ends_with("Identity::from_pkcs8")
1103 || path.ends_with("Identity::from_pkcs12"))
1104 {
1105 return Some("Fs");
1106 }
1107 if crate_name == "rustls" && path.ends_with("KeyLogFile::new") {
1108 return Some("Fs");
1109 }
1110 // THE COVERAGE-GATE REFRESH (2026-10-08): `KeyLogFile::log` (rustls-0.23.45 key_log_file.rs:100) is
1111 // `try_write` -> `file.write_all(..)` (:67) on the SSLKEYLOGFILE it opened.
1112 if crate_name == "rustls"
1113 && matches!(path, "rustls::KeyLogFile::log" | "rustls::key_log_file::KeyLogFile::log")
1114 {
1115 return Some("Fs");
1116 }
1117 // Message-queue clients fully encapsulate the socket (the underlying tokio::net lives
1118 // inside the crate, unseen), so a user's connect/publish/consume calls ARE the I/O
1119 // boundary — to a remote broker, hence Net. Match the broker round-trip verbs (snake_case
1120 // methods); the CamelCase option/property builders stay pure. (Found hardening on consumer
1121 // apps: lapin `basic_publish`/`queue_declare` and async-nats `publish`/`subscribe` were
1122 // classified pure — a message-queue client reporting no I/O.)
1123 if crate_name == "async_nats" {
1124 if path.ends_with("::connect")
1125 || path.contains("::publish")
1126 || path.ends_with("::subscribe")
1127 || path.ends_with("::queue_subscribe")
1128 || path.contains("::request")
1129 || path.ends_with("::flush")
1130 {
1131 return Some("Net");
1132 }
1133 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against async-nats 0.35.1 (the version this
1134 // ratchet's rows were generated from — 0.50.0, also cached locally, removed/relocated this
1135 // exact surface, a separate question for a future pass):
1136 //
1137 // `connect_with_options` (lib.rs:891) is the crate's OWN real entry point `connect()` calls one
1138 // hop down — the actual handshake, missing because the verb list above matches the bare
1139 // `::connect` suffix, not the `_with_options` sibling.
1140 // `ConnectOptions::credentials_file`/`with_credentials_file` (options.rs:417,438) load a real
1141 // credentials file off disk via `auth_utils::load_creds` before authenticating — Fs, not Net
1142 // (the network round-trip these feed happens later, at `connect`).
1143 // `ServerAddr::socket_addrs` (lib.rs:1473) does `tokio::net::lookup_host` — a real DNS query.
1144 if path == "async_nats::connect_with_options" {
1145 return Some("Net");
1146 }
1147 if path == "async_nats::ConnectOptions::credentials_file"
1148 || path == "async_nats::ConnectOptions::with_credentials_file"
1149 {
1150 return Some("Fs");
1151 }
1152 if path == "async_nats::ServerAddr::socket_addrs" {
1153 return Some("Net");
1154 }
1155 return None;
1156 }
1157 if crate_name == "lapin" {
1158 if path.ends_with("::connect")
1159 || path.ends_with("::create_channel")
1160 || path.contains("::basic_")
1161 || path.contains("::queue_")
1162 || path.contains("::exchange_")
1163 || path.contains("::tx_")
1164 || path.ends_with("::confirm_select")
1165 || path.ends_with("::close")
1166 {
1167 return Some("Net");
1168 }
1169 return None;
1170 }
1171 // SMTP email — lettre's `Transport::send` is the network dispatch; Message building is
1172 // pure. (Found hardening on a lettre consumer: `mailer.send(&email)` classified pure.)
1173 //
1174 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against lettre 0.11.23 source — the TLS-setup and
1175 // connection-establishment family this crate's own `send` rule never reached:
1176 //
1177 // `TlsParameters::new`/`new_rustls` (client/tls.rs:598,617) and `TlsParametersBuilder::build`/
1178 // `build_rustls` (tls.rs:325,446) — `build_rustls` calls `rustls_native_certs::load_native_certs()`
1179 // (a real OS-trust-store read) plus `tracing::debug!`; `build()` dispatches to it under the
1180 // (textually-visible, cfg-gated) `rustls` feature. `SmtpTransport`/`AsyncSmtpTransport::{from_url,
1181 // relay,starttls_relay}` (transport.rs:90,114,243; async_transport.rs:135,169,306) each construct a
1182 // `TlsParameters` directly (`relay`/`starttls_relay`) or via the crate's OWN `pub(crate)
1183 // from_connection_url` (`from_url`) — real TLS setup a consumer never spells `TlsParameters` to
1184 // reach. `FileTransport::read` (transport/file/mod.rs:216) calls `std::fs::read` directly.
1185 if crate_name == "lettre" {
1186 if path.ends_with("::send") || path.ends_with("::send_raw") {
1187 return Some("Net");
1188 }
1189 // THE COVERAGE-GATE REFRESH (2026-10-08), read against lettre-0.11.23 source: the SMTP client
1190 // connection layer, which `send` above reaches but a consumer driving the protocol by hand never
1191 // names. `SmtpConnection::connect` -> `NetworkStream::connect` (connection.rs:66, a TCP dial);
1192 // `command` -> `write_all` + `flush` on the stream (:275); `read_response` -> `read_line` (:289);
1193 // `quit`/`test_connected`/`auth`/`message`/`message_iter`/`starttls` are `command` +
1194 // `read_response`; `abort` sends QUIT and `shutdown`s the socket (:178). The async twins
1195 // (async_connection.rs) are the same over tokio, `connect_with_transport` reading the greeting.
1196 // `AsyncNetworkStream::{connect_tokio1, upgrade_tls}` dial / handshake the socket (the sync
1197 // `NetworkStream` is in the private `mod net;` and nameable by no consumer — no rule, as before);
1198 // `Tokio1Executor::connect` is `AsyncSmtpConnection::connect_tokio1` (executor.rs:140). NOT charged:
1199 // the accessors (`server_info`, `has_broken`, `can_starttls`, `is_encrypted`, `peer_*`,
1200 // `certificate_chain`, `tls_verify_result`, `set_stream`).
1201 if let Some((ty, m)) = type_method(path, "lettre", &["", "transport::smtp::client"]) {
1202 const CONN: &[&str] = &["connect", "connect_tokio1", "connect_with_transport", "starttls", "quit",
1203 "abort", "test_connected", "auth", "message", "message_iter", "command", "read_response"];
1204 let hit = match ty {
1205 "SmtpConnection" | "AsyncSmtpConnection" => CONN.contains(&m),
1206 "AsyncNetworkStream" => matches!(m, "connect_tokio1" | "upgrade_tls"),
1207 "Tokio1Executor" => m == "connect",
1208 _ => false,
1209 };
1210 if hit {
1211 return Some("Net");
1212 }
1213 }
1214 // SOUNDNESS R337 — lettre's half of the same wrapper gap. `Identity::from_pem(pem, key)`
1215 // (transport/smtp/client/tls.rs:749) is `native_tls::Identity::from_pkcs8(pem, key)`, which on
1216 // macOS writes a temporary keychain. The `::Identity::` in the suffix is load-bearing: the
1217 // `Certificate::from_pem` twenty lines above it (`:676`) is in-memory and must stay pure, and a
1218 // bare `::from_pem` would charge both.
1219 if path.ends_with("::Identity::from_pem") {
1220 return Some("Fs");
1221 }
1222 if path == "lettre::TlsParametersBuilder::build"
1223 || path == "lettre::transport::smtp::client::TlsParametersBuilder::build"
1224 || path == "lettre::TlsParametersBuilder::build_rustls"
1225 || path == "lettre::transport::smtp::client::TlsParametersBuilder::build_rustls"
1226 || path == "lettre::TlsParameters::new"
1227 || path == "lettre::transport::smtp::client::TlsParameters::new"
1228 || path == "lettre::TlsParameters::new_rustls"
1229 || path == "lettre::transport::smtp::client::TlsParameters::new_rustls"
1230 // SOUNDNESS R331 — the `_native` half of the same family, which this enumeration never had.
1231 // It surfaced the moment `TlsConnector::builder` started charging: the coverage gate's
1232 // oracle saw `Fs` on `TlsParameters::new_native` and `TlsParametersBuilder::build_native`
1233 // and `classify()` had no rule for either. `build_native` is `TlsConnector::builder()`
1234 // (`lettre-0.11.23/src/transport/smtp/client/tls.rs:338`) and `new_native` delegates
1235 // straight to it (`:610`); the already-listed `build`/`new` reach whichever backend is
1236 // enabled and were covered, so the list held every variant EXCEPT the one named after the
1237 // backend that does the disk read.
1238 //
1239 // `build_boring`/`new_boring` are deliberately NOT added. `build_boring` (`:384`) builds a
1240 // `boring::ssl::SslConnector`, and openssl's `SslConnector::builder` calls
1241 // `set_default_verify_paths()` — so it is PROBABLY `Fs` too, but `boring` is not a crate
1242 // this file has read, and the last three defects in this area came from charging a path
1243 // whose body nobody had opened. Left for a pass that reads it.
1244 || path == "lettre::TlsParameters::new_native"
1245 || path == "lettre::transport::smtp::client::TlsParameters::new_native"
1246 || path == "lettre::TlsParametersBuilder::build_native"
1247 || path == "lettre::transport::smtp::client::TlsParametersBuilder::build_native"
1248 || path == "lettre::SmtpTransport::from_url"
1249 || path == "lettre::SmtpTransport::relay"
1250 || path == "lettre::SmtpTransport::starttls_relay"
1251 || path == "lettre::AsyncSmtpTransport::from_url"
1252 || path == "lettre::AsyncSmtpTransport::relay"
1253 || path == "lettre::AsyncSmtpTransport::starttls_relay"
1254 || path == "lettre::FileTransport::read"
1255 {
1256 return Some("Fs");
1257 }
1258 // `AsyncStd1Executor::{connect,fs_read,fs_write}` (executor.rs:223,260,265, the sealed
1259 // `Executor` trait impl — `#[doc(hidden)]` in lettre's own source, so this is a narrower, less-
1260 // visited surface than the others above, but genuinely reachable: both the trait and the type
1261 // are `pub`, re-exported at the crate root) dial a real connection / read+write real files.
1262 // `AsyncSmtpConnection`/`AsyncNetworkStream::connect_asyncstd1` (client/async_connection.rs:121,
1263 // client/async_net.rs:207) dial a real `async-std` TCP connection — reachable only via the
1264 // module-qualified path (`transport::smtp::client::`), since neither type is re-exported at the
1265 // crate root (unlike `TlsParameters`'s sibling forms, which at least reach `client::` — the dual
1266 // spellings above, same shape as rusqlite's `Blob`/`Backup`).
1267 if path == "lettre::AsyncStd1Executor::connect"
1268 || path == "lettre::AsyncSmtpConnection::connect_asyncstd1"
1269 || path == "lettre::transport::smtp::client::AsyncSmtpConnection::connect_asyncstd1"
1270 || path == "lettre::AsyncNetworkStream::connect_asyncstd1"
1271 || path == "lettre::transport::smtp::client::AsyncNetworkStream::connect_asyncstd1"
1272 {
1273 return Some("Net");
1274 }
1275 if path == "lettre::AsyncStd1Executor::fs_read" || path == "lettre::AsyncStd1Executor::fs_write" {
1276 return Some("Fs");
1277 }
1278 // NOT included: `NetworkStream::{shutdown,set_read_timeout,set_write_timeout}` (client/net.rs)
1279 // LOOK like three more hits (self-scan flags them too, propagating from the coarse
1280 // `std::net::TcpStream` whole-handle rule) but `mod net;` (client/mod.rs:50) is PRIVATE and
1281 // `NetworkStream` is never re-exported (only imported crate-internally via a bare `use
1282 // self::net::NetworkStream;`, mod.rs:35) — no external consumer can ever name the type, so no
1283 // rule was added despite self-scan flagging it (the same `InnerConnection`/`RawStatement` shape
1284 // as the rusqlite fix elsewhere in this file).
1285 return None;
1286 }
1287 // WebSockets — tungstenite (the modern successor to the old `websocket` crate). connect
1288 // and the socket read/write/send are network; Message constructors are pure. (Found on a
1289 // tungstenite consumer: connect + send + read classified pure.)
1290 //
1291 // THE FIX: `connect` only names the URL-dialing client entry point. tungstenite ALSO ships the
1292 // stream-first client/server handshake free functions — `client`/`client_with_config` (client.rs:176,
1293 // 152: "use this if you need a nonblocking handshake ... or a custom stream") and
1294 // `accept`/`accept_with_config`/`accept_hdr`/`accept_hdr_with_config` (server.rs:23-63) — each of
1295 // which does `{Client,Server}Handshake::start(stream, ..).handshake()`, the REAL WS upgrade
1296 // handshake read/write over an already-open stream. These are the documented way to run tungstenite
1297 // over a caller-managed TCP/TLS/mio stream (exactly the shape most async runtimes use, since
1298 // tungstenite itself is sync/transport-agnostic) — missing from a verb list keyed only on the
1299 // dial-it-yourself `connect` spelling. Same vein as `git2::Submodule::clone`/`ignore::Walk::new`: an
1300 // established effect's SIBLING entry point absent from the allowlist.
1301 if crate_name == "tungstenite" {
1302 if path.ends_with("::connect")
1303 || path.ends_with("::connect_with_config")
1304 || path.ends_with("::client")
1305 || path.ends_with("::client_with_config")
1306 || path.ends_with("::client_tls")
1307 || path.ends_with("::client_tls_with_config")
1308 || path.ends_with("::accept")
1309 || path.ends_with("::accept_with_config")
1310 || path.ends_with("::accept_hdr")
1311 || path.ends_with("::accept_hdr_with_config")
1312 || path.ends_with("::read")
1313 || path.ends_with("::write")
1314 || path.ends_with("::send")
1315 || path.ends_with("::close")
1316 || path.ends_with("::flush")
1317 || path.ends_with("::read_message")
1318 || path.ends_with("::write_message")
1319 {
1320 return Some("Net");
1321 }
1322 return None;
1323 }
1324 // elasticsearch: request builders are pure; only the `.send()` dispatch is HTTP I/O
1325 // (same shape as reqwest / the AWS SDK). (Found on an elasticsearch consumer.)
1326 // SOUNDNESS R342 — …and the RESPONSE BODY, one wrapper up from reqwest's. `Response::json` is
1327 // `self.response.json::<B>().await` (`http/response.rs:102`) — reqwest's body drain, which this file
1328 // now charges — and `text`/`bytes`/`exception` are the same read. A consumer writing
1329 // `client.search(..).send().await?.json().await?` had the `send` charged and the read silent.
1330 //
1331 // This is EXACTLY the distinction this file's own exclusion list already draws for this type:
1332 // `Response::content_type` is documented there as a pure accessor reading a header off an
1333 // ALREADY-received response. `status_code`/`headers` are the same shape and stay pure; the body
1334 // methods are not accessors, they pull the remaining bytes off the wire. Both halves are asserted
1335 // in the test so the line between them cannot move by accident.
1336 if crate_name == "elasticsearch"
1337 && path.contains("Response::")
1338 && (path.ends_with("::json")
1339 || path.ends_with("::text")
1340 || path.ends_with("::bytes")
1341 || path.ends_with("::exception"))
1342 {
1343 return Some("Net");
1344 }
1345 if crate_name == "elasticsearch" && path.ends_with("::send") {
1346 return Some("Net");
1347 }
1348 // gRPC — tonic. The transport connect and the Grpc client RPC dispatch are network;
1349 // codecs and request/response wrappers are pure. (connect repro-confirmed on a consumer;
1350 // the unary/streaming RPC verbs are from the tonic::client::Grpc API.)
1351 //
1352 // THE COVERAGE-GATE FIX: the rule above only ever covered the CLIENT half. `Router::serve`/
1353 // `::serve_with_shutdown` (transport/server/mod.rs:783,811) are the SERVER's own most common entry
1354 // points — a tonic quickstart's last line — and both bind a real listening socket via
1355 // `TcpIncoming::new` (incoming.rs:197: `StdTcpListener::bind(addr)`), the crate's other bare-`pub`
1356 // constructor (`Server<L>::serve_with_shutdown` at mod.rs:530 is a DIFFERENT, `pub(crate)` method on
1357 // a different receiver — not reachable, and not this one). A `deny Net` on a gRPC SERVER app missed
1358 // its own listen call entirely.
1359 if crate_name == "tonic" {
1360 if path.ends_with("::connect")
1361 || path.ends_with("::unary")
1362 || path.ends_with("::server_streaming")
1363 || path.ends_with("::client_streaming")
1364 || path.ends_with("::streaming")
1365 || path.ends_with("::Router::serve")
1366 || path.ends_with("::Router::serve_with_shutdown")
1367 || path == "tonic::TcpIncoming::new"
1368 || path == "tonic::transport::server::TcpIncoming::new"
1369 {
1370 return Some("Net");
1371 }
1372 return None;
1373 }
1374 // Kafka — rdkafka (FFI to librdkafka). Producer send + consumer poll/recv/subscribe/
1375 // commit are network round-trips to the brokers. (API-calibrated + unit-tested; a real
1376 // repro needs librdkafka/cmake, deferred.)
1377 if crate_name == "rdkafka" {
1378 if path.ends_with("::send")
1379 || path.ends_with("::send_result")
1380 || path.ends_with("::recv")
1381 || path.ends_with("::poll")
1382 || path.ends_with("::subscribe")
1383 || path.ends_with("::commit")
1384 || path.ends_with("::commit_message")
1385 || path.ends_with("::commit_consumer_state")
1386 || path.ends_with("::store_offset")
1387 || path.ends_with("::seek")
1388 || path.ends_with("::fetch_metadata")
1389 || path.ends_with("::fetch_watermarks")
1390 || path.ends_with("::flush")
1391 {
1392 return Some("Net");
1393 }
1394 return None;
1395 }
1396 // cap-std: capability-oriented std. I/O goes *through* a held capability handle
1397 // (Dir/Pool/Clock/...), so these calls ARE the effect. Recognising them means a
1398 // cap-std project's real I/O is detected and matches the capability it declared
1399 // (via `declared_caps`/`capstd_cap`) — conformance against unforgeable capabilities.
1400 if crate_name.starts_with("cap_") {
1401 if path.contains("::net::Unix") || path.contains("::os::") {
1402 return Some("Ipc");
1403 }
1404 if path.contains("::net") {
1405 return Some("Net");
1406 }
1407 if path.contains("::time") {
1408 return Some("Clock");
1409 }
1410 if path.contains("::fs") || crate_name == "cap_tempfile" || crate_name == "cap_directories" {
1411 return Some("Fs");
1412 }
1413 return None;
1414 }
1415 // Local IPC (Unix-domain sockets) is I/O but not *network* — keep it distinct so
1416 // CANDOR_NO_AMBIENT and audits don't conflate it with internet access. async-std puts its
1417 // Unix sockets under `os::unix::net` (mirroring std); async-net (smol's net layer) under
1418 // `unix`.
1419 if path.starts_with("tokio::net::Unix")
1420 || path.starts_with("std::os::unix::net")
1421 || path.starts_with("async_std::os::unix::net")
1422 || path.starts_with("async_net::unix")
1423 {
1424 return Some("Ipc");
1425 }
1426 // Raw packet capture / raw sockets — libpnet (the dominant low-level networking crate; powers
1427 // bandwhich, sniffers, custom-protocol tools). `datalink::channel` opens an L2 socket and
1428 // `transport::transport_channel` an L3/L4 raw socket — both ARE network I/O. Packet construction
1429 // (pnet_packet / pnet_base, MacAddr, Ethernet frames…) is pure and stays unclassified. The actual
1430 // frame read/write happens via methods on the returned Sender/Receiver (trait-object dispatch the
1431 // syntactic backend can't resolve), so the channel-open call is the precise Net boundary. (Found
1432 // scanning bandwhich — a packet sniffer — which reported Net 0.)
1433 if crate_name == "pnet" || crate_name == "pnet_datalink" || crate_name == "pnet_transport" {
1434 if path.ends_with("::channel") || path.ends_with("::transport_channel") {
1435 return Some("Net");
1436 }
1437 return None;
1438 }
1439 // Directory traversal — `ignore` (BurntSushi's gitignore-aware walker; powers ripgrep, fd). The walk
1440 // EXECUTORS read the directory tree from disk = Fs. Type-precise on purpose: the configuration builders
1441 // (`OverrideBuilder::build`, `GitignoreBuilder::build`, the `WalkBuilder` setters) and `DirEntry`
1442 // accessors are PURE — only `WalkBuilder::build`/`build_parallel` (which kick off the walk) and
1443 // `WalkParallel::run` (which drives it) touch the filesystem. A bare `build` would wrongly flag the
1444 // config builders. (Found scanning fd — a file finder — which reported Fs 2: its own `fs::read_dir`
1445 // was caught, but the `ignore`-based traversal that IS fd was invisible cross-crate.)
1446 //
1447 // `Walk::new`/`Walk::from_iter` are the crate's own documented convenience constructors — literally
1448 // `WalkBuilder::new(path).build()` / `WalkBuilder::from_iter(paths).build()` in `ignore`'s own source
1449 // (walk.rs:1128-1146) — but a verb list keyed only on `WalkBuilder::build`/`build_parallel`/
1450 // `WalkParallel::run`/`add_ignore` never sees `Walk::new(root)` (the crate's own top-level doc example
1451 // and the SAME shape as the already-fixed `walkdir::WalkDir::new`/`git2::Repository::clone`: a
1452 // documented public entry point that reaches the modelled effect through a wrapper the allowlist
1453 // didn't name). `deny Fs` over `for e in Walk::new(root) { .. }` exited 0. Charged at the SAME
1454 // construction site as `WalkBuilder::build` — no receiver typing needed, it's a plain `Expr::Call` on
1455 // `ignore::Walk::new`/`ignore::Walk::from_iter`, crate-gated so an unrelated `Walk::new` (a different
1456 // crate, or a local type) cannot match this arm.
1457 if crate_name == "ignore" {
1458 if path == "ignore::WalkBuilder::build"
1459 || path == "ignore::WalkBuilder::build_parallel"
1460 || path.ends_with("::WalkParallel::run")
1461 // `add_ignore(path)` LOOKS like a config setter but reads that ignore file from disk at call
1462 // time (it returns the read error) — unlike the pure `add_custom_ignore_filename(name)` which
1463 // only stores a filename string. The lone Fs-touching builder method in the otherwise-pure setter
1464 // surface, so it was silently pure under the covered-crate floor.
1465 || path == "ignore::WalkBuilder::add_ignore"
1466 || path == "ignore::Walk::new"
1467 || path == "ignore::Walk::from_iter"
1468 {
1469 return Some("Fs");
1470 }
1471 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against ignore 0.4.33 — the gitignore-matcher
1472 // construction family, a DIFFERENT public surface from the walk-builder family above:
1473 //
1474 // `Gitignore::new` (gitignore.rs:103) opens and reads the given file directly via
1475 // `GitignoreBuilder::add`. `Gitignore::global` (gitignore.rs:141) reads `$PWD` then delegates to
1476 // `GitignoreBuilder::new(cwd).build_global()`. `GitignoreBuilder::add` (gitignore.rs:405) is the
1477 // crate's own per-file loader — `File::open` + line-by-line read — the same shape `add_ignore`
1478 // above was fixed for, on the SIBLING type. `GitignoreBuilder::build_global`/
1479 // `gitconfig_excludes_path` (gitignore.rs:377,583) resolve git's global excludesfile: read
1480 // `GIT_CONFIG_GLOBAL`/`XDG_CONFIG_HOME`/`HOME` env vars, then open and parse whichever gitconfig
1481 // file they point at. `WalkBuilder::build_matchers` (walk.rs:675) is the incremental-walk
1482 // sibling of the already-covered `build_parallel` — same `self.build_ignore()` call, different
1483 // caller-facing verb. `IncrementalIgnore::matched`/`matched_with_errors` (incremental.rs:194,213)
1484 // are the newer per-directory incremental-walk API: each lazily loads that directory's ignore
1485 // files (and, when `max_filesize` is set, `stat`s the candidate file) as the walk descends.
1486 //
1487 // `Gitignore`/`GitignoreBuilder`/`gitconfig_excludes_path` live in `pub mod gitignore;`
1488 // (lib.rs:59) but — unlike `Walk`/`WalkBuilder` two lines up (`pub use crate::walk::{..}`,
1489 // lib.rs:52) — are NOT re-exported at the crate root, so the only real, compilable spelling is
1490 // the module-qualified one (proven by a consumer fixture: `ignore::Gitignore::new` does not
1491 // exist, `ignore::gitignore::Gitignore::new` does). `IncrementalIgnore` IS re-exported at the
1492 // root (`pub use crate::incremental::{..}`, lib.rs:51), so the bare form is correct there.
1493 if path == "ignore::gitignore::Gitignore::new"
1494 || path == "ignore::gitignore::Gitignore::global"
1495 || path == "ignore::gitignore::GitignoreBuilder::add"
1496 || path == "ignore::gitignore::GitignoreBuilder::build_global"
1497 || path == "ignore::gitignore::gitconfig_excludes_path"
1498 || path == "ignore::WalkBuilder::build_matchers"
1499 || path == "ignore::IncrementalIgnore::matched"
1500 || path == "ignore::IncrementalIgnore::matched_with_errors"
1501 || path == "ignore::DirEntry::metadata"
1502 || path == "ignore::walk::DirEntry::metadata"
1503 || path == "ignore::WalkParallel::visit"
1504 || path == "ignore::walk::WalkParallel::visit"
1505 {
1506 return Some("Fs");
1507 }
1508 return None;
1509 }
1510 // Filesystem watching — `notify` (the de-facto fs-watch crate: watchexec, cargo-watch, mdbook). A
1511 // watcher opens an OS notification handle (inotify / FSEvents / kqueue / ReadDirectoryChanges) and
1512 // registers paths — observing filesystem state changes = Fs. The lifecycle boundary: any
1513 // `*Watcher::new` constructor (RecommendedWatcher/PollWatcher/INotifyWatcher/FsEventWatcher/…), the
1514 // `recommended_watcher` convenience fn, and the `watch`/`unwatch` registration verbs. `Config`/`Event`/
1515 // `EventKind` data types stay pure. (Found scanning watchexec: its watcher-`create` read Fs 0.)
1516 if crate_name == "notify" {
1517 if path.ends_with("Watcher::new")
1518 || path.ends_with("::recommended_watcher")
1519 || path.ends_with("::watch")
1520 || path.ends_with("::unwatch")
1521 // `ReadDirectoryChangesWatcher::create` (windows.rs:475, `pub use windows::
1522 // ReadDirectoryChangesWatcher` at the crate root) is a SECOND, directly-callable
1523 // constructor distinct from the `Watcher::new` trait method above — it takes a raw
1524 // meta-event channel `Watcher::new`'s impl builds internally by calling THIS same
1525 // `create`, then spawns the real ReadDirectoryChangesW watch server thread.
1526 || path.ends_with("ReadDirectoryChangesWatcher::create")
1527 {
1528 return Some("Fs");
1529 }
1530 return None;
1531 }
1532 // std DNS resolution — `("host", 80).to_socket_addrs()` / `std::net::lookup_host("host")` perform a
1533 // real getaddrinfo query (Net), but the classify table covered only the socket I/O *types*, so they
1534 // floored silently (sweep [37]; the syntactic engine modelled DNS only at the libc layer).
1535 if path.ends_with("::to_socket_addrs")
1536 || path == "std::net::lookup_host"
1537 || path.ends_with("ToSocketAddrs::to_socket_addrs")
1538 {
1539 return Some("Net");
1540 }
1541 // Raw sockets. Match the I/O *types* only — `std::net` also holds pure data types
1542 // (SocketAddr, IpAddr, …) whose construction must NOT be flagged.
1543 if path.starts_with("std::net::TcpStream")
1544 || path.starts_with("std::net::TcpListener")
1545 || path.starts_with("std::net::UdpSocket")
1546 || path.starts_with("tokio::net::")
1547 {
1548 // …but the PURE accessors read back local/option state — no network I/O — so the whole-type Net
1549 // rule fabricated Net on them (sweep [24], the precision failure; mirrors the arboard/memmap2 accessor
1550 // carve-outs). local_addr/peer_addr return bound/connected addresses; nodelay/ttl/take_error read
1551 // socket options/state. Every genuine verb (connect/read/write/send/recv/accept) stays Net.
1552 if path.ends_with("::local_addr")
1553 || path.ends_with("::peer_addr")
1554 || path.ends_with("::nodelay")
1555 || path.ends_with("::ttl")
1556 || path.ends_with("::take_error")
1557 {
1558 return None;
1559 }
1560 return Some("Net");
1561 }
1562 // Legacy tokio 0.1 socket crates — `tokio_tcp`/`tokio_udp` are *entirely* networking
1563 // (no pure types to over-flag), so the whole crate is Net. (Found hardening on websocat,
1564 // which is still on tokio 0.1: its `tokio_tcp::TcpStream::connect` was classified
1565 // network-free — a network tool confidently reporting 0 Net.)
1566 if matches!(crate_name, "tokio_tcp" | "tokio_udp") {
1567 return Some("Net");
1568 }
1569 // The other async runtimes mirror tokio's module layout, and their `net` modules hold only
1570 // socket I/O types (the pure `SocketAddr`/`IpAddr` are re-exports that resolve to `std::net`,
1571 // so they're excluded by def-path). `mio` is the low-level non-blocking-socket layer under
1572 // tokio/others; `async_net` is smol's net crate. Closes the async-std/smol/mio gap the
1573 // tokio_tcp note flagged. (Calibrated by module structure — these crates ARE networking — not
1574 // a live repro; the TCP/UDP types are defined in-crate so the def-path prefix is exact.)
1575 if path.starts_with("async_std::net::")
1576 || path.starts_with("mio::net::")
1577 || crate_name == "async_net"
1578 {
1579 return Some("Net");
1580 }
1581 // Database clients. Like the AWS/HTTP builders, only the execution verbs are I/O;
1582 // query *construction* is pure. Best-effort across crates (tune via CANDOR_CONFIG).
1583 // Note: bare `::query` is deliberately omitted — it executes in postgres/rusqlite but
1584 // only *builds* in sqlx, so including it would false-positive sqlx's `query()` builder.
1585 if DB_CRATES.contains(&crate_name) {
1586 // Postgres / SQLite-family clients: `query`/`batch_execute`/`prepare`/etc. ARE the
1587 // execution (round-trips to the server). sqlx is the outlier where bare `query()`
1588 // only BUILDS — it keeps the narrow set below. (Found by running on a real
1589 // tokio-postgres app, pgman: candor had reported only 4 of ~20 DB call sites.)
1590 if matches!(crate_name, "postgres" | "tokio_postgres" | "deadpool_postgres" | "rusqlite") {
1591 // `PG_DB_VERBS`, hoisted to module level so the AS-EFF-008 masking guard reads the SAME
1592 // table (SOUNDNESS R806; see `masks_locator` and `PG_DB_VERBS_NO_SQL`).
1593 const PG: &[&str] = PG_DB_VERBS;
1594 if PG.iter().any(|v| path.ends_with(v)) {
1595 return Some("Db");
1596 }
1597 // THE COVERAGE-GATE SWEEP (2026-08-27): `CancelToken::cancel_query` (tokio-postgres
1598 // cancel_token.rs:34) opens a BRAND NEW connection to the server to send a raw CancelRequest
1599 // packet — real socket I/O, but not a query round-trip on an existing connection like the PG
1600 // verb list above, so bucketed `Net` rather than `Db`.
1601 if matches!(crate_name, "tokio_postgres" | "postgres")
1602 && path.ends_with("CancelToken::cancel_query")
1603 {
1604 return Some("Net");
1605 }
1606 // rusqlite only: opening the database IS the connection establishment (`Connection::
1607 // open`/`open_in_memory`/`open_with_flags` — the embedded analog of `::connect`).
1608 //
1609 // THE FIX: rusqlite 0.32's `Connection` type has SIX `open*` constructors, not three —
1610 // `open_with_flags_and_vfs` and `open_in_memory_with_flags_and_vfs` call the real
1611 // `InnerConnection::open_with_flags` (the sqlite3_open_v2 FFI) directly (lib.rs:492,530), and
1612 // `open_in_memory_with_flags` (lib.rs:515) calls `Connection::open_with_flags(":memory:", ..)`
1613 // — but an exact-suffix allowlist keyed on the THREE simplest names never matches any of
1614 // them (`"open_in_memory_with_flags".ends_with("::open_with_flags")` is false: the suffix
1615 // check needs the string to END in that literal tail, and `_and_vfs`/`_with_flags` add
1616 // characters AFTER it). The same shape as `ignore::Walk::new`: a sibling spelling of an
1617 // already-modelled constructor missing from the allowlist, silently pure because rusqlite is
1618 // calibrated. Match on the `Connection::open` PREFIX rather than enumerating every suffix —
1619 // every current and future `Connection::open*` constructor opens a real handle. Scoped to the
1620 // `Connection::` segment specifically (not a bare leaf prefix) so an unrelated same-crate
1621 // `open`-prefixed method on a DIFFERENT type (e.g. the private `pragma::Sql::open_brace`,
1622 // which pushes one char to a string buffer — no I/O) cannot be swept in by accident.
1623 //
1624 // Same sighting, different constructor: `Connection::blob_open` and `Blob::reopen` call
1625 // `ffi::sqlite3_blob_open`/`sqlite3_blob_reopen` directly (blob/mod.rs:218,251) — genuinely
1626 // Db, and `sqlite3_blob_open`/`sqlite3_blob_reopen` are already in this file's own FFI-leaf DB
1627 // table above (matched only when a caller names the raw `ffi::` leaf directly) — but rusqlite's
1628 // own documented safe wrapper, the incremental-BLOB-I/O API every rusqlite blob consumer
1629 // actually calls, carried no rule of its own.
1630 if crate_name == "rusqlite"
1631 && (path.contains("::Connection::open")
1632 || path.ends_with("::Connection::blob_open")
1633 || path.ends_with("::Blob::reopen"))
1634 {
1635 return Some("Db");
1636 }
1637 // THE COVERAGE-GATE SWEEP (2026-08-27): same shape again, and the SAME root cause as
1638 // `git2`'s — this `if crate_name == "rusqlite"` block returns unconditionally, so it never
1639 // reaches this file's own `sqlite3_*` FFI-leaf table below even though every one of these
1640 // calls a leaf ALREADY listed there (`sqlite3_backup_init`/`_step`, `sqlite3_blob_read`/
1641 // `_write`). Verified against rusqlite 0.32.1 source. `Backup::new`/`new_with_names` are the
1642 // crate's own online-backup constructors (backup.rs:187,200); `step`/`run_to_completion`
1643 // drive it; `Connection::backup`/`restore` (backup.rs:61,99) are the one-call convenience
1644 // wrappers around the same API and need their OWN rule since a consumer calling THEM never
1645 // names `Backup` at all. `Blob::{read,write}_at[_exact]`/`raw_read_at[_exact]`/
1646 // `write_all_at` (blob/pos_io.rs) are the incremental-BLOB positional-I/O methods, each
1647 // calling `ffi::sqlite3_blob_read`/`_write` directly. `Connection::from_handle`/
1648 // `_owned`/`extension_init2` and the free fn `init_auto_extension` (lib.rs:950,999,966;
1649 // auto_extension.rs:26) wrap a caller-supplied raw `*mut ffi::sqlite3` into a live
1650 // `Connection` — the loadable-extension entry points, same "produces a live connection"
1651 // effect as `Connection::open`, just over a handle the caller already has instead of one
1652 // this call opens itself (the `tokio_postgres::connect_raw`/`sea_orm::connect_proxy` shape).
1653 //
1654 // NOT included: `InnerConnection::*` and `RawStatement::step` LOOK like six more hits (self-
1655 // scan flags them too) but `mod inner_connection;`/`mod raw_statement;` (lib.rs:119,127) are
1656 // PRIVATE — despite `pub struct InnerConnection`/`RawStatement`, neither type has any public
1657 // path a real consumer could ever name or obtain a value of, so no rule was added for them
1658 // (a rule would be dead weight, never reachable). `Context::get_connection` (functions.rs)
1659 // is ALSO left out, deliberately: it returns a `ConnectionRef` accessor to an
1660 // ALREADY-established connection (`ffi::sqlite3_context_db_handle`, not an I/O verb) — the
1661 // same "handle accessor, not a syscall" shape as `TcpStream::local_addr`, not confirmed
1662 // enough to charge without risking the over-charge this file's history has repeatedly warned
1663 // against. THE COVERAGE-GATE SWEEP (2026-08-27) resolved this from "left in the ratchet"
1664 // to a formal call: `REVIEWED_PURE_ENTRIES` now carries `("rusqlite",
1665 // "rusqlite::Context::get_connection")` so the gate stops re-flagging it every refresh.
1666 //
1667 // TWO SPELLINGS EACH for `Backup`/`Blob`/`init_auto_extension`: none of the three is
1668 // re-exported at rusqlite's crate root (unlike `Connection`, declared directly in lib.rs), so
1669 // the ONLY real spelling a consumer can write is the module-qualified one
1670 // (`rusqlite::backup::Backup::new`, `rusqlite::blob::Blob::read_at`,
1671 // `rusqlite::auto_extension::init_auto_extension`) — but the coverage-gate ratchet recorded
1672 // the SHORT, technically-unreachable guess (`generate.py` always adds a bare
1673 // `{crate}::{Type}::{fn}` guess as a possible root-alias, without confirming a `pub use`
1674 // actually exists for it). Both are listed: the short form so this fix closes the exact row
1675 // the ratchet carries, the long form so it fires for what a real consumer's source (proven
1676 // against a compiling fixture) actually contains — dropping the long form would leave the
1677 // real gap open while the bookkeeping said closed.
1678 if crate_name == "rusqlite"
1679 && (path == "rusqlite::Backup::new"
1680 || path == "rusqlite::backup::Backup::new"
1681 || path == "rusqlite::Backup::new_with_names"
1682 || path == "rusqlite::backup::Backup::new_with_names"
1683 || path == "rusqlite::Backup::step"
1684 || path == "rusqlite::backup::Backup::step"
1685 || path == "rusqlite::Backup::run_to_completion"
1686 || path == "rusqlite::backup::Backup::run_to_completion"
1687 || path == "rusqlite::Connection::backup"
1688 || path == "rusqlite::Connection::restore"
1689 || path == "rusqlite::Blob::read_at"
1690 || path == "rusqlite::blob::Blob::read_at"
1691 || path == "rusqlite::Blob::read_at_exact"
1692 || path == "rusqlite::blob::Blob::read_at_exact"
1693 || path == "rusqlite::Blob::raw_read_at"
1694 || path == "rusqlite::blob::Blob::raw_read_at"
1695 || path == "rusqlite::Blob::raw_read_at_exact"
1696 || path == "rusqlite::blob::Blob::raw_read_at_exact"
1697 || path == "rusqlite::Blob::write_at"
1698 || path == "rusqlite::blob::Blob::write_at"
1699 || path == "rusqlite::Blob::write_all_at"
1700 || path == "rusqlite::blob::Blob::write_all_at"
1701 || path == "rusqlite::Connection::from_handle"
1702 || path == "rusqlite::Connection::from_handle_owned"
1703 || path == "rusqlite::Connection::extension_init2"
1704 || path == "rusqlite::init_auto_extension"
1705 || path == "rusqlite::auto_extension::init_auto_extension"
1706 || path == "rusqlite::Statement::insert"
1707 || path == "rusqlite::statement::Statement::insert"
1708 || path == "rusqlite::Statement::raw_execute"
1709 || path == "rusqlite::statement::Statement::raw_execute"
1710 // SOUNDNESS R342 — `Rows::next` STEPS the statement: it is `self.advance()?`
1711 // (`rusqlite-0.39.0/src/row.rs:40`), i.e. `sqlite3_step`, which is where a query's
1712 // rows actually come from. Iterating a result set is the ordinary way to read from
1713 // sqlite and it carried no `Db`. `advance` is listed beside it because it is `pub`
1714 // and is the same call one level down.
1715 || path == "rusqlite::Rows::next"
1716 || path == "rusqlite::row::Rows::next"
1717 || path == "rusqlite::Rows::advance"
1718 || path == "rusqlite::row::Rows::advance")
1719 {
1720 return Some("Db");
1721 }
1722 // THE COVERAGE-GATE REFRESH (2026-10-08), read against rusqlite-0.39.0 source. Transactions,
1723 // savepoints and pragmas are SQL round-trips through `execute_batch`/`query_row`/`prepare`,
1724 // which this block already charges, but a consumer calling the wrapper never names them:
1725 // * `Transaction::new`/`new_unchecked` -> `conn.execute_batch("BEGIN …")` (transaction.rs:125);
1726 // `commit`/`rollback`/`finish` -> `execute_batch("COMMIT"/"ROLLBACK")` (:192,:204,:219);
1727 // `savepoint`/`savepoint_with_name` -> `Savepoint::with_name_` -> `execute_batch("SAVEPOINT …")` (:253).
1728 // * `Savepoint::{new, with_name, savepoint, savepoint_with_name}` (same `SAVEPOINT`), `commit`
1729 // (`RELEASE`, :314), `rollback` (`ROLLBACK TO`, :328), `finish` (:342).
1730 // * `Connection::{transaction_with_behavior, unchecked_transaction, savepoint,
1731 // savepoint_with_name}` (:427,:465,:495,:507) construct the two above.
1732 // * `Connection::pragma_query_value`/`pragma_update_and_check` -> `query_row` (pragma.rs:154,:262),
1733 // `pragma_query`/`pragma` -> `prepare` + rows (:172,:209), `pragma_update` -> `execute_batch` (:238).
1734 // NOT charged: `drop_behavior`/`set_drop_behavior` (a field), and the private `pragma::Sql` builder.
1735 if crate_name == "rusqlite" {
1736 if let Some((ty, m)) = type_method(path, "rusqlite", &["", "transaction", "pragma"]) {
1737 let hit = match ty {
1738 "Transaction" => matches!(m, "new" | "new_unchecked" | "commit" | "rollback" | "finish"
1739 | "savepoint" | "savepoint_with_name"),
1740 "Savepoint" => matches!(m, "new" | "with_name" | "savepoint" | "savepoint_with_name"
1741 | "commit" | "rollback" | "finish"),
1742 "Connection" => matches!(m, "transaction_with_behavior" | "unchecked_transaction"
1743 | "savepoint" | "savepoint_with_name" | "pragma" | "pragma_query"
1744 | "pragma_query_value" | "pragma_update" | "pragma_update_and_check"),
1745 _ => false,
1746 };
1747 if hit {
1748 return Some("Db");
1749 }
1750 }
1751 }
1752 return None;
1753 }
1754 // redis: the way redis is ACTUALLY used is the high-level `Commands`/`AsyncCommands`
1755 // traits (`con.get`/`set`/`hset`/`lpush`/…) — every method is a round-trip — plus
1756 // connection establishment. The shared VERBS below only catch the low-level
1757 // `cmd("GET").query(con)`, so without this a normal redis user's calls classify as
1758 // PURE. (Found hardening on redis-rs: a fn doing `con.get`/`set` reported no effects.)
1759 // SOUNDNESS R335 — the SENTINEL module, which the rules above reach only by accident. redis's
1760 // `sentinel` surface is how a client finds the current master in a failover deployment, and
1761 // every lookup TALKS TO THE SENTINEL SERVERS: `Sentinel::master_for` runs
1762 // `find_master_address` (a `SENTINEL masters` round-trip) and its own doc says "this will
1763 // connect to the master node to verify that it considers itself master"; `get_sentinel_client`
1764 // opens a client per address and issues `ROLE` until one answers. Measured on redis 1.6.0 —
1765 // which is this crate's REVIEWED CEILING, so the calibration exemption applied and every one of
1766 // them read as a positive purity claim, in both the receiver-typed and UFCS spellings.
1767 //
1768 // A DENYLIST, not the enumeration the surrounding rules use, and that is the point. The
1769 // enumerate-the-connecting-verbs shape is what goes stale: a release that ADDS a lookup verb
1770 // adds a SILENCE, the cardinal direction. Inverted, the whole module is `Db` except its
1771 // config surface, so a new verb is charged by default and only a new PURE verb costs a
1772 // fabrication. The exempt set is not guessed: every `pub fn` leaf in redis-1.6.0/src/sentinel.rs
1773 // is one of 33 names, and `build` / `new` / `set_*` are exactly the ones that touch no socket
1774 // (`Sentinel::build` parses URLs via `into_connection_info`; `SentinelClientBuilder::build`
1775 // assembles a config struct). The `contains("::Sentinel")` arm is not the loose match R321
1776 // punished: the module's ENTIRE public surface was enumerated before writing it, and no item
1777 // named `Sentinel*` exists anywhere else in the crate, nor any of these verb names.
1778 if crate_name == "redis"
1779 && (path.starts_with("redis::sentinel::") || path.contains("::Sentinel"))
1780 {
1781 let m = path.rsplit("::").next().unwrap_or(path);
1782 if m == "build" || m == "new" || m.starts_with("set_") {
1783 return None;
1784 }
1785 // §E1 HIT COUNTER (`CANDOR_ALIAS_DEBUG`) for `bin/corpus-ab.py --mark`.
1786 if std::env::var("CANDOR_ALIAS_DEBUG").is_ok() {
1787 eprintln!("R335SENTINEL {path}");
1788 }
1789 return Some("Db");
1790 }
1791 // THE COVERAGE-GATE REFRESH (2026-10-08), read against redis-1.7.1 source. The sync `Connection`'s
1792 // raw protocol pair and the sync `PubSub` reader touch the socket directly:
1793 // `send_packed_command` -> `send_bytes` -> `reader.write_all(bytes)` (connection.rs:1661,:1053);
1794 // `recv_response` -> `read(true)` -> `parser.parse_value(reader)` (:1668,:1850);
1795 // `PubSub::get_message` -> `self.con.read(false)` (:2204). `Net`, not the `Db` the command verbs
1796 // carry: these move bytes, they issue no command. Exact type segment (`type_method`), so
1797 // `MultiplexedConnection`/`ClusterConnection` are not swept in.
1798 if crate_name == "redis" {
1799 if let Some((ty, m)) = type_method(path, "redis", &["", "connection"]) {
1800 if (ty == "Connection" && matches!(m, "send_packed_command" | "recv_response"))
1801 || (ty == "PubSub" && m == "get_message")
1802 {
1803 return Some("Net");
1804 }
1805 }
1806 }
1807 if crate_name == "redis"
1808 && (path.contains("Commands::")
1809 // THE COVERAGE-GATE SWEEP (2026-08-27), a pre-existing over-report found in passing:
1810 // `path.contains("::get_connection")` is a SUBSTRING match, so it also matched
1811 // `Client::get_connection_info` (client.rs:78) — a pure accessor returning an
1812 // already-stored `&ConnectionInfo`, no round-trip at all. Verified against redis 1.6.0.
1813 || (path.contains("::get_connection") && !path.ends_with("::get_connection_info"))
1814 || path.contains("::get_async_connection")
1815 || path.contains("::get_multiplexed_async_connection")
1816 // a live `ConnectionManager` round-trips (Db), but `ConnectionManagerConfig` is a pure
1817 // in-memory builder (set_number_of_retries/set_max_delay) — exclude it (adversarial review).
1818 // `ConnectionManager::clone` is an Arc refcount bump — no Db round-trip (sweep [27]).
1819 || (path.contains("ConnectionManager") && !path.contains("ConnectionManagerConfig")
1820 && !path.ends_with("::clone"))
1821 || path.ends_with("::query")
1822 || path.ends_with("::query_async")
1823 || path.ends_with("::req_command")
1824 || path.ends_with("::req_packed_command")
1825 || path.ends_with("::req_packed_commands"))
1826 {
1827 return Some("Db");
1828 }
1829 // mongodb: a document-store API with none of the SQL verbs — the user calls
1830 // `coll.find_one`/`insert_one`/`aggregate`/… and `Client::with_uri_str`. Without
1831 // these a mongodb user's calls classify PURE. (Found hardening: a fn doing
1832 // `find_one`+`insert_one` reported no effects.) Handle accessors (name/namespace)
1833 // and option/doc builders don't match these verbs, so they stay pure.
1834 //
1835 // THE FIX: `Client::with_options(options)` IS `with_uri_str`'s own body one call down —
1836 // `with_uri_str` (client.rs:179) is literally `ClientOptions::parse(uri).await?;
1837 // Client::with_options(options)`, and `with_options` (client.rs:188) is where the topology/
1838 // monitoring actually spins up. A caller who already holds a `ClientOptions` (built
1839 // programmatically, or via `ClientOptions::parse` called separately) uses `with_options`
1840 // directly — mongodb's OWN alternate entry point for the identical effect, missing from an
1841 // allowlist keyed only on the URI-string spelling. Same shape as `ignore::Walk::new` and
1842 // diesel's `establish`: a sibling constructor of an already-modelled effect. Verified against
1843 // mongodb 3.8.1 source for both the async (`client.rs`) and sync (`sync/client.rs`) `Client`.
1844 if crate_name == "mongodb" {
1845 const MONGO: [&str; 28] = [
1846 "::with_uri_str", "::with_options", "::connect", "::find", "::find_one", "::insert_one",
1847 "::insert_many", "::update_one", "::update_many", "::delete_one",
1848 "::delete_many", "::replace_one", "::aggregate", "::count_documents",
1849 "::estimated_document_count", "::count", "::distinct", "::run_command",
1850 "::find_one_and_update", "::find_one_and_delete", "::find_one_and_replace",
1851 "::list_collections", "::list_collection_names", "::list_databases",
1852 "::list_database_names", "::create_collection", "::create_index", "::watch",
1853 ];
1854 if MONGO.iter().any(|v| path.ends_with(v)) {
1855 return Some("Db");
1856 }
1857 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against mongodb 3.8.1 — the client-side
1858 // field-level encryption (CSFLE) surface, entirely unrelated to the CRUD verb list above.
1859 //
1860 // `ClientEncryption::decrypt` (client/csfle/client_encryption.rs:195) is an ordinary `pub
1861 // async fn` that bottoms out in `CryptExecutor::run_ctx` (client/csfle/state_machine.rs:106),
1862 // a state-machine loop that — depending on state — runs a real `list_collections` query
1863 // against the key-vault database, executes a real command against `mongocryptd` (respawning
1864 // the child process if the connection drops), and fetches data keys from the key vault:
1865 // genuinely Net (and, via mongocryptd respawn, Exec), reached from a manual decrypt call, not
1866 // a guess. Suffix-matched because the real path runs through a re-export hop the ratchet's
1867 // crate-root-alias guess skips: `ClientEncryption` is defined inside `pub(crate) mod csfle;`
1868 // (client.rs:4) and reachable only via `pub use crate::client::csfle::client_encryption;` at
1869 // the crate root (lib.rs:74) — `mongodb::client_encryption::ClientEncryption`, not bare
1870 // `mongodb::ClientEncryption`. The name is specific enough within this crate that a suffix
1871 // match carries no fabrication risk (crate-gated, and `ClientEncryption` exists nowhere else
1872 // in mongodb's own source).
1873 if path.ends_with("ClientEncryption::decrypt") {
1874 return Some("Net");
1875 }
1876 // NOT modelled (removed from open.tsv, not guessed): `CreateDataKey::execute` /
1877 // `Encrypt::execute` — a DIFFERENT false-guess shape from every other row in this pass.
1878 // `CreateDataKey`/`Encrypt` (action/csfle/create_data_key.rs, action/csfle/encrypt.rs) are
1879 // the crate's real, publicly re-exported Action-builder types (`mongodb::action::csfle::
1880 // {CreateDataKey,Encrypt}`) — but the SAME effect self-scan found (the real `run_ctx` call)
1881 // lives in `impl Action for CreateDataKey<'a> { async fn execute(self) -> .. { .. } }`
1882 // (client/csfle/client_encryption/create_data_key.rs:14), which is the INPUT to the
1883 // `#[action_impl]` proc macro, not a real method: the `Action` trait (action.rs:104) declares
1884 // only `optional`, and its own doc comment says the crate's action types are "executed via
1885 // `await` (or `run` if using the sync client)" — the macro consumes this `execute` fn body
1886 // and re-emits it as `IntoFuture::into_future`, so no compiled `CreateDataKey`/`Encrypt`
1887 // value ever has a method literally named `execute` a consumer could call. Self-scan found a
1888 // real function performing a real effect; it is just never reachable under this spelling
1889 // (nor, by construction, under ANY spelling — a consumer's call site is `.await`, not a
1890 // `.execute()` method call, so candor-scan would need to resolve the `IntoFuture` desugaring
1891 // itself to see this at all, a different, deeper question than a classify() rule can answer).
1892 return None;
1893 }
1894 // mysql / mysql_async: the `query`/`exec` families + `get_conn`/`ping` execute
1895 // immediately — no build-then-execute split like sqlx, so matching `::query` is safe
1896 // here. Same DB-verb-dialect gap class as redis/mongodb; calibrated from the Queryable
1897 // API (unit-tested; a real-app repro is the remaining confirmation).
1898 //
1899 // THE FIX: `Conn::new(opts)` is each crate's OWN primary connection constructor — not a
1900 // pool helper like `get_conn`, but the raw handle. `mysql::Conn::new` (conn/mod.rs:342) calls
1901 // `conn.connect_stream()?; conn.connect()?` directly; `mysql_async::Conn::new` (conn/mod.rs:921)
1902 // returns a future that does the same handshake. Real connection establishment, same shape as
1903 // diesel's `establish`/rusqlite's `open*` — but `::new` never appeared in this verb list, so a
1904 // bare `Conn::new(opts)?` (the crate's OWN first doctest example) read pure. Scoped to the
1905 // `Conn::` segment specifically (`::new` alone is far too generic a suffix to key on blindly —
1906 // `Opts`/`Pool`/query-result types in the same two crates have their own pure `new`s).
1907 if matches!(crate_name, "mysql" | "mysql_async") {
1908 if path.ends_with("::Conn::new") {
1909 return Some("Db");
1910 }
1911 const MY: [&str; 16] = [
1912 "::query", "::query_first", "::query_iter", "::query_map", "::query_fold",
1913 "::query_drop", "::exec", "::exec_first", "::exec_iter", "::exec_map",
1914 "::exec_fold", "::exec_drop", "::exec_batch", "::prep", "::ping", "::get_conn",
1915 ];
1916 if MY.iter().any(|v| path.ends_with(v)) {
1917 return Some("Db");
1918 }
1919 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against mysql_async 0.37.0:
1920 // `Conn::from_url` (conn/mod.rs:1059) is `Conn::new(Opts::from_str(url)?)` one hop down —
1921 // the crate's OWN alternate entry point for the identical connect effect `Conn::new` above
1922 // already carries, same shape as `ignore::Walk::new`/diesel's `establish`.
1923 // `WhiteListFsHandler::handle` (local_infile_handler/builtin.rs:58, the `GlobalHandler` impl
1924 // for `LOAD DATA LOCAL INFILE`) opens a real file off the caller-supplied whitelist —
1925 // reachable at the crate root (`pub use self::local_infile_handler::{builtin::
1926 // WhiteListFsHandler, ..}`, lib.rs:500).
1927 if path == "mysql_async::Conn::from_url" {
1928 return Some("Db");
1929 }
1930 if path.ends_with("WhiteListFsHandler::handle") {
1931 return Some("Fs");
1932 }
1933 // NOT modelled (all three removed from open.tsv, not guessed): `mysql::MyTcpBuilder::connect`/
1934 // `Stream::connect_tcp`/`Stream::make_secure` (mysql, not mysql_async — io/tcp.rs, io/mod.rs,
1935 // io/tls/native_tls_io.rs). `mod io;` is PRIVATE in mysql's lib.rs with no re-export of
1936 // `MyTcpBuilder`/`Stream`, so none of the three is a path any external consumer can name —
1937 // the crate's real, externally-reachable connect effect is `Conn::new` above (already
1938 // covered), which calls into this private `io` module internally. Also NOT modelled:
1939 // `mysql_async::PathOrBuf::read` (opts/mod.rs) — `PathOrBuf` is declared in the same private
1940 // `mod opts;` whose OTHER types (`Opts`, `OptsBuilder`, …) ARE individually `pub use`-
1941 // re-exported at the crate root (lib.rs:493) but `PathOrBuf` specifically is not, so it too
1942 // is unreachable — the same private-module shape as tempfile's `imp::` functions above.
1943 return None;
1944 }
1945 // sea_orm: an ORM whose execution is split from building (like sqlx). The query
1946 // BUILDERS (`Entity::find`, `Entity::insert`) are pure; execution happens at `.all`/
1947 // `.one`/`.count`/`.stream` and `Insert/Update/Delete::exec`. The write path via an
1948 // ActiveModel (`model.insert(db)`) executes too — distinguished from the `EntityTrait`
1949 // builder by the trait in the path (`ActiveModelTrait::`). (Found hardening on a
1950 // sea_orm consumer app: `.all(db)` reads and `ActiveModel::insert` writes were pure.)
1951 if crate_name == "sea_orm" {
1952 // sea_orm RE-EXPORTS sea_query (`sea_orm::sea_query::…`), whose builder algebra collides with
1953 // the execution verbs: `Func::count(col)` builds a COUNT() expr, `Condition::all()` AND-groups
1954 // filters, `Expr::count(…)` — all PURE, none touch a db. The `::all`/`::count`/`::one` execution
1955 // rule fabricated Db on them (sweep [5]). sea_query is pure query construction end-to-end, so
1956 // exclude the whole re-exported namespace first.
1957 if path.contains("sea_query") {
1958 return None;
1959 }
1960 if path.ends_with("::all")
1961 || path.ends_with("::one")
1962 || path.ends_with("::count")
1963 || path.ends_with("::stream")
1964 || path.ends_with("::exec")
1965 || path.ends_with("::exec_with_returning")
1966 || path.ends_with("::exec_without_returning")
1967 || path.ends_with("::connect")
1968 // `Database::connect_proxy` (database/mod.rs:139, behind the `proxy` feature) is
1969 // `Database::connect`'s sibling for a caller-supplied `ProxyDatabaseTrait` backend —
1970 // same effect (produces a live `DatabaseConnection`), different verb, missing from an
1971 // allowlist keyed on the plain `connect` spelling.
1972 || path.ends_with("::connect_proxy")
1973 || path.ends_with("::execute")
1974 || path.ends_with("::execute_unprepared")
1975 || path.ends_with("::query_one")
1976 || path.ends_with("::query_all")
1977 || path.ends_with("::fetch_page")
1978 || path.ends_with("::num_items")
1979 || path.contains("ActiveModelTrait::")
1980 {
1981 return Some("Db");
1982 }
1983 // THE COVERAGE-GATE SWEEP (2026-08-27): sea_orm's TRANSACTION and PAGINATION families,
1984 // verified against sea-orm 1.1.20 source — each of these was found effectful by self-scan
1985 // under no guessed spelling this allowlist already covered.
1986 //
1987 // `DatabaseConnection::transaction`/`transaction_with_config` (db_connection.rs:302,345, the
1988 // `TransactionTrait` impl) is the crate's documented callback-transaction API
1989 // (`db.transaction(|txn| ...)`) — every sqlx-backed match arm dispatches to the
1990 // `SqlxXxxPoolConnection::transaction` methods FQN-listed below, a real BEGIN. FQN-exact
1991 // (not a bare `::transaction` suffix) because `MockDatabaseConnection`/`ProxyDatabaseConnection`
1992 // (driver/mock.rs, driver/proxy.rs, both feature-gated) have their OWN `begin`/`commit`/
1993 // `rollback`/`ping`/`transaction` methods sharing every one of these verb names — a mock
1994 // backend performs no real I/O by construction, and a caller-supplied proxy is a callback
1995 // boundary, not a provable effect; a bare suffix would fabricate Db on both.
1996 if path == "sea_orm::DatabaseConnection::transaction"
1997 || path == "sea_orm::DatabaseConnection::transaction_with_config"
1998 // BONUS, found while proving the above reachable with a real consumer fixture (not in
1999 // the original coverage-gate ratchet — self-scan's own reachability pass didn't flag
2000 // these, apparently because it doesn't track an enum match arm's payload binding as a
2001 // typed receiver, but they dispatch through the identical match-on-`self` shape as
2002 // `transaction` two lines up): `DatabaseConnection::ping` (db_connection.rs:462) and the
2003 // `TransactionTrait::begin`/`begin_with_config` impl (db_connection.rs:246,268) — a fixture
2004 // proved `ping` silently read pure with no rule at all.
2005 || path == "sea_orm::DatabaseConnection::ping"
2006 || path == "sea_orm::DatabaseConnection::begin"
2007 || path == "sea_orm::DatabaseConnection::begin_with_config"
2008 // `DatabaseTransaction::commit`/`rollback` (transaction.rs:119,159) call
2009 // `<sqlx::X as sqlx::Database>::TransactionManager::commit`/`rollback` directly — real
2010 // COMMIT/ROLLBACK, not sea_orm's OWN `::begin`/`::run` (both `pub(crate)`, unreachable).
2011 || path == "sea_orm::DatabaseTransaction::commit"
2012 || path == "sea_orm::DatabaseTransaction::rollback"
2013 // The three sqlx-backed pool connections' OWN `begin`/`ping`/`transaction` (driver/
2014 // sqlx_{mysql,postgres,sqlite}.rs) — `begin`/`transaction` acquire a pool connection and
2015 // start a real transaction, `ping` round-trips `conn.ping()` to the server.
2016 || path == "sea_orm::SqlxMySqlPoolConnection::begin"
2017 || path == "sea_orm::SqlxMySqlPoolConnection::ping"
2018 || path == "sea_orm::SqlxMySqlPoolConnection::transaction"
2019 || path == "sea_orm::SqlxPostgresPoolConnection::begin"
2020 || path == "sea_orm::SqlxPostgresPoolConnection::ping"
2021 || path == "sea_orm::SqlxPostgresPoolConnection::transaction"
2022 || path == "sea_orm::SqlxSqlitePoolConnection::begin"
2023 || path == "sea_orm::SqlxSqlitePoolConnection::ping"
2024 || path == "sea_orm::SqlxSqlitePoolConnection::transaction"
2025 // `Paginator::fetch`/`fetch_and_next`/`into_stream`/`num_pages`/`num_items_and_pages`
2026 // (executor/paginator.rs) all transitively reach `fetch_page`/`num_items` — already-
2027 // covered verbs — but each is ALSO a documented entry point a real consumer calls
2028 // directly (`cake::Entity::find().paginate(db, 50).into_stream()`, the crate's own
2029 // pagination doc example) and needs its own rule for the same reason
2030 // `Connection::backup` needed one beside `Backup::new` in rusqlite.
2031 || path == "sea_orm::Paginator::fetch"
2032 || path == "sea_orm::Paginator::fetch_and_next"
2033 || path == "sea_orm::Paginator::into_stream"
2034 || path == "sea_orm::Paginator::num_pages"
2035 || path == "sea_orm::Paginator::num_items_and_pages"
2036 // `Insert`/`Inserter`/`TryInsert::exec_with_returning_keys`/`_many` (executor/insert.rs)
2037 // are sibling spellings of the already-covered `::exec_with_returning` (same shape as
2038 // rusqlite's `open_with_flags_and_vfs` beside `open_with_flags`): each calls through to
2039 // the same underlying `exec_with_returning_keys`/`_many` executor, a real INSERT.
2040 || path.ends_with("::exec_with_returning_keys")
2041 || path.ends_with("::exec_with_returning_many")
2042 {
2043 return Some("Db");
2044 }
2045 return None;
2046 }
2047 // (Reached by sqlx + diesel — the build-vs-execute-split crates.) `first` is diesel's
2048 // LIMIT-1 round trip and `load_iter` its 2.x streaming execution; `fetch_many` is sqlx's
2049 // multi-result stream. All crate-gated, so a std `Vec::first` never resolves here.
2050 //
2051 // `establish` is diesel's OWN name for `::connect` — `Connection::establish(url)`
2052 // (connection/mod.rs:243), implemented by opening the real backend handle in
2053 // `SqliteConnection`/`PgConnection`/`MysqlConnection::establish` (sqlite/connection/mod.rs:230,
2054 // pg/connection/mod.rs:176, mysql/connection/mod.rs:158 — each does the real file-open/socket-
2055 // connect). It is diesel's canonical, and by far most common, connection entry point — every
2056 // diesel quickstart opens with it — yet it shares no verb spelling with `::connect`, so it fell
2057 // through this allowlist to `None`, and because `diesel` IS a CALIBRATED_CRATES entry the miss
2058 // produced NO `coverage.uncovered` disclosure either (that ledger is crate-level: a calibrated
2059 // crate's unmatched path reads as reviewed-pure, not as a gap) — the same silent-purity shape as
2060 // `ignore::Walk::new` above, one call away from `establish_test_transaction`/`establish_inner`,
2061 // which are diesel's own private plumbing and stay unmatched (no `::` in the allowed suffix keeps
2062 // this exact-verb, not a substring match).
2063 const VERBS: [&str; 20] = [
2064 "::execute", "::query_row", "::query_map", "::query_one", "::fetch_one",
2065 "::fetch_all", "::fetch_optional", "::fetch", "::fetch_many", "::connect",
2066 "::acquire", "::begin", "::commit", "::rollback", "::load", "::load_iter",
2067 "::first", "::get_result", "::get_results", "::establish",
2068 ];
2069 if VERBS.iter().any(|v| path.ends_with(v)) {
2070 return Some("Db");
2071 }
2072 return None;
2073 }
2074 // std::path::Path / PathBuf STAT-family methods hit the filesystem (each is a stat/readlink/
2075 // readdir syscall) — unlike the rest of the std::path surface, which is pure string manipulation
2076 // (join/file_name/extension/parent/…). Verb-precise so the scanner's receiver inference can safely
2077 // route a `path.symlink_metadata()` method call here. (A blackout screen caught gix-dir — an entire
2078 // directory WALKER — reporting ZERO Fs because all its I/O is Path-method calls; same class as
2079 // fd's residual `Path::symlink_metadata` under-report.)
2080 if let Some(m) = path
2081 .strip_prefix("std::path::Path::")
2082 .or_else(|| path.strip_prefix("std::path::PathBuf::"))
2083 {
2084 const STAT: &[&str] = &[
2085 "metadata", "symlink_metadata", "canonicalize", "read_link", "read_dir", "exists",
2086 "try_exists", "is_file", "is_dir", "is_symlink",
2087 ];
2088 return STAT.contains(&m).then_some("Fs");
2089 }
2090 // OPTION-BUILDERS, the shape SPEC §1 ⟨0.32⟩ separates from an invocation object: "option-builders for
2091 // other effects (`OpenOptions`, request builders) stay pure because their resource arrives at the
2092 // terminal verb, which is charged at its own call site". An `OpenOptions` holds a handful of bools and
2093 // names no file; `DirBuilder` holds one bool and a mode. Both sit under the coarse `std::fs::` prefix
2094 // below, which charged Fs for `OpenOptions::new()` and for every `o.read(true)` — MEASURED: a
2095 // `let o = OpenOptions::new().read(true);` with no `open` ANYWHERE reported `Fs`.
2096 //
2097 // A DENYLIST, and keyed on the TYPE. Only the provably-pure setters/ctors are subtracted; anything
2098 // else under the type keeps its effect, so the terminal verb (`OpenOptions::open`, `DirBuilder::create`)
2099 // stays Fs and a std addition we have not read about fails in the safe direction. The type key is not
2100 // decoration: `create` is a pure flag SETTER on `OpenOptions` and the mkdir SYSCALL on `DirBuilder` —
2101 // one leaf, opposite answers. (candor-java keys the same carve-out by DESCRIPTOR, because its collision
2102 // is an overload — `command()` reads back, `command(List)` sets. Rust has no overloads, so the type IS
2103 // the discriminator; a bare-name denylist would silence `DirBuilder::create`.)
2104 if let Some(m) = path.strip_prefix("std::fs::OpenOptions::") {
2105 const PURE: &[&str] = &[
2106 // the builder itself + the std flag setters
2107 "new", "read", "write", "append", "truncate", "create", "create_new",
2108 // the platform extension setters (`OpenOptionsExt`, unix + windows)
2109 "mode", "custom_flags", "security_qos_flags", "access_mode", "share_mode", "attributes",
2110 // derives
2111 "clone", "default", "fmt", "eq", "ne", "hash",
2112 ];
2113 return (!PURE.contains(&m)).then_some("Fs");
2114 }
2115 if let Some(m) = path.strip_prefix("std::fs::DirBuilder::") {
2116 const PURE: &[&str] = &["new", "recursive", "mode", "clone", "default", "fmt"];
2117 return (!PURE.contains(&m)).then_some("Fs");
2118 }
2119 // THE PLATFORM `fs` MODULES — `std::os::{unix,windows,wasi}::fs`. Measured 2026-09-02, ground truth
2120 // EXECUTED (a `cargo run` that really created a symlink on disk and then stat'd it back):
2121 // `std::os::unix::fs::symlink`, `chown`, `lchown` and `chroot` all read PURE, so `deny Fs` over a
2122 // crate whose only filesystem write is a symlink exited 0. The `std::fs::` prefix below is the whole
2123 // filesystem rule and these modules are simply not under it — the platform-specific half of std's
2124 // filesystem API had no rule at all. (Found tracing why `tokio::fs::symlink` carries no `Fs` of its
2125 // own; its body is `asyncify(move || std::os::unix::fs::symlink(..))`.)
2126 //
2127 // A DENYLIST keyed on the TRAIT, exactly like `OpenOptions`/`DirBuilder` above and for the same
2128 // reason: subtract only the provably-pure surfaces, so a std addition nobody here has read about
2129 // fails in the SAFE direction. What is subtracted, and why each is pure:
2130 // * `MetadataExt` / `DirEntryExt` / `FileTypeExt` — accessors over data ALREADY fetched by the
2131 // `metadata()`/`read_dir()` call that is charged at its own site. `m.uid()` issues no syscall.
2132 // * `PermissionsExt` — reads/sets bits on an in-memory `Permissions`; the syscall is
2133 // `set_permissions`, charged under `std::fs::`.
2134 // * `OpenOptionsExt` / `DirBuilderExt` — SPEC §1 ⟨0.32⟩ option-builders: the resource arrives at
2135 // the terminal verb (`open`/`create`), which is charged at its own call site.
2136 // `FileExt` is deliberately NOT subtracted — `read_at`/`write_at`/`seek_read`/`seek_write` are the
2137 // positional-I/O syscalls and are exactly what this rule must keep.
2138 if let Some(rest) = path
2139 .strip_prefix("std::os::unix::fs::")
2140 .or_else(|| path.strip_prefix("std::os::windows::fs::"))
2141 .or_else(|| path.strip_prefix("std::os::wasi::fs::"))
2142 {
2143 const PURE_TRAITS: &[&str] = &[
2144 "MetadataExt::", "DirEntryExt::", "DirEntryExt2::", "FileTypeExt::",
2145 "PermissionsExt::", "OpenOptionsExt::", "DirBuilderExt::",
2146 ];
2147 return (!PURE_TRAITS.iter().any(|t| rest.starts_with(t))).then_some("Fs");
2148 }
2149 // THE PLATFORM `process` MODULES — `std::os::{unix,windows,wasi}::process`. SOUNDNESS R141, the
2150 // EXACT SIBLING of the `fs` rule above, and unruled for the same reason it was: R130 closed
2151 // `std::os::*::fs` and translated that question across ENGINES while never translating it across
2152 // SIBLING MODULES of the namespace it had just found unruled.
2153 //
2154 // Ground truth EXECUTED when the row was filed: a consumer printed `before`, then
2155 // `EXECED-THROUGH-LIB`, and never reached the following line — `CommandExt::exec` is `execvp` and
2156 // really does replace the process image. Re-measured at HEAD 2026-09-27 on a 5-function fixture:
2157 // `CommandExt::exec(c)`, `std::os::unix::process::CommandExt::exec(c)` and
2158 // `fn f<C: CommandExt>(c: &mut C) { c.exec() }` were all ABSENT from `functions[]` — no `Unknown`,
2159 // no `unresolved` — and `deny Exec`, `pure` and `deny Exec Unknown` all exited 0, while on the SAME
2160 // tree the RECEIVER spelling `c.exec()` exited 1 and the sibling-namespace UFCS control
2161 // `FileExt::read_at(f, b, 0)` exited 1. So this is the one-spelling-of-two shape of R130 itself:
2162 // `c.exec()` was only ever charged because the receiver types to `std::process::Command` and the
2163 // whole-type rule below answers; nothing answered for the trait-path spelling.
2164 //
2165 // A DENYLIST keyed on the TRAIT, exactly like the `fs` rule and for the same reason — subtract only
2166 // the provably-pure surfaces so a std addition nobody here has read about fails in the SAFE
2167 // direction. What is subtracted, and why each is pure:
2168 // * `ExitStatusExt` / `ExitStatusError` / `ExitCodeExt` — accessors and constructors over an
2169 // ALREADY-COLLECTED status (`signal()`, `core_dumped()`, `from_raw(n)`). The wait that produced
2170 // it is charged at its own site under `std::process::Child`.
2171 // * `parent_id` — `getppid()`, a read of this process's own identity, no spawn. Kept at PARITY
2172 // with `std::process::id`, which carries no rule either; subtracting it here rather than
2173 // leaving it to the Exec cliff is the one place this rule departs from "unrecognised keeps
2174 // Exec", and it is named rather than folded in.
2175 // Everything else keeps Exec: `CommandExt::{exec, pre_exec, arg0, uid, gid, groups,
2176 // process_group}` and the windows `CommandExt::{creation_flags, raw_arg, …}` / `ChildExt`. That is
2177 // also what the receiver spelling already answers — `c.uid(0)` reports `['Exec']` at HEAD — so this
2178 // rule makes the two spellings agree rather than inventing a new verdict for either.
2179 if let Some(rest) = path
2180 .strip_prefix("std::os::unix::process::")
2181 .or_else(|| path.strip_prefix("std::os::windows::process::"))
2182 .or_else(|| path.strip_prefix("std::os::wasi::process::"))
2183 {
2184 const PURE: &[&str] =
2185 &["ExitStatusExt::", "ExitStatusError::", "ExitCodeExt::", "parent_id"];
2186 return (!PURE.iter().any(|t| rest.starts_with(t))).then_some("Exec");
2187 }
2188 // Filesystem. `tokio::fs`/`async_std::fs` are the async mirrors of `std::fs`; `async_fs` is
2189 // smol's fs crate; `fs_err` is a drop-in `std::fs` wrapper (its whole surface is fs I/O).
2190 if path.starts_with("std::fs::")
2191 || path.starts_with("tokio::fs::")
2192 || path.starts_with("async_std::fs::")
2193 || crate_name == "async_fs"
2194 || crate_name == "fs_err"
2195 {
2196 return Some("Fs");
2197 }
2198 // memmap2: only `MmapOptions::map*` (and the in-place `Mmap::flush`/`make_*` protection
2199 // changes / `remap`) actually issue the mmap/msync/mprotect/mremap syscall = Fs. The rest of the
2200 // crate is PURE: `MmapOptions::new`/setters BUILD the request, and once a region is mapped, reads
2201 // over it (`Mmap::len`/`is_empty`/`as_ptr`/`as_mut_ptr`/`deref` into the byte slice) are plain
2202 // memory access with no syscall. Whole-crate Fs fabricated Fs on those reads (a `m.len()` the
2203 // scanner's receiver inference routes to `memmap2::Mmap::len`). Match the syscall-issuing verbs;
2204 // everything else returns None (pure). `map*` covers `map`/`map_mut`/`map_exec`/`map_copy`/
2205 // `map_copy_read_only`/`map_raw`/`map_raw_read_only`/`map_anon`.
2206 if crate_name == "memmap2" {
2207 let m = path.rsplit("::").next().unwrap_or(path);
2208 if m.starts_with("map")
2209 || m == "flush"
2210 || m == "flush_async"
2211 || m == "flush_range"
2212 || m == "flush_async_range"
2213 || m == "remap"
2214 || m.starts_with("make_")
2215 || m == "advise"
2216 || m == "advise_range"
2217 || m == "lock"
2218 || m == "unlock"
2219 {
2220 return Some("Fs");
2221 }
2222 return None;
2223 }
2224 // tempfile: creating a temp file/dir touches the disk. Match the create/persist verbs (the
2225 // `Builder` setters — prefix/suffix/rand_bytes — stay pure). `persist`/`keep` rename/retain
2226 // the file on disk; `close` removes it.
2227 if crate_name == "tempfile"
2228 && (path.ends_with("::tempfile")
2229 || path.ends_with("::tempfile_in")
2230 || path.ends_with("::tempdir")
2231 || path.ends_with("::tempdir_in")
2232 || path.ends_with("NamedTempFile::new")
2233 || path.ends_with("NamedTempFile::new_in")
2234 || path.ends_with("TempDir::new")
2235 || path.ends_with("TempDir::new_in")
2236 || path.ends_with("::persist")
2237 || path.ends_with("::persist_noclobber")
2238 || path.ends_with("::keep"))
2239 {
2240 return Some("Fs");
2241 }
2242 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against tempfile 3.27.0 source — the SIBLING
2243 // constructors/closers the create/persist verb list above never reached:
2244 //
2245 // `Builder::make`/`make_in` (lib.rs:716,739) are the crate's OWN documented escape hatch for a
2246 // caller-supplied factory (`Builder::new().make_in(dir, |path| UnixListener::bind(path))`) — real
2247 // disk creation via `util::create_helper`, same mechanism as `Builder::tempfile`/`tempfile_in`
2248 // two lines up, just a different entry verb.
2249 // `NamedTempFile::with_prefix`/`with_prefix_in`/`with_suffix`/`with_suffix_in` (file/mod.rs:630-677)
2250 // are one-call convenience wrappers documented as equivalent to `Builder::new().prefix(..).tempfile()`
2251 // — real creation, missing because the verb list matched `NamedTempFile::new`/`new_in` but not these.
2252 // `NamedTempFile::reopen` (file/mod.rs:951) calls the platform `imp::reopen`, a real `open`/`fstat`
2253 // pair (found the original file was replaced, or hands back a fresh handle to it).
2254 // `TempPath::close` (file/mod.rs:161) and `TempDir::close` (dir/mod.rs:470) `fs::remove_file`/
2255 // `remove_dir_all` the real path — `NamedTempFile::close` (file/mod.rs:727) is a thin `self.path.close()`
2256 // delegate to the same `TempPath::close`, so matching the `::close` suffix (crate-gated, and the ONLY
2257 // three `pub fn close` in this crate) catches it too without a separate rule.
2258 // `SpooledTempFile::set_len` (spooled.rs:134) calls `File::set_len` on the real disk file once the
2259 // in-memory buffer has spilled past `max_size` — the crate's own roll-over threshold, not a corner case.
2260 //
2261 // NOT included: `tempfile::create`/`create_named`/`reopen` (dir/imp/any.rs, file/imp/windows.rs,
2262 // file/imp/unix.rs) — despite being `pub fn`, `mod dir;`/`mod file;` are PRIVATE at the crate root
2263 // (lib.rs:202,204) with no re-export of the `imp` submodules, so no external consumer can ever name
2264 // these paths; the ratchet's generator guesses a crate-root alias for every candidate regardless of
2265 // whether one is real (the same "second, related shape" gap the previous pass's rusqlite/lettre
2266 // private-module findings already documented — restricted_types()/is_bare_pub check a type's or fn's
2267 // OWN visibility keyword, not its enclosing module chain). Removed from open.tsv rather than guessed.
2268 if crate_name == "tempfile"
2269 && (path == "tempfile::Builder::make"
2270 || path == "tempfile::Builder::make_in"
2271 || path == "tempfile::NamedTempFile::with_prefix"
2272 || path == "tempfile::NamedTempFile::with_prefix_in"
2273 || path == "tempfile::NamedTempFile::with_suffix"
2274 || path == "tempfile::NamedTempFile::with_suffix_in"
2275 || path == "tempfile::NamedTempFile::reopen"
2276 || path == "tempfile::SpooledTempFile::set_len"
2277 || path.ends_with("::close"))
2278 {
2279 return Some("Fs");
2280 }
2281 // glob: walks the filesystem to expand a pattern (the returned iterator reads directories).
2282 // `Pattern::matches` is pure string matching — match only the directory-walking entry points.
2283 if crate_name == "glob" && (path.ends_with("::glob") || path.ends_with("::glob_with")) {
2284 return Some("Fs");
2285 }
2286 // Password-hashing / KDF crates — the entropy tier (the TS engine's CTA lesson: an invisible
2287 // argon2 landed on exactly the call a security review cares about). In this engine's
2288 // verb-precise style the ENTROPY is the salt mint: `SaltString::generate(OsRng)` in the
2289 // password-hash API family, and bcrypt's `hash`/`hash_with_result` (salt minted internally).
2290 // Verification and explicit-salt hashing are deterministic recomputation — pure. `rand_core`
2291 // carries the OsRng source itself (otherwise the most common salt mint is invisible).
2292 if matches!(crate_name, "argon2" | "scrypt" | "pbkdf2" | "password_hash") {
2293 // SOUNDNESS R318 — `SaltString::generate` was this block's ONLY match target and the type was
2294 // DELETED upstream in password-hash 0.6.0 (0.5.0: 21 occurrences; 0.6.1: zero). Everything below
2295 // it fell through to `return None`, so all four crates asserted purity — including on
2296 // `argon2.hash_password(password)`, the one-argument DEFAULT TRAIT METHOD at
2297 // `password-hash-0.6.1/src/lib.rs:107` whose body is `let salt = try_generate_salt()?;`. That is
2298 // the most documented call shape in the family, minting OS entropy, reading pure, on crates whose
2299 // entire purpose is a security review.
2300 //
2301 // `ends_with`, NOT `contains`: `hash_password_with_salt` takes an explicit salt and is
2302 // deterministic recomputation — correctly PURE — and a `contains` match would fabricate `Rand`
2303 // on it. `hash_password_with_rng` DOES draw, from the caller's RNG, so it is charged.
2304 // The old `SaltString::generate` clause is kept: it is still the right answer for 0.5.x.
2305 if path.contains("SaltString::generate")
2306 || path.ends_with("::generate_salt")
2307 || path.ends_with("::try_generate_salt")
2308 // SOUNDNESS R330 — this suffix is a DEFAULT, refined by arity at the call site. `hash_password`
2309 // is one name with two signatures (see `password_hash_explicit_salt` below), and the path
2310 // cannot tell them apart. Charging here and NARROWING there is deliberate: a consumer that
2311 // never consults the refinement keeps the over-report, never the silence.
2312 || path.ends_with("::hash_password")
2313 || path.ends_with("::hash_password_with_rng")
2314 {
2315 return Some("Rand");
2316 }
2317 return None;
2318 }
2319 if crate_name == "bcrypt" {
2320 // SOUNDNESS R319 — this list was EXACTLY bcrypt's internal-salt surface at 0.15.1 and fell
2321 // behind by ADDITION, not by rename: 0.19.3 adds four more verbs that each document "the salt
2322 // is generated randomly using the OS randomness" and reach `getrandom::fill`. A stale-PATH
2323 // check could never have found this, because every old path still exists and still works —
2324 // a version ceiling catches additions, a path check only catches removals.
2325 // The explicit-salt family (`hash_with_salt`, `hash_with_salt_bytes`,
2326 // `non_truncating_hash_with_salt*`) stays PURE: the caller supplies the salt.
2327 if path.ends_with("::hash")
2328 || path.ends_with("::hash_with_result")
2329 || path.ends_with("::non_truncating_hash")
2330 || path.ends_with("::hash_bytes")
2331 || path.ends_with("::non_truncating_hash_bytes")
2332 || path.ends_with("::non_truncating_hash_with_result")
2333 {
2334 return Some("Rand");
2335 }
2336 return None;
2337 }
2338 if crate_name == "rand_core" {
2339 if path.contains("OsRng")
2340 || path.ends_with("::next_u32")
2341 || path.ends_with("::next_u64")
2342 || path.ends_with("::fill_bytes")
2343 // SOUNDNESS R333 — the FALLIBLE trait. rand_core 0.10 splits `RngCore` from `TryRngCore`,
2344 // and `SysRng` (the renamed `OsRng`) is fallible, so `try_next_u32` is the natural API on
2345 // it. `ends_with("::next_u32")` does NOT match `::try_next_u32`, so the modern spelling of
2346 // a real entropy draw read PURE. Third instance in one day, after R319 and R321, of the
2347 // same shape: an upstream rename is rarely just a rename — it arrives with an API SHAPE
2348 // change, and a suffix list written against the old shape stops covering the new one.
2349 || path.ends_with("::try_next_u32")
2350 || path.ends_with("::try_next_u64")
2351 || path.ends_with("::try_fill_bytes")
2352 {
2353 return Some("Rand");
2354 }
2355 return None;
2356 }
2357 // Randomness / entropy. `getrandom`/`fastrand` are effectful end-to-end. `rand` is NOT — it
2358 // mixes entropy/generation (effectful) with *pure* distribution constructors (`Uniform::new`,
2359 // `Normal::new`) and deterministic-seed constructors (`seed_from_u64`). Flagging the whole crate
2360 // over-reported those as `Rand`; match only the calls that actually consume randomness — the
2361 // entropy sources (`OsRng`, `thread_rng`/`rng`, `from_entropy`/`from_os_rng`) and the generation
2362 // verbs (`gen*`/`random*`/`fill*`/`sample*`/`next_u*`). A `Uniform::new` is now correctly pure.
2363 if crate_name == "getrandom" {
2364 // SOUNDNESS R1150 — …except its ERROR VALUE. `getrandom::Error` is a wrapped error code; its own methods
2365 // (`raw_os_error`, `code`, `new_custom`, the derived `eq`/`hash`, `Debug`/`Display`) read or format that
2366 // code and draw nothing. Charged with the crate, rand_core 0.9.x's DERIVED `OsError::eq` read `Rand`
2367 // (EXECUTED: no draw happens). Every drawing entry point (`fill`, `fill_uninit`, `u32`, `u64`,
2368 // `getrandom`, `SysRng`'s methods) is not on `Error`, and keeps `Rand`.
2369 // `&Error` is the by-reference operator key (`getrandom::&Error::eq`, R1131).
2370 if path.split("::").skip(1).any(|s| s.trim_start_matches('&') == "Error") {
2371 return None;
2372 }
2373 return Some("Rand");
2374 }
2375 // fastrand: like `rand`, it mixes entropy-consuming generation (effectful) with PURE deterministic
2376 // pieces. `Rng::with_seed(42)` is a DETERMINISTIC seeded constructor (consumes no entropy — the same
2377 // seed gives the same stream), and `Rng::fork`/`Rng::clone` just split/copy existing state. Those are
2378 // PURE; whole-crate Rand fabricated Rand on them. The effect is the value-drawing methods (`u32`/
2379 // `usize`/`bool`/`f64`/`char`/`alphanumeric`/`choice`/`choose_multiple`/`shuffle`/`fill`/the range
2380 // forms) AND the entropy-seeded entry points: bare `Rng::new()` (seeds from the global entropy-backed
2381 // generator), `fastrand::seed`, and the top-level `fastrand::u32(..)` free functions (which draw from
2382 // the thread-local generator). `with_seed` is exempted explicitly; any other method on an `Rng`
2383 // (i.e. a value draw) is Rand.
2384 if crate_name == "fastrand" {
2385 let m = path.rsplit("::").next().unwrap_or(path);
2386 // Provably pure: deterministic seeded ctor + state split/copy.
2387 if m == "with_seed" || m == "fork" || m == "clone" {
2388 return None;
2389 }
2390 // Everything else fastrand exposes either draws a value or seeds from entropy → Rand. (The crate
2391 // has no pure data types beyond the `Rng` handle itself, so a non-draw stray would have to be a
2392 // method we don't recognise — keep the effect, the safe direction.)
2393 return Some("Rand");
2394 }
2395 if crate_name == "rand" {
2396 let rng_verb = path.ends_with("::gen")
2397 || path.ends_with("::gen_range")
2398 || path.ends_with("::gen_bool")
2399 || path.ends_with("::gen_ratio")
2400 || path.ends_with("::random")
2401 || path.ends_with("::random_range")
2402 || path.ends_with("::random_bool")
2403 || path.ends_with("::random_ratio")
2404 || path.ends_with("::random_iter") // rand 0.9 iterator generator
2405 || path.ends_with("::gen_iter")
2406 || path.ends_with("::fill")
2407 || path.ends_with("::fill_bytes")
2408 || path.ends_with("::try_fill")
2409 || path.ends_with("::try_fill_bytes")
2410 || path.ends_with("::sample")
2411 || path.ends_with("::sample_iter")
2412 || path.ends_with("::next_u32")
2413 || path.ends_with("::next_u64")
2414 // SOUNDNESS R333 — the FALLIBLE trait, in the `rand` block as well as `rand_core`'s. Two
2415 // verb lists answer one question ("is this a draw?") and only one had been widened, which
2416 // is why the first half of this fix moved nothing: correct code on a path the call does not
2417 // take. `TryRngCore`'s verbs are the natural API on the fallible `SysRng`.
2418 || path.ends_with("::try_next_u32")
2419 || path.ends_with("::try_next_u64")
2420 || path.ends_with("::thread_rng")
2421 || path.ends_with("::rng")
2422 || path.ends_with("::from_entropy")
2423 || path.ends_with("::from_os_rng");
2424 // `OsRng` is the OS entropy SOURCE, but `clone`/`fork`/`default` just copy or construct the
2425 // (zero-sized) handle and draw no entropy — pure, exactly like the `fastrand` arm's clone/fork
2426 // exemption above. The actual draws (`fill_bytes`/`next_u*`/…) are caught by `rng_verb`. Without
2427 // this exemption the blanket `contains("OsRng")` fabricated `Rand` on `OsRng::clone` (adversarial
2428 // review: OsRng is a unit struct, cloning consumes nothing).
2429 let m = path.rsplit("::").next().unwrap_or(path);
2430 let os_rng = path.contains("OsRng") && !matches!(m, "clone" | "fork" | "default");
2431 // SOUNDNESS R320 — …and the SAME clause for the type's CURRENT NAME. rand 0.10 renamed the OS
2432 // entropy source to `SysRng` and moved it to `getrandom`, re-exporting it as
2433 // `rand::rngs::SysRng` (`rand-0.10.2/src/rngs/mod.rs:119`). Measured, not assumed: candor
2434 // resolves the crate from the path the CONSUMER writes, so `rand::rngs::SysRng` arrives with
2435 // crate_name `rand` and lands here — NOT in getrandom's whole-crate exemption, which is what
2436 // R320 left open. The asymmetry that leaves was executed on a fixture: with `OsRng` as the
2437 // receiver an unrecognised verb charges `Rand` (the `contains` clause is the safety margin over
2438 // the verb list); with `SysRng` it read PURE. So the margin protected the DELETED spelling and
2439 // not its replacement, which is the wrong way round for a type whose only purpose is to draw
2440 // from the OS. Same construction exemption for the same reason — `SysRng` is a unit struct
2441 // (`getrandom-0.4.3/src/sys_rng.rs:34`), so copying the handle consumes nothing.
2442 let sys_rng = path.contains("SysRng") && !matches!(m, "clone" | "fork" | "default");
2443 // §E1 HIT COUNTER (`CANDOR_ALIAS_DEBUG`), for `bin/corpus-ab.py --mark`: a widened charge that
2444 // moves no rows is either inert or unreached, and those two print identically.
2445 if sys_rng && std::env::var("CANDOR_ALIAS_DEBUG").is_ok() {
2446 eprintln!("R320SYSRNG {path}");
2447 }
2448 if rng_verb || os_rng || sys_rng {
2449 return Some("Rand");
2450 }
2451 return None;
2452 }
2453 // Subprocess spawning. `tokio::process` is the async mirror of `std::process` — it exists
2454 // only to spawn/control subprocesses (`Command`/`Child`, no pure data types like std's
2455 // `Stdio`/`ExitStatus`/`exit`), so spawning through it is Exec just the same. Without this an
2456 // async app's `tokio::process::Command::new(..).spawn()` classified pure — a silent under-report
2457 // of subprocess execution, the dangerous direction (mirrors the tokio::fs/tokio::net coverage).
2458 if path.starts_with("std::process::Command")
2459 || path.starts_with("std::process::Child")
2460 || path.starts_with("tokio::process::Command")
2461 || path.starts_with("tokio::process::Child")
2462 || path.starts_with("async_std::process::Command")
2463 || path.starts_with("async_std::process::Child")
2464 {
2465 // PURE read-backs of the builder's stored fields / the cached pid — no spawn, no syscall — so the
2466 // whole-type Exec rule fabricated Exec on them (sweep [23]; mirrors the portable_pty getter carve-
2467 // out just below). get_program/get_args/get_envs/get_current_dir read the Command; Child::id reads
2468 // the cached pid. Every genuine verb (new/spawn/output/status/wait/kill) stays Exec.
2469 if path.ends_with("::get_program")
2470 || path.ends_with("::get_args")
2471 || path.ends_with("::get_envs")
2472 || path.ends_with("::get_current_dir")
2473 || path.ends_with("Child::id")
2474 {
2475 return None;
2476 }
2477 return Some("Exec");
2478 }
2479 // portable_pty / async_process are whole-crate Exec EXCEPT for the proven-pure surface they expose:
2480 // the `CommandBuilder` GETTERS (`get_argv`/`get_cwd`/`get_env`/`as_unix_command_line`…) read back
2481 // configuration, and the PURE DATA types (`PtySize::default`, `ExitStatus`/`Stdio`/`CommandBuilder`
2482 // construction/setters). The earlier `is_cmd_naming_method` fix stopped the head-refinement LEAK, but
2483 // the BASE Exec still fabricated on these accessors (a `cmd.get_cwd()` the scanner routes to
2484 // `portable_pty::CommandBuilder::get_cwd`). Subtract the read-back getters and the obvious pure
2485 // ctors/setters; the spawn/wait/exec surface (`spawn_command`/`openpty`/`wait`/`kill`/`exec`…) keeps
2486 // Exec. SUBTRACT only what is provably pure — when unrecognised, KEEP Exec (the safe direction).
2487 if crate_name == "async_process" || crate_name == "portable_pty" {
2488 let m = path.rsplit("::").next().unwrap_or(path);
2489 // THE COVERAGE-GATE SWEEP (2026-08-27): `CommandBuilder::get_shell` (cmdbuilder.rs:544, unix)
2490 // LOOKS like a config read-back getter — same `get_` naming as the pure getters below — but its
2491 // unix body calls `nix::unistd::access(shell, X_OK)`, a real filesystem-access syscall, before
2492 // falling back to a password-database lookup if `$SHELL` isn't set or isn't executable. Carved
2493 // out of the `get_` prefix exemption BEFORE it, not folded into it — the one getter this crate
2494 // exposes that is not actually pure. Verified against portable-pty 0.9.0.
2495 if m == "get_shell" {
2496 return Some("Fs");
2497 }
2498 // configuration read-back getters — pure (no spawn).
2499 if m.starts_with("get_") || m == "as_unix_command_line" {
2500 return None;
2501 }
2502 // pure data-type ctors/setters/derives that NAME no program and spawn nothing.
2503 if matches!(
2504 m,
2505 "default" | "new" | "piped" | "null" | "inherit" | "from_raw_fd"
2506 | "arg" | "args" | "arg0" | "env" | "envs" | "env_clear" | "env_remove"
2507 | "cwd" | "current_dir" | "rows" | "cols"
2508 | "clone" | "fmt" | "eq" | "ne" | "hash"
2509 ) {
2510 return None;
2511 }
2512 return Some("Exec");
2513 }
2514 // duct: a subprocess-orchestration crate. `cmd()`/`cmd!` only *build* an Expression; the
2515 // spawn/wait happens at `run`/`read`/`start`. Match the execution verbs, not the builder.
2516 if crate_name == "duct"
2517 && (path.ends_with("::run")
2518 || path.ends_with("::read")
2519 || path.ends_with("::start")
2520 || path.ends_with("::read_chars")
2521 // `Expression::reader` (lib.rs:357) calls the already-covered `.start()` internally to
2522 // launch the child and hands back a streaming `ReaderHandle` — a distinct public entry
2523 // point from `start` itself, so the suffix match above never reached it.
2524 || path.ends_with("Expression::reader"))
2525 {
2526 return Some("Exec");
2527 }
2528 if path.starts_with("std::env::") {
2529 return Some("Env");
2530 }
2531 // dotenvy / dotenv: load environment variables (reading a `.env` file and mutating the process
2532 // environment). Match the load/read entry points; `Error`/builder types stay pure.
2533 if matches!(crate_name, "dotenvy" | "dotenv")
2534 && (path.ends_with("::dotenv")
2535 || path.ends_with("::dotenv_override")
2536 || path.ends_with("::from_path")
2537 || path.ends_with("::from_path_override")
2538 || path.ends_with("::from_filename")
2539 || path.ends_with("::from_filename_override")
2540 || path.ends_with("::from_read")
2541 || path.ends_with("::from_read_override")
2542 || path.ends_with("::load")
2543 || path.ends_with("::var")
2544 || path.ends_with("::vars"))
2545 {
2546 return Some("Env");
2547 }
2548 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against dotenv 0.15.0 and dotenvy 0.15.7
2549 // (byte-identical shape in both crates) — the ITERATOR-returning siblings of the load verbs above,
2550 // which return the parsed pairs instead of mutating the environment but still touch disk to get
2551 // them: `dotenv_iter`/`from_filename_iter` (lib.rs) resolve and open a `.env`-shaped file exactly
2552 // like `dotenv`/`from_filename` two lines up (same `Finder::new().find()` call); `from_path_iter`
2553 // opens the given path directly. Bucketed `Env` for consistency with the rest of this family rather
2554 // than `Fs` (self-scan's raw signal): the crate's OWN purpose is env-var loading, and splitting the
2555 // iterator variants into a different effect bucket from their non-iterator siblings would be an
2556 // arbitrary distinction a reviewer gains nothing from.
2557 //
2558 // NOT included: `Finder::find` / the free fn `find` (find.rs) — both are used only via a PRIVATE
2559 // `use crate::find::Finder;` (no `pub use`) inside a private `mod find;` (lib.rs), so neither
2560 // `dotenv(y)::Finder` nor `dotenv(y)::find` is a path any external consumer can compile a call to;
2561 // the ratchet's generator still guesses a crate-root alias for every candidate (the same private-
2562 // module shape as tempfile's `imp::` functions above). Removed from open.tsv rather than guessed.
2563 if matches!(crate_name, "dotenvy" | "dotenv")
2564 && (path.ends_with("::dotenv_iter")
2565 || path.ends_with("::from_filename_iter")
2566 || path.ends_with("::from_path_iter"))
2567 {
2568 return Some("Env");
2569 }
2570 // Wall-clock reads. Match the `now` accessor precisely (ends_with), not any path
2571 // containing the substring "now". The `time` crate (distinct from `std::time`/`chrono`)
2572 // reads the clock via `now_utc`/`now_local` (and the deprecated `Instant::now`).
2573 if (crate_name == "chrono" || path.starts_with("std::time::")) && path.ends_with("::now") {
2574 return Some("Clock");
2575 }
2576 if crate_name == "time"
2577 && (path.ends_with("::now_utc") || path.ends_with("::now_local") || path.ends_with("::now"))
2578 {
2579 return Some("Clock");
2580 }
2581 // SOUNDNESS R1288 — THIRD-PARTY CLOCK WRAPPERS. `tokio::time::Instant::now()` reads the monotonic clock (through
2582 // tokio's mockable time source) exactly as `std::time::Instant::now()` does, and read PURE: tokio-util's
2583 // `DelayQueue::insert`/`reset` and tokio-stream's `throttle` were covered only by a false `ambiguous:` hedge in
2584 // v0.40.13. The same `now` accessor on the dedicated clock crates' instant types: `quanta` (`Instant::now`,
2585 // `Clock::now`), `minstant`, `web_time`, `instant`, `coarsetime` (`Instant::now`). `now` only — their arithmetic and
2586 // conversions are pure.
2587 if path.ends_with("::now")
2588 && (path.starts_with("tokio::time::Instant::")
2589 || matches!(crate_name, "quanta" | "minstant" | "web_time" | "instant" | "coarsetime"))
2590 {
2591 return Some("Clock");
2592 }
2593 // `tracing`: same principle as the `log` facade below — the crate's TYPES are pure data, so match
2594 // the emit, not the whole crate. The actual program output is the macro-expanded
2595 // `Subscriber::event`/`event!`/`Span::*enter*` dispatch and the `Span::new*`/`Span::record`
2596 // recording path that drives the subscriber. The data-type accessors — `Level::as_str`,
2597 // `Span::is_disabled`/`metadata`/`id`, and constructing/reading `Level`/`LevelFilter`/`Span`/
2598 // `Event`/`Metadata`/`Field`/`FieldSet`/`Id` — are PURE (no output is produced), so whole-crate Log
2599 // fabricated Log on them. Match the emit verbs; everything else returns None.
2600 if crate_name == "tracing" {
2601 let m = path.rsplit("::").next().unwrap_or(path);
2602 // The user-facing emit MACROS (`tracing::info!`/`warn!`/…) — candor-scan is pre-expansion, so it
2603 // sees the raw macro path `tracing::info`, not the expanded `__tracing`/`Subscriber::event` the
2604 // deep (post-expansion) engine sees. Only the macro names; the pure DATA types (Level/Span/Event)
2605 // have other tails and stay None.
2606 if m == "trace" || m == "debug" || m == "info" || m == "warn" || m == "error"
2607 || m == "trace_span" || m == "debug_span" || m == "info_span" || m == "warn_span"
2608 || m == "error_span" || m == "span"
2609 || m == "event"
2610 || m == "new_span"
2611 || m == "record"
2612 || m == "record_follows_from"
2613 || m == "enter"
2614 || m == "exit"
2615 || m == "in_scope"
2616 || m == "entered"
2617 || path.contains("::__macro_support")
2618 || path.contains("::__tracing")
2619 || path.contains("Subscriber::event")
2620 || path.contains("Subscriber::new_span")
2621 || path.contains("Subscriber::enter")
2622 || path.contains("Subscriber::exit")
2623 {
2624 return Some("Log");
2625 }
2626 return None;
2627 }
2628 // The `log` facade: its macros route through `log::__private_api`; the crate's types
2629 // (`Level`, `LevelFilter`) are pure, so match the logging entry, not the whole crate.
2630 if crate_name == "log" {
2631 // Expanded macro form (deep engine) OR the raw user-facing macro names (candor-scan, pre-expansion).
2632 // `log::Level`/`LevelFilter`/`Record`/`Metadata` have other tails, so the type surface stays pure.
2633 let m = path.rsplit("::").next().unwrap_or(path);
2634 if path.contains("::__private_api")
2635 || m == "error" || m == "warn" || m == "info" || m == "debug" || m == "trace" || m == "log"
2636 {
2637 return Some("Log");
2638 }
2639 }
2640 // Compiler diagnostic emission — the ONE genuinely effectful operation in the otherwise-pure
2641 // rustc_* surface (a dylint lint's actual OUTPUT: it writes warnings/errors to the compiler's
2642 // diagnostic sink). Classified `Log` (same family as `tracing`/`log` — program output). Match the
2643 // emission verbs precisely; rustc_lint/rustc_errors are mostly pure types (Lint, LintId, the Diag
2644 // BUILDERS), and only the terminal `emit`/`emit_span_lint` actually produces output.
2645 if crate_name == "rustc_lint"
2646 && (path.ends_with("::emit_span_lint")
2647 || path.ends_with("::span_lint")
2648 || path.ends_with("::span_lint_hir"))
2649 {
2650 return Some("Log");
2651 }
2652 if crate_name == "rustc_errors"
2653 && (path.ends_with("::emit")
2654 || path.ends_with("::emit_diagnostic")
2655 || path.ends_with("::emit_now"))
2656 {
2657 return Some("Log");
2658 }
2659 // arboard: the effectful surface is the `Clipboard` handle's read/write verbs (each talks to the
2660 // OS clipboard / X11/Wayland/Win32/NSPasteboard server). The data types — chiefly `arboard::Error`
2661 // (whose `Display`/`to_string` formatting is pure) and the `ImageData`/`GetExtLinux`/`SetExtLinux`
2662 // option types — are PURE, so whole-crate Clipboard fabricated Clipboard on e.g. an error
2663 // `to_string()`. Match the handle verbs; everything else returns None. `Clipboard::new` opens the
2664 // connection to the clipboard server, so it's an effect too; `get`/`set` return the
2665 // builder-then-read `Get`/`Set` cursors whose `text`/`image`/`html` terminals do the I/O.
2666 //
2667 // THE ~79-CRATE R59-CLASS AUDIT (2026-08-28), verified against arboard 3.6.1: `file_list`
2668 // (lib.rs:205,251, `Get::file_list`/`Set::file_list`) is the SAME builder-then-terminal shape as
2669 // `text`/`image`/`html` two lines up — reading/writing the clipboard's file-path list — but was
2670 // missing, not because it's ambiguous (it's the only `file_list` in the crate) but because
2671 // `eval/coverage-gate/generate.py`'s own generator only triggers on a self-scan `inferred` set
2672 // containing Fs/Net/Db/Exec; Clipboard isn't in that trigger set, so a missing Clipboard verb is
2673 // structurally invisible to the completeness gate regardless of phrasing. `Clear::default`
2674 // (lib.rs:265) is `Clipboard::clear`'s own documented alternate entry point
2675 // (`clear() { self.clear_with().default() }`, lib.rs:156-158) — the SAME "sibling constructor"
2676 // shape as `ignore::Walk::new` beside `WalkBuilder::build`; `clear_with()` itself doesn't share
2677 // `clear`'s leaf, so neither the constructor nor its terminal was covered. `default` is otherwise
2678 // unused in this crate (no derived `Default` impls), so the bare leaf carries no fabrication risk
2679 // once crate-gated.
2680 if crate_name == "arboard" {
2681 let m = path.rsplit("::").next().unwrap_or(path);
2682 if m == "new"
2683 || m == "get"
2684 || m == "set"
2685 || m == "clear"
2686 || m == "get_text"
2687 || m == "set_text"
2688 || m == "set_html"
2689 || m == "get_image"
2690 || m == "set_image"
2691 || m == "text"
2692 || m == "image"
2693 || m == "html"
2694 || m == "file_list"
2695 || m == "default"
2696 {
2697 return Some("Clipboard");
2698 }
2699 return None;
2700 }
2701 // ── Coverage-differential additions (calibrated against each crate's real API; see the per-crate
2702 // notes). All verb-keyed + crate-gated, with the pure builder/config/data surface returning None.
2703
2704 // `etcetera` — XDG/known-folder base+app directory resolution. Each dir ACCESSOR reads the
2705 // environment at call time (`$HOME`/`$XDG_*` on Unix, `%APPDATA%`/`%LOCALAPPDATA%` on Windows), and
2706 // the `choose_*`/`home_dir` entry points read `$HOME`. The `AppStrategyArgs` data struct and the
2707 // strategy types themselves are PURE. (Found DISCLOSED-but-unmodeled in 3/4 differential projects.)
2708 if crate_name == "etcetera" {
2709 let m = path.rsplit("::").next().unwrap_or(path);
2710 if m == "home_dir"
2711 || m == "choose_base_strategy" || m == "choose_native_strategy" || m == "choose_app_strategy"
2712 || m == "config_dir" || m == "data_dir" || m == "cache_dir"
2713 || m == "state_dir" || m == "runtime_dir" || m == "data_local_dir"
2714 {
2715 return Some("Env");
2716 }
2717 return None;
2718 }
2719 // `sqlx-core` (crate `sqlx_core`) — the execution terminals under the sqlx core (the `sqlx` builder
2720 // table maps `sqlx::query*`; here it's the core `Executor`/`Connection`/`Pool` round-trips). Opening
2721 // the connection is the network boundary (Net); the query/transaction round-trips are Db. The
2722 // `*Options`/query-builder/row data types are PURE. Crate-gated so the generic verbs never spread.
2723 if crate_name == "sqlx_core" {
2724 if path.ends_with("::connect") || path.ends_with("::connect_with") {
2725 return Some("Net");
2726 }
2727 if path.ends_with("::fetch") || path.ends_with("::fetch_all") || path.ends_with("::fetch_one")
2728 || path.ends_with("::fetch_optional") || path.ends_with("::fetch_many")
2729 || path.ends_with("::execute") || path.ends_with("::execute_many")
2730 || path.ends_with("::prepare") || path.ends_with("::prepare_with")
2731 || path.ends_with("::acquire") || path.ends_with("::begin") || path.ends_with("::ping")
2732 {
2733 return Some("Db");
2734 }
2735 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against sqlx-core 0.8.6/0.9.0 (identical
2736 // shape in both) — the free-function surface under `pub mod fs`/`pub mod net::tls`/
2737 // `pub mod migrate`, none of which end in the connect/fetch/execute verbs above:
2738 //
2739 // `fs::{read,read_to_string,create_dir_all,remove_file,remove_dir,remove_dir_all,read_dir}`
2740 // (fs.rs) are direct `std::fs::*` calls off-loaded to `spawn_blocking` — sqlx's OWN async
2741 // filesystem shim, used by the migrator to read `.sql` files and by SQLite to manage on-disk
2742 // databases. `net::tls::handshake` (net/tls/mod.rs:69) is the real TLS handshake dispatch
2743 // reqwest/ureq-style crates already model — sqlx has its OWN copy because it drives the
2744 // handshake over its own `Socket` trait rather than a `TcpStream` directly.
2745 // `migrate::resolve_blocking`/`resolve_blocking_with_config` (migrate/source.rs:151,156) are
2746 // `#[doc(hidden)]` but still `pub use`-exported at `sqlx_core::migrate` (mod.rs:16) — real,
2747 // callable, and reads the migrations directory off disk (`fs::read_dir` + per-file `canonicalize`).
2748 //
2749 // NOT included: `net::tls::tls_rustls::RustlsSocket::poll_{flush,read_ready,write_ready,
2750 // shutdown}` — `RustlsSocket` is declared inside `mod tls_rustls;` (net/tls/mod.rs), which is
2751 // PRIVATE with no re-export, so no external consumer can ever name it (the same private-module
2752 // shape as tempfile's `imp::` functions above). Removed from open.tsv rather than guessed.
2753 if path == "sqlx_core::fs::read"
2754 || path == "sqlx_core::fs::read_to_string"
2755 || path == "sqlx_core::fs::create_dir_all"
2756 || path == "sqlx_core::fs::remove_file"
2757 || path == "sqlx_core::fs::remove_dir"
2758 || path == "sqlx_core::fs::remove_dir_all"
2759 || path == "sqlx_core::fs::read_dir"
2760 || path.ends_with("::migrate::resolve_blocking")
2761 || path.ends_with("::migrate::resolve_blocking_with_config")
2762 || path == "sqlx_core::config::Config::try_from_path"
2763 || path == "sqlx_core::Config::try_from_path"
2764 || path == "sqlx_core::config::Config::try_from_path_or_default"
2765 || path == "sqlx_core::Config::try_from_path_or_default"
2766 || path == "sqlx_core::config::Config::try_from_crate_or_default"
2767 || path == "sqlx_core::Config::try_from_crate_or_default"
2768 {
2769 return Some("Fs");
2770 }
2771 if path.ends_with("::net::tls::handshake") {
2772 return Some("Net");
2773 }
2774 return None;
2775 }
2776 // `walkdir` — recursive directory traversal. Charged at `WalkDir::new` (construction), the SAME
2777 // point `ignore::WalkBuilder::build`/`glob::glob` are charged, and NOT at `IntoIter::next` (driving
2778 // the iterator) as this rule originally read. The disk read (`read_dir`+`stat`) is technically lazy
2779 // — nothing happens until the value is iterated — but `.next()` is reached ONLY through a receiver
2780 // typed as `walkdir::IntoIter`, and candor-scan's receiver-typing (`ctor_type`/`resolve_recv_type`)
2781 // hard-blocks the `.into_iter()` verb (a guard against fabricating onto a DIFFERENT std type, e.g.
2782 // `Vec::into_iter()` -> `std::vec::IntoIter`) with no per-crate exception for the SAME-crate case.
2783 // `WalkDir::into_iter()` returns `walkdir::IntoIter` (same crate as `WalkDir` itself, exactly the
2784 // shape the blocklist exists to protect, not the shape it should be blocking) — so every idiomatic
2785 // form (`for e in WalkDir::new(p)`, `.into_iter().count()`, `.into_iter().filter_map(..)`, an
2786 // untyped `let it = ...into_iter(); it.next()`) never reaches a typed `IntoIter::next` receiver and
2787 // read silent-pure under the OLD rule, with `deny Fs` exiting 0 over code that walks the filesystem.
2788 // Charging at `WalkDir::new` needs no receiver typing at all — it's a plain `Expr::Call`, robust to
2789 // however the iterator is later consumed. `max_depth`/`follow_links`/`sort_by`/`into_iter` (the
2790 // BUILDER chain, no I/O until pulled) and the cached `DirEntry::path`/`file_name`/`file_type`/`depth`
2791 // accessors (`file_type` makes NO syscall) stay PURE — matched by nothing here, so they fall through.
2792 // The `IntoIter::next`/`DirEntry::metadata` rule is KEPT, not dead: it still fires whenever the
2793 // concrete `walkdir::IntoIter` type reaches `.next()` by a route the blocklist doesn't gate — an
2794 // EXPLICIT type annotation (`let it: walkdir::IntoIter = ...`), a struct field declared with that
2795 // type, or a local fn's declared return type — real, if rarer, shapes than the inline-chain idiom.
2796 // (Companion to the already-modeled `ignore`.)
2797 if crate_name == "walkdir" {
2798 if path.ends_with("::WalkDir::new")
2799 || path.ends_with("::IntoIter::next")
2800 || path.ends_with("::DirEntry::metadata")
2801 {
2802 return Some("Fs");
2803 }
2804 return None;
2805 }
2806 // `filetime` — file-timestamp mutation. The `set_*` free fns issue utimes/utimensat/futimens (Fs).
2807 // `FileTime::now` reads the system clock (Clock). The `FileTime::from_*`/`zero` value constructors
2808 // (incl. `from_last_modification_time(&Metadata)` etc., which read an ALREADY-loaded `&Metadata`, not
2809 // the disk) and the `seconds`/`nanoseconds` accessors are PURE.
2810 if crate_name == "filetime" {
2811 if path.ends_with("::set_file_mtime") || path.ends_with("::set_file_atime")
2812 || path.ends_with("::set_file_times") || path.ends_with("::set_symlink_file_times")
2813 || path.ends_with("::set_file_handle_times")
2814 {
2815 return Some("Fs");
2816 }
2817 if path.ends_with("::FileTime::now") {
2818 return Some("Clock");
2819 }
2820 return None;
2821 }
2822 // `execute` — the `Execute` trait that extends `std::process::Command` with run helpers. The
2823 // `execute*` verbs SPAWN a child process (Exec). The `execute::command`/`shell` free fns and the
2824 // `command!`/`command_args!` macros only BUILD a Command (no spawn) and stay PURE.
2825 if crate_name == "execute" {
2826 if path.contains("::execute") {
2827 return Some("Exec");
2828 }
2829 return None;
2830 }
2831 // `ctrlc` — installs an OS signal handler (Unix SIGINT/SIGTERM/SIGHUP, Windows CTRL_C_EVENT) and
2832 // spawns its handler thread. Signals are an inter-process control channel, so the closest bucket is
2833 // Ipc (candor has no dedicated Signal effect; same judgment as routing SysV/pipe IPC to Ipc).
2834 if crate_name == "ctrlc" {
2835 if path.ends_with("::set_handler") || path.ends_with("::try_set_handler") {
2836 return Some("Ipc");
2837 }
2838 return None;
2839 }
2840 // `clap` — argument parsing. ONLY the terminals that read `std::env::args_os` at call time are an
2841 // effect (Env): `get_matches`/`get_matches_mut`/`try_get_matches` and the derive `parse`/`try_parse`.
2842 // clap is MOSTLY PURE: the ENTIRE builder surface (`Command::new`/`arg`/`about`/`Arg::new`) stays
2843 // None, and crucially the `*_from`/`*_parse_from` variants take an EXPLICIT iterator (they do NOT
2844 // read argv) so they stay pure too.
2845 //
2846 // THE ~79-CRATE R59-CLASS AUDIT (2026-08-28): this file used to leave `Arg::env` unmodeled, calling
2847 // bare `::env` "too generic to gate safely" — the same words used for libc's genuinely ambiguous fd
2848 // verbs. It isn't the same shape: `Arg::env(name)` calls `env::var_os(&name)` DIRECTLY at builder
2849 // time (verified against clap_builder 4.6.6, builder/arg.rs:2205-2213 — real, immediate, not
2850 // deferred to `get_matches()`), and — unlike a bare fd, which could be Fs/Net/Ipc — `clap_builder`
2851 // has exactly ONE `pub fn` ending `::env` in its entire source (`Arg::env`; the deprecated
2852 // `Arg::env_os` is a one-line delegate to it). This whole match arm is already crate-gated on
2853 // `crate_name == "clap"`, so no ambiguity survives to fabricate on: a fixture proved the untreated
2854 // gap silently vanishes the function ("functions": [] — the exact R59 shape, worse than an
2855 // uncalibrated dependency, since `clap` is fully `CALIBRATED_CRATES` and the miss reads as
2856 // reviewed-pure rather than disclosed). Classified, not carved into
2857 // `CALIBRATED_BUT_PARTIAL_CRATES`, because the effect IS unambiguous once actually checked.
2858 if crate_name == "clap" {
2859 if path.ends_with("::get_matches") || path.ends_with("::get_matches_mut")
2860 || path.ends_with("::try_get_matches")
2861 || path.ends_with("::parse") || path.ends_with("::try_parse")
2862 || path.ends_with("::env") || path.ends_with("::env_os")
2863 {
2864 return Some("Env");
2865 }
2866 return None;
2867 }
2868 // `jiff` — date/time. `Timestamp::now`/`Zoned::now`/`Zoned::now_with` read the wall clock (Clock).
2869 // `tz::TimeZone::system`/`get` and `tz::db().get` read the system tzdb files from disk
2870 // (`/etc/localtime`, `/usr/share/zoneinfo`; `system` is also `$TZ`-overridable — Fs is the dominant
2871 // op, modeled as Fs). The `Span`/`civil` date math and `Timestamp`/`Zoned` arithmetic are PURE.
2872 if crate_name == "jiff" {
2873 if path.ends_with("::now") || path.ends_with("::now_with") {
2874 return Some("Clock");
2875 }
2876 if path.ends_with("::TimeZone::system") || path.ends_with("::TimeZone::get")
2877 || path.ends_with("::TimeZoneDatabase::get")
2878 // `TimeZone::try_system` (tz/timezone.rs:391) is `TimeZone::system`'s fallible sibling —
2879 // same `crate::tz::system::get(crate::tz::db())` call, real localtime/zoneinfo disk read.
2880 || path.ends_with("::TimeZone::try_system")
2881 {
2882 return Some("Fs");
2883 }
2884 return None;
2885 }
2886 // `env_logger` — installs the global logger and emits to stderr; reads `RUST_LOG`/`RUST_LOG_STYLE`.
2887 // The init terminals are the effect (Log — program output, same family as `log`/`tracing`). The
2888 // `Builder::new`/`build` and the format/filter/target config setters are PURE.
2889 if crate_name == "env_logger" {
2890 if path.ends_with("::init") || path.ends_with("::try_init")
2891 || path.ends_with("::init_from_env") || path.ends_with("::try_init_from_env")
2892 {
2893 return Some("Log");
2894 }
2895 return None;
2896 }
2897 // `dialoguer` — interactive terminal prompts. The `interact*` verbs read stdin + write the tty (a
2898 // console dialogue with the user — Ipc, like the other local-channel effects). The
2899 // `with_prompt`/`default`/`items`/`validate_with` BUILDERS are PURE.
2900 if crate_name == "dialoguer" {
2901 if path.ends_with("::interact") || path.ends_with("::interact_on")
2902 || path.ends_with("::interact_text") || path.ends_with("::interact_text_on")
2903 || path.ends_with("::interact_opt") || path.ends_with("::interact_on_opt")
2904 {
2905 return Some("Ipc");
2906 }
2907 // `Editor::edit` (edit.rs:93) writes the caller's string to a real tempfile, spawns
2908 // `$VISUAL`/`$EDITOR` on it via `process::Command::spawn().wait()`, then reads the result back
2909 // — a real subprocess launch, not a terminal-dialogue read/write like the verbs above.
2910 if path.ends_with("Editor::edit") {
2911 return Some("Exec");
2912 }
2913 // WIDENED-CORE AUDIT (2026-08-28): `Editor::new`/`Editor::default` (edit.rs:54,44) call
2914 // `get_default_editor()` (edit.rs:31), which reads `env::var_os("VISUAL")` then `("EDITOR")`
2915 // IMMEDIATELY at construction — a real, independent Env read a consumer triggers just by
2916 // building an `Editor`, whether or not `.edit()` is ever called (the SAME "builder-time env
2917 // read, separate from the terminal verb" shape as `clap::Arg::env`, R59-class). Verified against
2918 // dialoguer 0.12.0: `get_default_editor` is the ONLY caller of either `env::var_os`, so there is
2919 // no ambiguity gating this to just these two constructors.
2920 if path.ends_with("Editor::new") || path.ends_with("Editor::default") {
2921 return Some("Env");
2922 }
2923 return None;
2924 }
2925 // `tracing_subscriber` — the subscriber that gives `tracing` somewhere to go. TWO effects, and the
2926 // filing said "Log/Fs": VERIFIED against 0.3.23, the Fs half is WRONG.
2927 //
2928 // Log — `fmt/fmt_layer.rs:749` defaults `make_writer: io::stdout`, so the fmt INIT terminals install
2929 // a subscriber that writes program output. Same family as `log`/`tracing`/`env_logger`.
2930 // Env — `fmt/mod.rs:1219` reads `RUST_LOG` on the `init()` path, `fmt_layer.rs` reads `NO_COLOR`,
2931 // and `filter/env/builder.rs:189,203` read `env::var(self.env_var_name())`.
2932 //
2933 // NOT Fs. The only `std::fs` in the crate is `impl MakeWriter for std::fs::File` — the crate ACCEPTING
2934 // a caller-supplied File, not opening one. The caller's `File::create` is classified on the caller, so
2935 // charging Fs here would double-count, exactly the `serde_json::from_reader` caveat one crate over.
2936 //
2937 // The builders (`fmt()`, `layer()`, `with_writer`, `with_target`, `EnvFilter::new`) are PURE: they
2938 // describe a subscriber. Only the INIT terminals install one, and only the from-env constructors read.
2939 if crate_name == "tracing_subscriber" {
2940 if path.ends_with("::init") || path.ends_with("::try_init") {
2941 return Some("Log");
2942 }
2943 if path.ends_with("::from_default_env") || path.ends_with("::try_from_default_env")
2944 || path.ends_with("::from_env") || path.ends_with("::from_env_lossy")
2945 || path.ends_with("::try_from_env")
2946 {
2947 return Some("Env");
2948 }
2949 return None;
2950 }
2951 // `crossterm` — the terminal driver. The tty is a USER DIALOGUE CHANNEL, so this is Ipc, matching the
2952 // ruling `dialoguer`/`console`/`terminal_colorsaurus` already carry rather than a new one.
2953 //
2954 // VERIFIED against crossterm-0.28.1 rather than assumed: `command.rs` `execute`/`queue` end in
2955 // `self.flush()?` on the writer (real code, not a doc example), `event::read`/`poll` read tty input,
2956 // and `terminal::{enable,disable}_raw_mode` + `size`/`window_size` talk to the device.
2957 //
2958 // `size`/`window_size`/`is_raw_mode_enabled` ARE classified, and that is deliberate: once a crate is
2959 // CALIBRATED every unmatched path becomes a PURITY CLAIM rather than a disclosed blind spot, so a tty
2960 // ioctl left to fall through would be claimed pure. The genuinely pure surface — the Command VALUE
2961 // types (`Print`, `MoveTo`, `SetForegroundColor`), the style/event data types — carries none of these
2962 // tails and stays pure correctly.
2963 if crate_name == "crossterm" {
2964 if path.ends_with("::execute") || path.ends_with("::queue")
2965 || path.ends_with("::event::read") || path.ends_with("::event::poll")
2966 || path.ends_with("::enable_raw_mode") || path.ends_with("::disable_raw_mode")
2967 || path.ends_with("::size") || path.ends_with("::window_size")
2968 || path.ends_with("::is_raw_mode_enabled")
2969 {
2970 return Some("Ipc");
2971 }
2972 // `terminal::supports_keyboard_enhancement` (terminal/sys/unix.rs:188, re-exported `pub use
2973 // sys::supports_keyboard_enhancement` in terminal.rs) drives the SAME channel as
2974 // `event::read`/`enable_raw_mode` above (it toggles raw mode, then reads/polls terminal events
2975 // to detect the kitty keyboard protocol) — bucketed `Ipc` for consistency with the rest of this
2976 // tty-dialogue family rather than the `Fs` self-scan's raw signal reports (that signal traces
2977 // through the crate's internal event source opening `/dev/tty` as a fallback fd, the same
2978 // primitive `tty_fd` below wraps — not a distinct effect from the terminal channel itself).
2979 //
2980 // NOT modelled: `tty_fd` (terminal/sys/file_descriptor.rs:124) — declared inside `pub(crate) mod
2981 // sys;` (terminal.rs:99), so despite being `pub fn` it is unreachable from outside the crate
2982 // (only used internally by the event source). Removed from open.tsv rather than guessed.
2983 if path.ends_with("::supports_keyboard_enhancement") {
2984 return Some("Ipc");
2985 }
2986 return None;
2987 }
2988 // `ratatui` — the TUI renderer, and the single loudest source of disclosed-blind calls measured in the
2989 // 2026-07-14 four-ecosystem sweep (3,345 across three real repos). The backlog filed it as
2990 // "mark reviewed-pure"; VERIFYING against ratatui-0.29.0 REFUTES that for part of the surface:
2991 // `terminal/terminal.rs` `draw`/`flush`/`clear`/`autoresize`/`hide_cursor`/`show_cursor` end in a
2992 // backend flush, and `backend/` writes to the terminal. Marking the whole crate pure would have
2993 // claimed purity over the one API that actually writes.
2994 //
2995 // So the split is where the sweep's noise actually is: the BULK of those 3,345 calls are widget,
2996 // layout, buffer, style and text constructors — genuinely pure, and now covered rather than disclosed.
2997 // The Terminal/backend verbs are Ipc, same channel as crossterm underneath them.
2998 if crate_name == "ratatui" {
2999 // CARVE-OUT FIRST: `widgets::canvas` is an IN-MEMORY grid. `Context::draw(&shape)` sets
3000 // `self.dirty` and paints into a `Painter` — no terminal, no writer, provably pure — but it ends
3001 // in `::draw` and the tails below would have charged it `Ipc`. MEASURED as a live fabrication on a
3002 // fixture (`plot(ctx) -> ['Ipc']`) before this line existed, and it is a HOT path: a TUI drawing
3003 // charts or maps calls it per shape per frame.
3004 //
3005 // A DENYLIST (carve out the proven-pure module) rather than an allowlist of `Terminal::`, per the
3006 // family rule: an allowlist silently under-reports whatever it forgot, and the write surface here
3007 // is Terminal AND the backends (`CrosstermBackend::flush`), so pinning to `Terminal::` would drop
3008 // a direct backend call. Reading the crate, canvas is the only module whose methods collide with
3009 // these tails.
3010 if path.contains("::canvas::") {
3011 return None;
3012 }
3013 if path.ends_with("::draw") || path.ends_with("::try_draw") || path.ends_with("::flush")
3014 || path.ends_with("::autoresize") || path.ends_with("::clear")
3015 || path.ends_with("::hide_cursor") || path.ends_with("::show_cursor")
3016 || path.ends_with("::insert_before")
3017 || path.ends_with("::set_cursor_position") || path.ends_with("::get_cursor_position")
3018 {
3019 return Some("Ipc");
3020 }
3021 return None;
3022 }
3023 // `console` — terminal handle + styling. The `Term` read/write verbs do tty I/O (Ipc, the user
3024 // dialogue channel; note there is NO `write_str` — `Term` impls `io::Write`). The free-fn terminal
3025 // detection (`colors_enabled`/`user_attended`) reads `CLICOLOR`/`CLICOLOR_FORCE` (Env). The `Style`
3026 // color/format methods and the text utils (`strip_ansi_codes`/`pad_str`/`measure_text_width`) are PURE.
3027 //
3028 // THE ~79-CRATE R59-CLASS AUDIT (2026-08-28): the note above ("no `write_str` — `Term` impls
3029 // `io::Write`") RECORDED the trait impl without covering it. `Term::write`/`Term::flush` (console
3030 // 0.15.11, term.rs:622-633) call `self.write_through(buf)` — the SAME primitive `write_line` already
3031 // charges Ipc for — and `Term::read` (term.rs:650-654) calls `io::stdin().read(buf)` directly. These
3032 // are a second, real entry point to the identical tty channel, missed because `read`/`write`/`flush`
3033 // are the names every I/O type shares — but crate-gated on `crate_name == "console"`, term.rs
3034 // defines exactly one of each (verified against the real source; no ambiguity survives the gate,
3035 // same shape as `clap::Arg::env`). Left unclassified, a fn calling only `term.write_all(..)` (no
3036 // `write_line`) read as reviewed-pure, since `console` is fully `CALIBRATED_CRATES`.
3037 if crate_name == "console" {
3038 if path.ends_with("::write_line") || path.ends_with("::read_line")
3039 || path.ends_with("::read_line_initial_text") || path.ends_with("::read_char")
3040 || path.ends_with("::read_key") || path.ends_with("::read_key_raw")
3041 || path.ends_with("::read_secure_line")
3042 || path.ends_with("Term::write") || path.ends_with("Term::flush")
3043 || path.ends_with("Term::read")
3044 {
3045 return Some("Ipc");
3046 }
3047 if path.ends_with("::colors_enabled") || path.ends_with("::colors_enabled_stderr")
3048 || path.ends_with("::user_attended") || path.ends_with("::user_attended_stderr")
3049 {
3050 return Some("Env");
3051 }
3052 return None;
3053 }
3054 // `terminal_colorsaurus` — queries the terminal's colours by writing OSC 10/11 escapes and reading the
3055 // reply (bidirectional tty dialogue — Ipc, consistent with dialoguer/console). Nothing else is I/O.
3056 if crate_name == "terminal_colorsaurus" {
3057 if path.ends_with("::background_color") || path.ends_with("::foreground_color")
3058 || path.ends_with("::color_palette")
3059 // SOUNDNESS R321 — FQN-exact, not a suffix. `1.0.3` added
3060 // `ColorPalette::theme_mode(&self)` (src/lib.rs:112), a PURE derived accessor
3061 // comparing `perceived_lightness()` on two already-fetched colours, alongside the
3062 // effectful free `theme_mode(options)` (src/lib.rs:157). A `::theme_mode` suffix
3063 // charges `Ipc` to both. Counterpart to R319: the same upstream mechanism — a
3064 // release ADDING a name — under-reports in one crate and fabricates in another,
3065 // depending only on whether the new name is effectful.
3066 || path == "terminal_colorsaurus::theme_mode"
3067 {
3068 return Some("Ipc");
3069 }
3070 return None;
3071 }
3072 // `backoff` — retry-with-backoff. `retry`/`retry_notify` consult the clock and `thread::sleep`
3073 // between attempts (Clock). The `ExponentialBackoff`/builder config is PURE. (The user closure's own
3074 // effects are out of scope here — we model only backoff's own Clock effect.)
3075 if crate_name == "backoff" {
3076 if path.ends_with("::retry") || path.ends_with("::retry_notify") {
3077 return Some("Clock");
3078 }
3079 return None;
3080 }
3081 // `lscolors` — LS_COLORS parsing. ONLY `from_env` reads the environment (Env). `from_string`/
3082 // `style_for_path`/`style_for*` and the `Style` type take explicit input and are PURE.
3083 if crate_name == "lscolors" {
3084 if path.ends_with("::from_env") {
3085 return Some("Env");
3086 }
3087 return None;
3088 }
3089 // `wild` — argv with glob expansion. `args`/`args_os` read `std::env::args(_os)` (Env). Nothing else.
3090 if crate_name == "wild" {
3091 if path.ends_with("::args") || path.ends_with("::args_os") {
3092 return Some("Env");
3093 }
3094 return None;
3095 }
3096 // `grep_cli` — only the firm effect is modeled: `CommandReaderBuilder::build` spawns a child process
3097 // (Exec). The `is_readable_stdin`/`is_tty_*` fd probes (isatty/fstat on the std descriptors) are
3098 // deliberately NOT modeled — candor doesn't classify `IsTerminal`/isatty as an effect anywhere, and
3099 // they read no data; flagging them would be an inconsistent over-report.
3100 if crate_name == "grep_cli" {
3101 if path.ends_with("::build") {
3102 return Some("Exec");
3103 }
3104 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against grep-cli 0.1.12 — the sibling entry
3105 // points to `::build` above, none of which end in that suffix:
3106 //
3107 // `CommandReader::new` (process.rs:195) is `CommandReaderBuilder::new().build(cmd)` — the same
3108 // spawn, reached through the type's own one-call constructor instead of the builder.
3109 // `CommandReader::close` (process.rs:218) drops the child's stdout handle then calls
3110 // `self.child.wait()` — `std::process::Child::wait` is ALREADY classified `Exec` elsewhere in
3111 // this file (reaping/blocking on a spawned child is part of the subprocess lifecycle this
3112 // project tracks), and self-scan resolves `self.child`'s concrete `process::Child` type to
3113 // confirm it, not a guess.
3114 // `DecompressionMatcher::command` (decompress.rs:179) builds a REAL `Command::new(&decomp_cmd.bin)`
3115 // naming an actual decompressor binary (gzip/xz/bzip2/…) and returns it un-spawned — the same
3116 // "constructs a real argv, caller spawns it" shape `Cred::credential_helper` was classified for
3117 // in git2 (see the FQN-exact rule near this file's top).
3118 // `DecompressionReader::new` (decompress.rs:352) is `DecompressionReaderBuilder::new().build(path)`,
3119 // which calls the already-covered `CommandReaderBuilder::build` two hops down.
3120 // `patterns_from_path` (pattern.rs:82) opens and reads the given file directly (`std::fs::File::
3121 // open`) — unrelated to the Exec family above, genuinely Fs.
3122 // SOUNDNESS R342 — `DecompressionReader::close` was the sibling this enumeration missed. It is
3123 // `match self.rdr { Ok(ref mut rdr) => rdr.close(), Err(_) => Ok(()) }` (`decompress.rs:383`),
3124 // i.e. the already-listed `CommandReader::close` one level up, so it reaps the same child. Its
3125 // `CommandReader` counterpart was listed and it was not — measured on a consumer fixture:
3126 // `CommandReader::close` charged `Exec` and `DecompressionReader::close` read ABSENT.
3127 //
3128 // The `read` pair is listed for the same reason one hop over: reading a `DecompressionReader`
3129 // or a `CommandReader` pulls bytes from the child's stdout, which is the point of having
3130 // spawned it. FQN-exact rather than a `::read`/`::close` suffix, because both are far too
3131 // common a leaf to match crate-wide — the R321 lesson.
3132 //
3133 // Found because R185 taught the scanner to see through `match self.rdr { Ok(..) }`, which made
3134 // grep-cli's own self-scan nominate it. The rule is what a CONSUMER needs; the self-scan only
3135 // pointed at it.
3136 if path == "grep_cli::CommandReader::new"
3137 || path == "grep_cli::CommandReader::close"
3138 || path == "grep_cli::CommandReader::read"
3139 || path == "grep_cli::DecompressionMatcher::command"
3140 || path == "grep_cli::DecompressionReader::new"
3141 || path == "grep_cli::DecompressionReader::close"
3142 || path == "grep_cli::DecompressionReader::read"
3143 || path == "grep_cli::decompress::DecompressionReader::close"
3144 || path == "grep_cli::decompress::DecompressionReader::read"
3145 || path == "grep_cli::process::CommandReader::close"
3146 || path == "grep_cli::process::CommandReader::read"
3147 {
3148 return Some("Exec");
3149 }
3150 if path == "grep_cli::patterns_from_path" {
3151 return Some("Fs");
3152 }
3153 return None;
3154 }
3155 // `clircle` — detects whether two handles are the same file (cycle protection). `Identifier::try_from`
3156 // (File/Stdio) issues an `fstat`, and `surely_conflicts_with` does an `lseek` (`stream_position`) — both
3157 // Fs. The `PartialEq`/`Hash` comparisons read stored dev/ino and are PURE. (The named methods
3158 // `are_identical`/`same_file` do NOT exist in the crate — not modeled.)
3159 if crate_name == "clircle" {
3160 if path.ends_with("::try_from") || path.ends_with("::surely_conflicts_with") {
3161 return Some("Fs");
3162 }
3163 return None;
3164 }
3165 None
3166}
3167
3168pub fn cap_from_name(name: &str) -> Option<&'static str> {
3169 EFFECTS.iter().copied().find(|e| *e == name)
3170}
3171
3172/// Refine the `Exec` cliff (spec §4 ⟨0.5⟩): the effects a *literal, statically-known* subprocess
3173/// head implies, matched by basename (`/usr/bin/curl` → `curl`). The head's effects are ADDED to a
3174/// caller that already carries `Exec` (a subprocess is still spawned — `Exec` is never dropped); an
3175/// unrecognised or dynamically-built head returns `&[]` and keeps the bare cliff (never guess). A
3176/// **candor engine** reads `Fs`/`Env` only — spec §7 item 12 (the analyzer self-boundary) guarantees
3177/// that, so that case is spec-supplied, not curation. The rest is a small curated table under the
3178/// same under-report rule as the crate classifier. INVARIANT: every head here is an external tool
3179/// that does NOT run the analysed project's own code (so `make`/`npm`/`cargo` are deliberately
3180/// absent — they stay the cliff). The reference engines share this table so the `Exec` boundary —
3181/// the one boundary every engine hits — refines identically (the §4-consistency argument).
3182/// SPEC §2 `fs` — for a call ALREADY classified `Fs`, the read/write direction its path implies.
3183/// `["read"]`, `["write"]`, `["read","write"]`, or `[]` when the verb does not say.
3184///
3185/// THE EMPTY CASE IS THE DISCIPLINE. §2: *"when `Fs` is reached but its kind is unknown … the field MUST
3186/// be omitted rather than guessed. An empty or partial `fs` would be read as a positive claim ('reads but
3187/// never writes'), which is the §4 trust contract's forbidden direction."* So an unrecognised verb
3188/// contributes nothing and the field stays absent; absence means "kind undetermined", never "read-only".
3189///
3190/// A syntactic refinement of an effect already proved, NOT a soundness claim. Deliberately the same
3191/// vocabulary as candor-java's `fsKind`, candor-swift's and candor-ts's — the surface is spec'd four-way,
3192/// and four engines inventing four verb tables for one field is how a shared field stops meaning one thing.
3193pub fn fs_kind(path: &str) -> &'static [&'static str] {
3194 // the terminal segment is the verb (`std::fs::write`, `File::create`, `f.read_to_string`)
3195 let leaf = path.rsplit("::").next().unwrap_or(path);
3196 // `OpenOptions::open` is the one verb that does NOT carry its own direction — the direction was set
3197 // by the BUILDER chain (`.read(true)`/`.write(true)`), which this function cannot see. The READ list
3198 // below holds a bare `open` for `File::open`, unambiguously a read; letting an `OpenOptions` receiver
3199 // reach it publishes `fs: ["read"]` for a builder configured `write(true)`, and §2 reads that as the
3200 // positive claim "reads but never writes" — the forbidden direction. The paragraph under the READ
3201 // list has always SAID this; nothing implemented it, and it only became REACHABLE when
3202 // `OpenOptions::open` started classifying on its own — before, the `Fs` came from the
3203 // `OpenOptions::new` constructor, whose leaf claims nothing, so the field was simply absent.
3204 // MEASURED on this commit's own fix before this guard: `OpenOptions::new().read(true).open(p)` went
3205 // from `fs` ABSENT to `fs: ["read"]`, i.e. the fix minted a claim the engine cannot support. Keyed on
3206 // the TYPE segment, so it holds for the `fs_err`/`tokio::fs`/`async_std::fs` spellings too.
3207 if path.rsplit("::").nth(1) == Some("OpenOptions") {
3208 return &[];
3209 }
3210 // Reads the source AND writes the destination in one call.
3211 if matches!(leaf, "copy" | "rename" | "hard_link" | "soft_link" | "symlink") {
3212 return &["read", "write"];
3213 }
3214 const WRITE: &[&str] = &[
3215 "write", "write_all", "write_fmt", "write_vectored", "create", "create_new", "create_dir",
3216 "create_dir_all", "remove_file", "remove_dir", "remove_dir_all", "set_permissions",
3217 "set_len", "set_modified", "set_times", "append", "flush", "sync_all", "sync_data",
3218 "write_at", "truncate",
3219 ];
3220 const READ: &[&str] = &[
3221 "read", "read_to_string", "read_to_end", "read_exact", "read_dir", "read_link",
3222 "metadata", "symlink_metadata", "exists", "try_exists", "canonicalize", "open",
3223 "read_at", "file_type", "permissions", "modified", "accessed", "created", "len",
3224 ];
3225 if WRITE.contains(&leaf) { return &["write"]; }
3226 if READ.contains(&leaf) { return &["read"]; }
3227 // `OpenOptions` carries the direction in its BUILDER chain, not its terminal verb, so `.open()` on one
3228 // says nothing here and is deliberately left to the READ arm above only when it is `File::open`
3229 // (unambiguously a read). Anything else: no claim.
3230 if leaf.starts_with("write") || leaf.starts_with("append") { return &["write"]; }
3231 if leaf.starts_with("read") { return &["read"]; }
3232 &[]
3233}
3234
3235pub fn classify_command_head(cmd: &str) -> &'static [&'static str] {
3236 // Only UNAMBIGUOUS single-effect tools belong here. A multi-modal head (`git status` is local,
3237 // `git push` is Net; `rsync` local-vs-remote) would FABRICATE the effect for its common case —
3238 // the under-report rule forbids it, so such heads keep the bare cliff.
3239 match cmd.rsplit(['/', '\\']).next().unwrap_or(cmd) {
3240 "curl" | "wget" | "http" | "ssh" | "scp" | "sftp" | "ftp" | "telnet" => &["Net"],
3241 "psql" | "mysql" | "sqlite3" | "mongosh" | "mongo" | "redis-cli" | "cqlsh" | "influx" => &["Db"],
3242 // candor engines — Fs/Env only, guaranteed by spec §7 item 12 (the analyzer self-boundary)
3243 "candor" | "candor-run.sh" | "candor-scan" | "candor-query" | "candor-java"
3244 | "candor-classify" | "candor-report" | "cargo-candor" => &["Env", "Fs"],
3245 _ => &[],
3246 }
3247}
3248
3249/// Known machine-learning MODEL-provider hosts — the SPEC §1 ⟨0.13⟩ `Llm` host-literal refinement:
3250/// a statically-known `Net` request to one of these classifies `Llm` IN ADDITION to `Net` (Net is
3251/// never dropped — a model call IS network I/O, exactly as an `Exec`-refined subprocess keeps `Exec`),
3252/// just as a jdbc URL classifies `Db`. Matched by host, case-insensitive; a SUBDOMAIN of a listed host
3253/// counts. The reference engines share this table VERBATIM with candor-java's `Literals.MODEL_HOSTS`
3254/// (the analog of `classify_command_head`) so the `Net` boundary refines to `Llm` identically. An
3255/// UNKNOWN host stays bare `Net` — never guessed. Curated STARTER set; the §7 coverage ledger
3256/// discloses an uncovered provider like any other.
3257pub const MODEL_HOSTS: &[&str] = &[
3258 "api.openai.com",
3259 "api.anthropic.com",
3260 "generativelanguage.googleapis.com",
3261 "api.mistral.ai",
3262 "api.cohere.ai",
3263 "api.cohere.com",
3264 "api.groq.com",
3265 "api.together.xyz",
3266 "api.perplexity.ai",
3267 "openrouter.ai",
3268];
3269
3270/// Whether an endpoint HOST literal is a known model provider (case-insensitive; a subdomain of a
3271/// `MODEL_HOSTS` entry counts). Strips a `:port` suffix first. Two special forms carry their own rule,
3272/// matching candor-java's `Literals.isModelHost` exactly: any host whose port is `11434` is a local
3273/// Ollama endpoint (a LOOPBACK host — `localhost`/`127.0.0.1`/`::1` — on port 11434); and an AWS Bedrock
3274/// runtime host (the model-inference service label `bedrock-runtime`/`bedrock-agent-runtime`).
3275pub fn is_model_host(host_literal: &str) -> bool {
3276 // Strip any `:port` (via the shared host_part) and lowercase for the name comparisons.
3277 let host = policy::host_part(host_literal).to_ascii_lowercase();
3278 // Ollama is a LOCAL endpoint: :11434 → Llm ONLY on a loopback host (max-review r3 parity fix — "any
3279 // host on :11434" fabricated Llm on unrelated internal services on that port).
3280 if let Some((_, port)) = host_literal.rsplit_once(':') {
3281 if port == "11434" {
3282 return matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1");
3283 }
3284 }
3285 if MODEL_HOSTS.contains(&host.as_str()) {
3286 return true;
3287 }
3288 // A subdomain of a known model host counts (`eu.api.openai.com` → api.openai.com).
3289 if MODEL_HOSTS.iter().any(|m| host.ends_with(&format!(".{m}"))) {
3290 return true;
3291 }
3292 // AWS Bedrock runtime: the FIRST label is the model-inference service (`bedrock-runtime.<region>.
3293 // amazonaws.com`), NOT the substring "bedrock" (which caught `bedrock-backups.s3.amazonaws.com`, an
3294 // S3 bucket) and NOT the control-plane `bedrock.<region>.amazonaws.com`.
3295 host.ends_with(".amazonaws.com")
3296 && matches!(host.split('.').next(), Some("bedrock-runtime") | Some("bedrock-agent-runtime"))
3297}
3298
3299/// ⟨0.20⟩ Curated telemetry / analytics / APM hosts — the `Net` destination-class `known-telemetry` set
3300/// (NET-DESTINATION-CLASS-DESIGN.md), shared VERBATIM with candor-java's `Literals.TELEMETRY_HOSTS` (like
3301/// `MODEL_HOSTS`). A benign observability endpoint. Matched by host, case-insensitive; a SUBDOMAIN of a
3302/// listed host counts. Tight, high-precision STARTER set — mis-including an exfil-capable host would
3303/// under-gate `deny Net[unknown-host]`.
3304pub const TELEMETRY_HOSTS: &[&str] = &[
3305 "sentry.io",
3306 "bugsnag.com",
3307 "rollbar.com",
3308 "segment.io",
3309 "segment.com",
3310 "mixpanel.com",
3311 "amplitude.com",
3312 "google-analytics.com",
3313 "analytics.google.com",
3314 "datadoghq.com",
3315 "datadoghq.eu",
3316 "newrelic.com",
3317 "nr-data.net",
3318 "honeycomb.io",
3319 "logtail.com",
3320 // ⟨0.20.1⟩ corpus-grown (a real-repo dogfood): more single-purpose analytics / session-replay / RUM
3321 // providers — vendor-specific product domains only (no general-purpose host), so no under-gate risk.
3322 "posthog.com",
3323 "plausible.io",
3324 "usefathom.com",
3325 "heapanalytics.com",
3326 "fullstory.com",
3327 "hotjar.com",
3328 "logrocket.com",
3329 "cloudflareinsights.com",
3330];
3331
3332/// Whether an endpoint HOST literal is in `set` (case-insensitive; a subdomain of a listed host counts).
3333/// Strips a `:port` suffix first via `host_part`. The shared membership test for `TELEMETRY_HOSTS` and the
3334/// config-declared partner set (mirrors candor-java's `Literals.hostInSet`).
3335pub fn host_in_set(host_literal: &str, set: &[&str]) -> bool {
3336 let host = policy::host_part(host_literal).to_ascii_lowercase();
3337 set.contains(&host.as_str()) || set.iter().any(|e| host.ends_with(&format!(".{e}")))
3338}
3339
3340/// Whether an endpoint HOST literal is a known telemetry/analytics/APM host (`TELEMETRY_HOSTS`).
3341pub fn is_telemetry_host(host_literal: &str) -> bool {
3342 host_in_set(host_literal, TELEMETRY_HOSTS)
3343}
3344
3345/// ⟨0.20⟩ The `Net` DESTINATION CLASS of a host literal (NET-DESTINATION-CLASS-DESIGN.md): `known-telemetry`
3346/// (curated), `known-partner` (config `net-partner` OR a model host — a declared-ish external API), else
3347/// `unknown-host` — the HONEST default (candor makes no claim; the security gate bites this). A partner set
3348/// is per-project (config-declared). Never fabricated onto a safe class: an unresolved host is unknown-host.
3349/// Mirrors candor-java's `Literals.netDestClass`.
3350pub fn net_dest_class(host_literal: &str, partners: &std::collections::BTreeSet<String>) -> &'static str {
3351 if is_telemetry_host(host_literal) {
3352 return "known-telemetry";
3353 }
3354 if partner_for(host_literal, partners).is_some() || is_model_host(host_literal) {
3355 return "known-partner";
3356 }
3357 "unknown-host"
3358}
3359
3360/// ⟨0.31⟩ WHICH declared partner a host matched, or `None` — the SAME match `net_dest_class` decides on,
3361/// extracted so the DISCLOSURE and the DECISION cannot use different rules.
3362///
3363/// That is not a stylistic preference. The reverted first attempt at the `net-partner` disclosure
3364/// re-implemented this match against a normaliser that KEEPS the port, so an observed
3365/// `partner.example:443` never equalled a declared `partner.example` and the disclosure came back
3366/// SILENTLY EMPTY on every real run — while the verdicts it was reporting on had flipped. A disclosure
3367/// normalised differently from the decision it reports can only be wrong, and one function with two
3368/// callers is what makes writing one impossible.
3369pub fn partner_for(
3370 host_literal: &str,
3371 partners: &std::collections::BTreeSet<String>,
3372) -> Option<String> {
3373 if partners.is_empty() {
3374 return None;
3375 }
3376 let host = policy::host_part(host_literal).to_ascii_lowercase();
3377 if partners.contains(&host) {
3378 return Some(host);
3379 }
3380 partners
3381 .iter()
3382 .find(|p| host.ends_with(&format!(".{p}")))
3383 .cloned()
3384}
3385
3386/// ⟨0.20⟩ The closed `Net` destination-class vocabulary, for the `deny Net[<dest…>]` policy filter.
3387pub const NET_DEST_CLASSES: &[&str] = &["known-telemetry", "known-partner", "unknown-host"];
3388
3389/// Curated Rust model-provider SDK crates — the SPEC §1 ⟨0.13⟩ `Llm` model-SDK surface, the Rust analog
3390/// of candor-java's `Rules.MODEL_SDK_PACKAGES`. A resolved call into one of these crates classifies
3391/// `Llm` + `Net` (the caller adds both — a model dispatch IS network I/O). NO method-name gating: these
3392/// are single-purpose provider clients, so ANY call into the crate is a model dispatch (matches the java
3393/// reference's judgment call). Curated STARTER list; the §7 coverage ledger discloses the rest.
3394pub const MODEL_SDK_CRATES: &[&str] = &[
3395 "async_openai", // async-openai — the de-facto OpenAI client
3396 "anthropic_sdk", // anthropic-sdk
3397 "anthropic", // anthropic (community client crate)
3398 "aws_sdk_bedrockruntime", // AWS Bedrock runtime (invoke/converse) — the model surface of the aws-sdk family
3399 "ollama_rs", // ollama-rs — local Ollama client
3400 "langchain_rust", // langchain-rust — the LangChain invoke surfaces
3401 "mistralai", // mistralai (Mistral client)
3402 "genai", // genai — a multi-provider model client
3403];
3404
3405/// Whether a resolved call's CRATE is a curated model-provider SDK (`MODEL_SDK_CRATES`) → the SPEC §1
3406/// ⟨0.13⟩ `Llm` model-SDK classification (the caller adds both `Llm` and `Net`). Crate-level, no
3407/// method gating — a single-purpose client, matching candor-java's `isModelSdkOwner`.
3408pub fn is_model_sdk_crate(crate_name: &str) -> bool {
3409 MODEL_SDK_CRATES.contains(&crate_name)
3410}
3411
3412/// The libc / nix / rustix PATH-and-directory syscall leaves `classify` charges `Fs` (incl. `*64` and
3413/// `*at` variants). Module-level, not local to `classify`, because it is ONE of the two answers to one
3414/// question: the AS-EFF-008 masking guard (`masks_locator`) reads this same table, so a leaf added here
3415/// is masked by default — see `LIBC_FS_NO_PATH` for the direction. SOUNDNESS R806.
3416pub const LIBC_FS: &[&str] = &[
3417 "open", "open64", "openat", "openat2", "creat", "creat64", "stat", "stat64", "lstat",
3418 "lstat64", "fstatat", "fstatat64", "newfstatat", "statx", "access", "faccessat",
3419 "faccessat2", "mkdir", "mkdirat", "rmdir", "unlink", "unlinkat", "rename", "renameat",
3420 "renameat2", "link", "linkat", "symlink", "symlinkat", "readlink", "readlinkat", "chmod",
3421 "fchmodat", "chown", "lchown", "fchownat", "truncate", "truncate64", "ftruncate",
3422 "ftruncate64", "opendir", "fdopendir", "readdir", "readdir64", "readdir_r", "closedir",
3423 "rewinddir", "seekdir", "telldir", "scandir", "mkstemp", "mkstemps", "mkostemp", "mkdtemp",
3424 "mknod", "mknodat", "chdir", "fchdir", "getcwd", "get_current_dir_name", "chroot",
3425 "pivot_root", "statfs", "statfs64", "fstatfs", "fstatfs64", "statvfs", "fstatvfs", "mount",
3426 "umount", "umount2", "fsync", "fdatasync", "sync", "syncfs", "sync_file_range", "fallocate",
3427 "posix_fallocate", "posix_fadvise", "sendfile", "sendfile64", "copy_file_range", "flock",
3428 "getdents", "getdents64", "utime", "utimes", "lutimes", "futimens", "utimensat", "futimesat",
3429 "realpath",
3430 ];
3431
3432/// The libc / nix / rustix socket-family leaves `classify` charges `Net`. See `LIBC_NET_NO_DESTINATION`.
3433pub const LIBC_NET: &[&str] = &[
3434 "socket", "setsockopt", "getsockopt", "bind", "listen", "accept", "accept4", "connect",
3435 "shutdown", "send", "sendto", "sendmsg", "sendmmsg", "recv", "recvfrom", "recvmsg",
3436 "recvmmsg", "getpeername", "getsockname", "getaddrinfo", "freeaddrinfo", "getnameinfo",
3437 ];
3438
3439/// The libc / nix / rustix process creation / replacement / reaping leaves `classify` charges `Exec`.
3440/// See `LIBC_EXEC_NO_PROGRAM`.
3441pub const LIBC_EXEC: &[&str] = &[
3442 "fork", "vfork", "clone", "clone3", "execl", "execlp", "execle", "execv", "execvp",
3443 "execvpe", "execve", "execveat", "fexecve", "posix_spawn", "posix_spawnp", "system",
3444 "popen", "pclose", "wait", "waitpid", "wait3", "wait4", "waitid",
3445 ];
3446
3447/// The Postgres / SQLite-family client verbs `classify` charges `Db` for `postgres`, `tokio_postgres`,
3448/// `deadpool_postgres` and `rusqlite` — the round-trips themselves. Module-level because the masking
3449/// guard reads it too (`masks_locator`, SOUNDNESS R806): every verb here takes the SQL text at
3450/// argument 0 except the three in `PG_DB_VERBS_NO_SQL`, so a verb added here is masked by default.
3451/// `(type, method)` of a `crate::[modules::]Type::method` path whose modules are exactly one of `mods`
3452/// (the crate-root re-export when `mods` holds `""`). Used by the coverage-gate rules below that name a
3453/// method on ONE type: comparing the type SEGMENT, not a suffix, so `MultiplexedConnection::recv_response`
3454/// can never satisfy a rule written for `Connection::recv_response`.
3455fn type_method<'a>(path: &'a str, krate: &str, mods: &[&str]) -> Option<(&'a str, &'a str)> {
3456 let rest = path.strip_prefix(krate)?.strip_prefix("::")?;
3457 let (head, method) = rest.rsplit_once("::")?;
3458 let (module, ty) = head.rsplit_once("::").unwrap_or(("", head));
3459 mods.contains(&module).then_some((ty, method))
3460}
3461
3462pub const PG_DB_VERBS: &[&str] = &[
3463 "::query", "::query_one", "::query_opt", "::query_raw", "::execute",
3464 "::batch_execute", "::simple_query", "::prepare", "::prepare_typed",
3465 "::copy_in", "::copy_out", "::transaction", "::connect",
3466 // `Config::connect_raw` (tokio-postgres config.rs:739) does the SAME protocol handshake
3467 // as `Config::connect` over a caller-supplied stream (a Unix socket, a TLS-terminated
3468 // proxy) instead of dialing one itself — real Db/Net I/O, missing because the verb
3469 // doesn't end in the plain `connect` spelling.
3470 "::connect_raw",
3471 // rusqlite's dialect of the same verbs (a verb-probe found the CANONICAL rusqlite
3472 // consumer API classifying pure): `query_row` is the one-row read, `query_map`/
3473 // `query_and_then` the many-row reads, `execute_batch` is rusqlite's name for
3474 // batch_execute, `prepare_cached` round-trips like prepare. `query_typed` is
3475 // tokio_postgres 0.7.10+.
3476 "::query_row", "::query_map", "::query_and_then", "::execute_batch",
3477 "::prepare_cached", "::query_typed",
3478 "::prepare_typed_cached",
3479];
3480
3481/// The members of `PG_DB_VERBS` that take NO SQL text, each checked against its signature:
3482/// `Client::transaction(&mut self)`, `connect(params, tls)` / `Config::connect_raw(stream, tls)` —
3483/// a connection string or a stream, never a query. A name wrongly listed here is a silent bypass;
3484/// a name missing from here is an over-mask that fails closed. SOUNDNESS R806.
3485pub const PG_DB_VERBS_NO_SQL: &[&str] = &["::transaction", "::connect", "::connect_raw"];
3486
3487/// The members of `LIBC_FS` that take NO path — every one operates on a file DESCRIPTOR, a `DIR*`
3488/// stream, or nothing — so a missing literal there hides no locator. **CHECKED AGAINST EACH SIGNATURE,
3489/// because this is a denylist on a masking guard: a path-taking name wrongly listed here is the
3490/// benign-sibling-literal bypass (R399), while a no-path name missing from here only over-masks.**
3491/// Everything else in `LIBC_FS` names a path, at argument 0 or — for the `*at` family — argument 1.
3492pub const LIBC_FS_NO_PATH: &[&str] = &[
3493 "ftruncate", "ftruncate64", "fdopendir", "readdir", "readdir64", "readdir_r", "closedir",
3494 "rewinddir", "seekdir", "telldir", "fchdir", "getcwd", "get_current_dir_name", "fstatfs",
3495 "fstatfs64", "fstatvfs", "fsync", "fdatasync", "sync", "syncfs", "sync_file_range", "fallocate",
3496 "posix_fallocate", "posix_fadvise", "sendfile", "sendfile64", "copy_file_range", "flock",
3497 "getdents", "getdents64", "futimens",
3498];
3499
3500/// The members of `LIBC_EXEC` that name NO program: process duplication (`fork`/`vfork`/`clone*`,
3501/// which run THIS image) and reaping (`wait*`, `pclose`). Every other member names what runs —
3502/// `system`/`popen` a shell command line, `exec*`/`posix_spawn*` a path or file name, `fexecve` an fd
3503/// whose program is invisible at the call. Same direction rule as `LIBC_FS_NO_PATH`.
3504pub const LIBC_EXEC_NO_PROGRAM: &[&str] = &[
3505 "fork", "vfork", "clone", "clone3", "wait", "waitpid", "wait3", "wait4", "waitid", "pclose",
3506];
3507
3508/// The members of `LIBC_NET` that name NO destination: socket setup and options, the LOCAL side
3509/// (`bind`/`listen`/`accept*` — where the process listens, never a place it reaches; ⟨0.29⟩
3510/// `is_net_binding`), use-verbs on an already-connected or already-bound socket (`send`/`recv*`),
3511/// teardown and read-backs. The destination-naming remainder is `connect`, `sendto`, `sendmsg`,
3512/// `sendmmsg`, and the resolver pair `getaddrinfo`/`getnameinfo`.
3513pub const LIBC_NET_NO_DESTINATION: &[&str] = &[
3514 "socket", "setsockopt", "getsockopt", "bind", "listen", "accept", "accept4", "shutdown", "send",
3515 "recv", "recvfrom", "recvmsg", "recvmmsg", "getpeername", "getsockname", "freeaddrinfo",
3516];
3517
3518/// Whether `crate_name` is one of the three crates `classify` routes through the libc syscall tables.
3519pub fn is_syscall_crate(crate_name: &str) -> bool {
3520 matches!(crate_name, "libc" | "nix" | "rustix")
3521}
3522
3523/// Whether a subprocess-builder method only MODIFIES the command (`.arg`, `.env`, `.current_dir`)
3524/// rather than NAMING the program (`Command::new`, `duct::cmd`). A WHOLE-CRATE-Exec crate
3525/// (`portable_pty`, `duct`, `async_process`) classifies *every* method as `Exec`, so the
3526/// head-refinement must skip these: an arg or env-var-name literal that happened to match a head
3527/// (`.env("psql", …)`, `.arg("curl")`) would FABRICATE that effect — the §1 under-report rule. The
3528/// method is the call path's last segment.
3529pub fn is_cmd_builder_method(method: &str) -> bool {
3530 matches!(
3531 method,
3532 "arg" | "args" | "arg0" | "env" | "envs" | "env_clear" | "env_remove" | "current_dir"
3533 | "cwd" | "stdin" | "stdout" | "stderr" | "pre_exec" | "creation_flags" | "uid" | "gid"
3534 | "groups" | "process_group"
3535 )
3536}
3537
3538/// Whether a subprocess method NAMES the program (so its first string literal IS the command head to
3539/// refine): `Command::new("curl")`, `duct::cmd("curl", …)`. The head-refinement must fire ONLY here —
3540/// an ALLOWLIST, not "any method except known modifiers". A whole-crate-Exec crate classifies EVERY
3541/// method as `Exec`, so a denylist leaked NON-naming methods that aren't modifiers — a getter like
3542/// `CommandBuilder::get_env("psql")` (reading back an env-var KEY, not a program) fed `"psql"` to the
3543/// head classifier and FABRICATED `Db` (review find). Only `new`/`cmd` name a program; everything else
3544/// (modifiers, getters `get_*`, custom builder methods) keeps the bare `Exec` cliff — under-refine
3545/// (safe) rather than fabricate. `std::process::Command` is verb-precise so getters never fire `Exec`
3546/// there anyway; the allowlist makes the whole-crate-Exec crates safe too.
3547pub fn is_cmd_naming_method(method: &str) -> bool {
3548 matches!(method, "new" | "cmd")
3549}
3550
3551/// The masking guard (AS-EFF-008): a Net call whose method takes the HOST/URL as an argument is
3552/// "establishing" — a classified Net call here with no captured host literal leaves the endpoint
3553/// structurally INVISIBLE (a runtime-built host), so the surface is incomplete and the gate must fail
3554/// closed (else a benign sibling literal masks the runtime endpoint). An ALLOWLIST of connection-
3555/// establishing verbs — the SAFE direction: a USE-verb on an already-connected socket
3556/// (`stream.write`/`read`/`flush`, `socket.send`/`recv`) is NOT here, so a missing literal there (the
3557/// host was fixed at `connect`) never false-positives. **THAT LAST CLAIM WAS FALSE AND IS WITHDRAWN —
3558/// see R379 in the body: under-catching an establishing verb IS a broken gate whenever a benign sibling
3559/// literal sits in the same function, which is the exact scenario the first sentence names.** The list is
3560/// now an ALLOWLIST still — see the R379 body below: the denylist inversion was BUILT, A/B'd over
3561/// 1,545 crates and REFUSED (544 rows over-masked). An earlier draft of this line said "now a
3562/// DENYLIST", which was a leftover from the rejected cut and asserted the opposite of the code it
3563/// sits on — the comment-asserting-safety shape this register records. The arg is the method (path's
3564/// last segment).
3565pub fn is_net_establishing(method: &str) -> bool {
3566 // SOUNDNESS R379 — the DNS RESOLVER verbs were missing, and their absence was a GATE BYPASS.
3567 //
3568 // `classify` (see the `hickory_resolver` arm) already maps `::lookup_ip`, `::lookup` and any
3569 // `*_lookup` to `Net`, and asserts it. This list did not, so a `Net` call whose host is a runtime
3570 // value was never marked `incomplete` and a benign sibling literal certified it. Measured against
3571 // real hickory-resolver 0.26.2, on a fixture that builds clean:
3572 //
3573 // pub async fn masked(r: &Resolver<TokioRuntimeProvider>, host: &str) {
3574 // let _ = r.lookup_ip("good.example.com").await; // the benign literal
3575 // let _ = r.lookup_ip(host).await; // the runtime target
3576 // }
3577 //
3578 // reported `hosts:['good.example.com'] incomplete:NONE`, and `allow Net good.example.com` exited
3579 // **0** over a DNS resolution of a caller-controlled hostname. `TcpStream::connect(host)` — one
3580 // verb over, same fixture — marked `incomplete:['Net']` and failed closed. The mechanism was
3581 // sound; only the enumeration was short.
3582 //
3583 // THE DOC ABOVE USED TO CLAIM under-catching here is "a missed mask (sound-with-disclosure), never
3584 // a broken gate". That is false and is withdrawn: it is a broken gate whenever a benign sibling
3585 // literal shares the function, which is the scenario the guard's own first sentence names.
3586 //
3587 // **AND IT STAYS AN ALLOWLIST, WHICH IS NOT THE FAMILY'S USUAL DIRECTION — the exception is
3588 // measured and it is the same one `is_cmd_naming_method` states two doc-blocks up.** Inverting to
3589 // a denylist of use-verbs was built and A/B'd over all 1,545 registry crates: **544 rows gained
3590 // `incomplete` and none lost it**, because a WHOLE-CRATE-Net crate classifies EVERY method as Net,
3591 // so a denylist masks essentially everything in one — `cap-std` 66 rows, `hickory-net` 44. The
3592 // triggers it added were conversions, accessors and combinators (`into` 24, `from_std` 20,
3593 // `as_socket` 15, `setsockopt` 32, `map_err` 7, `ok` 5, `try_clone` 4) — none of which hides a
3594 // host. Worse, it masked **`bind` (31)**, which this very file has `is_net_binding` to exclude
3595 // because a bind address is where the process LISTENS, never a destination it reaches. So here the
3596 // allowlist is the precise side and the denylist is the leaky one, exactly as recorded for Exec.
3597 //
3598 // The resolver family is written out rather than the one spelling in hand (R346): `_lookup`
3599 // catches `reverse_lookup`/`srv_lookup`/`mx_lookup`/`txt_lookup`/`ipv4_lookup`/`ipv6_lookup` and
3600 // the rest of the record-type verbs in one test, mirroring `classify`'s own `ends_with("_lookup")`.
3601 method.ends_with("_lookup")
3602 || matches!(
3603 method,
3604 "connect"
3605 | "connect_timeout"
3606 | "get"
3607 | "post"
3608 | "put"
3609 | "patch"
3610 | "delete"
3611 | "head"
3612 | "request"
3613 | "send_to"
3614 | "lookup_host"
3615 | "to_socket_addrs"
3616 // R379 — the DNS resolver verbs `classify` already calls Net.
3617 | "lookup"
3618 | "lookup_ip"
3619 | "resolve"
3620 )
3621}
3622
3623/// ⟨0.29⟩ The Net verbs whose LOCATOR is at argument **1**, not 0 — the rust analogue of candor-ts's
3624/// `NET_URL_ARG1_MEMBERS`.
3625///
3626/// **A REGRESSION FOUND IN REVIEW, and the direction matters.** The positional-literal rung replaced
3627/// `first_str_lit` ("the first string literal ANYWHERE in the call") with `positional_str_lit(args, 0)`
3628/// as the UNIVERSAL default. That is right for `Fs`/`Db`/`Exec`, whose locator is always argument 0 —
3629/// but `is_net_establishing` already listed two verbs whose locator is not: `reqwest::Client::request`
3630/// takes `(Method, url)` and `UdpSocket::send_to` takes `(buf, addr)`. MEASURED after the swap:
3631/// `c.request(Method::GET, "https://api.example.com/v1")` published NO `hosts` at all and could not be
3632/// certified, while `c.get("https://api.example.com/v1")` certified normally.
3633///
3634/// ⟨0.29⟩ MEASURED FOR `request` ONLY. `send_to` is a METHOD on a receiver the STABLE syntactic backend
3635/// does not type, so `s.send_to(buf, "203.0.113.9:53")` is not classified at all there and its arg-1
3636/// literal is still uncaptured on the floor; the position is right and the deep engine can use it. Said
3637/// plainly because the first version of this fix's changelog listed both verbs as measured.
3638///
3639/// The direction is SAFE — an uncaptured locator fails closed, it never certifies something invisible —
3640/// which is exactly why it needed a review to find: the gate stayed sound and quietly stopped being
3641/// USABLE for a common shape. candor-ts avoided this by making its resolver verb-aware in the same rung;
3642/// this is that discipline arriving one engine late.
3643pub fn is_net_host_arg1(method: &str) -> bool {
3644 matches!(method, "request" | "send_to")
3645}
3646
3647/// ⟨0.29⟩ / ⟨0.40⟩ A LOCAL BIND/LISTEN VERB — the address it names is where the process LISTENS, never a
3648/// destination it reaches (SPEC §2 ⟨0.40⟩ *A BIND OR LISTEN ADDRESS IS WHERE THE PROCESS LISTENS*, PART 96
3649/// `a_litbind`). Its literal never enters `hosts`.
3650///
3651/// **MEASURED, and it is a false all-clear rather than a naming quibble.** `UdpSocket::bind("0.0.0.0:0")`
3652/// put `0.0.0.0:0` into `hosts`, the DESTINATION surface `allow Net` gates on (§2), and — because a
3653/// literal had been captured — nothing marked the surface incomplete. So:
3654///
3655/// ```text
3656/// let s = UdpSocket::bind("0.0.0.0:0")?; // local
3657/// s.send_to(b"secrets", dst); // destination is a RUNTIME value
3658/// allow Net 0.0.0.0 -> policy ✓, exit 0
3659/// ```
3660///
3661/// A local listen address certified a send to an endpoint nobody can see. That is the masking evasion
3662/// AS-EFF-008 exists to close, reached through a verb whose literal is not a destination at all.
3663///
3664/// **A BIND MARKS NOTHING** (⟨0.40⟩). This doc used to say *"A BIND CANNOT BE CERTIFIED, EVER, which is
3665/// why this marks the surface incomplete"*, and that has been false since `16fb46f` dropped the hedge:
3666/// neither rust engine marks a bind, and both only withhold its literal. ⟨0.40⟩ makes the withholding the
3667/// rule — an ephemeral client's own `send_to`/`connect` carries its locator under `is_net_establishing`,
3668/// so hedging the bind as well made every UDP client uncertifiable while adding nothing a gate could use;
3669/// and a bind with nothing beside it still fails `allow Net` closed on its EMPTY literal surface. The
3670/// half of the old sentence that WAS right — *a server that binds and accepts talks to whoever connects*
3671/// — belongs to the ACCEPT, not the bind, and lives in `is_net_accepting`, which is why `accept` and
3672/// `incoming` are no longer members here.
3673pub fn is_net_binding(method: &str) -> bool {
3674 matches!(method, "bind" | "listen" | "bind_to_device")
3675}
3676
3677/// ⟨0.40⟩ A CALL THAT ACCEPTS CONNECTIONS — its peers are chosen by whoever connects, so no literal can name
3678/// them, and the unit's `Net` surface is INCOMPLETE (SPEC §2 ⟨0.40⟩ *A UNIT THAT ACCEPTS A CONNECTION …*,
3679/// AS-EFF-008, SEMANTICS §6 `masked_Net`; PART 96 `c_accept`). Both rust engines mark `incomplete` on a
3680/// `Net`-classified call this answers, WHETHER OR NOT a literal was captured (a server bootstrap's literal is
3681/// a LISTEN address, withheld like a bind's), and contribute no `hosts` entry for it.
3682///
3683/// **SOUNDNESS R817 — `accept`/`incoming` WERE MEMBERS OF `is_net_binding`, WHICH WITHHOLDS AND NEVER
3684/// MARKS**, so they were read exactly like a use-verb. `pub fn f(l: &TcpListener) { let _ =
3685/// TcpStream::connect("ok.example:80"); if let Ok((mut s, _)) = l.accept() { s.write_all(b"hi") } }`
3686/// reported `hosts: ["ok.example:80"]`, no `incomplete`, and `allow Net in f ok.example` exited **0** over a
3687/// server writing to whoever connected — EXECUTED: a client on 127.0.0.1 received the bytes.
3688///
3689/// The membership, by spelling (`path` is the resolved call path, `leaf` its last segment):
3690/// * `accept`, `accept4` (libc/nix/rustix), `poll_accept` (tokio's poll form), `incoming` and
3691/// `into_incoming` (std's iterator forms) — exact LEAF. `accept_bi`/`accept_uni` (quinn) are NOT here:
3692/// they accept a STREAM on a connection whose peer was already fixed by the endpoint's `accept`.
3693/// * tonic's `Router::serve`/`Router::serve_with_shutdown` and `TcpIncoming::new`, the server bootstraps
3694/// `classify` already charges `Net` — each binds a listener and begins accepting on it (classify's own
3695/// tonic arm cites the source lines). By PATH, because `serve`/`new` are not accept verbs anywhere else.
3696///
3697/// A wrong `true` over-masks and fails CLOSED, saying so; a wrong `false` is the benign-sibling-literal
3698/// bypass this row closed. An exact `accept` leaf also matches a TLS/WebSocket acceptor wrapping a stream
3699/// that was already accepted (`TlsAcceptor::accept(stream)`, `tungstenite::accept`) — whose peer is likewise
3700/// whoever connected, so the mark is redundant there, never false.
3701pub fn is_net_accepting(path: &str, leaf: &str) -> bool {
3702 matches!(leaf, "accept" | "accept4" | "poll_accept" | "incoming" | "into_incoming")
3703 || (path.starts_with("tonic::")
3704 && (path.ends_with("::Router::serve")
3705 || path.ends_with("::Router::serve_with_shutdown")
3706 || path.ends_with("::TcpIncoming::new")))
3707}
3708
3709/// The masking guard (AS-EFF-008), the `Fs` analog of `is_net_establishing`: whether an `Fs`-classified
3710/// call takes the filesystem PATH as a string argument (so a missing literal leaves the path
3711/// structurally INVISIBLE — a runtime-built path — and the surface is incomplete, fail-closed). An
3712/// ALLOWLIST of the path-NAMING free functions / constructors (`fs::write`/`read`/`File::open`/…), the
3713/// SAFE direction: a path-stat METHOD whose path is the RECEIVER (`p.metadata()`, `p.exists()`) is
3714/// invoked method-form and the caller gates on `!is_method`, so this never sees it; an op on an
3715/// already-opened handle (`file.write_all`, `mmap.flush`, `tempfile()` — a random name, no path arg)
3716/// is not here, so a missing literal there never false-positives. Under-catching an unusual
3717/// path-naming fn is a missed mask (sound-with-disclosure), ~~never a broken gate~~ — **AND THAT LAST
3718/// CLAUSE IS FALSE. SOUNDNESS R399.** R379 withdrew this exact sentence from `is_net_establishing` after
3719/// MEASURING it wrong, and R383 and R386 are counter-examples on this guard and its Db sibling: an
3720/// unlisted path-taking call contributes no locator, nothing marks the surface incomplete, and a benign
3721/// sibling literal then CERTIFIES the invisible destination — `allow Fs /etc/hostname` exit 0 over
3722/// `unixfs::chown(caller, …)`, with the control (`OpenOptions::open(caller)`) exiting 1. The sentence
3723/// survived here only because nobody re-read it after R379, and it is what licenses leaving the list
3724/// short. Under-catching is a BROKEN GATE, not a missed mask. The arg is the
3725/// method/fn leaf (the path's last segment).
3726pub fn is_fs_path_arg(leaf: &str) -> bool {
3727 matches!(
3728 leaf,
3729 // SOUNDNESS R399 — the POSIX ownership/root verbs take a path as argument 0 and were absent, so a
3730 // benign sibling literal certified them: `allow Fs /etc/hostname` exit 0 over
3731 // `unixfs::chown(caller, …)`, control `OpenOptions::open(caller)` exit 1. `std::os::unix::fs`.
3732 "chown"
3733 | "lchown"
3734 | "chroot"
3735 // std::fs / tokio::fs / async_std::fs / fs_err free functions taking a path argument
3736 | "write"
3737 | "read"
3738 | "read_to_string"
3739 | "read_dir"
3740 | "read_link"
3741 | "copy"
3742 | "rename"
3743 | "remove_file"
3744 | "remove_dir"
3745 | "remove_dir_all"
3746 | "create_dir"
3747 | "create_dir_all"
3748 | "hard_link"
3749 | "soft_link"
3750 | "symlink"
3751 | "symlink_file"
3752 | "symlink_dir"
3753 | "symlink_metadata"
3754 | "canonicalize"
3755 | "metadata"
3756 | "set_permissions"
3757 | "exists"
3758 | "try_exists"
3759 // File / OpenOptions constructors taking a path argument
3760 | "open"
3761 | "create"
3762 | "create_new"
3763 )
3764}
3765
3766/// SOUNDNESS R334 — IS THIS PATH SEGMENT THE OS ENTROPY SOURCE ITSELF (as a VALUE, not a verb)?
3767///
3768/// **THE DEFECT THIS EXISTS FOR.** Every entropy rule in the `rand` block keys on the CALLEE — a verb
3769/// (`from_os_rng`, `next_u32`, `fill_bytes`) or the source type in RECEIVER position
3770/// (`contains("OsRng")`/`contains("SysRng")`, R320). The idiomatic way to seed a userspace CSPRNG from
3771/// the OS puts the source in ARGUMENT position, where neither key can see it. Executed, one fixture,
3772/// four rows:
3773///
3774/// ```text
3775/// StdRng::try_from_rng(&mut SysRng) ABSENT quinn-proto-0.11.17 endpoint.rs:81
3776/// StdRng::from_rng(&mut OsRng) ABSENT the same hole in the OLD spelling
3777/// StdRng::from_seed([0u8; 32]) ABSENT correct — deterministic, and the control
3778/// StdRng::from_os_rng() ['Rand'] the verb form
3779/// ```
3780///
3781/// Both spellings, so this is NOT a rename residual and predates R320 — whose "stale `OsRng` clause"
3782/// framing would have sent the audit straight past it.
3783///
3784/// **WHY THE ARGUMENT AND NOT THE VERB.** `from_rng`/`try_from_rng` cannot simply be charged: their
3785/// effect is decided by what is passed to them, `from_rng(&mut pcg)` is deterministic, and there are
3786/// 802 `from_seed`/`seed_from_u64` call sites in this machine's registry cache — the deterministic
3787/// population is the larger one. Charging the verb is R330's mistake one crate over. The discriminator
3788/// is the ARGUMENT, so that is what is read.
3789///
3790/// **THE OVER-APPROXIMATION IS DELIBERATE AND STATED.** Naming one of these types as a VALUE is charged
3791/// wherever it appears in an argument list, including moves that do not themselves draw (`Some(OsRng)`,
3792/// `Box::new(SysRng)`). That is sound and it is also the ONLY place candor can see it: a source handed
3793/// off into a struct field is drawn from in some later function whose own call list never mentions it.
3794/// A user type coincidentally named `OsRng` would be charged too — an over-report, the safe direction,
3795/// and cheaper than the silence it replaces.
3796pub fn is_entropy_source_ident(seg: &str) -> bool {
3797 matches!(seg, "OsRng" | "SysRng")
3798}
3799
3800/// SOUNDNESS R338 — DOES THIS CALL ALSO READ THE SYSTEM TRUST STORE? A SECOND effect, not a
3801/// replacement for the one `classify` returns.
3802///
3803/// **THE DEFECT.** candor charges `native_tls::TlsConnector::new` `Fs` because on the openssl backend
3804/// it loads the probed cert file/dir from disk. Several calibrated crates construct that connector
3805/// INSIDE a public entry point, so the consumer's call discloses the transport effect and drops the
3806/// `Fs` — `tungstenite::client_tls` reports `['Net']`, and `deny Fs` passes over a real trust-store
3807/// read. `wrap_stream` does `tls_connector.map_or_else(TlsConnector::new, Ok)`
3808/// (`tungstenite-0.30.0/src/tls.rs:54`), so a caller supplying no connector gets `TlsConnector::new()`.
3809/// (The row the coverage gate nominated — `tungstenite::tls::encryption::native_tls::wrap_stream` — is
3810/// unreachable from outside: `mod tls;` is private at `lib.rs:32`. The reachable form is one level up.)
3811///
3812/// **WHY THIS IS SO SHORT: the sweep found four more crates and every one was PRICED AND REJECTED,
3813/// which is the deliverable rather than a gap.** Callers of `native_tls::TlsConnector::new` in the
3814/// local registry are redis, tungstenite, ureq, minreq and tokio-websockets.
3815/// - `minreq`, `tokio_websockets` — NOT calibrated, so their calls already disclose rather than
3816/// claim purity. No rule can improve them.
3817/// - `redis` — `ActualConnection::new` (`redis-1.6.0/src/connection.rs:932`) constructs it only for a
3818/// `rediss://` address, and the consumer-visible verb is `Client::get_connection`. Charging that
3819/// `Fs` would fabricate on every PLAIN redis user, which is the majority. Rejected on the trade.
3820/// - `ureq` — `agent.rs:265` takes `crate::default_tls_config()`, and ureq's default features are
3821/// `["tls", "gzip"]`: `native-certs` is OFF, so the default build resolves to
3822/// `rtls::root_certs()`'s `#[cfg(not(feature = "native-certs"))]` arm, which is
3823/// `webpki_roots::TLS_SERVER_ROOTS` — COMPILED IN, no disk read (`ureq-2.12.1/src/rtls.rs:82`).
3824/// Charging it would fabricate on the default build. Rejected on the evidence.
3825///
3826/// What is left is tungstenite's two explicitly-TLS-named entry points, where no caller is
3827/// over-charged: `client_tls` passes `None` for the connector unconditionally, and
3828/// `client_tls_with_config`'s callers are by definition doing TLS. **`tungstenite::connect` is a stated
3829/// residual, not an oversight** — it serves `ws://` and `wss://` alike, so charging it would hit real
3830/// plain-WebSocket users; the scheme is often a string literal candor already captures in `str_arg`,
3831/// which is the shape a future fix should use rather than a blanket charge.
3832pub fn reads_trust_store(crate_name: &str, path: &str) -> bool {
3833 crate_name == "tungstenite"
3834 && (path.ends_with("::client_tls") || path.ends_with("::client_tls_with_config"))
3835}
3836
3837/// SOUNDNESS R330 — IS THIS `hash_password` CALL PROVABLY THE EXPLICIT-SALT ONE (password-hash 0.5.x)?
3838///
3839/// **THE DEFECT THIS EXISTS FOR, and it was mine, introduced by R318's fix the previous day.** R318
3840/// correctly found that `hash_password` mints OS entropy — at password-hash **0.6.x**, where
3841/// `fn hash_password(&self, password)` (`lib.rs:107`) has the body `let salt = try_generate_salt()?;`.
3842/// At **0.5.0** (`traits.rs:33`) and argon2 **0.5.3** (`lib.rs:572`) the identical name is
3843/// `fn hash_password(&self, password, salt)`: the caller supplies the salt and the call is deterministic
3844/// recomputation — PURE. So the `ends_with`-not-`contains` guard R318 relied on was keyed on the 0.6.x
3845/// name, and at 0.5.x the explicit-salt call IS named `hash_password`. Measured: two calls with the same
3846/// stored salt produce byte-identical hashes, and all four 0.5.x spellings were charged `Rand`.
3847///
3848/// **Why arity and not the resolved version.** R330's remedy offered both. The version is only knowable
3849/// where a lockfile resolves, and `calibration_exceeded` is ONE-SIDED — it fires when resolved > ceiling,
3850/// so it can never see a rule that mis-describes an OLDER release, which is exactly this direction. The
3851/// arity is written at the call site, needs no lockfile, and distinguishes the two signatures exactly.
3852///
3853/// **The direction is stated so it cannot drift:** this returns true only for the shape it can PROVE is
3854/// explicit-salt. An unrecorded arity (`argc == 0`, every synthesized edge and every pre-R330 cache
3855/// entry) returns false and the call stays charged. Narrowing a sound over-approximation by DENYLIST,
3856/// never by allowlist — the family rule.
3857pub fn password_hash_explicit_salt(crate_name: &str, path: &str, argc: u8, method: bool) -> bool {
3858 if !matches!(crate_name, "argon2" | "scrypt" | "pbkdf2" | "password_hash")
3859 || !path.ends_with("::hash_password")
3860 {
3861 return false;
3862 }
3863 if argc == 0 {
3864 return false; // NOT RECORDED — see `Call::argc`. Unknown charges.
3865 }
3866 // `syn` hands a method call its args WITHOUT the receiver and a UFCS `Trait::method(&recv, ..)`
3867 // WITH it, so normalize to the non-receiver count before comparing: 0.5.x is `(password, salt)` = 2,
3868 // 0.6.x is `(password)` = 1.
3869 let non_receiver = if method { argc } else { argc - 1 };
3870 let hit = non_receiver == 2;
3871 // §E1 HIT COUNTER, the `CANDOR_ALIAS_DEBUG` pattern. A refinement that narrows a charge must be
3872 // able to prove it REACHED real code, because "nothing moved" and "the branch never ran" print the
3873 // same corpus A/B. This is what `bin/corpus-ab.py --mark` counts.
3874 if hit && std::env::var("CANDOR_ALIAS_DEBUG").is_ok() {
3875 eprintln!("R330EXPLICITSALT {path} argc={argc} method={method}");
3876 }
3877 hit
3878}
3879
3880/// ⟨0.29⟩ HOW MANY LEADING ARGUMENTS OF AN `Fs` PATH-TAKING CALL ARE PATHS.
3881///
3882/// **THE DEFECT THIS EXISTS FOR.** The literal harvester took the first string literal found ANYWHERE in
3883/// the argument list, so `fs::write(user_path, "/tmp/lit")` published `paths: ["/tmp/lit"]` — the BYTES
3884/// BEING WRITTEN — as the destination surface, and `allow Fs /tmp/lit` certified a write to an
3885/// attacker-controlled path at exit 0. Measured on candor-scan and candor-ts; candor-java and
3886/// candor-swift read the path POSITION and fail closed correctly. Removing the sibling literal made the
3887/// same call fail closed, which is what identified the mechanism: any literal in the call, in any
3888/// position, defeated the runtime-path incompleteness marker.
3889///
3890/// So the surface is read from the PATH POSITIONS and nowhere else, and every one of them must be a
3891/// literal for the surface to be complete. Two-path operations are the reason this is an arity and not a
3892/// boolean: `fs::copy("/safe", user_path)` has a literal at position 0 and still writes somewhere
3893/// nobody can see, so requiring only position 0 would leave the identical hole one argument along.
3894pub fn fs_path_arity(leaf: &str) -> usize {
3895 match leaf {
3896 "copy" | "rename" | "hard_link" | "soft_link" | "symlink" | "symlink_file" | "symlink_dir" => 2,
3897 _ => 1,
3898 }
3899}
3900
3901/// SOUNDNESS R383 — THE METHOD FORMS WHOSE PATH IS AN ARGUMENT, not the receiver.
3902///
3903/// `scan.rs`'s Fs masking arm reads `!c.method && is_fs_path_arg(leaf)`. The `!c.method` gate exists for
3904/// a real reason — the path-STAT methods (`p.metadata()`, `p.exists()`) carry the path as the RECEIVER,
3905/// so masking them would be wrong — but it excluded EVERY method form, and `OpenOptions::open(path)` is
3906/// a method whose path IS the argument. Measured, with the instrument calibrated first: a function doing
3907/// `File::open("/etc/hostname")` beside `OpenOptions::new().read(true).open(caller_path)` reported
3908/// `paths:['/etc/hostname'] incomplete:NONE`; `deny Fs` exited 1 and **`allow Fs /etc/hostname` exited
3909/// 0** — a policy saying "only /etc/hostname" going green over a caller-supplied path. The FREE-fn
3910/// spelling of the same call is the isolating control: it marks incomplete and fails closed.
3911///
3912/// **`argc` does not discriminate; the RECEIVER TYPE does.** Instrumented, the four shapes are
3913/// `OpenOptions::open` (argc 1, path IS the arg), `File::read_to_string` and `File::write` (argc 1,
3914/// BUFFER arg) and `Path::exists` (argc 0, path is the receiver).
3915///
3916/// **The family is from a 1,545-crate sweep, not from the one spelling that was found (R346):** the only
3917/// path-taking METHOD pairs in the registry are `OpenOptions::open` (69 std + 2 tokio + 9
3918/// `cap_primitives`) and `DirBuilder::create` (5 std + tokio). Every other method on those same receivers
3919/// is a builder modifier (`read`/`write`/`truncate`/`mode`) or a combinator (`map`/`map_err`/`or_else`/
3920/// `with_context`), and `std::fs::File`'s 267 method hits are all use-verbs whose path was fixed at open.
3921///
3922/// Matched on the TAIL of the resolved call path, so std, tokio, `cap_primitives` and `fs_err` are all
3923/// covered by one rule without naming a crate. **Stays an ALLOWLIST:** R379 built the denylist inversion
3924/// for the Net twin and the corpus refused it, and here a denylist over method forms would mask all 267
3925/// `File` use-verbs and every combinator on a whole-crate-Fs receiver.
3926///
3927/// **SOUNDNESS R417 — THE CAPABILITY-`Dir` FAMILY WAS ABSENT, AND IT IS A LIVE GATE BYPASS.**
3928/// `cap_std::fs::Dir` (and its `fs_utf8`/`cap_async_std` twins, and `openat::Dir`) is a whole second
3929/// filesystem API whose path is an ARGUMENT to a METHOD on a directory handle — `d.write(caller, …)`,
3930/// `d.open(caller)`, `d.remove_file(caller)`. MEASURED, with the free-fn spelling as the isolating
3931/// control in the same fixture:
3932///
3933/// ```text
3934/// control(caller) std::fs::write("/tmp/benign",…); std::fs::write(caller,…)
3935/// → paths:['/tmp/benign'] incomplete:['Fs'] allow Fs /tmp/benign → exit 1 ✔
3936/// mixed(d,caller) std::fs::write("/tmp/benign",…); d.write(caller,…)
3937/// → paths:['/tmp/benign'] incomplete:NONE allow Fs /tmp/benign → exit 0 ✘
3938/// ```
3939///
3940/// The call path resolves in full (`cap_std::fs::Dir::write`), so this is not a resolution gap — the
3941/// guard simply had two names in it. `alone(d,caller)` confirms the call IS classified `Fs`: the
3942/// effect was seen, the locator was not, and nothing said so.
3943///
3944/// **WHY THIS HALF IS A DENYLIST WHILE THE REST OF THE GUARD IS AN ALLOWLIST**, which is not a
3945/// contradiction but the point: the allowlist above exists because the receiver is UNKNOWN, so masking
3946/// by default would swallow `File`'s 267 use-verbs. Here the receiver is KNOWN to be a directory
3947/// capability, and on that receiver essentially every method names a path — 24 of the 49 declared on
3948/// `cap_std::fs::Dir` are already `is_fs_path_arg` leaves, and most of the remaining 25 (`open_with`,
3949/// `open_dir`, `is_file`, `is_dir`, `symlink_contents`, `read_link_contents`, `create_dir_with`,
3950/// the `bind_unix_*`/`connect_unix_*` socket-path verbs) take one too. An allowlist here would be the
3951/// R346→R348 hand-list vein by construction: every name I failed to write down is a silent bypass, and
3952/// R399 settled that an under-catch on this guard is a BROKEN GATE, not a missed mask.
3953///
3954/// So the default on a `Dir` receiver is MASK, and the exceptions are the handful of methods that
3955/// provably take no path: handle-to-handle conversions and the iterator/`self`-only verbs. Being wrong
3956/// about one of those costs an over-mask on a rare API (fails CLOSED, disclosed); being wrong the other
3957/// way is the exit-0 above. [[candor-denylist-over-allowlist]] — say which direction it fails in first.
3958///
3959/// Scoped on the RECEIVER SEGMENT being exactly `Dir`, so `cap_std`, `cap_std::fs_utf8`,
3960/// `cap_async_std` and `openat` are covered by one rule without naming a crate — the same tail-matching
3961/// discipline as the two names above. A non-filesystem type that happens to be called `Dir` would be
3962/// over-masked, which fails closed.
3963/// SOUNDNESS R414 / SPEC ⟨0.37⟩ — **a RECEIVER-form path stat names its destination, and every engine
3964/// in this family was silent on it.** `p.exists()`, `p.metadata()`, `p.canonicalize()` reach the
3965/// filesystem against the path they are invoked ON; the locator is the RECEIVER, not an argument.
3966/// MEASURED before the clause was written, one variable, a benign allowed literal beside a
3967/// caller-chosen path:
3968///
3969/// ```text
3970/// fn f(p: &Path) { fs::write("/tmp/benign", b"x"); p.exists(); }
3971/// -> paths:['/tmp/benign'] incomplete:NONE allow Fs /tmp/benign -> exit 0, "nothing hidden"
3972/// ```
3973///
3974/// The ARGUMENT spelling of the identical reach (`fs::metadata(p)`) was already marked, which is what
3975/// makes this a drift rather than a policy: `is_fs_path_arg` deliberately excluded the method form
3976/// because its receiver was assumed to be an OPEN HANDLE. That assumption holds for `File` and is
3977/// false for `Path` — a `Path` is a path VALUE, and a stat on it is `Fs` reaching that value.
3978///
3979/// **NO CARVE-OUT LIST, and that is structural rather than lucky.** The pure `Path` algebra —
3980/// `join`, `parent`, `file_name`, `extension`, `to_str`, `display`, `starts_with`, `to_path_buf`,
3981/// `as_path` — is NOT classified `Fs` (measured: a function calling all nine reports `fs:['write']`
3982/// from an unrelated sibling call and nothing else), so it never reaches the branch that consults
3983/// this predicate. The carve-out falls out of the classification, exactly as it does on the java
3984/// side, instead of being a list that must be kept in sync with `std`.
3985///
3986/// Matched on the RECEIVER SEGMENT so `std::path` and the `camino` mirrors are covered by one rule
3987/// without naming a crate. Being generous here is the SAFE direction: the predicate is only ever
3988/// consulted for a call the classifier ALREADY resolved to `Fs`, so an extra receiver name can at
3989/// worst over-mask (fails closed, disclosed), while a missing one is the exit-0 above — R399's
3990/// ruling that an under-catch on a masking guard is a broken gate, not a missed mask.
3991pub fn is_fs_receiver_locator(call_path: &str) -> bool {
3992 let mut segs = call_path.rsplit("::");
3993 let (Some(_leaf), Some(recv)) = (segs.next(), segs.next()) else { return false };
3994 matches!(recv, "Path" | "PathBuf" | "Utf8Path" | "Utf8PathBuf")
3995}
3996
3997/// SOUNDNESS R460 / SPEC ⟨0.37⟩ — **the Exec twin of `is_fs_receiver_locator`: a spawn's locator is
3998/// its RECEIVER, and rust was silent on it.** `is_cmd_naming_method` marks the surface incomplete at
3999/// `Command::new(<runtime value>)`, which covers every spelling that NAMES the program inside the
4000/// function. It cannot cover the spelling that names it somewhere else:
4001///
4002/// ```text
4003/// fn masked(cmd: &mut Command) { Command::new("git").status(); cmd.spawn(); }
4004/// -> cmds:['git'] incomplete:NONE allow Exec git -> exit 0, "nothing hidden"
4005/// ```
4006///
4007/// MEASURED on the pre-fix binary, one variable — deleting the benign `Command::new("git")` line, and
4008/// nothing else, turns the same gate to exit 1. The real instance is tokio's own
4009/// `process::unix::spawn_child(cmd: &mut std::process::Command) { cmd.spawn()? }`, production source,
4010/// charged `Exec` with no `cmds` and no `incomplete`.
4011///
4012/// **A DENYLIST, and the direction is the point.** On a `Command` receiver essentially every method
4013/// that is not a documented BUILDER modifier reaches the boundary (`spawn`, `status`, `output`, `exec`,
4014/// and every async / pty mirror of them), so an allowlist of terminal names would be the R346→R348
4015/// hand-list vein by construction: each name not written down is a silent bypass. The carve-out is
4016/// `is_cmd_builder_method` — the SAME list the head-refinement already trusts, not a second copy of it
4017/// (§G, ask the authority) — plus the `get_*` getters that list's own doc-block names. Being wrong
4018/// about a carve-out costs an over-mask on a configure-only call (fails closed, disclosed); being
4019/// wrong the other way is the exit-0 above.
4020///
4021/// Scoped on the RECEIVER SEGMENT being exactly `Command`, so `std::process`, `tokio::process`,
4022/// `async_process` and the `std::os::unix` extension trait are one rule without naming a crate — the
4023/// same tail-matching discipline as `is_fs_receiver_locator`. The predicate is only ever consulted for
4024/// a call the classifier ALREADY resolved to `Exec`, so a non-spawning type that happens to be called
4025/// `Command` (clap's builder) is unreachable unless something already charged it Exec.
4026///
4027/// The DETERMINED receiver does not arrive here: the collector resolves a builder chain rooted at
4028/// `Command::new("git")` — inline or through a `let` binding — into `str_arg`, so only a receiver whose
4029/// program was never named in this function reaches the mask. That split is this fix's over-charge
4030/// control, and it is what keeps the dominant spelling certifiable.
4031pub fn is_exec_receiver_locator(call_path: &str) -> bool {
4032 let mut segs = call_path.rsplit("::");
4033 let (Some(leaf), Some(recv)) = (segs.next(), segs.next()) else { return false };
4034 // SOUNDNESS R808 — AND ITS EXTENSION TRAIT. The deep engine resolves `cmd.exec()` to the TRAIT method
4035 // `std::os::unix::process::CommandExt::exec`, not to `Command::exec`, so a receiver test on the
4036 // literal segment `Command` let the deep engine certify a caller-supplied `exec` while `candor-scan`
4037 // (which forms `Command::exec` from the typed receiver) flagged it — the R808 port's own boundary,
4038 // measured on the port's fixture one method over. `CommandExt`'s other members (`uid`, `gid`,
4039 // `groups`, `pre_exec`, `arg0`, `process_group`) are already `is_cmd_builder_method` carve-outs.
4040 matches!(recv, "Command" | "CommandExt") && !is_cmd_builder_method(leaf) && !leaf.starts_with("get_")
4041}
4042
4043pub fn is_fs_path_arg_method(call_path: &str) -> bool {
4044 if call_path.ends_with("OpenOptions::open") || call_path.ends_with("DirBuilder::create") {
4045 return true;
4046 }
4047 let mut segs = call_path.rsplit("::");
4048 let (Some(leaf), Some(recv)) = (segs.next(), segs.next()) else { return false };
4049 if recv != "Dir" {
4050 return false;
4051 }
4052 // The no-path exceptions on a directory capability: handle↔handle conversions, the entry
4053 // iterators, and the verbs whose only operand is `self`. Everything else on a `Dir` names a path.
4054 //
4055 // EVERY NAME HERE WAS CHECKED AGAINST ITS SIGNATURE, because a wrong entry here is not a precision
4056 // loss, it is the exit-0 above. `read_dir` was in this list on the first cut on the reasonable-
4057 // sounding grounds that `entries()` is the self-only form — and `cap_std::fs::Dir::read_dir` is
4058 // `fn read_dir<P: AsRef<Path>>(&self, path: P)`, so that entry was a bypass I had just written.
4059 // Adding a name here requires reading its signature, not recognising it.
4060 !matches!(
4061 leaf,
4062 "try_clone"
4063 | "into_std_file"
4064 | "from_std_file"
4065 | "into_std"
4066 | "from_std"
4067 | "entries"
4068 | "entries_utf8"
4069 | "dir_metadata"
4070 | "reopen_dir"
4071 | "remove_open_dir"
4072 | "remove_open_dir_all"
4073 | "open_parent_dir"
4074 | "rewind"
4075 | "as_raw_fd"
4076 | "as_fd"
4077 )
4078}
4079
4080/// The masking guard (AS-EFF-008), the `Db` analog of `is_net_establishing`: whether a `Db`-classified
4081/// call takes the raw SQL QUERY as a string argument (so a missing literal leaves the table
4082/// structurally INVISIBLE — a runtime-built query — and the surface is incomplete, fail-closed). An
4083/// ALLOWLIST of the SQL-string-bearing execution/prepare verbs, the SAFE direction: a
4084/// build-then-execute terminal that takes NO SQL string (sqlx/diesel/sea_orm `fetch*`/`load*`/`first`/
4085/// `all`/`one`/`stream`, the document-store `find*`/`insert*`/…), and a non-query op (`connect`/
4086/// `open`/`acquire`/`begin`/`commit`/`ping`/`get_conn`), are NOT here — their query is built
4087/// structurally (never a maskable string literal) so a missing literal must not false-positive.
4088/// Under-catching an unusual query verb is a missed mask (sound-with-disclosure), ~~never a broken
4089/// gate~~ — **FALSE, SOUNDNESS R399**, and R386 three lines below is this guard's own counter-example.
4090/// `mysql`'s `query_drop`/`query_iter` are that crate's PRIMARY API, take `Q: AsRef<str>`, and were
4091/// absent: `allow Db users` exited 0 over `c.query_drop(caller_sql)` beside one literal query, with the
4092/// verb-only control exiting 1. The arg is the method leaf (the path's last segment).
4093pub fn is_db_query_arg(leaf: &str) -> bool {
4094 // SOUNDNESS R386 — THE `sqlite3_*` C API TAKES SQL AND WAS ABSENT, so a benign sibling literal
4095 // certified arbitrary caller-supplied SQL. `classify` in this same crate maps `sqlite3_exec`/
4096 // `sqlite3_step`/`sqlite3_prepare*` to `Db`; this list had none of them, while candor-swift covers
4097 // the whole family by prefix — a cross-engine divergence, which is how it was found.
4098 //
4099 // MEASURED, calibrated first (`deny Db` exits 1): `conn.execute("INSERT INTO users (id) VALUES (1)",
4100 // [])` beside `sqlite3_exec(db, caller_sql, …)` reported `tables:['users'] incomplete:NONE` and
4101 // **`allow Db users` exited 0**. R383 recorded this gap as "REAL BUT NOT LIVE" on the grounds that a
4102 // C-string literal never reaches `tables` — true of an ISOLATED function, and the wrong shape: the
4103 // literal does not have to come from the masked call, only from the same FUNCTION.
4104 //
4105 // ONLY THE SQL-BEARING SPELLINGS. `sqlite3_step`/`_close`/`_serialize`/`_deserialize` carry no query,
4106 // and `sqlite3_open*` takes a FILENAME — masking that would claim a Db surface for an Fs locator,
4107 // which is the over-mask this allowlist exists to avoid. Direction stays an ALLOWLIST for the reason
4108 // R379 measured: a denylist over a whole-crate-Db crate masks every lifecycle verb in it.
4109 if matches!(
4110 leaf,
4111 "sqlite3_exec"
4112 | "sqlite3_prepare"
4113 | "sqlite3_prepare_v2"
4114 | "sqlite3_prepare_v3"
4115 | "sqlite3_prepare16"
4116 | "sqlite3_prepare16_v2"
4117 | "sqlite3_prepare16_v3"
4118 // R386's OWN BOUNDARY DEFECT, found by a release panel: `sqlite3_get_table(db, zSql, …)`
4119 // takes SQL as its second argument and `classify` maps it to `Db`, and the first cut of
4120 // this list — which cites R346 — omitted it. Two of the family's SQL-bearing spellings
4121 // written down out of three. Reach on the local registry is ZERO call sites (only
4122 // declarations in `libsqlite3-sys` and `sqlite-wasm-rs`), so this is latent, not live.
4123 | "sqlite3_get_table"
4124 ) {
4125 return true;
4126 }
4127 matches!(
4128 leaf,
4129 "execute"
4130 | "execute_batch"
4131 | "execute_unprepared"
4132 | "batch_execute"
4133 | "simple_query"
4134 | "query"
4135 | "query_one"
4136 | "query_opt"
4137 | "query_raw"
4138 | "query_row"
4139 | "query_map"
4140 // SOUNDNESS R399 — `mysql`'s OWN PRIMARY API was absent, and this is a live gate bypass rather
4141 // than the "missed mask" the struck sentence above promised. All four take `Q: AsRef<str>`
4142 // (`mysql-28.0.2/src/conn/queryable.rs:29,50,126`), so the SQL is a maskable string argument of
4143 // THIS call, which is the exact condition this list encodes. Measured: `allow Db users` exits 0
4144 // over `c.query_drop(caller_sql)` beside one literal query; the control, the same fixture with
4145 // the verb changed to `query`, exits 1.
4146 | "query_drop"
4147 | "query_iter"
4148 | "query_first"
4149 | "query_fold"
4150 // postgres COPY: the statement is a SQL string argument (`postgres-0.19.14/src/client.rs:492,520`).
4151 | "copy_in"
4152 | "copy_out"
4153 | "query_and_then"
4154 | "query_typed"
4155 | "query_all"
4156 | "prepare"
4157 | "prepare_typed"
4158 | "prepare_cached"
4159 | "exec"
4160 | "exec_first"
4161 | "exec_iter"
4162 | "exec_map"
4163 | "exec_fold"
4164 | "exec_drop"
4165 | "exec_batch"
4166 | "prep"
4167 | "run_command"
4168 )
4169}
4170
4171/// THE AS-EFF-008 MASKING GUARD, ONE AUTHORITY FOR BOTH RUST ENGINES. SOUNDNESS R806 / R808.
4172///
4173/// The question: this call carries a surface effect `eff` and NO literal locator was captured for it —
4174/// is its locator (host, program, path, SQL) therefore a RUNTIME value the gate cannot see? If so the
4175/// caller marks the surface `incomplete`, so a benign sibling literal cannot certify it.
4176///
4177/// **WHY ONE FUNCTION.** Both rust engines asked this question, each with its own copy of the
4178/// disjunction over the `is_*` predicates below, and the copies drifted: R460's receiver-locator arm and
4179/// R383's `OpenOptions::open` arm reached `candor-scan` and never the deep engine (R808). A rule added
4180/// here now reaches both, which is the only way this class stops recurring one crate over.
4181///
4182/// `leaf` is the call's own method/function name; `path` the resolved call path; `method` whether the
4183/// call is written in method form on a receiver (so a path-stat `p.exists()` is not mistaken for an
4184/// argument-form `fs::metadata(p)`, and the receiver-locator arms can fire). `crate_name` is the
4185/// resolved crate, used only to scope the table-derived FFI arms.
4186///
4187/// **THE DIRECTION, stated so it cannot drift:** a wrong `true` over-masks — the gate fails CLOSED and
4188/// says so; a wrong `false` is the benign-sibling-literal bypass, a broken gate (R399). Every arm that
4189/// reads a `classify` table below reads it with a NO-LOCATOR DENYLIST, so a leaf added to that table is
4190/// masked until someone reads its signature and says otherwise.
4191pub fn masks_locator(eff: &str, crate_name: &str, path: &str, leaf: &str, method: bool) -> bool {
4192 let by_list = match eff {
4193 "Net" => is_net_establishing(leaf),
4194 // SOUNDNESS R808 — the receiver arm is NOT gated on method form. `Command::spawn(cmd)` (UFCS)
4195 // runs the same caller-supplied program as `cmd.spawn()`, and with the gate BOTH engines
4196 // certified it at exit 0 on the unit and its caller. In call form the receiver is argument 0;
4197 // neither engine resolves a determined argument-0 command, so a UFCS spawn on a command built
4198 // in the same function over-masks (fails closed) — the rare spelling paying for the silent one.
4199 "Exec" => is_cmd_naming_method(leaf) || is_exec_receiver_locator(path),
4200 "Fs" => {
4201 (!method && is_fs_path_arg(leaf))
4202 || (method && is_fs_path_arg_method(path))
4203 || (method && is_fs_receiver_locator(path))
4204 }
4205 "Db" => is_db_query_arg(leaf),
4206 _ => false,
4207 };
4208 by_list || masks_locator_by_table(eff, crate_name, path, leaf)
4209}
4210
4211/// SOUNDNESS R806 — THE TABLE-DERIVED HALF OF `masks_locator`, separate only so a reach probe can ask
4212/// the very function the guard asks (`R806MASK` in candor-scan) rather than a copy of it.
4213///
4214/// Each hand list in `masks_locator` was SHORT against the `classify` table that charges the effect:
4215/// `is_fs_path_arg` held 28 names while `LIBC_FS` charges 92, `is_cmd_naming_method` held `new`/`cmd`
4216/// while `LIBC_EXEC` names `system`/`popen`/`exec*`/`posix_spawn*`, `is_net_establishing` lacked
4217/// `getaddrinfo`, and `is_db_query_arg` lacked `prepare_typed_cached` from `PG_DB_VERBS`. EXECUTED on
4218/// each: a caller's file really unlinked, a caller's shell command really run, a real resolver lookup,
4219/// all under `allow <E> in <unit> <benign literal>` at exit 0 on the unit AND its caller.
4220///
4221/// Adding the missing names would leave two tables to drift again the next time one grows — the shape
4222/// swift closed as R787 and java as R794, both by derivation. So these arms READ the charging table and
4223/// subtract a signature-checked NO-LOCATOR denylist: a leaf added to the table is masked by default.
4224pub fn masks_locator_by_table(eff: &str, crate_name: &str, path: &str, leaf: &str) -> bool {
4225 let syscall = is_syscall_crate(crate_name);
4226 match eff {
4227 "Net" => {
4228 syscall
4229 && LIBC_NET.contains(&leaf)
4230 && !LIBC_NET_NO_DESTINATION.contains(&leaf)
4231 // `rustix::net::sendmsg(socket, iov, control, flags)` takes NO address — rustix spells
4232 // the addressed forms `sendmsg_addr`/`sendmsg_v4`/`sendmsg_v6`/`sendmsg_unix` (not in
4233 // `LIBC_NET`, so unclassified). libc's and nix's `sendmsg` DO carry one (`msg_name` /
4234 // `addr: Option<&S>`), which is why this is scoped to the crate rather than the leaf.
4235 // Measured on the A/B: x11rb's `DefaultStream::do_write` over its connected X11 socket
4236 // was the only reach, an over-mask, removed here.
4237 && !(crate_name == "rustix" && leaf == "sendmsg")
4238 }
4239 "Exec" => syscall && LIBC_EXEC.contains(&leaf) && !LIBC_EXEC_NO_PROGRAM.contains(&leaf),
4240 "Fs" => syscall && LIBC_FS.contains(&leaf) && !LIBC_FS_NO_PATH.contains(&leaf),
4241 "Db" => {
4242 (matches!(crate_name, "postgres" | "tokio_postgres" | "deadpool_postgres" | "rusqlite")
4243 && PG_DB_VERBS.iter().any(|v| path.ends_with(v))
4244 && !PG_DB_VERBS_NO_SQL.iter().any(|v| path.ends_with(v)))
4245 // `sqlx_core`'s `Executor::prepare_with(sql, params)` — the one SQL-TEXT verb in that
4246 // crate's Db rule that the leaf list lacked (`prepare` is there). The rest of that rule
4247 // (`fetch*`/`execute*` on a `Query`) takes an EXECUTOR at argument 0, so deriving the
4248 // whole rule would mask every `query(lit).fetch_all(&pool)`; it is a named member
4249 // rather than a derivation for that reason.
4250 || (crate_name == "sqlx_core" && leaf == "prepare_with")
4251 }
4252 _ => false,
4253 }
4254}
4255
4256/// Map a cap-std capability *type* to the effect it authorises. Holding one of these
4257/// (e.g. `&Dir`) is the real, unforgeable right to perform that effect — so candor
4258/// treats it as a declared capability, exactly like its own `&Fs` token.
4259pub fn capstd_cap(crate_name: &str, type_name: &str) -> Option<&'static str> {
4260 if !crate_name.starts_with("cap_") {
4261 return None;
4262 }
4263 Some(match type_name {
4264 "Dir" => "Fs",
4265 "TcpListener" | "TcpStream" | "UdpSocket" | "Pool" => "Net",
4266 "UnixListener" | "UnixStream" | "UnixDatagram" => "Ipc",
4267 "SystemClock" | "MonotonicClock" => "Clock",
4268 _ => return None,
4269 })
4270}
4271
4272/// Table names a SQL string literal STATICALLY reaches — the `Db` analog of the `Net` host /
4273/// `Exec` command / `Fs` path literal surface (feeds `allow Db in <scope> <table>…`, AS-EFF-008).
4274/// Conservative by construction, because a wrong capture here would FABRICATE: the string must
4275/// open with a SQL statement keyword, and only identifiers in table position are taken —
4276/// `FROM`/`JOIN` anywhere, `INTO` anywhere, statement-leading `UPDATE`/`TRUNCATE`, and
4277/// `TABLE` (create/drop/alter), skipping `ONLY`/`IF NOT EXISTS`. `UPDATE` mid-statement is
4278/// deliberately ignored (`FOR UPDATE SKIP LOCKED` must not yield a table "skip"). A
4279/// dynamically-built query yields nothing — the gate's opaque case — never a guess.
4280/// Output is lower-cased, quote/backtick-stripped, `schema.table` kept qualified, deduped.
4281/// SPEC §2 pins this algorithm token-for-token across engines; the cross-impl vector battery
4282/// (candor-spec conformance/tables/vectors.json, run.sh Part 4b) enforces the JVM/TS mirrors.
4283pub fn tables_in_sql(sql: &str) -> Vec<String> {
4284 const STMT: &[&str] =
4285 &["select", "insert", "update", "delete", "create", "drop", "alter", "truncate", "merge", "replace", "with"];
4286 // Tokens that can FOLLOW a table-introducing keyword without being a table.
4287 const SKIP: &[&str] = &["only", "if", "not", "exists", "table"];
4288 // Identifier-position tokens that are grammar, not a table (subqueries, locking clauses…).
4289 const STOP: &[&str] = &[
4290 "select", "set", "where", "values", "on", "using", "group", "order", "by", "limit",
4291 "returning", "as", "inner", "outer", "left", "right", "cross", "lateral", "natural",
4292 "union", "all", "distinct", "case", "when", "null", "default", "skip", "nowait", "of",
4293 "from", "join", "into", "update", "delete", "insert",
4294 ];
4295 // `,` survives as its OWN token (not a space): it's what lets `FROM t1, t2` continue the table
4296 // list without fabricating from other comma-ridden positions (column lists, ON clauses).
4297 let cleaned: String = sql
4298 .to_lowercase()
4299 .chars()
4300 .flat_map(|c| match c {
4301 '(' | ')' | ';' => vec![' '],
4302 ',' => vec![' ', ',', ' '],
4303 _ => vec![c],
4304 })
4305 .collect();
4306 let toks: Vec<&str> = cleaned.split_whitespace().collect();
4307 let Some(first) = toks.first() else { return Vec::new() };
4308 if !STMT.contains(first) {
4309 return Vec::new(); // not SQL — nothing to certify, nothing fabricated
4310 }
4311 let ident = |t: &str| -> Option<String> {
4312 let t = t.trim_matches(|c| matches!(c, '"' | '`' | '\''));
4313 let mut chars = t.chars();
4314 let ok_first = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
4315 let ok_rest = t.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '$' | '"' | '`'));
4316 (ok_first && ok_rest && !STOP.contains(&t)).then(|| t.replace(['"', '`'], ""))
4317 };
4318 let mut out: Vec<String> = Vec::new();
4319 let mut push = |t: Option<String>| {
4320 if let Some(t) = t {
4321 if !out.contains(&t) {
4322 out.push(t);
4323 }
4324 }
4325 };
4326 for (i, tok) in toks.iter().enumerate() {
4327 let table_pos = match *tok {
4328 "from" | "join" | "into" | "table" => true,
4329 // statement-leading only (see doc comment): `update t set …`, `truncate [table] t`.
4330 "update" | "truncate" => i == 0,
4331 _ => false,
4332 };
4333 if !table_pos {
4334 continue;
4335 }
4336 let mut j = i + 1;
4337 while j < toks.len() && SKIP.contains(&toks[j]) {
4338 j += 1;
4339 }
4340 let Some(next) = toks.get(j) else { continue };
4341 let Some(first) = ident(next) else { continue };
4342 push(Some(first));
4343 // Comma-ADJACENT continuation only: `FROM t1, t2, t3` takes all three, while an alias breaks
4344 // the chain (`FROM t1 a, t2` keeps just t1 — an under-report, never a guess: skipping an
4345 // alias to chase the comma would fabricate tables out of `INSERT INTO t (a, b)`'s column
4346 // list, whose parens are spaces by the time we tokenize).
4347 while j + 2 < toks.len() && toks[j + 1] == "," {
4348 let Some(more) = ident(toks[j + 2]) else { break };
4349 push(Some(more));
4350 j += 2;
4351 }
4352 }
4353 out
4354}
4355
4356#[cfg(test)]
4357mod tests {
4358 #[test]
4359 fn r1150_getrandom_error_values_draw_nothing_but_the_draws_do() {
4360 for p in ["getrandom::Error::eq", "getrandom::&Error::eq", "getrandom::Error::raw_os_error", "getrandom::Error::fmt",
4361 "getrandom::error::Error::new_custom", "getrandom::Error::UNSUPPORTED"] {
4362 assert_eq!(classify("getrandom", p), None, "{p}");
4363 }
4364 for p in ["getrandom::fill", "getrandom::fill_uninit", "getrandom::u32", "getrandom::u64", "getrandom::getrandom",
4365 "getrandom::SysRng::try_fill_bytes", "getrandom::getrandom_uninit"] {
4366 assert_eq!(classify("getrandom", p), Some("Rand"), "{p}");
4367 }
4368 }
4369 #[test]
4370 fn model_host_recognizes_known_providers_and_special_forms() {
4371 use super::is_model_host as m;
4372 // exact known hosts (case-insensitive), with/without a port
4373 assert!(m("api.openai.com"));
4374 assert!(m("API.OpenAI.com"));
4375 assert!(m("api.anthropic.com:443"));
4376 assert!(m("generativelanguage.googleapis.com"));
4377 assert!(m("api.mistral.ai"));
4378 assert!(m("api.cohere.ai"));
4379 assert!(m("api.cohere.com")); // BOTH cohere hosts
4380 assert!(m("api.groq.com"));
4381 assert!(m("api.together.xyz"));
4382 assert!(m("api.perplexity.ai"));
4383 assert!(m("openrouter.ai"));
4384 // a subdomain of a known host counts
4385 assert!(m("eu.api.openai.com"));
4386 // Ollama: :11434 on a LOOPBACK host only (max-review r3 — a remote host on 11434 is not Ollama)
4387 assert!(m("localhost:11434"));
4388 assert!(m("127.0.0.1:11434"));
4389 assert!(!m("ollama.internal:11434")); // a remote/internal service on 11434 is NOT a model host
4390 // Bedrock: the FIRST label is the model-inference service, not the substring "bedrock"
4391 assert!(m("bedrock-runtime.us-east-1.amazonaws.com"));
4392 assert!(m("bedrock-runtime.eu-west-1.amazonaws.com"));
4393 assert!(m("bedrock-agent-runtime.us-east-1.amazonaws.com"));
4394 // NOT model hosts (never guessed)
4395 assert!(!m("example.com"));
4396 assert!(!m("api.stripe.com"));
4397 assert!(!m("localhost:8080")); // a non-Ollama local port
4398 assert!(!m("s3.us-east-1.amazonaws.com")); // amazonaws but not bedrock
4399 assert!(!m("bedrock-backups.s3.amazonaws.com")); // an S3 bucket merely NAMED bedrock — not the runtime
4400 assert!(!m("bedrock.us-east-1.amazonaws.com")); // the Bedrock CONTROL plane — not model inference
4401 assert!(!m("openai.com.evil.com")); // suffix trick — not a subdomain of a known host
4402 }
4403
4404 #[test]
4405 fn model_sdk_crate_is_crate_level_no_method_gating() {
4406 use super::is_model_sdk_crate as s;
4407 assert!(s("async_openai"));
4408 assert!(s("aws_sdk_bedrockruntime"));
4409 assert!(s("ollama_rs"));
4410 assert!(s("langchain_rust"));
4411 assert!(!s("reqwest"));
4412 assert!(!s("aws_sdk_s3"));
4413 }
4414
4415 #[test]
4416 fn sql_table_extraction_is_conservative() {
4417 use super::tables_in_sql as t;
4418 assert_eq!(t("SELECT id FROM users WHERE x = 1"), vec!["users"]);
4419 assert_eq!(t("select * from ledger.entries e join customers c on c.id = e.cid"),
4420 vec!["ledger.entries", "customers"]);
4421 assert_eq!(t("INSERT INTO audit_log (a) VALUES (?1)"), vec!["audit_log"]);
4422 assert_eq!(t("UPDATE accounts SET v = ?"), vec!["accounts"]);
4423 assert_eq!(t("DELETE FROM sessions WHERE id = ?"), vec!["sessions"]);
4424 assert_eq!(t("CREATE TABLE IF NOT EXISTS cache (k TEXT)"), vec!["cache"]);
4425 assert_eq!(t("TRUNCATE TABLE staging"), vec!["staging"]);
4426 // FOR UPDATE locking clause must not yield a phantom table (mid-statement update ignored)
4427 assert_eq!(t("SELECT * FROM jobs FOR UPDATE SKIP LOCKED"), vec!["jobs"]);
4428 // a subquery in FROM position yields nothing for that position
4429 assert_eq!(t("SELECT * FROM (SELECT 1) q"), Vec::<String>::new());
4430 // not SQL -> nothing (never fabricate)
4431 assert_eq!(t("/tmp/some/path"), Vec::<String>::new());
4432 assert_eq!(t("hello world from nowhere"), Vec::<String>::new());
4433 // comma-ADJACENT continuation: a FROM list takes every table in the chain…
4434 assert_eq!(t("SELECT a FROM t1, t2, s.t3 WHERE x = 1"), vec!["t1", "t2", "s.t3"]);
4435 // …but an alias breaks it (under-report, never a guess)…
4436 assert_eq!(t("SELECT a FROM t1 a1, t2 WHERE x = 1"), vec!["t1"]);
4437 // …which is exactly what keeps a column list from fabricating (parens are spaces by now).
4438 assert_eq!(t("INSERT INTO t (a, b) VALUES (1, 2)"), vec!["t"]);
4439 // a subquery after the comma stops the chain too
4440 assert_eq!(t("SELECT a FROM t1, (SELECT 1) q"), vec!["t1"]);
4441 }
4442
4443 use super::*;
4444
4445 /// The routed-handle list's MEMBERSHIP RULE, enforced rather than merely documented.
4446 ///
4447 /// (a) Every routed type must have a whole-type rule that answers an ARBITRARY method on it.
4448 /// If it did not, routing it would hand the scanner a path the classifier drops — motion with no
4449 /// effect, and the silent under-report would survive the fix that was supposed to close it. The
4450 /// probe leaf is deliberately a name no carve-out lists, so it measures the BASE rule.
4451 ///
4452 /// (b) The named exclusions stay excluded. `ReadDir` cannot have a verb-precise rule written for it
4453 /// (its `next` is a syscall and is spelled like every other iterator's), and `Metadata`/`DirEntry`/
4454 /// `Permissions`/`FileType` are pure DATA under the coarse `std::fs::` prefix — routing any of them
4455 /// charges an effect for reading a field. `OpenOptions`/`DirBuilder` LEFT this list by the route the
4456 /// list's own comment prescribed: their type-keyed setter carve-outs were written first, so the
4457 /// whole-type rule under them now answers only for the terminal verb.
4458 #[test]
4459 fn std_effect_handles_have_a_whole_type_rule_and_exclude_the_pure_surfaces() {
4460 for ty in STD_EFFECT_HANDLES {
4461 let probe = format!("{ty}::__candor_probe_verb");
4462 assert!(
4463 classify("std", &probe).is_some(),
4464 "`{ty}` is routed into the classifier by receiver inference, but an arbitrary method \
4465 on it classifies to NOTHING — the routing cannot close any under-report"
4466 );
4467 assert!(is_std_effect_handle(ty), "the predicate must agree with the list");
4468 }
4469 for ty in [
4470 "std::fs::ReadDir",
4471 "std::fs::Metadata", "std::fs::DirEntry", "std::fs::Permissions", "std::fs::FileType",
4472 "std::path::Path", "std::path::PathBuf", "std::vec::Vec",
4473 ] {
4474 assert!(!is_std_effect_handle(ty),
4475 "`{ty}` has a pure surface the coarse prefix rules would charge — it must not be \
4476 routed as a handle (Path/PathBuf route through their own verb-precise carve-out)");
4477 }
4478 }
4479
4480 /// The pure read-backs on the ROUTED types, which is what makes (a) above safe: a whole-type rule
4481 /// answering every method is only acceptable because the genuinely-pure methods were carved out
4482 /// first. Receiver routing is a second door into these rules, so it must not re-fabricate.
4483 #[test]
4484 fn routed_std_handles_keep_their_pure_accessor_carve_outs() {
4485 for p in [
4486 "std::process::Command::get_program", "std::process::Command::get_args",
4487 "std::process::Command::get_envs", "std::process::Command::get_current_dir",
4488 "std::process::Child::id",
4489 "std::net::TcpStream::local_addr", "std::net::TcpStream::peer_addr",
4490 "std::net::TcpStream::nodelay", "std::net::TcpStream::ttl",
4491 "std::net::UdpSocket::take_error",
4492 "std::fs::File::as_raw_fd", "std::fs::File::into_raw_fd",
4493 "std::os::unix::net::UnixStream::as_raw_fd",
4494 // The OPTION-BUILDER setters (SPEC §1 ⟨0.32⟩): flags in a struct, no file named, nothing
4495 // opened. `create` is here as `OpenOptions`' setter and is a VERB on `DirBuilder` below —
4496 // one leaf, two answers, which is why these carve-outs are keyed on the TYPE.
4497 "std::fs::OpenOptions::new", "std::fs::OpenOptions::read", "std::fs::OpenOptions::write",
4498 "std::fs::OpenOptions::append", "std::fs::OpenOptions::truncate",
4499 "std::fs::OpenOptions::create", "std::fs::OpenOptions::create_new",
4500 "std::fs::OpenOptions::mode", "std::fs::OpenOptions::custom_flags",
4501 "std::fs::DirBuilder::new", "std::fs::DirBuilder::recursive", "std::fs::DirBuilder::mode",
4502 ] {
4503 assert_eq!(classify("std", p), None, "`{p}` is a pure read-back");
4504 }
4505 // …and the verbs on the same types still answer, or the carve-outs would have eaten the rule.
4506 for (p, want) in [
4507 ("std::process::Command::spawn", "Exec"),
4508 ("std::process::Child::wait", "Exec"),
4509 ("std::fs::File::write_all", "Fs"),
4510 ("std::net::TcpStream::write_all", "Net"),
4511 ("std::os::unix::net::UnixStream::send", "Ipc"),
4512 // the TERMINAL VERBS of the two option-builders — the whole point of routing them.
4513 ("std::fs::OpenOptions::open", "Fs"),
4514 ("std::fs::DirBuilder::create", "Fs"),
4515 ] {
4516 assert_eq!(classify("std", p), Some(want), "`{p}` must stay {want}");
4517 }
4518 }
4519
4520 #[test]
4521 fn db_crates_are_calibrated() {
4522 // The calibrated set must cover every DB client the classifier knows, or the receipt's coverage
4523 // check would flag a recognized crate as a blind spot. (Was nightly-lint-only; now runs on stable.)
4524 for c in DB_CRATES {
4525 assert!(
4526 CALIBRATED_CRATES.contains(&c),
4527 "DB crate `{c}` is matched by classify() but missing from CALIBRATED_CRATES"
4528 );
4529 }
4530 }
4531
4532 /// The two coverage lists mean OPPOSITE things and must stay disjoint.
4533 ///
4534 /// `CALIBRATED_CRATES` = "classify has effect rules here". `REVIEWED_PURE_CRATES` = "read it, it
4535 /// performs nothing". A crate in both would be asserting both at once, and the ledger consults them
4536 /// with an OR — so the contradiction would resolve silently to "covered" and nobody would look again.
4537 #[test]
4538 fn reviewed_pure_and_calibrated_are_disjoint() {
4539 for c in REVIEWED_PURE_CRATES {
4540 assert!(!CALIBRATED_CRATES.contains(&c),
4541 "`{c}` is in BOTH lists — it cannot be rule-covered AND effect-free");
4542 assert!(!PATH_CALIBRATED_CRATES.contains(&c), "`{c}` is in BOTH lists (path-calibrated)");
4543 assert!(!CALIBRATED_PREFIXES.iter().any(|p| c.starts_with(p)),
4544 "`{c}` is covered by a calibrated PREFIX as well as the pure list");
4545 }
4546 }
4547
4548 /// A reviewed-pure crate must actually classify as pure — the mirror of `calibrated_crates_are_live`.
4549 ///
4550 /// The list makes candor BELIEVE these crates rather than disclose them, so if someone later adds a
4551 /// rule for one, the claim "performs no effect of its own" is dead and the entry has to be re-read,
4552 /// not silently outvoted by the rule. Probed with the same tails the liveness test uses, which is a
4553 /// broad sweep of the effectful verb shapes candor knows.
4554 #[test]
4555 fn reviewed_pure_crates_classify_as_pure() {
4556 for c in REVIEWED_PURE_CRATES {
4557 for t in CALIBRATION_PROBE_TAILS {
4558 assert!(classify(c, &format!("{c}{t}")).is_none(),
4559 "`{c}` is listed REVIEWED-PURE but classify() gives it an effect on `{c}{t}` — \
4560 one of the two is wrong, and the list is the claim");
4561 }
4562 }
4563 }
4564
4565 /// ⟨CALIBRATION CEILING — the table must agree with the REVIEW RECORD⟩ SOUNDNESS R328.
4566 ///
4567 /// `CALIBRATED_CEILINGS` is the version each crate's rules were actually written against. Nothing
4568 /// derived it from, or checked it against, the review record — and three separate failures followed
4569 /// in one day: a wholesale table rewrite silently DROPPED nine entries while the commit message
4570 /// claimed four were absent "each with a stated reason"; `git2` was ceilinged ABOVE the only version
4571 /// its rules record reading (the SILENT direction, and 0.21 added `author_from_env` /
4572 /// `committer_from_env` / `Oid::hash_file_ext`, none of them ruled); and `sqlx_core` sat BELOW the
4573 /// version its own comment says was verified, which is also an impossible sibling of `sqlx 0.9.0`'s
4574 /// `=0.9.0` pin. All three were invisible to 410 tests and 29 gates.
4575 ///
4576 /// This asserts the one invariant that catches all of them: **a crate whose rules cite a reviewed
4577 /// version must carry a ceiling, and it must be THAT version.** Citations are read with wrapped
4578 /// comment lines joined and both name spellings tried — R315, where a line-by-line dashed-only
4579 /// sweep silently under-counted the citations by three.
4580 #[test]
4581 fn calibration_ceilings_match_the_recorded_review() {
4582 let src = include_str!("lib.rs");
4583 // join wrapped comment continuations, then collapse whitespace: a citation is prose and prose wraps.
4584 let flat = src
4585 .replace("\n //", " ")
4586 .replace("\n //", " ")
4587 .replace("\n //", " ");
4588 let flat: String = flat.split_whitespace().collect::<Vec<_>>().join(" ");
4589 let ceilings: std::collections::HashMap<&str, &str> =
4590 CALIBRATED_CEILINGS.iter().copied().collect();
4591 // Absences are deliberate and enumerated in the table's own comment; keep the list HERE too so
4592 // dropping one silently is impossible — that is exactly what happened.
4593 let deliberate = ["postgres", "async_fs", "getrandom", "rand_core"];
4594 let mut problems: Vec<String> = Vec::new();
4595 for c in CALIBRATED_CRATES {
4596 if deliberate.contains(&c) {
4597 continue;
4598 }
4599 // EVERY citation, both verbs, both name spellings, and slash-separated lists —
4600 // "verified against sqlx-core 0.8.6/0.9.0" records TWO reviewed versions, and a crate may
4601 // be cited in more than one comment ("validated against rusqlite 0.39" elsewhere). An
4602 // extractor that stops at the first hit under-collects, and under-collecting here makes a
4603 // correct ceiling look like it is above the record — R315's lesson, one level up.
4604 let mut cited: Vec<String> = Vec::new();
4605 for form in [c.to_string(), c.replace('_', "-")] {
4606 for verb in ["verified against ", "validated against "] {
4607 let pat = format!("{verb}{form} ");
4608 let mut from = 0usize;
4609 while let Some(i) = flat[from..].find(&pat) {
4610 let at = from + i + pat.len();
4611 let tail: String = flat[at..]
4612 .chars()
4613 .take_while(|ch| ch.is_ascii_digit() || *ch == '.' || *ch == '/')
4614 .collect();
4615 for v in tail.split('/') {
4616 let v = v.trim_end_matches('.').to_string();
4617 if v.split('.').count() >= 2 && !cited.contains(&v) {
4618 cited.push(v);
4619 }
4620 }
4621 from = at;
4622 }
4623 }
4624 }
4625 if cited.is_empty() {
4626 continue;
4627 }
4628 match ceilings.get(c) {
4629 None => problems.push(format!(
4630 "{c}: rules cite {} but there is NO ceiling — the exemption is unconditional",
4631 cited.join("/")
4632 )),
4633 // A comment may legitimately record MORE THAN ONE reviewed version
4634 // ("verified against sqlx-core 0.8.6/0.9.0"), so matching any of them is fine, and a
4635 // ceiling BELOW the newest review is merely noisy. The failure that matters is a
4636 // ceiling ABOVE EVERY recorded review: that grants reviewed-purity over source nobody
4637 // read, which is the SILENT direction and the whole reason this table exists.
4638 Some(have) => {
4639 let ok = cited.iter().any(|v| have.starts_with(v) || v.starts_with(*have))
4640 || cited.iter().any(|v| calibration_exceeded_raw(v, have));
4641 if !ok {
4642 problems.push(format!(
4643 "{c}: ceiling {have} is ABOVE every recorded review ({}) — it grants \
4644 reviewed-purity over source no comment records anyone reading",
4645 cited.join("/")
4646 ));
4647 }
4648 }
4649 }
4650 }
4651 assert!(
4652 problems.is_empty(),
4653 "CALIBRATED_CEILINGS disagrees with the review record:\n {}\n\n\
4654 A ceiling ABOVE the recorded review grants reviewed-purity over source nobody read (SILENT); \
4655 a ceiling BELOW it is noisy but safe. Either way the table and the record must agree, or \
4656 neither is evidence.",
4657 problems.join("\n ")
4658 );
4659 }
4660
4661 #[test]
4662 fn calibrated_crates_are_live() {
4663 // Conversely, every crate advertised as calibrated must actually be matched by classify() for
4664 // some representative path — a dead entry would silently suppress a real coverage warning.
4665 for c in CALIBRATED_CRATES {
4666 assert!(
4667 CALIBRATION_PROBE_TAILS.iter().any(|t| classify(c, &format!("{c}{t}")).is_some()),
4668 "calibrated crate `{c}` is matched by no path in classify() — dead list entry"
4669 );
4670 }
4671 }
4672
4673 #[test]
4674 fn async_http_stack_classifies() {
4675 // The modern async-HTTP/TLS/QUIC/DNS stack (found by the independent-method differential on oha):
4676 // verb-keyed Net/Ipc/Fs/Env, crate-gated so generic verbs never fabricate across crates.
4677 assert_eq!(classify("hyper", "hyper::client::conn::http1::SendRequest::send_request"), Some("Net"));
4678 assert_eq!(classify("hyper", "hyper::client::conn::http1::handshake"), Some("Net"));
4679 assert_eq!(classify("hyper_util", "hyper_util::client::legacy::Client::request"), Some("Net"));
4680 assert_eq!(classify("hickory_resolver", "hickory_resolver::Resolver::lookup_ip"), Some("Net"));
4681 assert_eq!(classify("quinn", "quinn::Endpoint::connect"), Some("Net"));
4682 assert_eq!(classify("quinn", "quinn::RecvStream::read_to_end"), Some("Net")); // stream byte I/O, not just open
4683 assert_eq!(classify("quinn", "quinn::SendStream::write_all"), Some("Net"));
4684 assert_eq!(classify("tokio_rustls", "tokio_rustls::TlsConnector::connect"), Some("Net"));
4685 assert_eq!(classify("native_tls", "native_tls::TlsConnector::connect"), Some("Net"));
4686 assert_eq!(classify("tokio_vsock", "tokio_vsock::VsockStream::connect"), Some("Ipc"));
4687 assert_eq!(classify("rustls_native_certs", "rustls_native_certs::load_native_certs"), Some("Fs"));
4688 assert_eq!(classify("rlimit", "rlimit::setrlimit"), Some("Env"));
4689 // num_cpus is deliberately PURE (consistency with std::thread::available_parallelism; avoids Env spray)
4690 assert_eq!(classify("num_cpus", "num_cpus::get"), None);
4691 assert_eq!(classify("num_cpus", "num_cpus::get_physical"), None);
4692 // pure surface stays None (no fabrication): builder/type/config paths, and other crates' generic verbs
4693 assert_eq!(classify("hyper", "hyper::Request::builder"), None);
4694 assert_eq!(classify("hyper", "hyper::body::Bytes::new"), None);
4695 assert_eq!(classify("native_tls", "native_tls::TlsConnectorBuilder::min_protocol_version"), None);
4696 assert_eq!(classify("serde", "serde::Deserialize::request"), None); // generic verb, wrong crate
4697 }
4698
4699 #[test]
4700 fn coverage_differential_crates_classify() {
4701 // Crates the coverage differential found DISCLOSED-but-unmodeled. Each rule is verb-keyed +
4702 // crate-gated; the EFFECT verbs map to the right bucket and the PURE surface stays None (a
4703 // wrongly-flagged pure crate is a fabrication, so the negatives matter as much as the positives).
4704
4705 // rustls (sync TLS core) — record I/O is Net; config/cert + the buffered-decrypt step are pure.
4706 assert_eq!(classify("rustls", "rustls::ClientConnection::read_tls"), Some("Net"));
4707 assert_eq!(classify("rustls", "rustls::ConnectionCommon::write_tls"), Some("Net"));
4708 assert_eq!(classify("rustls", "rustls::Connection::complete_io"), Some("Net"));
4709 assert_eq!(classify("rustls", "rustls::ConnectionCommon::process_new_packets"), None); // buffered decrypt, no I/O
4710 assert_eq!(classify("rustls", "rustls::ClientConfig::builder"), None); // pure config
4711
4712 // native-tls variants — handshake is Net; builder is pure.
4713 assert_eq!(classify("native_tls_crate", "native_tls_crate::TlsConnector::connect"), Some("Net"));
4714 assert_eq!(classify("tokio_native_tls", "tokio_native_tls::TlsAcceptor::accept"), Some("Net"));
4715 assert_eq!(classify("native_tls_crate", "native_tls_crate::TlsConnectorBuilder::min_protocol_version"), None);
4716
4717 // etcetera — dir resolution reads env; the args data type is pure.
4718 assert_eq!(classify("etcetera", "etcetera::home_dir"), Some("Env"));
4719 assert_eq!(classify("etcetera", "etcetera::base_strategy::choose_base_strategy"), Some("Env"));
4720 assert_eq!(classify("etcetera", "etcetera::base_strategy::Xdg::config_dir"), Some("Env"));
4721 assert_eq!(classify("etcetera", "etcetera::app_strategy::AppStrategyArgs::new"), None); // pure data
4722
4723 // sqlx-core — connect is Net, execute/fetch round-trips are Db; options/builders pure.
4724 assert_eq!(classify("sqlx_core", "sqlx_core::connection::Connection::connect"), Some("Net"));
4725 assert_eq!(classify("sqlx_core", "sqlx_core::executor::Executor::fetch_one"), Some("Db"));
4726 assert_eq!(classify("sqlx_core", "sqlx_core::executor::Executor::execute"), Some("Db"));
4727 assert_eq!(classify("sqlx_core", "sqlx_core::pool::Pool::acquire"), Some("Db"));
4728 assert_eq!(classify("sqlx_core", "sqlx_core::pool::PoolOptions::max_connections"), None); // pure builder
4729
4730 // walkdir — charged at construction (`WalkDir::new`, mirroring `ignore`/`glob`), NOT solely at
4731 // the lazy `next()`/`metadata()` read: candor-scan's receiver-typing blocklist hard-blocks
4732 // `.into_iter()`, so a typed `IntoIter::next` receiver is unreachable from the idiomatic
4733 // `WalkDir::new(p).into_iter()...` chain — `WalkDir::new` is the only point every real usage
4734 // (for-loop, `.count()`, untyped `.next()`) actually reaches. `IntoIter::next`/`DirEntry::metadata`
4735 // stay Some("Fs") as a secondary rule for the narrower explicit-type-annotation case. Builder
4736 // setters (`into_iter` itself, `max_depth`, …) and cached accessors stay pure.
4737 assert_eq!(classify("walkdir", "walkdir::WalkDir::new"), Some("Fs"));
4738 assert_eq!(classify("walkdir", "walkdir::IntoIter::next"), Some("Fs"));
4739 assert_eq!(classify("walkdir", "walkdir::DirEntry::metadata"), Some("Fs"));
4740 assert_eq!(classify("walkdir", "walkdir::WalkDir::into_iter"), None); // no I/O until pulled
4741 assert_eq!(classify("walkdir", "walkdir::WalkDir::max_depth"), None); // builder setter
4742 assert_eq!(classify("walkdir", "walkdir::DirEntry::file_type"), None); // cached, no syscall
4743
4744 // filetime — set_* are utimes (Fs), now is Clock; from_* constructors pure.
4745 assert_eq!(classify("filetime", "filetime::set_file_mtime"), Some("Fs"));
4746 assert_eq!(classify("filetime", "filetime::set_file_handle_times"), Some("Fs"));
4747 assert_eq!(classify("filetime", "filetime::FileTime::now"), Some("Clock"));
4748 assert_eq!(classify("filetime", "filetime::FileTime::from_unix_time"), None);
4749 assert_eq!(classify("filetime", "filetime::FileTime::from_last_modification_time"), None); // reads &Metadata, not disk
4750
4751 // execute — the execute* verbs spawn (Exec); command/shell builders pure.
4752 assert_eq!(classify("execute", "execute::Execute::execute"), Some("Exec"));
4753 assert_eq!(classify("execute", "execute::Execute::execute_output"), Some("Exec"));
4754 assert_eq!(classify("execute", "execute::Execute::execute_multiple_output"), Some("Exec"));
4755 assert_eq!(classify("execute", "execute::command"), None); // only builds a Command
4756 assert_eq!(classify("execute", "execute::shell"), None);
4757
4758 // ctrlc — install signal handler (Ipc).
4759 assert_eq!(classify("ctrlc", "ctrlc::set_handler"), Some("Ipc"));
4760 assert_eq!(classify("ctrlc", "ctrlc::try_set_handler"), Some("Ipc"));
4761
4762 // clap — only the argv-reading terminals are Env; the whole builder + *_from variants pure.
4763 assert_eq!(classify("clap", "clap::Command::get_matches"), Some("Env"));
4764 assert_eq!(classify("clap", "clap::Command::try_get_matches"), Some("Env"));
4765 assert_eq!(classify("clap", "clap::Parser::parse"), Some("Env"));
4766 assert_eq!(classify("clap", "clap::Command::new"), None); // builder
4767 assert_eq!(classify("clap", "clap::Arg::about"), None); // builder
4768 assert_eq!(classify("clap", "clap::Command::get_matches_from"), None); // explicit args, no argv read
4769 // R59-class audit: `Arg::env` calls `env::var_os` DIRECTLY at builder time (verified against
4770 // clap_builder 4.6.6) — unambiguous (the crate's only `::env` method), so classify it rather
4771 // than leave a real effect to fall silently into the CALIBRATED_CRATES purity exemption.
4772 assert_eq!(classify("clap", "clap::Arg::env"), Some("Env"));
4773 assert_eq!(classify("clap", "clap::Arg::env_os"), Some("Env"));
4774
4775 // jiff — now* is Clock; tz lookups read the tzdb (Fs); span/civil math pure.
4776 assert_eq!(classify("jiff", "jiff::Timestamp::now"), Some("Clock"));
4777 assert_eq!(classify("jiff", "jiff::Zoned::now_with"), Some("Clock"));
4778 assert_eq!(classify("jiff", "jiff::tz::TimeZone::system"), Some("Fs"));
4779 assert_eq!(classify("jiff", "jiff::tz::TimeZone::get"), Some("Fs"));
4780 assert_eq!(classify("jiff", "jiff::Span::checked_add"), None); // pure arithmetic
4781
4782 // env_logger — init installs the logger + reads RUST_LOG (Log); config setters pure.
4783 // TUI — the tty is a user dialogue channel (Ipc), the ruling dialoguer/console already carry.
4784 // Each verb below was read off the crate source (crossterm-0.28.1, ratatui-0.29.0), not guessed.
4785 assert_eq!(classify("crossterm", "crossterm::ExecutableCommand::execute"), Some("Ipc"));
4786 assert_eq!(classify("crossterm", "crossterm::QueueableCommand::queue"), Some("Ipc"));
4787 assert_eq!(classify("crossterm", "crossterm::event::read"), Some("Ipc"));
4788 assert_eq!(classify("crossterm", "crossterm::event::poll"), Some("Ipc"));
4789 assert_eq!(classify("crossterm", "crossterm::terminal::enable_raw_mode"), Some("Ipc"));
4790 // a tty IOCTL must not fall through: in a CALIBRATED crate an unmatched path is a purity CLAIM
4791 assert_eq!(classify("crossterm", "crossterm::terminal::size"), Some("Ipc"));
4792 // the Command VALUE types are pure — they describe an action, they do not perform one
4793 assert_eq!(classify("crossterm", "crossterm::style::Print"), None);
4794 assert_eq!(classify("crossterm", "crossterm::cursor::MoveTo"), None);
4795
4796 // ratatui: the backlog said "mark reviewed-pure"; the SOURCE says `Terminal::draw` ends in a
4797 // backend flush, so the write surface is Ipc and only the render surface is pure.
4798 assert_eq!(classify("ratatui", "ratatui::Terminal::draw"), Some("Ipc"));
4799 assert_eq!(classify("ratatui", "ratatui::Terminal::flush"), Some("Ipc"));
4800 assert_eq!(classify("ratatui", "ratatui::Terminal::clear"), Some("Ipc"));
4801 assert_eq!(classify("ratatui", "ratatui::Terminal::hide_cursor"), Some("Ipc"));
4802 // REGRESSION: `widgets::canvas` is an in-memory grid. `Context::draw` ends in `::draw` and was
4803 // FABRICATING Ipc — caught in review, measured on a fixture, and a hot path (per shape, per frame).
4804 assert_eq!(classify("ratatui", "ratatui::widgets::canvas::Context::draw"), None);
4805 assert_eq!(classify("ratatui", "ratatui::widgets::canvas::Context::layer"), None);
4806 // …while the real write surface still classifies, including a DIRECT backend call (which is why
4807 // the carve-out is a denylist on canvas rather than an allowlist on `Terminal::`).
4808 assert_eq!(classify("ratatui", "ratatui::backend::CrosstermBackend::flush"), Some("Ipc"));
4809 // the BULK of the 3,345 disclosed calls — widgets, layout, style — are genuinely pure
4810 assert_eq!(classify("ratatui", "ratatui::widgets::Paragraph::new"), None);
4811 assert_eq!(classify("ratatui", "ratatui::layout::Layout::split"), None);
4812 assert_eq!(classify("ratatui", "ratatui::style::Style::fg"), None);
4813 assert_eq!(classify("ratatui", "ratatui::buffer::Buffer::set_string"), None);
4814
4815 // tracing_subscriber — two effects, both read off 0.3.23. The filing said "Log/Fs"; the Fs half
4816 // is wrong (the crate ACCEPTS a File as a writer, it never opens one).
4817 assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::init"), Some("Log"));
4818 assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::try_init"), Some("Log"));
4819 assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::SubscriberBuilder::init"), Some("Log"));
4820 assert_eq!(classify("tracing_subscriber", "tracing_subscriber::EnvFilter::from_default_env"), Some("Env"));
4821 assert_eq!(classify("tracing_subscriber", "tracing_subscriber::EnvFilter::from_env"), Some("Env"));
4822 // builders DESCRIBE a subscriber; they do not install one
4823 assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::layer"), None);
4824 assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::SubscriberBuilder::with_target"), None);
4825 assert_eq!(classify("tracing_subscriber", "tracing_subscriber::EnvFilter::new"), None);
4826
4827 assert_eq!(classify("env_logger", "env_logger::init"), Some("Log"));
4828 assert_eq!(classify("env_logger", "env_logger::try_init"), Some("Log"));
4829 assert_eq!(classify("env_logger", "env_logger::Builder::init"), Some("Log"));
4830 assert_eq!(classify("env_logger", "env_logger::Builder::format_timestamp"), None); // config
4831 assert_eq!(classify("env_logger", "env_logger::Builder::build"), None); // pure build
4832
4833 // dialoguer — interact* is tty I/O (Ipc); builders pure.
4834 assert_eq!(classify("dialoguer", "dialoguer::Input::interact_text"), Some("Ipc"));
4835 assert_eq!(classify("dialoguer", "dialoguer::Confirm::interact"), Some("Ipc"));
4836 assert_eq!(classify("dialoguer", "dialoguer::Select::interact_opt"), Some("Ipc"));
4837 assert_eq!(classify("dialoguer", "dialoguer::Input::with_prompt"), None); // builder
4838 // widened-CORE audit: `Editor::new`/`::default` read VISUAL/EDITOR at construction, independent
4839 // of `Editor::edit`'s already-covered Exec (spawning that editor is a separate, later step).
4840 assert_eq!(classify("dialoguer", "dialoguer::Editor::new"), Some("Env"));
4841 assert_eq!(classify("dialoguer", "dialoguer::Editor::default"), Some("Env"));
4842 assert_eq!(classify("dialoguer", "dialoguer::Editor::edit"), Some("Exec"));
4843 assert_eq!(classify("dialoguer", "dialoguer::Editor::extension"), None); // pure setter
4844
4845 // console — Term I/O is Ipc, detection is Env, Style is pure.
4846 assert_eq!(classify("console", "console::Term::write_line"), Some("Ipc"));
4847 assert_eq!(classify("console", "console::Term::read_key"), Some("Ipc"));
4848 assert_eq!(classify("console", "console::colors_enabled"), Some("Env"));
4849 assert_eq!(classify("console", "console::Style::cyan"), None); // pure styling
4850 assert_eq!(classify("console", "console::strip_ansi_codes"), None); // pure text util
4851 // R59-class audit: `Term` also implements raw `io::{Read,Write}` (console 0.15.11) — the same
4852 // tty channel as `write_line`/`read_key`, reached through the generic trait methods instead of
4853 // the crate's own named convenience methods. Crate-gated and `Term::`-scoped, so it cannot spread.
4854 assert_eq!(classify("console", "console::Term::write"), Some("Ipc"));
4855 assert_eq!(classify("console", "console::Term::flush"), Some("Ipc"));
4856 assert_eq!(classify("console", "console::Term::read"), Some("Ipc"));
4857
4858 // terminal_colorsaurus — tty colour query (Ipc).
4859 assert_eq!(classify("terminal_colorsaurus", "terminal_colorsaurus::background_color"), Some("Ipc"));
4860 assert_eq!(classify("terminal_colorsaurus", "terminal_colorsaurus::color_palette"), Some("Ipc"));
4861
4862 // backoff — retry sleeps + reads the clock (Clock); config pure.
4863 assert_eq!(classify("backoff", "backoff::retry"), Some("Clock"));
4864 assert_eq!(classify("backoff", "backoff::retry_notify"), Some("Clock"));
4865 assert_eq!(classify("backoff", "backoff::ExponentialBackoff::default"), None);
4866
4867 // lscolors — ONLY from_env reads the environment; from_string/style_for_path pure.
4868 assert_eq!(classify("lscolors", "lscolors::LsColors::from_env"), Some("Env"));
4869 assert_eq!(classify("lscolors", "lscolors::LsColors::from_string"), None);
4870 assert_eq!(classify("lscolors", "lscolors::LsColors::style_for_path"), None);
4871
4872 // wild — argv readers (Env).
4873 assert_eq!(classify("wild", "wild::args"), Some("Env"));
4874 assert_eq!(classify("wild", "wild::args_os"), Some("Env"));
4875
4876 // grep_cli — only the firm Exec (CommandReader spawn); the isatty probes stay unmodeled.
4877 assert_eq!(classify("grep_cli", "grep_cli::CommandReaderBuilder::build"), Some("Exec"));
4878 assert_eq!(classify("grep_cli", "grep_cli::is_readable_stdin"), None); // isatty/fstat, not modeled
4879 assert_eq!(classify("grep_cli", "grep_cli::is_tty_stdout"), None);
4880
4881 // clircle — same-file detection issues fstat/lseek (Fs); equality is pure.
4882 assert_eq!(classify("clircle", "clircle::Identifier::try_from"), Some("Fs"));
4883 assert_eq!(classify("clircle", "clircle::Clircle::surely_conflicts_with"), Some("Fs"));
4884 }
4885
4886 #[test]
4887 fn log_tracing_emit_macros_classify_pre_expansion() {
4888 // candor-scan is pre-expansion: it sees the raw macro path (`log::info`, `tracing::warn`), not the
4889 // expanded dispatch the deep engine sees. Both the user-facing macro names AND the type surface:
4890 assert_eq!(classify("log", "log::info"), Some("Log"));
4891 assert_eq!(classify("log", "log::error"), Some("Log"));
4892 assert_eq!(classify("tracing", "tracing::warn"), Some("Log"));
4893 assert_eq!(classify("tracing", "tracing::info_span"), Some("Log"));
4894 // pure data-type surface stays None (no fabricated Log)
4895 assert_eq!(classify("log", "log::Level::as_str"), None);
4896 assert_eq!(classify("tracing", "tracing::Level::INFO"), None);
4897 }
4898
4899 #[test]
4900 fn classify_core_effects() {
4901 // A representative smoke test of the classifier's main families, so the published crate is not
4902 // shipped untested (these used to live only in the nightly-only src/lib.rs).
4903 assert_eq!(classify("std", "std::fs::read_to_string"), Some("Fs"));
4904 // std::path stat-family methods are Fs (each is a stat/readdir syscall); the pure
4905 // string-manipulation surface stays unclassified (the blackout screen's gix-dir find).
4906 assert_eq!(classify("std", "std::path::Path::symlink_metadata"), Some("Fs"));
4907 assert_eq!(classify("std", "std::path::PathBuf::read_dir"), Some("Fs"));
4908 assert_eq!(classify("std", "std::path::Path::exists"), Some("Fs"));
4909 assert_eq!(classify("std", "std::path::Path::join"), None); // pure string manipulation
4910 assert_eq!(classify("std", "std::path::PathBuf::file_name"), None);
4911 assert_eq!(classify("std", "std::path::Path::parent"), None);
4912 assert_eq!(classify("std", "std::process::Command::new"), Some("Exec"));
4913 assert_eq!(classify("std", "std::env::var"), Some("Env"));
4914 assert_eq!(classify("reqwest", "reqwest::Client::execute"), Some("Net"));
4915 // one-shot convenience fns send immediately → Net.
4916 assert_eq!(classify("reqwest", "reqwest::get"), Some("Net"));
4917 assert_eq!(classify("reqwest", "reqwest::blocking::get"), Some("Net"));
4918 // the URL-BEARING builder methods classify Net too — the DOMINANT idiom is the builder chain
4919 // `Client::new().post(url).send()`, whose URL literal rides the `.post(url)` step (NOT `.send()`),
4920 // so the endpoint (and the Llm host refinement) only get captured if the URL-naming step is Net.
4921 assert_eq!(classify("reqwest", "reqwest::Client::get"), Some("Net"));
4922 assert_eq!(classify("reqwest", "reqwest::Client::post"), Some("Net"));
4923 assert_eq!(classify("reqwest", "reqwest::Client::put"), Some("Net"));
4924 assert_eq!(classify("reqwest", "reqwest::Client::delete"), Some("Net"));
4925 assert_eq!(classify("reqwest", "reqwest::Client::request"), Some("Net"));
4926 // the PURE builder surface stays None (no URL, no dispatch).
4927 assert_eq!(classify("reqwest", "reqwest::RequestBuilder::header"), None);
4928 assert_eq!(classify("reqwest", "reqwest::RequestBuilder::json"), None);
4929 assert_eq!(classify("reqwest", "reqwest::ClientBuilder::build"), None);
4930 // RAW POSIX SOCKETS — the lowest network tier, pinned as a regression guard (four-way close:
4931 // swift got a raw-socket regression this week from a bare-identifier collision; rust never had
4932 // the gap because it classifies path-QUALIFIED via the syscall-leaf table, but pin it so the
4933 // `socket`/`connect` Net rows can't silently drop). `libc::connect`/`libc::socket` are the direct
4934 // FFI syscalls; `nix::sys::socket::connect` is the safe wrapper; both bottom out in the NET table.
4935 assert_eq!(classify("libc", "libc::connect"), Some("Net"));
4936 assert_eq!(classify("libc", "libc::socket"), Some("Net"));
4937 assert_eq!(classify("libc", "libc::bind"), Some("Net"));
4938 assert_eq!(classify("libc", "libc::accept"), Some("Net"));
4939 // nix routes through the libc syscall table (same leaves): I/O classified, generic fd ops skipped.
4940 assert_eq!(classify("nix", "nix::fcntl::open"), Some("Fs"));
4941 assert_eq!(classify("nix", "nix::sys::socket::connect"), Some("Net"));
4942 assert_eq!(classify("nix", "nix::sys::socket::socket"), Some("Net"));
4943 assert_eq!(classify("nix", "nix::unistd::execvp"), Some("Exec"));
4944 assert_eq!(classify("nix", "nix::unistd::write"), None); // generic fd op — deliberately unclassified
4945 assert_eq!(classify("nix", "nix::unistd::getpid"), None); // not I/O
4946 // rustix does raw syscalls (no libc underneath) → classified directly by leaf, same table.
4947 assert_eq!(classify("rustix", "rustix::time::clock_settime"), Some("Clock"));
4948 assert_eq!(classify("rustix", "rustix::fs::symlink"), Some("Fs"));
4949 assert_eq!(classify("rustix", "rustix::net::connect"), Some("Net"));
4950 assert_eq!(classify("rustix", "rustix::io::read"), None); // generic fd op
4951 // pnet raw packet capture: channel openers are Net, packet construction stays pure.
4952 assert_eq!(classify("pnet", "pnet::datalink::channel"), Some("Net"));
4953 assert_eq!(classify("pnet", "pnet::transport::transport_channel"), Some("Net"));
4954 assert_eq!(classify("pnet_datalink", "pnet_datalink::channel"), Some("Net"));
4955 assert_eq!(classify("pnet", "pnet::packet::ethernet::EthernetPacket::new"), None);
4956 assert_eq!(classify("pnet_base", "pnet_base::MacAddr::new"), None);
4957 // ignore (gitignore-aware walker): walk executors are Fs, config builders stay pure.
4958 assert_eq!(classify("ignore", "ignore::WalkBuilder::build_parallel"), Some("Fs"));
4959 assert_eq!(classify("ignore", "ignore::WalkBuilder::build"), Some("Fs"));
4960 assert_eq!(classify("ignore", "ignore::WalkParallel::run"), Some("Fs"));
4961 assert_eq!(classify("ignore", "ignore::WalkBuilder::add_ignore"), Some("Fs")); // reads the ignore file
4962 assert_eq!(classify("ignore", "ignore::overrides::OverrideBuilder::build"), None); // pure config
4963 assert_eq!(classify("ignore", "ignore::gitignore::GitignoreBuilder::build"), None); // pure config
4964 assert_eq!(classify("ignore", "ignore::DirEntry::path"), None); // pure accessor
4965 // THE FIX: `Walk::new`/`Walk::from_iter` are `WalkBuilder::new(path).build()` /
4966 // `WalkBuilder::from_iter(paths).build()` in ignore's own source — the crate's documented
4967 // convenience constructors, and its own top-level doc example (`for entry in Walk::new(path)`).
4968 // A corpus round caught `deny Fs` exiting 0 over exactly that idiom.
4969 assert_eq!(classify("ignore", "ignore::Walk::new"), Some("Fs"));
4970 assert_eq!(classify("ignore", "ignore::Walk::from_iter"), Some("Fs"));
4971 // NO FABRICATION: a same-named `Walk::new` from an unrelated crate must not gain Fs — crate-gated.
4972 assert_eq!(classify("walkdir", "walkdir::Walk::new"), None);
4973 assert_eq!(classify("some_local_crate", "some_local_crate::Walk::new"), None);
4974 // notify fs-watching: watcher constructors + watch/unwatch are Fs, data types stay pure.
4975 assert_eq!(classify("notify", "notify::RecommendedWatcher::new"), Some("Fs"));
4976 assert_eq!(classify("notify", "notify::PollWatcher::new"), Some("Fs"));
4977 assert_eq!(classify("notify", "notify::recommended_watcher"), Some("Fs"));
4978 assert_eq!(classify("notify", "notify::INotifyWatcher::watch"), Some("Fs"));
4979 assert_eq!(classify("notify", "notify::Config::default"), None); // pure config
4980 assert_eq!(classify("notify", "notify::Event::new"), None); // pure data type
4981 assert_eq!(classify("rusqlite", "rusqlite::Connection::execute"), Some("Db"));
4982 // the rusqlite verb DIALECT (a verb probe found the canonical consumer API classifying pure):
4983 assert_eq!(classify("rusqlite", "rusqlite::Connection::query_row"), Some("Db"));
4984 assert_eq!(classify("rusqlite", "rusqlite::Statement::query_map"), Some("Db"));
4985 assert_eq!(classify("rusqlite", "rusqlite::Connection::execute_batch"), Some("Db"));
4986 assert_eq!(classify("rusqlite", "rusqlite::Connection::prepare_cached"), Some("Db"));
4987 assert_eq!(classify("rusqlite", "rusqlite::Connection::open"), Some("Db"));
4988 assert_eq!(classify("rusqlite", "rusqlite::Connection::open_in_memory"), Some("Db"));
4989 // THE FIX: three more real `Connection::open*` constructors an exact-suffix list missed
4990 // (`open_in_memory_with_flags` doesn't END in `open_with_flags`), plus the documented blob-I/O
4991 // API (`blob_open`/`Blob::reopen`), each calling the real sqlite3 FFI directly.
4992 assert_eq!(classify("rusqlite", "rusqlite::Connection::open_in_memory_with_flags"), Some("Db"));
4993 assert_eq!(classify("rusqlite", "rusqlite::Connection::open_with_flags_and_vfs"), Some("Db"));
4994 assert_eq!(
4995 classify("rusqlite", "rusqlite::Connection::open_in_memory_with_flags_and_vfs"),
4996 Some("Db")
4997 );
4998 assert_eq!(classify("rusqlite", "rusqlite::Connection::blob_open"), Some("Db"));
4999 assert_eq!(classify("rusqlite", "rusqlite::blob::Blob::reopen"), Some("Db"));
5000 // NO FABRICATION: a same-crate, DIFFERENT type's `open`-prefixed method (rusqlite's own private
5001 // `pragma::Sql::open_brace`, which pushes one char to a string buffer) must not gain Db — the
5002 // fix keys on the `Connection::` segment, not a bare leaf prefix.
5003 assert_eq!(classify("rusqlite", "rusqlite::pragma::Sql::open_brace"), None);
5004 // THE COVERAGE-GATE SWEEP FIX: the online-backup API and incremental-BLOB positional I/O,
5005 // verified against rusqlite 0.32.1 (each calls an `ffi::sqlite3_*` leaf already in this file's
5006 // own FFI-leaf DB table, but the `crate_name == "rusqlite"` block above returned before ever
5007 // reaching it — same shape as the git2 fix elsewhere in this file).
5008 assert_eq!(classify("rusqlite", "rusqlite::Backup::new"), Some("Db"));
5009 assert_eq!(classify("rusqlite", "rusqlite::Backup::new_with_names"), Some("Db"));
5010 assert_eq!(classify("rusqlite", "rusqlite::Backup::step"), Some("Db"));
5011 assert_eq!(classify("rusqlite", "rusqlite::Backup::run_to_completion"), Some("Db"));
5012 assert_eq!(classify("rusqlite", "rusqlite::Connection::backup"), Some("Db"));
5013 assert_eq!(classify("rusqlite", "rusqlite::Connection::restore"), Some("Db"));
5014 assert_eq!(classify("rusqlite", "rusqlite::Blob::read_at"), Some("Db"));
5015 assert_eq!(classify("rusqlite", "rusqlite::Blob::read_at_exact"), Some("Db"));
5016 assert_eq!(classify("rusqlite", "rusqlite::Blob::raw_read_at"), Some("Db"));
5017 assert_eq!(classify("rusqlite", "rusqlite::Blob::raw_read_at_exact"), Some("Db"));
5018 assert_eq!(classify("rusqlite", "rusqlite::Blob::write_at"), Some("Db"));
5019 assert_eq!(classify("rusqlite", "rusqlite::Blob::write_all_at"), Some("Db"));
5020 assert_eq!(classify("rusqlite", "rusqlite::Connection::from_handle"), Some("Db"));
5021 assert_eq!(classify("rusqlite", "rusqlite::Connection::from_handle_owned"), Some("Db"));
5022 assert_eq!(classify("rusqlite", "rusqlite::Connection::extension_init2"), Some("Db"));
5023 assert_eq!(classify("rusqlite", "rusqlite::init_auto_extension"), Some("Db"));
5024 // …and the REAL module-qualified spellings (`Backup`/`Blob`/`init_auto_extension` are not
5025 // re-exported at rusqlite's crate root, unlike `Connection` — a real consumer's source, proven
5026 // against a compiling fixture, must use these, not the short forms above).
5027 assert_eq!(classify("rusqlite", "rusqlite::backup::Backup::new"), Some("Db"));
5028 assert_eq!(classify("rusqlite", "rusqlite::blob::Blob::read_at"), Some("Db"));
5029 assert_eq!(classify("rusqlite", "rusqlite::auto_extension::init_auto_extension"), Some("Db"));
5030 // NO FABRICATION: `InnerConnection`/`RawStatement` live in PRIVATE modules (lib.rs `mod
5031 // inner_connection;`/`mod raw_statement;`, no `pub`) — no external consumer can ever name either
5032 // type, so no rule was added despite self-scan flagging them too.
5033 assert_eq!(classify("rusqlite", "rusqlite::InnerConnection::close"), None);
5034 assert_eq!(classify("rusqlite", "rusqlite::RawStatement::step"), None);
5035 // …but `open` stays rusqlite-only (postgres has no open; nothing else may borrow it):
5036 assert_eq!(classify("postgres", "postgres::Client::open"), None);
5037 assert_eq!(classify("tokio_postgres", "tokio_postgres::Client::query_typed"), Some("Db"));
5038 // THE FIX: `Config::connect_raw` does the same protocol handshake as `Config::connect`, over a
5039 // caller-supplied stream instead of one it dials itself.
5040 assert_eq!(classify("tokio_postgres", "tokio_postgres::Config::connect_raw"), Some("Db"));
5041 // diesel's LIMIT-1 + streaming executions; sqlx's multi-result stream:
5042 assert_eq!(classify("diesel", "diesel::RunQueryDsl::first"), Some("Db"));
5043 assert_eq!(classify("diesel", "diesel::RunQueryDsl::load_iter"), Some("Db"));
5044 // THE FIX: `Connection::establish` is diesel's own name for `::connect` — the crate's canonical,
5045 // most-used connection entry point (`SqliteConnection::establish(url)` in every diesel
5046 // quickstart), implemented by really opening the backend handle — but shared no verb spelling
5047 // with `::connect`, so it read pure with zero `coverage.uncovered` disclosure (diesel is
5048 // calibrated, so the miss reads as reviewed-pure, not as a gap).
5049 assert_eq!(classify("diesel", "diesel::sqlite::SqliteConnection::establish"), Some("Db"));
5050 assert_eq!(classify("diesel", "diesel::pg::PgConnection::establish"), Some("Db"));
5051 assert_eq!(classify("diesel", "diesel::mysql::MysqlConnection::establish"), Some("Db"));
5052 assert_eq!(classify("diesel", "diesel::Connection::establish"), Some("Db"));
5053 // NO FABRICATION: diesel's own private plumbing shares the substring but not the exact verb.
5054 assert_eq!(classify("diesel", "diesel::sqlite::SqliteConnection::establish_inner"), None);
5055 assert_eq!(classify("sqlx", "sqlx::query::Query::fetch_many"), Some("Db"));
5056 // sqlx's bare `query()` builder must STAY pure (the original sqlx lesson):
5057 assert_eq!(classify("sqlx", "sqlx::query"), None);
5058 // tracing: the emit/span-lifecycle dispatch is Log; the pure DATA-type accessors are not
5059 // (whole-crate Log fabricated Log on `Level::as_str` / `Span::is_disabled` — the data types are
5060 // pure, same principle as the `log` facade).
5061 assert_eq!(classify("tracing", "tracing::event"), Some("Log"));
5062 assert_eq!(classify("tracing", "tracing::Span::new_span"), Some("Log"));
5063 assert_eq!(classify("tracing", "tracing::Span::record"), Some("Log"));
5064 assert_eq!(classify("tracing", "tracing::Span::enter"), Some("Log"));
5065 assert_eq!(classify("tracing", "tracing::Level::as_str"), None); // pure accessor
5066 assert_eq!(classify("tracing", "tracing::Span::is_disabled"), None); // pure state read
5067 assert_eq!(classify("tracing", "tracing::Span::metadata"), None); // pure accessor
5068 assert_eq!(classify("tracing", "tracing::metadata::Level::TRACE"), None); // pure data type
5069 assert_eq!(classify("tracing", "tracing::field::Field::name"), None); // pure data type
5070 // git2 (high-level Rust API, not the `raw::git_*` FFI tier tested above): remote verbs are Net.
5071 assert_eq!(classify("git2", "git2::Remote::fetch"), Some("Net"));
5072 assert_eq!(classify("git2", "git2::Remote::push"), Some("Net"));
5073 assert_eq!(classify("git2", "git2::Remote::download"), Some("Net"));
5074 assert_eq!(classify("git2", "git2::Remote::connect"), Some("Net"));
5075 assert_eq!(classify("git2", "git2::Remote::connect_auth"), Some("Net"));
5076 assert_eq!(classify("git2", "git2::Remote::ls"), Some("Net"));
5077 assert_eq!(classify("git2", "git2::Remote::upload"), Some("Net"));
5078 // THE COVERAGE-GATE SWEEP FIX: `Repository::open`/`init` (and every other local .git-directory
5079 // operation below) used to read PURE here — the comment this replaced called that "honest, no
5080 // network", but candor-scan self-scanning git2's OWN source (eval/coverage-gate) proved these
5081 // reach a REAL Fs effect (`raw::git_repository_open`/`_init_ext`, already in this file's FFI-tier
5082 // table) that a real consumer's call to the safe wrapper could never surface, because THIS
5083 // block's early `return None` sat in front of that table for every `crate_name == "git2"` call.
5084 // "local, no network" was true and beside the point: Fs was the effect being missed. See MEMORY /
5085 // the commit that added this block for the full sweep (Config/Index/Odb/PackBuilder/Reference/
5086 // Repository/TreeBuilder — every FQN below calls a listed FS FFI leaf; verified against git2
5087 // 0.20.4 source).
5088 assert_eq!(classify("git2", "git2::Repository::open"), Some("Fs"));
5089 assert_eq!(classify("git2", "git2::Repository::init"), Some("Fs"));
5090 assert_eq!(classify("git2", "git2::Repository::open_bare"), Some("Fs"));
5091 assert_eq!(classify("git2", "git2::Repository::open_ext"), Some("Fs"));
5092 assert_eq!(classify("git2", "git2::Repository::open_from_env"), Some("Fs"));
5093 assert_eq!(classify("git2", "git2::Repository::discover"), Some("Fs"));
5094 assert_eq!(classify("git2", "git2::Repository::discover_path"), Some("Fs"));
5095 assert_eq!(classify("git2", "git2::Repository::init_bare"), Some("Fs"));
5096 assert_eq!(classify("git2", "git2::Repository::init_opts"), Some("Fs"));
5097 assert_eq!(classify("git2", "git2::Repository::blob_path"), Some("Fs"));
5098 assert_eq!(classify("git2", "git2::Repository::checkout_head"), Some("Fs"));
5099 assert_eq!(classify("git2", "git2::Repository::checkout_index"), Some("Fs"));
5100 assert_eq!(classify("git2", "git2::Repository::checkout_tree"), Some("Fs"));
5101 assert_eq!(classify("git2", "git2::Repository::commit"), Some("Fs"));
5102 assert_eq!(classify("git2", "git2::Repository::reference"), Some("Fs"));
5103 assert_eq!(classify("git2", "git2::Repository::tag"), Some("Fs"));
5104 assert_eq!(classify("git2", "git2::Config::add_file"), Some("Fs"));
5105 assert_eq!(classify("git2", "git2::Config::open"), Some("Fs"));
5106 assert_eq!(classify("git2", "git2::Config::open_default"), Some("Fs"));
5107 assert_eq!(classify("git2", "git2::Index::add_all"), Some("Fs"));
5108 assert_eq!(classify("git2", "git2::Index::add_path"), Some("Fs"));
5109 assert_eq!(classify("git2", "git2::Index::read"), Some("Fs"));
5110 assert_eq!(classify("git2", "git2::Index::write"), Some("Fs"));
5111 assert_eq!(classify("git2", "git2::Index::write_tree"), Some("Fs"));
5112 assert_eq!(classify("git2", "git2::Index::write_tree_to"), Some("Fs"));
5113 assert_eq!(classify("git2", "git2::Odb::read"), Some("Fs"));
5114 assert_eq!(classify("git2", "git2::Odb::reader"), Some("Fs"));
5115 assert_eq!(classify("git2", "git2::Odb::write"), Some("Fs"));
5116 assert_eq!(classify("git2", "git2::Odb::writer"), Some("Fs"));
5117 assert_eq!(classify("git2", "git2::PackBuilder::write"), Some("Fs"));
5118 assert_eq!(classify("git2", "git2::Reference::delete"), Some("Fs"));
5119 assert_eq!(classify("git2", "git2::Reference::set_target"), Some("Fs"));
5120 assert_eq!(classify("git2", "git2::TreeBuilder::write"), Some("Fs"));
5121 // `Remote::list`/`RemoteConnection::list` call `raw::git_remote_ls` directly — the same
5122 // reference-advertisement Net effect `Remote::ls` was meant to catch, under the name git2 0.20
5123 // actually uses (there is no `ls` method left in the crate at all — that suffix rule is dead
5124 // weight, kept rather than removed since deleting it is a separate, unrelated cleanup).
5125 assert_eq!(classify("git2", "git2::Remote::list"), Some("Net"));
5126 assert_eq!(classify("git2", "git2::RemoteConnection::list"), Some("Net"));
5127 // `Cred::credential_helper` spawns a real `sh -c "<helper> get"` subprocess to resolve
5128 // `credential.helper` config (cred.rs:370-390) — a real consumer's most common way to honor it.
5129 assert_eq!(classify("git2", "git2::Cred::credential_helper"), Some("Exec"));
5130 assert_eq!(classify("git2", "git2::CredentialHelper::execute"), Some("Exec"));
5131 // NO FABRICATION controls: a `write`/`read`/`open` on some OTHER git2 type (not FQN-listed above)
5132 // must stay pure — this fix is FQN-exact, not a suffix re-widen.
5133 assert_eq!(classify("git2", "git2::Repository::state"), None);
5134 assert_eq!(classify("git2", "git2::Blob::content"), None);
5135 // tonic's SERVER half (the coverage-gate sweep found only the client verbs were covered):
5136 // `Router::serve`/`::serve_with_shutdown` bind a real listening socket via `TcpIncoming::new`
5137 // (`StdTcpListener::bind`), verified against tonic 0.12.3 source.
5138 assert_eq!(classify("tonic", "tonic::transport::server::Router::serve"), Some("Net"));
5139 assert_eq!(
5140 classify("tonic", "tonic::transport::server::Router::serve_with_shutdown"),
5141 Some("Net")
5142 );
5143 assert_eq!(classify("tonic", "tonic::TcpIncoming::new"), Some("Net"));
5144 // NO FABRICATION control: `Server<L>::serve_with_shutdown` (mod.rs:530) is a DIFFERENT,
5145 // `pub(crate)` method on a different type — not reachable by any consumer, and this fix does not
5146 // fabricate an effect for it by widening past the `Router::` FQN.
5147 assert_eq!(classify("tonic", "tonic::transport::server::Server::serve_with_shutdown"), None);
5148 // THE FIX: `Repository::clone`/`clone_recurse` and `RepoBuilder::clone` ARE libgit2's real
5149 // network clone — a corpus round found `git2::Repository::clone(url, path)` reporting ZERO
5150 // effects and passing `deny Net` at exit 0, because the bare `::clone` denylist meant to keep
5151 // out `Remote`'s derived `Clone` dup (below) also excluded the one thing the old comment named
5152 // as NOT meant to be excluded.
5153 assert_eq!(classify("git2", "git2::Repository::clone"), Some("Net"));
5154 assert_eq!(classify("git2", "git2::Repository::clone_recurse"), Some("Net"));
5155 assert_eq!(classify("git2", "git2::build::RepoBuilder::clone"), Some("Net"));
5156 // THE CONTROL: `Remote::clone` is the DERIVED `Clone` trait impl (an Arc/handle duplication, no
5157 // libgit2 call) — it must stay pure. Re-fabricating Net here would undo the very over-charge the
5158 // original exclusion existed to prevent. FQN-exact matching (not a bare `::clone` re-widen) keeps
5159 // this pure while catching `Repository::clone` above.
5160 assert_eq!(classify("git2", "git2::Remote::clone"), None);
5161 // THE SWEEP FIX: a fourth+fifth true positive in the SAME gap — `Submodule::clone`/
5162 // `Submodule::update` call `raw::git_submodule_clone`/`git_submodule_update` directly (the exact
5163 // leaves already in this file's FFI-tier NET table), but git2's documented submodule-init idiom
5164 // calls the safe Rust wrapper, which carried no rule of its own.
5165 assert_eq!(classify("git2", "git2::Submodule::clone"), Some("Net"));
5166 assert_eq!(classify("git2", "git2::Submodule::update"), Some("Net"));
5167 // NO FABRICATION: git2's many pure `update_*` setters (CheckoutBuilder/DiffOptions/StatusOptions)
5168 // must not gain Net — FQN-exact, not a bare `::update` substring.
5169 assert_eq!(classify("git2", "git2::build::CheckoutBuilder::update_only"), None);
5170 assert_eq!(classify("git2", "git2::DiffOptions::update_index"), None);
5171 assert_eq!(classify("git2", "git2::Submodule::update_strategy"), None);
5172 // memmap2: only the syscall-issuing map/flush/protect verbs are Fs; reads over an already-mapped
5173 // region (len/as_ptr/is_empty) and the request builder are PURE (whole-crate Fs fabricated Fs).
5174 assert_eq!(classify("memmap2", "memmap2::MmapOptions::map"), Some("Fs"));
5175 assert_eq!(classify("memmap2", "memmap2::MmapOptions::map_mut"), Some("Fs"));
5176 assert_eq!(classify("memmap2", "memmap2::Mmap::flush"), Some("Fs"));
5177 assert_eq!(classify("memmap2", "memmap2::MmapMut::make_read_only"), Some("Fs"));
5178 assert_eq!(classify("memmap2", "memmap2::Mmap::len"), None); // length read — pure
5179 assert_eq!(classify("memmap2", "memmap2::Mmap::is_empty"), None); // pure
5180 assert_eq!(classify("memmap2", "memmap2::Mmap::as_ptr"), None); // pointer — pure
5181 assert_eq!(classify("memmap2", "memmap2::MmapOptions::new"), None); // request builder — pure
5182 // arboard: the Clipboard handle's read/write verbs are Clipboard; `arboard::Error` formatting
5183 // and option data types are PURE (whole-crate Clipboard fabricated Clipboard on `Error::to_string`).
5184 assert_eq!(classify("arboard", "arboard::Clipboard::new"), Some("Clipboard"));
5185 assert_eq!(classify("arboard", "arboard::Clipboard::get_text"), Some("Clipboard"));
5186 assert_eq!(classify("arboard", "arboard::Clipboard::set_text"), Some("Clipboard"));
5187 assert_eq!(classify("arboard", "arboard::Clipboard::clear"), Some("Clipboard"));
5188 assert_eq!(classify("arboard", "arboard::Error::to_string"), None); // error formatting — pure
5189 assert_eq!(classify("arboard", "arboard::Error::fmt"), None); // Display impl — pure
5190 assert_eq!(classify("arboard", "arboard::ImageData::to_owned_img"), None); // pure data type
5191 // R59-class audit: `file_list` is `Get`/`Set`'s sibling terminal beside `text`/`image`/`html`
5192 // (verified against arboard 3.6.1 — the only `file_list` in the crate, so no ambiguity survives
5193 // the crate gate); `Clear::default` is `clear()`'s own alternate entry point.
5194 assert_eq!(classify("arboard", "arboard::Get::file_list"), Some("Clipboard"));
5195 assert_eq!(classify("arboard", "arboard::Set::file_list"), Some("Clipboard"));
5196 assert_eq!(classify("arboard", "arboard::Clear::default"), Some("Clipboard"));
5197 // fastrand: value draws + entropy-seeded entry points are Rand; the DETERMINISTIC seeded ctor
5198 // `with_seed` and state split/copy (`fork`/`clone`) are PURE (whole-crate Rand fabricated Rand).
5199 assert_eq!(classify("fastrand", "fastrand::u32"), Some("Rand")); // top-level draw
5200 assert_eq!(classify("fastrand", "fastrand::Rng::usize"), Some("Rand"));
5201 assert_eq!(classify("fastrand", "fastrand::Rng::shuffle"), Some("Rand"));
5202 assert_eq!(classify("fastrand", "fastrand::Rng::new"), Some("Rand")); // entropy-seeded
5203 assert_eq!(classify("fastrand", "fastrand::Rng::with_seed"), None); // deterministic ctor — pure
5204 assert_eq!(classify("fastrand", "fastrand::Rng::fork"), None); // state split — pure
5205 assert_eq!(classify("fastrand", "fastrand::Rng::clone"), None); // state copy — pure
5206 // portable_pty / async_process: spawn/wait keep Exec; config GETTERS and pure data ctors/setters
5207 // do NOT (base Exec fabricated on `CommandBuilder::get_cwd` / `PtySize::default` / `Stdio::piped`).
5208 assert_eq!(classify("portable_pty", "portable_pty::PtySystem::openpty"), Some("Exec"));
5209 assert_eq!(classify("portable_pty", "portable_pty::SlavePty::spawn_command"), Some("Exec"));
5210 assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::get_argv"), None); // getter
5211 assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::get_cwd"), None); // getter
5212 assert_eq!(classify("portable_pty", "portable_pty::PtySize::default"), None); // pure data type
5213 assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::new"), None); // builder ctor
5214 assert_eq!(classify("async_process", "async_process::Command::spawn"), Some("Exec"));
5215 assert_eq!(classify("async_process", "async_process::Command::output"), Some("Exec"));
5216 assert_eq!(classify("async_process", "async_process::Stdio::piped"), None); // pure data type
5217 assert_eq!(classify("async_process", "async_process::Stdio::null"), None); // pure data type
5218 // FFI tiers (matched by distinctive leaf, alias-independent)
5219 assert_eq!(classify("libc", "libc::open"), Some("Fs"));
5220 assert_eq!(classify("libc", "libc::connect"), Some("Net"));
5221 assert_eq!(classify("libc", "libc::read"), None); // generic fd op — deliberately unclassified
5222 assert_eq!(classify("ffi", "ffi::sqlite3_step"), Some("Db"));
5223 // SOUNDNESS R166 — the PROCESS-GLOBAL extension registry. `rusqlite::register_auto_extension`
5224 // calls `ffi::sqlite3_auto_extension` directly and read `[]` on the 0.34.0 binary: an explicit
5225 // empty effect set over a call that changes what every LATER `sqlite3_open` executes.
5226 assert_eq!(classify("ffi", "ffi::sqlite3_auto_extension"), Some("Db"));
5227 assert_eq!(classify("ffi", "ffi::sqlite3_cancel_auto_extension"), Some("Db"));
5228 assert_eq!(classify("ffi", "ffi::sqlite3_reset_auto_extension"), Some("Db"));
5229 assert_eq!(classify("ffi", "ffi::sqlite3_enable_load_extension"), Some("Db"));
5230 // …and the database-CONTENT I/O the same sweep found unlisted beside it.
5231 assert_eq!(classify("ffi", "ffi::sqlite3_deserialize"), Some("Db"));
5232 assert_eq!(classify("ffi", "ffi::sqlite3_serialize"), Some("Db"));
5233 assert_eq!(classify("ffi", "ffi::sqlite3_db_cacheflush"), Some("Db"));
5234 assert_eq!(classify("ffi", "ffi::sqlite3_file_control"), Some("Db"));
5235 assert_eq!(classify("ffi", "ffi::sqlite3_wal_autocheckpoint"), Some("Db"));
5236 // CONTROLS for the boundary this list deliberately stops at — an in-memory accessor and a
5237 // per-connection callback INSTALLER stay unclassified, so the additions above are not read as
5238 // "every `sqlite3_*` is Db". Both pass before and after R166; recorded as boundary, not coverage.
5239 assert_eq!(classify("ffi", "ffi::sqlite3_column_int64"), None);
5240 assert_eq!(classify("ffi", "ffi::sqlite3_create_function_v2"), None);
5241 assert_eq!(classify("raw", "raw::git_remote_fetch"), Some("Net"));
5242 // libgit2 clone + submodule clone/update fetch over the network (an A/B on git2 0.20 caught
5243 // `Submodule::update`/`clone` and `Repository::clone` reporting no Net — the latter because the
5244 // `src/build.rs` module was being dropped as if it were the Cargo build script).
5245 assert_eq!(classify("raw", "raw::git_clone"), Some("Net"));
5246 assert_eq!(classify("raw", "raw::git_submodule_clone"), Some("Net"));
5247 assert_eq!(classify("raw", "raw::git_submodule_update"), Some("Net"));
5248 assert_eq!(classify("raw", "raw::git_submodule_open"), None); // local subrepo open — not Net
5249 // libcurl: the transfer/raw-socket entry points are Net (an A/B on curl 0.4 caught the whole
5250 // crate reporting ZERO Net); the big setopt/init/getinfo surface — and the readiness-wait
5251 // multi_wait/poll — stay unclassified (the loop's perform is the boundary).
5252 assert_eq!(classify("curl_sys", "curl_sys::curl_easy_perform"), Some("Net"));
5253 assert_eq!(classify("curl_sys", "curl_sys::curl_easy_send"), Some("Net"));
5254 assert_eq!(classify("curl_sys", "curl_sys::curl_multi_perform"), Some("Net"));
5255 assert_eq!(classify("curl_sys", "curl_sys::curl_multi_socket_action"), Some("Net"));
5256 assert_eq!(classify("curl_sys", "curl_sys::curl_easy_setopt"), None); // in-memory option write
5257 assert_eq!(classify("curl_sys", "curl_sys::curl_easy_init"), None); // handle alloc
5258 assert_eq!(classify("curl_sys", "curl_sys::curl_multi_wait"), None); // readiness wait, no payload
5259 // consumer-side `curl` crate rule: the dispatch verbs are Net, the setopt builders pure.
5260 assert_eq!(classify("curl", "curl::easy::Easy::perform"), Some("Net"));
5261 assert_eq!(classify("curl", "curl::multi::Multi::perform"), Some("Net"));
5262 assert_eq!(classify("curl", "curl::easy::Easy::send"), Some("Net"));
5263 assert_eq!(classify("curl", "curl::easy::Easy::url"), None); // CURLOPT setter — pure
5264 assert_eq!(classify("curl", "curl::easy::Easy::timeout"), None); // pure setter; Multi::timeout under-reported by design
5265 assert_eq!(classify("ffi", "ffi::SSL_connect"), Some("Net"));
5266 // pure crates stay pure
5267 assert_eq!(classify("serde", "serde::Serialize::serialize"), None);
5268 assert_eq!(classify("std", "std::vec::Vec::push"), None);
5269
5270 // ── sweep 2026-06-17: fabrication carve-outs + DNS coverage (each fails pre-fix) ──
5271 // [24] std::net socket accessors are pure; the I/O verbs stay Net.
5272 assert_eq!(classify("std", "std::net::TcpStream::connect"), Some("Net"));
5273 assert_eq!(classify("std", "std::net::TcpStream::local_addr"), None);
5274 assert_eq!(classify("std", "std::net::TcpStream::nodelay"), None);
5275 assert_eq!(classify("std", "std::net::TcpStream::ttl"), None);
5276 assert_eq!(classify("std", "std::net::UdpSocket::peer_addr"), None);
5277 // [37] std DNS resolution is Net (was floored).
5278 assert_eq!(classify("std", "std::net::lookup_host"), Some("Net"));
5279 assert_eq!(classify("std", "core::net::ToSocketAddrs::to_socket_addrs"), Some("Net"));
5280 // [23] std::process getters are pure; spawn/new stay Exec.
5281 assert_eq!(classify("std", "std::process::Command::get_program"), None);
5282 assert_eq!(classify("std", "std::process::Command::get_args"), None);
5283 assert_eq!(classify("std", "std::process::Child::id"), None);
5284 assert_eq!(classify("std", "std::process::Command::spawn"), Some("Exec"));
5285 // [27] redis ConnectionManager::clone is an Arc bump (pure); a query round-trips.
5286 assert_eq!(classify("redis", "redis::aio::ConnectionManager::clone"), None);
5287 assert_eq!(classify("redis", "redis::aio::ConnectionManager::send_packed_command"), Some("Db"));
5288 // [5] sea_orm re-exported sea_query builder algebra is pure; execution verbs stay Db.
5289 assert_eq!(classify("sea_orm", "sea_orm::sea_query::Func::count"), None);
5290 assert_eq!(classify("sea_orm", "sea_orm::sea_query::Condition::all"), None);
5291 assert_eq!(classify("sea_orm", "sea_orm::Select::all"), Some("Db"));
5292 // THE FIX: `Database::connect_proxy` (the `proxy`-feature sibling of `Database::connect`) opens
5293 // a live `DatabaseConnection` through a caller-supplied `ProxyDatabaseTrait` — same effect,
5294 // missing from an allowlist keyed on the plain `connect` spelling.
5295 assert_eq!(classify("sea_orm", "sea_orm::Database::connect_proxy"), Some("Db"));
5296 // THE COVERAGE-GATE SWEEP: the transaction and pagination families, verified against sea-orm
5297 // 1.1.20 source.
5298 assert_eq!(classify("sea_orm", "sea_orm::DatabaseConnection::transaction"), Some("Db"));
5299 assert_eq!(
5300 classify("sea_orm", "sea_orm::DatabaseConnection::transaction_with_config"),
5301 Some("Db")
5302 );
5303 // BONUS (found proving the above reachable with a real fixture, not in the original ratchet):
5304 assert_eq!(classify("sea_orm", "sea_orm::DatabaseConnection::ping"), Some("Db"));
5305 assert_eq!(classify("sea_orm", "sea_orm::DatabaseConnection::begin"), Some("Db"));
5306 assert_eq!(
5307 classify("sea_orm", "sea_orm::DatabaseConnection::begin_with_config"),
5308 Some("Db")
5309 );
5310 assert_eq!(classify("sea_orm", "sea_orm::DatabaseTransaction::commit"), Some("Db"));
5311 assert_eq!(classify("sea_orm", "sea_orm::DatabaseTransaction::rollback"), Some("Db"));
5312 assert_eq!(classify("sea_orm", "sea_orm::SqlxMySqlPoolConnection::begin"), Some("Db"));
5313 assert_eq!(classify("sea_orm", "sea_orm::SqlxMySqlPoolConnection::ping"), Some("Db"));
5314 assert_eq!(classify("sea_orm", "sea_orm::SqlxMySqlPoolConnection::transaction"), Some("Db"));
5315 assert_eq!(classify("sea_orm", "sea_orm::SqlxPostgresPoolConnection::begin"), Some("Db"));
5316 assert_eq!(classify("sea_orm", "sea_orm::SqlxSqlitePoolConnection::ping"), Some("Db"));
5317 assert_eq!(classify("sea_orm", "sea_orm::Paginator::fetch"), Some("Db"));
5318 assert_eq!(classify("sea_orm", "sea_orm::Paginator::fetch_and_next"), Some("Db"));
5319 assert_eq!(classify("sea_orm", "sea_orm::Paginator::into_stream"), Some("Db"));
5320 assert_eq!(classify("sea_orm", "sea_orm::Paginator::num_pages"), Some("Db"));
5321 assert_eq!(classify("sea_orm", "sea_orm::Paginator::num_items_and_pages"), Some("Db"));
5322 assert_eq!(classify("sea_orm", "sea_orm::Insert::exec_with_returning_keys"), Some("Db"));
5323 assert_eq!(classify("sea_orm", "sea_orm::Inserter::exec_with_returning_many"), Some("Db"));
5324 assert_eq!(classify("sea_orm", "sea_orm::TryInsert::exec_with_returning_keys"), Some("Db"));
5325 // NO FABRICATION: the mock/proxy backends share every one of these verb names but perform no
5326 // real I/O (mock) or dispatch through a caller-supplied trait (proxy) — FQN-exact, not a bare
5327 // `::ping`/`::begin`/`::transaction`/`::commit`/`::rollback` suffix, keeps them pure/unknown.
5328 assert_eq!(classify("sea_orm", "sea_orm::MockDatabaseConnection::ping"), None);
5329 assert_eq!(classify("sea_orm", "sea_orm::MockDatabaseConnection::begin"), None);
5330 assert_eq!(classify("sea_orm", "sea_orm::ProxyDatabaseConnection::commit"), None);
5331 assert_eq!(classify("sea_orm", "sea_orm::ProxyDatabaseConnection::ping"), None);
5332 }
5333
5334 #[test]
5335 fn lettre_tls_setup_and_connection_family_is_covered() {
5336 // THE COVERAGE-GATE SWEEP, verified against lettre 0.11.23 source. `build_rustls` calls
5337 // `rustls_native_certs::load_native_certs()` (a real OS-trust-store read) + `tracing::debug!`.
5338 assert_eq!(
5339 classify("lettre", "lettre::transport::smtp::client::TlsParametersBuilder::build_rustls"),
5340 Some("Fs")
5341 );
5342 assert_eq!(classify("lettre", "lettre::TlsParametersBuilder::build"), Some("Fs"));
5343 assert_eq!(classify("lettre", "lettre::TlsParameters::new"), Some("Fs"));
5344 assert_eq!(classify("lettre", "lettre::TlsParameters::new_rustls"), Some("Fs"));
5345 assert_eq!(classify("lettre", "lettre::SmtpTransport::from_url"), Some("Fs"));
5346 assert_eq!(classify("lettre", "lettre::SmtpTransport::relay"), Some("Fs"));
5347 assert_eq!(classify("lettre", "lettre::SmtpTransport::starttls_relay"), Some("Fs"));
5348 assert_eq!(classify("lettre", "lettre::AsyncSmtpTransport::from_url"), Some("Fs"));
5349 assert_eq!(classify("lettre", "lettre::AsyncSmtpTransport::relay"), Some("Fs"));
5350 assert_eq!(classify("lettre", "lettre::AsyncSmtpTransport::starttls_relay"), Some("Fs"));
5351 assert_eq!(classify("lettre", "lettre::FileTransport::read"), Some("Fs"));
5352 // The sealed `Executor` trait impl — `#[doc(hidden)]`, a narrower surface, but genuinely
5353 // reachable (both the trait and `AsyncStd1Executor` are `pub`, re-exported at the crate root).
5354 assert_eq!(classify("lettre", "lettre::AsyncStd1Executor::connect"), Some("Net"));
5355 assert_eq!(classify("lettre", "lettre::AsyncStd1Executor::fs_read"), Some("Fs"));
5356 assert_eq!(classify("lettre", "lettre::AsyncStd1Executor::fs_write"), Some("Fs"));
5357 // Only reachable via the module-qualified path — neither type is re-exported at the crate root.
5358 assert_eq!(
5359 classify(
5360 "lettre",
5361 "lettre::transport::smtp::client::AsyncSmtpConnection::connect_asyncstd1"
5362 ),
5363 Some("Net")
5364 );
5365 assert_eq!(
5366 classify(
5367 "lettre",
5368 "lettre::transport::smtp::client::AsyncNetworkStream::connect_asyncstd1"
5369 ),
5370 Some("Net")
5371 );
5372 // NO FABRICATION: `NetworkStream` lives in a PRIVATE module (`mod net;`, never re-exported) — no
5373 // external consumer can ever name it, so no rule was added despite self-scan flagging its
5374 // `shutdown`/`set_read_timeout`/`set_write_timeout` (propagated from the coarse
5375 // `std::net::TcpStream` whole-handle rule).
5376 assert_eq!(classify("lettre", "lettre::NetworkStream::shutdown"), None);
5377 assert_eq!(classify("lettre", "lettre::NetworkStream::set_read_timeout"), None);
5378 }
5379
5380 #[test]
5381 fn rand_osrng_handle_ops_are_pure_but_draws_are_rand() {
5382 // Adversarial-review fabrication: the blanket `contains("OsRng")` tagged `OsRng::clone` Rand,
5383 // but OsRng is a unit struct — clone/fork/default draw no entropy. The real draws still fire.
5384 assert_eq!(classify("rand", "rand::rngs::OsRng::clone"), None);
5385 assert_eq!(classify("rand", "rand::rngs::OsRng::default"), None);
5386 assert_eq!(classify("rand", "rand::rngs::OsRng::fill_bytes"), Some("Rand")); // a real draw
5387 assert_eq!(classify("rand", "rand::rngs::OsRng::next_u32"), Some("Rand"));
5388 assert_eq!(classify("rand", "rand::Rng::gen"), Some("Rand")); // verb path unaffected
5389 assert_eq!(classify("rand", "rand::distributions::Uniform::new"), None); // pure ctor still pure
5390 }
5391
5392 #[test]
5393 fn redis_connection_manager_config_builder_is_pure() {
5394 // Adversarial-review fabrication: `contains("ConnectionManager")` hit the pure *Config* builder.
5395 assert_eq!(classify("redis", "redis::aio::ConnectionManagerConfig::new"), None);
5396 assert_eq!(classify("redis", "redis::aio::ConnectionManagerConfig::set_max_delay"), None);
5397 // the LIVE manager still round-trips (Db).
5398 assert_eq!(classify("redis", "redis::aio::ConnectionManager::new"), Some("Db"));
5399 assert_eq!(classify("redis", "redis::Commands::get"), Some("Db"));
5400 }
5401
5402 #[test]
5403 fn redis_get_connection_info_is_a_pure_accessor_not_swept_by_the_substring() {
5404 // coverage-gate sweep (2026-08-27): `contains("::get_connection")` also matched
5405 // `Client::get_connection_info` (a pure accessor over an already-stored field) because it is a
5406 // literal substring of that name — the real connection-getters stay caught.
5407 assert_eq!(classify("redis", "redis::Client::get_connection_info"), None);
5408 assert_eq!(classify("redis", "redis::Client::get_connection"), Some("Db"));
5409 assert_eq!(classify("redis", "redis::Client::get_async_connection"), Some("Db"));
5410 assert_eq!(
5411 classify("redis", "redis::Client::get_multiplexed_async_connection"),
5412 Some("Db")
5413 );
5414 }
5415
5416 #[test]
5417 fn aws_config_load_from_env_is_loads_own_convenience_wrapper() {
5418 // THE FIX: `load_from_env()` is the crate's OWN "convenience wrapper" (its doc comment's words)
5419 // around `from_env().load().await` — the exact already-modelled effect one call down — but
5420 // "load_from_env".ends_with("::load") is false.
5421 assert_eq!(classify("aws_config", "aws_config::load"), Some("Net"));
5422 assert_eq!(classify("aws_config", "aws_config::load_defaults"), Some("Net"));
5423 assert_eq!(classify("aws_config", "aws_config::load_from_env"), Some("Net"));
5424 assert_eq!(classify("aws_config", "aws_config::from_env"), None); // pure builder
5425 }
5426
5427 #[test]
5428 fn tungstenite_stream_first_handshake_entry_points_are_net() {
5429 // THE FIX: `client`/`client_with_config`/`client_tls`/`client_tls_with_config` (client.rs:176,
5430 // 159; tls.rs:163,179) and `accept`/`accept_with_config`/`accept_hdr`/`accept_hdr_with_config`
5431 // (server.rs:23-63) each run `{Client,Server}Handshake::start(stream, ..).handshake()` — the real
5432 // WS upgrade I/O over an already-open stream. tungstenite's OWN documented way to run over a
5433 // caller-managed TCP/TLS/mio stream, missing from a verb list keyed only on `connect`.
5434 assert_eq!(classify("tungstenite", "tungstenite::client::client"), Some("Net"));
5435 assert_eq!(classify("tungstenite", "tungstenite::client::client_with_config"), Some("Net"));
5436 assert_eq!(classify("tungstenite", "tungstenite::tls::client_tls"), Some("Net"));
5437 assert_eq!(classify("tungstenite", "tungstenite::tls::client_tls_with_config"), Some("Net"));
5438 assert_eq!(classify("tungstenite", "tungstenite::server::accept"), Some("Net"));
5439 assert_eq!(classify("tungstenite", "tungstenite::server::accept_with_config"), Some("Net"));
5440 assert_eq!(classify("tungstenite", "tungstenite::server::accept_hdr"), Some("Net"));
5441 assert_eq!(classify("tungstenite", "tungstenite::server::accept_hdr_with_config"), Some("Net"));
5442 assert_eq!(classify("tungstenite", "tungstenite::client::connect_with_config"), Some("Net"));
5443 }
5444
5445 #[test]
5446 fn mysql_conn_new_is_the_real_connect() {
5447 // THE FIX: `mysql::Conn::new` (conn/mod.rs:342) calls `connect_stream()?; connect()?` directly;
5448 // `mysql_async::Conn::new` (conn/mod.rs:921) is the async equivalent. Each crate's own primary
5449 // connection constructor, the crate's own first doctest example — but `::new` never appeared in
5450 // the verb list.
5451 assert_eq!(classify("mysql", "mysql::Conn::new"), Some("Db"));
5452 assert_eq!(classify("mysql_async", "mysql_async::Conn::new"), Some("Db"));
5453 // NO FABRICATION: the SAME two crates' unrelated pure `new`s (Opts/PoolConstraints/TxOpts/…)
5454 // must not gain Db — scoped to the `Conn::` segment, not a bare `::new` suffix.
5455 assert_eq!(classify("mysql", "mysql::Opts::new"), None);
5456 assert_eq!(classify("mysql", "mysql::PoolConstraints::new"), None);
5457 assert_eq!(classify("mysql_async", "mysql_async::TxOpts::new"), None);
5458 }
5459
5460 #[test]
5461 fn mongodb_with_options_is_the_same_effect_as_with_uri_str() {
5462 // THE FIX: `with_uri_str` (client.rs:179) is `ClientOptions::parse(uri).await?;
5463 // Client::with_options(options)` one call down — `with_options` (client.rs:188) is where the
5464 // topology/monitoring actually spins up, and mongodb's own doc calls it the entry point for a
5465 // caller who already holds parsed `ClientOptions`. Verified against mongodb 3.8.1 (both the
5466 // async `client.rs` and sync `sync/client.rs` `Client`).
5467 assert_eq!(classify("mongodb", "mongodb::Client::with_uri_str"), Some("Db"));
5468 assert_eq!(classify("mongodb", "mongodb::Client::with_options"), Some("Db"));
5469 assert_eq!(classify("mongodb", "mongodb::sync::Client::with_options"), Some("Db"));
5470 assert_eq!(classify("mongodb", "mongodb::Collection::find_one"), Some("Db"));
5471 // NO FABRICATION: an unrelated crate's `with_options` builder must not gain Db — crate-gated.
5472 assert_eq!(classify("some_other_crate", "some_other_crate::Widget::with_options"), None);
5473 }
5474
5475 #[test]
5476 fn pure_fd_transfer_is_not_an_effect() {
5477 // ADOPTING / EXTRACTING / BORROWING an already-open descriptor (or unwrapping an async type back
5478 // to its std type) issues NO syscall — it must be PURE even though it hangs off a std I/O type
5479 // whose prefix rule would otherwise fire Net/Fs/Ipc. (Real tokio sweep: `into_std`, `from_raw_fd`,
5480 // `as_raw_fd` all fabricated effects.)
5481 assert_eq!(classify("std", "std::net::TcpStream::from_raw_fd"), None);
5482 assert_eq!(classify("std", "std::net::TcpStream::into_raw_fd"), None);
5483 assert_eq!(classify("std", "std::net::TcpStream::as_raw_fd"), None);
5484 assert_eq!(classify("std", "std::net::TcpListener::from_raw_fd"), None);
5485 assert_eq!(classify("std", "std::net::UdpSocket::from_raw_socket"), None);
5486 assert_eq!(classify("std", "std::fs::File::from_raw_fd"), None);
5487 assert_eq!(classify("std", "std::fs::File::into_raw_fd"), None);
5488 assert_eq!(classify("std", "std::fs::File::as_raw_handle"), None);
5489 assert_eq!(classify("std", "std::os::unix::net::UnixStream::from_raw_fd"), None);
5490 // `SocketAddr::from_pathname` builds an address struct, opens no socket — pure. (socket2 sweep.)
5491 assert_eq!(classify("std", "std::os::unix::net::SocketAddr::from_pathname"), None);
5492 assert_eq!(classify("tokio", "tokio::net::TcpStream::from_raw_fd"), None);
5493 assert_eq!(classify("tokio", "tokio::net::TcpStream::into_std"), None); // unwrap → std type, pure
5494 assert_eq!(classify("tokio", "tokio::fs::File::into_std"), None);
5495 // …but a REAL open/connect on the SAME types still fires the effect — the carve-out is leaf-precise.
5496 assert_eq!(classify("std", "std::net::TcpStream::connect"), Some("Net"));
5497 assert_eq!(classify("std", "std::fs::File::open"), Some("Fs"));
5498 assert_eq!(classify("std", "std::fs::read"), Some("Fs"));
5499 assert_eq!(classify("std", "std::os::unix::net::UnixStream::connect"), Some("Ipc"));
5500 assert_eq!(classify("tokio", "tokio::net::TcpStream::connect"), Some("Net"));
5501 // R1288 — tokio's clock, and only its `now`.
5502 assert_eq!(classify("tokio", "tokio::time::Instant::now"), Some("Clock"));
5503 assert_eq!(classify("tokio", "tokio::time::Instant::from_std"), None);
5504 assert_eq!(classify("quanta", "quanta::Instant::now"), Some("Clock"));
5505 assert_eq!(classify("web_time", "web_time::Instant::now"), Some("Clock"));
5506 }
5507
5508 /// THE PLATFORM `fs` MODULES. `std::fs::` was the whole filesystem rule, so every function in
5509 /// `std::os::{unix,windows,wasi}::fs` read PURE — measured with EXECUTED ground truth (a `cargo run`
5510 /// that created a real symlink and stat'd it back), `deny Fs` over a crate whose only filesystem
5511 /// write was `std::os::unix::fs::symlink` exited 0.
5512 ///
5513 /// The controls are the point: the `*Ext` DATA/BUILDER traits must stay pure, or the fix trades a
5514 /// silent under-report for a fabrication on every `m.uid()` — and `FileExt` must NOT, because
5515 /// `read_at`/`write_at` are the positional-I/O syscalls this rule exists to catch.
5516 #[test]
5517 fn the_platform_fs_modules_are_filesystem_io_and_their_data_traits_are_not() {
5518 for p in [
5519 "std::os::unix::fs::symlink", "std::os::unix::fs::chown", "std::os::unix::fs::lchown",
5520 "std::os::unix::fs::fchown", "std::os::unix::fs::chroot",
5521 "std::os::windows::fs::symlink_dir", "std::os::windows::fs::symlink_file",
5522 "std::os::windows::fs::junction_point",
5523 "std::os::unix::fs::FileExt::read_at", "std::os::unix::fs::FileExt::write_at",
5524 "std::os::windows::fs::FileExt::seek_read", "std::os::windows::fs::FileExt::seek_write",
5525 ] {
5526 assert_eq!(classify("std", p), Some("Fs"), "{p} performs filesystem I/O");
5527 }
5528 for p in [
5529 "std::os::unix::fs::MetadataExt::uid", "std::os::unix::fs::MetadataExt::mode",
5530 "std::os::unix::fs::PermissionsExt::mode", "std::os::unix::fs::PermissionsExt::set_mode",
5531 "std::os::unix::fs::OpenOptionsExt::custom_flags",
5532 "std::os::unix::fs::DirBuilderExt::mode",
5533 "std::os::unix::fs::DirEntryExt::ino", "std::os::unix::fs::FileTypeExt::is_fifo",
5534 "std::os::windows::fs::MetadataExt::file_attributes",
5535 ] {
5536 assert_eq!(classify("std", p), None,
5537 "{p} reads or configures data already in hand — charging it would FABRICATE Fs");
5538 }
5539 // `std::os::unix::net` is Ipc and must be untouched by a rule keyed one segment along.
5540 assert_eq!(classify("std", "std::os::unix::net::UnixStream::connect"), Some("Ipc"));
5541 }
5542
5543 /// SOUNDNESS R141 — the platform `process` modules, the sibling `fs` had a rule and `process` did
5544 /// not. `CommandExt::exec` is `execvp`: it REPLACES the process image, ground-truthed by a
5545 /// `cargo run` that printed a line from the exec'd image and never reached the line after the call.
5546 /// Both directions are driven here: the spawn surface must charge Exec (without the rule every
5547 /// assertion in the first loop returns `None` and `deny Exec` over a library whose only function
5548 /// calls `CommandExt::exec` exits 0), and the already-collected-status traits must NOT, or the fix
5549 /// trades a silent under-report for a fabrication on every `status.signal()` — the same control
5550 /// shape as the `fs` test above.
5551 #[test]
5552 fn the_platform_process_modules_are_exec_and_their_status_traits_are_not() {
5553 for p in [
5554 "std::os::unix::process::CommandExt::exec",
5555 "std::os::unix::process::CommandExt::pre_exec",
5556 "std::os::unix::process::CommandExt::arg0",
5557 "std::os::unix::process::CommandExt::uid",
5558 "std::os::unix::process::CommandExt::gid",
5559 "std::os::unix::process::CommandExt::groups",
5560 "std::os::unix::process::CommandExt::process_group",
5561 "std::os::windows::process::CommandExt::creation_flags",
5562 "std::os::windows::process::CommandExt::raw_arg",
5563 "std::os::windows::process::ChildExt::main_thread_handle",
5564 "std::os::wasi::process::CommandExt::exec",
5565 ] {
5566 assert_eq!(classify("std", p), Some("Exec"), "{p} is subprocess machinery");
5567 }
5568 for p in [
5569 "std::os::unix::process::ExitStatusExt::signal",
5570 "std::os::unix::process::ExitStatusExt::core_dumped",
5571 "std::os::unix::process::ExitStatusExt::from_raw",
5572 "std::os::unix::process::ExitStatusExt::into_raw",
5573 "std::os::unix::process::ExitStatusError::signal",
5574 "std::os::windows::process::ExitStatusExt::from_raw",
5575 "std::os::windows::process::ExitCodeExt::from_raw",
5576 "std::os::unix::process::parent_id",
5577 ] {
5578 assert_eq!(classify("std", p), None,
5579 "{p} reads a status already collected (or this process's own pid) — charging it \
5580 would FABRICATE Exec");
5581 }
5582 // Keyed one segment along: `fs` and `net` under the same platform root must be untouched.
5583 assert_eq!(classify("std", "std::os::unix::fs::FileExt::read_at"), Some("Fs"));
5584 assert_eq!(classify("std", "std::os::unix::net::UnixStream::connect"), Some("Ipc"));
5585 }
5586
5587 #[test]
5588 fn command_head_refines_the_exec_cliff() {
5589 use super::classify_command_head as h;
5590 // unambiguous external tools classify by basename (spec §4 ⟨0.5⟩)
5591 assert_eq!(h("curl"), &["Net"]);
5592 assert_eq!(h("telnet"), &["Net"]);
5593 assert_eq!(h("sftp"), &["Net"]);
5594 assert_eq!(h("/usr/local/bin/psql"), &["Db"]); // basename match strips the path
5595 assert_eq!(h("mongo"), &["Db"]);
5596 assert_eq!(h("cqlsh"), &["Db"]);
5597 // a candor engine is Fs/Env — spec-SUPPLIED by §7 item 12, not curation
5598 assert_eq!(h("candor-scan"), &["Env", "Fs"]);
5599 assert_eq!(h("candor-run.sh"), &["Env", "Fs"]);
5600 // an unrecognised head adds nothing — the bare Exec cliff stands (never guess). `make`/`npm`
5601 // run the project's own code; `git`/`rsync` are multi-modal (local vs remote) — all keep the
5602 // cliff rather than fabricate an effect for the common case.
5603 assert_eq!(h("some-unknown-tool"), &[] as &[&str]);
5604 assert_eq!(h("make"), &[] as &[&str]);
5605 assert_eq!(h("npm"), &[] as &[&str]);
5606 assert_eq!(h("git"), &[] as &[&str]);
5607 assert_eq!(h("rsync"), &[] as &[&str]);
5608 // a builder MODIFIER (`.arg`/`.env`) names no program — its literal must NOT refine (a
5609 // whole-crate-Exec crate classifies every method; `.env("psql",..)` must not fabricate Db).
5610 assert!(is_cmd_builder_method("env") && is_cmd_builder_method("arg") && is_cmd_builder_method("current_dir"));
5611 assert!(!is_cmd_builder_method("new")); // Command::new NAMES the program
5612 assert!(!is_cmd_builder_method("cmd")); // duct::cmd NAMES the program
5613 // The gate that ADMITS a literal to classify_command_head is an ALLOWLIST of program-NAMING
5614 // methods, not the builder denylist. Inversion matters: a whole-crate-Exec crate (portable_pty)
5615 // classifies EVERY method as Exec, so a getter like `cmd.get_env("psql")` — absent from the
5616 // builder denylist — would have leaked "psql" to the head and FABRICATED Db. Only `new`/`cmd`
5617 // name a program, so only they may refine.
5618 assert!(is_cmd_naming_method("new") && is_cmd_naming_method("cmd"));
5619 assert!(!is_cmd_naming_method("get_env")); // a GETTER, not a namer — the leak this closes
5620 assert!(!is_cmd_naming_method("arg") && !is_cmd_naming_method("env") && !is_cmd_naming_method("current_dir"));
5621 }
5622
5623 /// SOUNDNESS R379 — THE MASKING GUARD'S ALLOWLIST, AND THE DENYLIST THAT WAS MEASURED AND REJECTED.
5624 ///
5625 /// The verbs here take the HOST as an argument, so a runtime value in that position is invisible to
5626 /// the gate and the surface must be marked incomplete. The DNS resolver verbs were missing, and
5627 /// their absence was a GATE BYPASS, not a missed disclosure: `allow Net good.example.com` exited 0
5628 /// over `r.lookup_ip(caller_host)` sitting beside a benign literal, measured against real
5629 /// hickory-resolver 0.26.2.
5630 /// SOUNDNESS R386 — the `sqlite3_*` C API takes SQL, and its absence let a benign SIBLING literal
5631 /// certify arbitrary caller-supplied SQL.
5632 ///
5633 /// R383 recorded this gap as "REAL BUT NOT LIVE" because a C-string literal never reaches `tables`.
5634 /// That is true of an ISOLATED function and is the WRONG SHAPE: the literal only has to come from
5635 /// the same FUNCTION, not the same call. Measured, calibrated first (`deny Db` exits 1):
5636 /// `conn.execute("INSERT INTO users (id) VALUES (1)", [])` beside `sqlite3_exec(db, caller_sql, …)`
5637 /// gave `tables:['users'] incomplete:NONE` and `allow Db users` exited 0.
5638 #[test]
5639 fn db_query_arg_covers_the_sqlite3_sql_bearing_family() {
5640 // Every spelling that TAKES SQL — the whole family, not the one that was measured (R346).
5641 for f in ["sqlite3_exec", "sqlite3_prepare", "sqlite3_prepare_v2", "sqlite3_prepare_v3",
5642 "sqlite3_prepare16", "sqlite3_prepare16_v2", "sqlite3_prepare16_v3",
5643 // R386's own boundary, found by a release panel: SQL is arg 1 here too.
5644 "sqlite3_get_table"] {
5645 assert!(is_db_query_arg(f), "{f} carries SQL — a runtime query there is invisible (R386)");
5646 }
5647 // THE OVER-MASK CONTROLS, and they carry as much weight as the fix. These take no query, so
5648 // masking them would fail `allow Db` closed over nothing. `sqlite3_open*` is the sharpest: it
5649 // takes a FILENAME, so masking it would claim a Db surface for an Fs locator.
5650 for f in ["sqlite3_step", "sqlite3_close", "sqlite3_open", "sqlite3_open_v2", "sqlite3_open16",
5651 "sqlite3_serialize", "sqlite3_deserialize", "sqlite3_finalize", "sqlite3_reset",
5652 // the backup/blob handle verbs carry no query either — the other half of the family
5653 "sqlite3_backup_init", "sqlite3_backup_step", "sqlite3_blob_open", "sqlite3_blob_read"] {
5654 assert!(!is_db_query_arg(f),
5655 "{f} carries no SQL — masking it claims an incomplete surface over nothing, and for \
5656 the open* family it would claim a Db surface for a FILENAME");
5657 }
5658 // The pre-existing high-level verbs must survive.
5659 for f in ["execute", "query", "prepare", "prepare_cached", "simple_query"] {
5660 assert!(is_db_query_arg(f), "{f} was already covered and must stay covered");
5661 }
5662 }
5663
5664 /// SOUNDNESS R383 — the METHOD forms whose path is an ARGUMENT. The guard's `!c.method` gate
5665 /// excluded every method form because the path-STAT methods carry the path as their RECEIVER;
5666 /// `OpenOptions::open(path)` is the counter-example, and `allow Fs <lit>` exited 0 over a
5667 /// caller-supplied path because of it.
5668 #[test]
5669 fn fs_path_arg_methods_are_the_receiver_pairs_not_the_leaf() {
5670 // The path-taking pairs, matched on the TAIL so std/tokio/cap_primitives/fs_err all qualify.
5671 for p in ["std::fs::OpenOptions::open", "tokio::fs::OpenOptions::open",
5672 "cap_primitives::fs::OpenOptions::open", "fs_err::OpenOptions::open",
5673 "std::fs::DirBuilder::create", "tokio::fs::DirBuilder::create"] {
5674 assert!(is_fs_path_arg_method(p), "{p} takes its path as an ARGUMENT — R383");
5675 }
5676 // THE CONTROLS, and they are the row: every other method on the SAME receivers is a builder
5677 // modifier or a combinator, and `File`'s methods are use-verbs whose path was fixed at open.
5678 // Masking these is the over-mask a denylist would have caused (R379 measured that cost).
5679 for p in ["std::fs::File::read_to_string", "std::fs::File::write", "std::fs::File::write_all",
5680 "std::fs::File::read", "std::fs::File::seek", "std::path::Path::exists",
5681 "std::fs::OpenOptions::read", "std::fs::OpenOptions::truncate",
5682 "std::fs::OpenOptions::map_err", "tokio::fs::OpenOptions::mode"] {
5683 assert!(!is_fs_path_arg_method(p),
5684 "{p} hides no path — it is a builder modifier, a combinator, or a use-verb whose \
5685 path was fixed at open. Masking it is the over-mask this allowlist avoids.");
5686 }
5687 }
5688
5689 #[test]
5690 fn net_establishing_allowlist() {
5691 // Host-bearing verbs establish — a runtime value here is invisible, so the gate must fail closed.
5692 for m in ["connect", "connect_timeout", "get", "post", "put", "patch", "delete", "head",
5693 "request", "send_to", "lookup_host", "to_socket_addrs"] {
5694 assert!(is_net_establishing(m), "`{m}` takes the host as an argument and must establish");
5695 }
5696 // R379: the DNS resolver verbs `classify` already maps to Net. The WHOLE FAMILY, not the one
5697 // spelling that was found — `_lookup` is a suffix test mirroring `classify`'s own.
5698 for m in ["lookup", "lookup_ip", "resolve", "reverse_lookup", "srv_lookup", "mx_lookup",
5699 "txt_lookup", "ipv4_lookup", "ipv6_lookup", "ns_lookup", "soa_lookup"] {
5700 assert!(is_net_establishing(m),
5701 "`{m}` resolves a caller-supplied host — R379. Without it, `allow Net <benign \
5702 literal>` exits 0 over a DNS lookup of a runtime hostname.");
5703 }
5704 // USE-verbs on an already-established connection: the host was fixed at `connect`, so a missing
5705 // literal here hides nothing and must NOT flag — else `connect("h").write(..)` fails closed.
5706 for m in ["write", "read", "send", "flush", "recv", "peek", "write_all", "read_to_end",
5707 "write_vectored", "shutdown", "status", "headers", "text", "json"] {
5708 assert!(!is_net_establishing(m), "`{m}` is a use-verb on an established connection");
5709 }
5710 }
5711
5712 /// ⟨0.40⟩ SOUNDNESS R817 — an ACCEPT is not a BIND. The two lists answer different questions and must
5713 /// not share a member: a binding verb's literal is withheld and marks nothing, an accepting verb marks
5714 /// the surface incomplete. `accept`/`incoming` used to be binding verbs, which is the defect.
5715 #[test]
5716 fn r817_accepting_is_not_binding() {
5717 for (path, leaf) in [
5718 ("std::net::TcpListener::accept", "accept"),
5719 ("std::net::TcpListener::incoming", "incoming"),
5720 ("std::net::TcpListener::into_incoming", "into_incoming"),
5721 ("tokio::net::TcpListener::accept", "accept"),
5722 ("tokio::net::TcpListener::poll_accept", "poll_accept"),
5723 ("libc::accept", "accept"),
5724 ("libc::accept4", "accept4"),
5725 ("quinn::Endpoint::accept", "accept"),
5726 ("tonic::transport::server::Router::serve", "serve"),
5727 ("tonic::transport::server::Router::serve_with_shutdown", "serve_with_shutdown"),
5728 ("tonic::transport::server::TcpIncoming::new", "new"),
5729 ] {
5730 assert!(is_net_accepting(path, leaf), "`{path}` accepts connections: its peers are unseen");
5731 assert!(!is_net_binding(leaf), "`{leaf}` must not be a binding verb — binding withholds and marks nothing");
5732 }
5733 // CONTROLS — binds mark nothing; a stream accepted on an ESTABLISHED connection (quinn) has a peer
5734 // fixed at the endpoint's accept; a datagram receive is not an accept; `serve`/`new` elsewhere are
5735 // not accepts.
5736 for (path, leaf) in [
5737 ("std::net::UdpSocket::bind", "bind"),
5738 ("std::net::TcpListener::bind", "bind"),
5739 ("libc::listen", "listen"),
5740 ("quinn::Connection::accept_bi", "accept_bi"),
5741 ("quinn::Connection::accept_uni", "accept_uni"),
5742 ("std::net::UdpSocket::recv_from", "recv_from"),
5743 ("tonic::transport::server::Server::serve_with_shutdown", "serve_with_shutdown"),
5744 ("reqwest::Client::new", "new"),
5745 ] {
5746 assert!(!is_net_accepting(path, leaf), "`{path}` does not accept a connection");
5747 }
5748 for m in ["bind", "listen", "bind_to_device"] {
5749 assert!(is_net_binding(m), "`{m}` names a LOCAL address");
5750 }
5751 }
5752
5753 /// R379 — WHY THIS STAYS AN ALLOWLIST, which is NOT this family's usual direction.
5754 ///
5755 /// `candor-denylist-over-allowlist` says narrow a sound over-approximation with a denylist, and
5756 /// R350 made exactly that inversion one list over. It is wrong HERE, for the reason
5757 /// `is_cmd_naming_method` states two doc-blocks above for Exec: a WHOLE-CRATE-Net crate classifies
5758 /// every method as Net, so a denylist of use-verbs masks essentially everything in one. Measured by
5759 /// building the inversion and A/B-ing all 1,545 registry crates: **544 rows gained `incomplete`,
5760 /// none lost it** — `cap-std` 66, `hickory-net` 44 — and the triggers were conversions, accessors
5761 /// and combinators that hide no host.
5762 ///
5763 /// These are the measured leaks, pinned so the inversion cannot be re-attempted without meeting
5764 /// them. `bind` is the sharpest: this file has `is_net_binding` precisely because a bind address
5765 /// is where the process LISTENS, never a destination it reaches.
5766 #[test]
5767 fn the_rejected_denylist_leaks_are_not_establishing() {
5768 for m in ["bind", "into", "from", "from_std", "as_socket", "setsockopt", "getsockopt",
5769 "map_err", "map", "ok", "try_clone", "complete_io"] {
5770 assert!(!is_net_establishing(m),
5771 "`{m}` hides no host and must not mark a surface incomplete. It was a MEASURED \
5772 trigger of the rejected denylist inversion (544 rows gained `incomplete` over the \
5773 registry, 0 lost) — a whole-crate-Net crate classifies every method as Net, which \
5774 is why the allowlist is the precise side here. See R379.");
5775 }
5776 }
5777
5778 #[test]
5779 fn fs_path_arg_allowlist() {
5780 // The Fs masking guard's path-naming-fn allowlist — free fns / constructors take the path as a
5781 // string arg (a runtime path there is invisible to the gate). Stat methods (path on the receiver)
5782 // and handle ops carry no path arg and must NOT flag — but they're caught by the caller's
5783 // `!is_method` gate; the allowlist itself just enumerates the path-NAMING leaves.
5784 assert!(is_fs_path_arg("write") && is_fs_path_arg("read") && is_fs_path_arg("read_to_string"));
5785 assert!(is_fs_path_arg("open") && is_fs_path_arg("create") && is_fs_path_arg("create_new"));
5786 assert!(is_fs_path_arg("remove_file") && is_fs_path_arg("rename") && is_fs_path_arg("copy"));
5787 assert!(is_fs_path_arg("create_dir_all") && is_fs_path_arg("canonicalize") && is_fs_path_arg("metadata"));
5788 // handle ops / pure builders take NO path arg — never path-naming.
5789 assert!(!is_fs_path_arg("write_all") && !is_fs_path_arg("flush") && !is_fs_path_arg("read_exact"));
5790 assert!(!is_fs_path_arg("new") && !is_fs_path_arg("sync_all") && !is_fs_path_arg("set_len"));
5791 }
5792
5793 #[test]
5794 fn db_query_arg_allowlist() {
5795 // The Db masking guard's query-bearing-verb allowlist — these take the raw SQL as a string arg
5796 // (a runtime query there is invisible to the gate). Build-then-execute terminals and non-query
5797 // ops carry no SQL string and must NOT flag.
5798 assert!(is_db_query_arg("execute") && is_db_query_arg("query") && is_db_query_arg("query_one"));
5799 assert!(is_db_query_arg("prepare") && is_db_query_arg("batch_execute") && is_db_query_arg("execute_batch"));
5800 assert!(is_db_query_arg("query_row") && is_db_query_arg("query_map") && is_db_query_arg("exec"));
5801 // build-then-execute terminals (query built structurally, no SQL string) must NOT flag.
5802 assert!(!is_db_query_arg("fetch_all") && !is_db_query_arg("load") && !is_db_query_arg("first"));
5803 assert!(!is_db_query_arg("all") && !is_db_query_arg("one") && !is_db_query_arg("stream"));
5804 // connection / lifecycle ops take no SQL — must NOT flag.
5805 assert!(!is_db_query_arg("connect") && !is_db_query_arg("open") && !is_db_query_arg("begin"));
5806 assert!(!is_db_query_arg("commit") && !is_db_query_arg("ping") && !is_db_query_arg("get_conn"));
5807 }
5808}
5809
5810#[cfg(test)]
5811mod fs_kind_tests {
5812 use super::fs_kind;
5813
5814 /// SPEC §2 `fs`. Most of what these assert is what the classifier REFUSES to say: §2 requires the
5815 /// field be "omitted rather than guessed", because an empty or partial `fs` reads as a positive claim
5816 /// ("reads but never writes") — the §4 trust contract's forbidden direction.
5817 #[test]
5818 fn write_verbs() {
5819 for p in ["std::fs::write", "std::fs::create_dir_all", "std::fs::remove_file",
5820 "File::create", "std::fs::set_permissions", "f::write_all"] {
5821 assert_eq!(fs_kind(p), &["write"], "{p} mutates the disk");
5822 }
5823 }
5824
5825 #[test]
5826 fn read_verbs() {
5827 for p in ["std::fs::read_to_string", "std::fs::read_dir", "std::fs::metadata",
5828 "File::open", "std::fs::canonicalize", "f::read_to_end"] {
5829 assert_eq!(fs_kind(p), &["read"], "{p} observes without mutating");
5830 }
5831 }
5832
5833 /// A copy/rename reads the source AND writes the destination — one call, both kinds.
5834 #[test]
5835 fn two_locator_verbs_are_both() {
5836 for p in ["std::fs::copy", "std::fs::rename", "std::fs::hard_link"] {
5837 assert_eq!(fs_kind(p), &["read", "write"], "{p}");
5838 }
5839 }
5840
5841 /// THE LOAD-BEARING CASE. A verb that does not reveal direction must contribute NOTHING — not a
5842 /// default, not a guess. Anything here returning a kind would let a function claim "reads but never
5843 /// writes" on the strength of a verb that said neither.
5844 #[test]
5845 fn unrevealing_verbs_make_no_claim() {
5846 for p in ["std::fs::OpenOptions", "some_crate::do_thing", "std::fs::File", "f::seek"] {
5847 assert!(fs_kind(p).is_empty(), "{p} must not claim a direction it did not reveal");
5848 }
5849 }
5850
5851 /// `OpenOptions::open` is `File::open`'s leaf and NOT its verb: the direction lives in the builder
5852 /// chain (`.read(true)`/`.write(true)`), which `fs_kind` cannot see. A `write(true)` builder claiming
5853 /// `["read"]` is the forbidden direction, so the type wins over the leaf here. `File::open` — where
5854 /// `open` DOES mean read — is unaffected, which is the whole reason this is keyed on the type.
5855 #[test]
5856 fn openoptions_open_claims_no_direction_but_file_open_still_reads() {
5857 for p in ["std::fs::OpenOptions::open", "fs_err::OpenOptions::open",
5858 "tokio::fs::OpenOptions::open"] {
5859 assert!(fs_kind(p).is_empty(),
5860 "{p}'s direction was set by the builder chain, which this function cannot see");
5861 }
5862 assert_eq!(fs_kind("std::fs::File::open"), &["read"], "`File::open` is unambiguously a read");
5863 }
5864}
5865
5866#[cfg(test)]
5867mod masks_locator_tests {
5868 use super::*;
5869
5870 /// SOUNDNESS R806 — each no-locator DENYLIST must be a SUBSET of the table it subtracts from. A
5871 /// typo'd or stale entry that is not in the table would subtract nothing and read as a decision;
5872 /// a table entry renamed without its denylist twin would silently start masking (the safe
5873 /// direction) — but only this check says which happened.
5874 #[test]
5875 fn every_no_locator_denylist_is_a_subset_of_its_charging_table() {
5876 for n in LIBC_FS_NO_PATH {
5877 assert!(LIBC_FS.contains(n), "LIBC_FS_NO_PATH names `{n}`, which LIBC_FS does not charge");
5878 }
5879 for n in LIBC_EXEC_NO_PROGRAM {
5880 assert!(LIBC_EXEC.contains(n), "LIBC_EXEC_NO_PROGRAM names `{n}`, absent from LIBC_EXEC");
5881 }
5882 for n in LIBC_NET_NO_DESTINATION {
5883 assert!(LIBC_NET.contains(n), "LIBC_NET_NO_DESTINATION names `{n}`, absent from LIBC_NET");
5884 }
5885 for n in PG_DB_VERBS_NO_SQL {
5886 assert!(PG_DB_VERBS.contains(n), "PG_DB_VERBS_NO_SQL names `{n}`, absent from PG_DB_VERBS");
5887 }
5888 }
5889
5890 /// SOUNDNESS R806 — THE DERIVATION, pinned from the table side: every leaf the table charges that is
5891 /// not on the denylist MASKS. So a leaf added to `LIBC_FS` tomorrow is masked with no second edit,
5892 /// which is the property the hand list lacked.
5893 #[test]
5894 fn every_charged_libc_leaf_off_the_denylist_masks() {
5895 for (eff, table, deny) in [
5896 ("Fs", LIBC_FS, LIBC_FS_NO_PATH),
5897 ("Exec", LIBC_EXEC, LIBC_EXEC_NO_PROGRAM),
5898 ("Net", LIBC_NET, LIBC_NET_NO_DESTINATION),
5899 ] {
5900 for leaf in table.iter().filter(|l| !deny.contains(l)) {
5901 for cr in ["libc", "nix"] {
5902 let path = format!("{cr}::{leaf}");
5903 assert_eq!(classify(cr, &path), Some(eff), "{path} must still be charged {eff}");
5904 assert!(masks_locator(eff, cr, &path, leaf, false), "{path} names a locator — must mask");
5905 }
5906 }
5907 for leaf in deny.iter() {
5908 let path = format!("libc::{leaf}");
5909 assert!(!masks_locator(eff, "libc", &path, leaf, false) || is_net_establishing(leaf),
5910 "{path} is on the no-locator denylist — must not mask via the table");
5911 }
5912 }
5913 // The named members, from the row: the sweep's EXECUTED arms.
5914 for (eff, leaf) in [("Fs", "unlink"), ("Fs", "unlinkat"), ("Fs", "utimensat"), ("Exec", "system"),
5915 ("Exec", "popen"), ("Exec", "execvp"), ("Exec", "posix_spawn"), ("Net", "getaddrinfo"),
5916 ("Net", "sendto")] {
5917 assert!(masks_locator(eff, "libc", &format!("libc::{leaf}"), leaf, false), "libc::{leaf}");
5918 }
5919 // Scoped to the syscall crates: the same leaf on an unrelated crate is not this table's to judge.
5920 assert!(!masks_locator("Fs", "mycrate", "mycrate::unlink", "unlink", false));
5921 // rustix spells `sendmsg` WITHOUT an address; libc and nix carry one.
5922 assert!(!masks_locator("Net", "rustix", "rustix::net::sendmsg", "sendmsg", false));
5923 assert!(masks_locator("Net", "libc", "libc::sendmsg", "sendmsg", false));
5924 }
5925
5926 #[test]
5927 fn pg_verbs_take_sql_except_the_named_three() {
5928 for v in PG_DB_VERBS.iter().filter(|v| !PG_DB_VERBS_NO_SQL.contains(v)) {
5929 let path = format!("deadpool_postgres::Client{v}");
5930 let leaf = &v[2..];
5931 assert!(masks_locator("Db", "deadpool_postgres", &path, leaf, true), "{path} takes SQL — must mask");
5932 }
5933 assert!(masks_locator("Db", "tokio_postgres", "tokio_postgres::Client::prepare_typed_cached",
5934 "prepare_typed_cached", true));
5935 assert!(!masks_locator("Db", "tokio_postgres", "tokio_postgres::Client::transaction", "transaction", true));
5936 assert!(masks_locator("Db", "sqlx_core", "sqlx_core::executor::Executor::prepare_with", "prepare_with", true));
5937 assert!(!masks_locator("Db", "sqlx_core", "sqlx_core::query::Query::fetch_all", "fetch_all", true),
5938 "a Query terminal takes an EXECUTOR at arg 0 — masking it would swallow every literal query");
5939 }
5940
5941 /// THE COVERAGE-GATE REFRESH (2026-10-08) — each family's rule fires on the spellings a consumer writes
5942 /// and NOT on a sibling type or an accessor (each charge is traced to a body in the rule's comment).
5943 #[test]
5944 fn coverage_gate_refresh_2026_10_08_rules() {
5945 for p in ["rusqlite::Transaction::commit", "rusqlite::transaction::Transaction::new",
5946 "rusqlite::Savepoint::rollback", "rusqlite::Connection::pragma_update",
5947 "rusqlite::Connection::unchecked_transaction", "rusqlite::Connection::savepoint_with_name"] {
5948 assert_eq!(classify("rusqlite", p), Some("Db"), "{p}");
5949 }
5950 for p in ["rusqlite::Transaction::drop_behavior", "rusqlite::Savepoint::set_drop_behavior"] {
5951 assert_eq!(classify("rusqlite", p), None, "{p}");
5952 }
5953 for p in ["redis::Connection::send_packed_command", "redis::connection::Connection::recv_response",
5954 "redis::PubSub::get_message"] {
5955 assert_eq!(classify("redis", p), Some("Net"), "{p}");
5956 }
5957 assert_eq!(classify("redis", "redis::aio::MultiplexedConnection::recv_response"), None);
5958 assert_eq!(classify("redis", "redis::Connection::set_write_timeout"), None);
5959 for p in ["lettre::SmtpConnection::command", "lettre::transport::smtp::client::SmtpConnection::connect",
5960 "lettre::AsyncSmtpConnection::read_response",
5961 "lettre::transport::smtp::client::AsyncNetworkStream::connect_tokio1",
5962 "lettre::Tokio1Executor::connect"] {
5963 assert_eq!(classify("lettre", p), Some("Net"), "{p}");
5964 }
5965 for p in ["lettre::SmtpConnection::server_info", "lettre::NetworkStream::peer_addr",
5966 "lettre::AsyncSmtpConnection::is_encrypted"] {
5967 assert_eq!(classify("lettre", p), None, "{p}");
5968 }
5969 assert_eq!(classify("rustls", "rustls::KeyLogFile::log"), Some("Fs"));
5970 assert_eq!(classify("rustls", "rustls::NoKeyLog::log"), None);
5971 }
5972}