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/// Project-supplied rules, consulted only when the built-in `classify` returns None.
25pub fn classify_extra(
26 crate_name: &str,
27 path: &str,
28 extra: &[(&'static str, bool, String)],
29) -> Option<&'static str> {
30 for (eff, is_crate, prefix) in extra {
31 let hit = if *is_crate { crate_name.starts_with(prefix.as_str()) } else { path.starts_with(prefix.as_str()) };
32 if hit {
33 return Some(eff);
34 }
35 }
36 None
37}
38
39/// The exact third-party crates `classify` has effect rules for, and the crate-name
40/// PREFIXES it recognizes. This is the single source of truth for "what candor knows":
41/// it is emitted beside the JSON report (`<prefix>.calibrated.json`) so the Claude Code
42/// receipt's coverage check reads candor's real coverage instead of a hand-copied list.
43/// Keep in lockstep with `classify` below — the `db_crates_are_calibrated` and
44/// `calibrated_crates_are_live` tests (in this crate's `tests` module) enforce both directions.
45pub const CALIBRATED_CRATES: [&str; 82] = [
46 // network (aws_config resolves credentials over the network on `.load()`;
47 // git2 remote ops — fetch/push/connect — contact the network; async_net is smol's net layer;
48 // pnet is raw L2/L3 packet capture)
49 "reqwest", "isahc", "ureq", "curl", "aws_config", "git2", "tokio_tcp", "tokio_udp", "async_net",
50 "async_nats", "lapin", "lettre", "tungstenite", "elasticsearch", "tonic", "rdkafka", "pnet",
51 // directory traversal (ignore = gitignore-aware walker, powers ripgrep/fd; its walk executors are Fs)
52 // + filesystem watching (notify = inotify/FSEvents/kqueue wrapper; powers watchexec/cargo-watch)
53 "ignore", "notify",
54 // database (see DB_CRATES in classify)
55 "sqlx", "rusqlite", "postgres", "tokio_postgres", "diesel", "redis", "mongodb",
56 "mysql", "mysql_async", "sea_orm", "deadpool_postgres",
57 // filesystem (async_fs = smol; fs_err = std::fs wrapper; tempfile; glob) / entropy /
58 // subprocess (async_process = smol; duct) / env (dotenvy/dotenv) / clock (time) / log / clipboard
59 "memmap2", "fs_err", "async_fs", "tempfile", "glob",
60 "rand", "getrandom", "fastrand",
61 // entropy: the password-hashing tier (salt mints + bcrypt's internal salt) + the OsRng source
62 "argon2", "bcrypt", "scrypt", "pbkdf2", "password_hash", "rand_core",
63 "portable_pty", "async_process", "duct",
64 "dotenvy", "dotenv",
65 "chrono", "time", "tracing", "log", "arboard",
66 // compiler diagnostic emission (a dylint lint's output) — see the Log rules in classify
67 "rustc_lint", "rustc_errors",
68 // raw syscalls via FFI — the syscall-name table that lights up the FFI-thin tier (nix is routed
69 // through the same table by leaf name, so a consumer of nix is covered without nix's own source)
70 "libc", "nix", "rustix",
71 // coverage-differential additions (verb-keyed; see the per-crate rules near the end of classify):
72 // sync TLS core + native-tls variants (Net); env/dir resolution + argv + LS_COLORS (Env);
73 // sqlx-core execution terminals (Net/Db); directory walk + timestamp mutation + same-file (Fs);
74 // process-spawn helpers (Exec); signal handler + interactive-tty prompts (Ipc); env_logger (Log);
75 // jiff/backoff clock reads (Clock).
76 "rustls", "native_tls_crate", "tokio_native_tls",
77 "etcetera", "wild", "lscolors",
78 "sqlx_core", "walkdir", "filetime", "clircle",
79 "execute", "ctrlc", "clap", "jiff", "env_logger",
80 "dialoguer", "console", "terminal_colorsaurus", "backoff", "grep_cli",
81 // TUI: the terminal is a user dialogue channel (Ipc), exactly as dialoguer/console already rule.
82 // crossterm does the tty I/O; ratatui renders to a Buffer and drives a backend that does.
83 "crossterm", "ratatui",
84 // tracing_subscriber: the fmt INIT terminals write program output (Log); the EnvFilter constructors
85 // read RUST_LOG (Env). Everything else — layers, formatters, filter types — is a builder.
86 "tracing_subscriber",
87];
88
89pub const CALIBRATED_PREFIXES: [&str; 3] = ["aws_sdk_", "aws_smithy", "cap_"];
90
91/// Crates `classify` matches by PATH prefix rather than crate-name equality (their effectful modules
92/// are recognised, e.g. `tokio::net::`/`async_std::fs::`/`mio::net::`), so they're absent from
93/// `CALIBRATED_CRATES` (which the liveness test probes by crate name). The coverage check must still
94/// treat them as *covered* — otherwise it would mislabel the most common async crates as blind spots.
95pub const PATH_CALIBRATED_CRATES: [&str; 3] = ["tokio", "async_std", "mio"];
96
97/// Crates REVIEWED AND FOUND TO PERFORM NO EFFECT OF THEIR OWN — the κ ledger treats them as covered, so
98/// their calls stop being disclosed blind spots.
99///
100/// SEPARATE FROM `CALIBRATED_CRATES` BY NECESSITY, not taste. That list means "classify has effect rules
101/// here", and `calibrated_crates_are_live` fails any entry no rule matches — "a dead entry would silently
102/// suppress a real coverage warning". A genuinely pure crate has no rule to be live, so it cannot go
103/// there; without this list the only way to silence its noise would be to invent a rule, which is worse.
104///
105/// **THIS LIST MANUFACTURES PURITY CLAIMS, so an entry needs evidence and not a reputation.** A crate here
106/// stops being disclosed and starts being believed. Each of these was checked against its source in the
107/// local cargo registry for `std::{fs,net,process,env}` and stdio use, and every apparent hit was a DOC
108/// COMMENT (serde_json's `/// [`File`]: std::fs::File`, serde_yml's `/// io::stdout()`):
109///
110/// serde_json 1.0.151, serde_yml 0.0.12, toml 1.1.3, regex 1.13.1, sha2 0.11.0
111///
112/// `color_eyre` was on the same filing and is NOT here — FETCHED AND CHECKED 2026-08-03, and it is not
113/// pure. It reads `RUST_BACKTRACE` / `RUST_LIB_BACKTRACE` / `RUST_SPANTRACE` / `COLORBT_SHOW_HIDDEN`
114/// (Env, `config.rs:939..1175`) and **opens source files to render code snippets** (Fs,
115/// `config.rs:248`). It is also not CALIBRATED, deliberately: the `File::open` sits inside
116/// `impl fmt::Display for SourceSection`, reached when a report is RENDERED rather than through any named
117/// verb a caller invokes — so there is no path for a rule to match, and calibrating the crate would turn
118/// that render path into an unmatched path, i.e. a PURITY CLAIM over the file read. Its calls therefore
119/// stay disclosed as a blind spot. The noise is real; it is also honest, and that is the right trade.
120///
121/// THE SERIALIZER CAVEAT, worth stating because it is the one that looks wrong: `serde_json::from_reader`
122/// and `to_writer` do move bytes — but through a handle the CALLER had to obtain, and obtaining it (a
123/// `File::open`, a `TcpStream::connect`) is already classified on the caller. The crate performs no
124/// syscall of its own, so charging it would double-count an effect the caller already carries.
125pub const REVIEWED_PURE_CRATES: [&str; 5] = ["serde_json", "serde_yml", "toml", "regex", "sha2"];
126
127/// The completeness gate's escape hatch (`eval/coverage-gate/`, `tests/coverage_gate.rs`): exact
128/// (crate, consumer-facing path) pairs a human has read and found genuinely pure, despite `candor-scan`
129/// self-scanning the crate's own vendored source finding a reachable Fs/Net/Db/Exec effect there. SEPARATE
130/// from `REVIEWED_PURE_CRATES` — that list exempts a WHOLE crate from the coverage LEDGER's crate-level
131/// disclosure; this one exempts a single ENTRY from the completeness GATE's per-function differential.
132/// The gate's generator run (2026-08-27) found 669 self-scan-confirmed entries `classify()` already
133/// recognizes (checked in as `covered.tsv`, asserted every push) and 251 it did not yet recognize under
134/// any guessed spelling (checked in as `open.tsv`, a ratchet — NOT individually hand-verified, see that
135/// file's header). An `open.tsv` row belongs here ONLY once read against the crate's real source and
136/// confirmed to perform no effect (the SAME evidence bar `REVIEWED_PURE_CRATES` documents) — until then
137/// it stays in the ratchet as an open question, not a silent assumption. The five below were the first
138/// batch, from the 2026-08-27 coverage-gate triage:
139///
140/// - `curl::Multi::timeout` — this file's own `curl` rule (above) already documents WHY: `Easy::
141/// timeout` is a pure `CURLOPT_TIMEOUT` setter sharing the `::timeout` leaf, and an under-report on
142/// the rare event-loop kick beats mis-tagging every consumer that sets a timeout.
143/// - `execute::command` / `execute::shell` — this file's own `execute` rule (above) already documents
144/// WHY: both free functions only BUILD a `std::process::Command` (no `.spawn()`/`.output()`/
145/// `.status()` call in either body) and return it to the caller, who spawns it themselves — the
146/// caller's own `Command::spawn()` carries the effect. Self-scan's raw "Exec" signal comes from
147/// `std::process::Command::new`'s coarse whole-type rule (constructing a `Command` is Exec by
148/// default, narrowed only for crates like `async_process` that document their own pure-setter
149/// surface), not from an actual subprocess launch in either function.
150/// - `elasticsearch::Response::content_type` — a pure accessor reading a header off an ALREADY-received
151/// response (`self.response.headers().get(..)`, http/response.rs:61) — no I/O of its own. Self-scan's
152/// raw "Net" signal is the underlying HTTP client response type's coarse whole-type propagation, not
153/// a second network round-trip.
154/// - `rusqlite::Context::get_connection` — this file's own rusqlite rule (above) already documents WHY:
155/// it hands back a `ConnectionRef` BORROWED from the ALREADY-established connection a running SQL
156/// function executes within (`ffi::sqlite3_context_db_handle`, not a syscall) — the same "handle
157/// accessor, not a syscall" shape as `TcpStream::local_addr`.
158pub const REVIEWED_PURE_ENTRIES: &[(&str, &str)] = &[
159 ("curl", "curl::Multi::timeout"),
160 ("execute", "execute::command"),
161 ("execute", "execute::shell"),
162 ("elasticsearch", "elasticsearch::Response::content_type"),
163 ("rusqlite", "rusqlite::Context::get_connection"),
164];
165
166/// Representative path tails (each appended to a crate name) that the `calibrated_crates_are_live`
167/// liveness test probes: at least one must match for every `CALIBRATED_CRATES` entry, else the entry is
168/// dead. Exported as ONE source of truth because the nightly lint crate (`src/lib.rs`) runs the SAME
169/// liveness test — when the two probe lists were duplicated they drifted, and a rule keyed on a
170/// distinctive tail (pnet `::datalink::channel`, ignore `::WalkBuilder::build_parallel`, notify
171/// `::RecommendedWatcher::new`) added to only one list silently broke the other crate's `cargo test`.
172pub const CALIBRATION_PROBE_TAILS: &[&str] = &[
173 "::X::send", "::X::execute", "::X::call", "::X::query", "::X::fetch_one", "::Remote::fetch",
174 "::datalink::channel", "::WalkBuilder::build_parallel", "::RecommendedWatcher::new",
175 "::X::connect", "::Utc::now", "::X::load", "::__private_api::log", "::tempfile", "::glob",
176 "::X::run", "::dotenv", "::random", "::emit", "::X::emit_span_lint", "::X::anything",
177 "::X::draw",
178 "::SaltString::generate", "::hash", "::OsRng::fill_bytes",
179 // verb-precise crates whose whole-crate rules were narrowed to the effectful surface (the pure
180 // accessors/ctors/data-types now return None), so the liveness probe must name an EFFECTFUL path:
181 "::Mmap::map", "::event", "::u32", "::Clipboard::get_text", "::spawn_command",
182 // coverage-differential crates (each needs ≥1 effectful tail; existing tails already cover
183 // native_tls_crate/tokio_native_tls/sqlx_core via ::X::connect, execute via ::X::execute, jiff via ::now):
184 "::read_tls", "::home_dir", "::args", "::from_env", "::IntoIter::next", "::set_file_mtime",
185 "::surely_conflicts_with", "::set_handler", "::get_matches", "::init", "::interact",
186 "::write_line", "::background_color", "::retry", "::build",
187];
188
189/// Database client crates whose execution verbs are I/O (see the DB branch in `classify`).
190/// Module-level so `db_crates_are_calibrated` can enforce `DB_CRATES ⊆ CALIBRATED_CRATES`.
191pub const DB_CRATES: [&str; 11] = [
192 "sqlx", "rusqlite", "postgres", "tokio_postgres", "diesel", "redis", "mongodb",
193 "mysql", "mysql_async", "sea_orm", "deadpool_postgres",
194];
195
196/// Pure file-descriptor *ownership-transfer* leaves. These ADOPT an already-open descriptor
197/// (`from_raw_fd`/`from_raw_socket`/`from_raw_handle`), EXTRACT/BORROW one
198/// (`into_raw_fd`/`into_raw_socket`/`into_raw_handle`, `as_raw_fd`/`as_raw_socket`/`as_raw_handle`),
199/// or UNWRAP an async wrapper back to its std type (`into_std`) — none of them issue a syscall or
200/// 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
201/// coarse std-type PREFIX rules (`std::net::TcpStream`/`std::fs::File`/`std::os::unix::net` → Net/Fs/Ipc)
202/// even though the descriptor was opened ELSEWHERE. The portable_pty/async_process Exec rule already
203/// exempts `from_raw_fd`; this generalises the same carve-out across the net/fs/ipc prefix rules.
204/// (Found by a real-world sweep of tokio: `TcpStream::into_std`, `*::from_raw_fd`, `*::as_raw_fd` all
205/// fabricated Net/Fs/Ipc.)
206const PURE_FD_TRANSFER: &[&str] = &[
207 "from_raw_fd", "from_raw_socket", "from_raw_handle",
208 "into_raw_fd", "into_raw_socket", "into_raw_handle",
209 "as_raw_fd", "as_raw_socket", "as_raw_handle",
210 "into_std",
211 // `SocketAddr::from_pathname` (std/async-std unix net) builds an address STRUCT from a path —
212 // it opens no socket. The `std::os::unix::net` prefix rule below would otherwise fabricate Ipc
213 // on it. (Found sweeping socket2: `SockAddr::as_unix` → `from_pathname` reported Ipc.)
214 "from_pathname",
215];
216
217/// The std I/O **handle** types whose methods the scanner's receiver inference may route into
218/// `classify` as `Type::method`.
219///
220/// WHY A LIST AND NOT "std". The std rules in `classify` are a MIXTURE: a few are type-precise with
221/// reviewed pure-accessor carve-outs (`std::net::TcpStream` → Net minus `local_addr`/`peer_addr`/…;
222/// `std::process::Command` → Exec minus `get_program`/`get_args`/…), but most are coarse MODULE
223/// prefixes (`std::fs::` → Fs) written for free functions and constructors. Applied to arbitrary
224/// method calls, a coarse prefix charges an effect for reading a struct field: `std::fs::Metadata`,
225/// `DirEntry`, `Permissions` and `FileType` all sit under `std::fs::` and are pure DATA. So the
226/// scanner used to skip EVERY std-rooted receiver — which shut the door on the pure data types and on
227/// the real handles alike, and `fn run(cmd: &mut Command) { cmd.spawn(); }` certified PURE while it
228/// spawned a process (a silent false all-clear; the `cc` crate's `command_helpers::spawn` was a live
229/// instance). This list re-opens the door for the handles ONLY.
230///
231/// MEMBERSHIP RULE, and the reason it is safe to apply a whole-type rule to an inferred method call:
232/// a type belongs here only when EVERY method on it is either the effect itself or an already-carved-
233/// out pure read-back. That holds for an open descriptor's handle — a `File`/`TcpStream`/`Child` does
234/// nothing but I/O on the thing it owns — and the `PURE_FD_TRANSFER` guard at the top of `classify`
235/// exempts the `as_raw_fd`/`into_std` family for all of them before any prefix rule runs.
236///
237/// THE OPTION-BUILDERS, `OpenOptions` and `DirBuilder`, were absent for exactly this rule and now
238/// satisfy it: each has a type-keyed carve-out in `classify` subtracting its pure setters, so only its
239/// TERMINAL VERB (`open` / `create`) is Fs. The exclusion was written as "a narrow residual, not the
240/// reported hole" and MEASURED otherwise — `fn load(o: &OpenOptions, p: &Path) { o.open(p) }` opened a
241/// file and reported NOTHING, the same silent false all-clear as the `Command` parameter, while a
242/// setter-only `OpenOptions::new().read(true)` reported `Fs` for opening nothing. The order the old
243/// comment prescribed is the order taken: the verb-precise rules went in FIRST, and this list widened
244/// onto them. `ReadDir` stays out — its `next` IS a syscall, but nothing distinguishes it from any
245/// other iterator's `next`, so no verb-precise rule can be written and the honest miss stands.
246///
247/// `std::path::Path`/`PathBuf` are NOT here: they are pure data with an effectful STAT sub-surface —
248/// the exact inverse shape — and they route through their own verb-precise carve-out in the scanner.
249const STD_EFFECT_HANDLES: &[&str] = &[
250 // Exec — the whole type is the subprocess boundary (`new` names a program, `arg`/`env` build the
251 // invocation, `spawn`/`output`/`status` run it); the read-back getters are already carved out.
252 "std::process::Command",
253 "std::process::Child",
254 // Net — an open socket. `connect`/`accept`/`read`/`write`/`shutdown`/`set_*` are socket syscalls;
255 // `local_addr`/`peer_addr`/`nodelay`/`ttl`/`take_error` are carved out.
256 "std::net::TcpStream",
257 "std::net::TcpListener",
258 "std::net::UdpSocket",
259 // Ipc — a Unix-domain socket, the same shape as the TCP/UDP handles above.
260 "std::os::unix::net::UnixStream",
261 "std::os::unix::net::UnixListener",
262 "std::os::unix::net::UnixDatagram",
263 // Fs — an open file. Every method reads, writes, syncs, truncates or stats it; the descriptor
264 // conversions are carved out by `PURE_FD_TRANSFER`.
265 "std::fs::File",
266 // Fs — the option-builders, whose PURE setters are subtracted by the type-keyed carve-outs in
267 // `classify` so that only the terminal verb (`OpenOptions::open`, `DirBuilder::create`) routes to Fs.
268 "std::fs::OpenOptions",
269 "std::fs::DirBuilder",
270];
271
272/// Is `ty` a std I/O handle type whose methods may be routed into `classify` by the scanner's
273/// receiver inference? See `STD_EFFECT_HANDLES` for the membership rule and the named exclusions.
274pub fn is_std_effect_handle(ty: &str) -> bool {
275 STD_EFFECT_HANDLES.contains(&ty)
276}
277
278/// Classify a resolved callee by the crate it belongs to and its full path.
279pub fn classify(crate_name: &str, path: &str) -> Option<&'static str> {
280 // Pure fd ownership-transfer/extraction leaves are never an effect, regardless of which std I/O
281 // type they hang off — exempt them BEFORE the coarse prefix rules can fabricate Net/Fs/Ipc.
282 if PURE_FD_TRANSFER.contains(&path.rsplit("::").next().unwrap_or(path)) {
283 return None;
284 }
285 if crate_name.starts_with("aws_sdk_") || crate_name.starts_with("aws_smithy") {
286 // Only request dispatch is network I/O; builder setters/accessors are pure.
287 if path.ends_with("::send") || path.ends_with("::send_with") {
288 return Some("Net");
289 }
290 return None;
291 }
292 // aws-config resolves credentials/region on `.load()` — it reaches the IMDS metadata
293 // endpoint / STS over the network (and reads ~/.aws + env). Builders (`defaults()`,
294 // `SdkConfig::builder()`, `BehaviorVersion::latest()`) are pure; the `load` is the I/O.
295 // (Found hardening on a real app, ebman: `builder.load().await` was classified pure.)
296 //
297 // THE FIX: `load_from_env()` (lib.rs:170/188, both the `behavior-version-latest` and deprecated
298 // forms) is `from_env().load().await` / `load_defaults(latest).await` one call down — the crate's OWN
299 // "convenience wrapper" (its own doc comment's phrase) around the already-modelled `.load()` — but
300 // `"load_from_env".ends_with("::load")` is false, so the wrapper read pure. Same vein as
301 // `ignore::Walk::new`.
302 if crate_name == "aws_config" {
303 if path.ends_with("::load") || path.ends_with("::load_defaults") || path.ends_with("::load_from_env") {
304 return Some("Net");
305 }
306 return None;
307 }
308 // git2 (libgit2 FFI): remote operations contact the network; everything else is local
309 // to the .git directory. Match the remote verbs precisely — NOT bare `::clone`, which is
310 // the `Clone`-trait dup of a `Remote` handle (pure), not `Repository::clone`. (Found
311 // hardening on gitui: `remote.fetch`/`remote.push` were classified network-free — a git
312 // client reporting it makes no network calls.)
313 //
314 // `Repository::clone`/`clone_recurse`/`clone_local`/`init` (via `RepoBuilder::clone`) IS the
315 // thing the comment above named and then excluded anyway: the bare `::clone` denylist meant
316 // to keep out `Remote::clone` (the derived trait dup) also swallowed `Repository::clone` —
317 // libgit2's actual network clone, and arguably git2's single most common entry point. A
318 // fabrication corpus round caught it: `git2::Repository::clone(url, path)` reported ZERO
319 // effects and passed `deny Net` at exit 0. FQN-exact (like the `reqwest::get` disambiguation
320 // a few lines up) so the fix doesn't just re-widen `::clone` and reintroduce the very
321 // over-charge the comment was written to prevent: `Remote::clone` (and `Repository::clone`'s
322 // own `Clone` derive, if it has one) must still read pure.
323 if crate_name == "git2" {
324 if path.ends_with("::fetch")
325 || path.ends_with("::push")
326 || path.ends_with("::download")
327 || path.ends_with("::connect")
328 || path.ends_with("::connect_auth")
329 || path.ends_with("::ls")
330 || path.ends_with("::upload")
331 || path == "git2::Repository::clone"
332 || path == "git2::Repository::clone_recurse"
333 || path == "git2::build::RepoBuilder::clone"
334 // A FOURTH+FIFTH true positive in the same gap the `Repository::clone` fix left open:
335 // `Submodule::clone`/`Submodule::update` (submodule.rs:39,234) call `raw::git_submodule_clone`/
336 // `git_submodule_update` DIRECTLY — the exact FFI leaves already in this file's own
337 // FFI-tier NET table below, but only when a caller names the raw `ffi`/`raw::` leaf itself.
338 // git2's documented, standard submodule-init idiom (`sub.clone(None)` /
339 // `sub.update(true, None)`) never does that — it calls the safe wrapper, which carried no
340 // rule of its own and read pure under `deny Net`. FQN-exact, matching the `Repository::clone`
341 // fix's own discipline: a bare `::update`/`::clone` substring would sweep in git2's many pure
342 // `update_*` setters (`CheckoutBuilder::update_only`, `DiffOptions::update_index`, …) and the
343 // derive-`Clone` dup on every other git2 type.
344 || path == "git2::Submodule::clone"
345 || path == "git2::Submodule::update"
346 // `Remote::list`/`RemoteConnection::list` (remote.rs:378,755) call `raw::git_remote_ls`
347 // directly — the reference-advertisement fetch already in the FFI-tier NET table below,
348 // reached the same way `Submodule::clone`/`update` were: the safe wrapper's OWN name
349 // (`list`, not `ls`) never matched the `::ls` suffix this block already carried (which, on
350 // git2 0.20, matches no real method at all — dead weight kept for whatever older API it once
351 // fit). FQN-exact so a `list` on some other git2 type (none exist today, but the same
352 // discipline as `Submodule::clone` above) can't be swept in by a bare suffix later.
353 || path == "git2::Remote::list"
354 || path == "git2::RemoteConnection::list"
355 {
356 return Some("Net");
357 }
358 // THE COVERAGE-GATE SWEEP (2026-08-27): every one of these is a public entry point whose body
359 // calls a `raw::git_*` FFI leaf ALREADY in this file's own FS FFI-tier table below (config.rs/
360 // index.rs/odb.rs/packbuilder.rs/reference.rs/repo.rs/treebuilder.rs, verified against git2
361 // 0.20.4 source) — but that table is UNREACHABLE for a real consumer's call, because it only
362 // fires when self-scan resolves the FFI leaf's OWN crate (`libgit2_sys`, via git2's internal
363 // `use libgit2_sys as raw`), never for `crate_name == "git2"`, which returns from THIS block
364 // first. A consumer never spells `raw::git_repository_open`; they call `git2::Repository::open`,
365 // which fell through this entire branch to `None` — the exact `ignore::Walk::new` shape (a
366 // lower-level rule the top-level entry point never reaches) but affecting git2's single most
367 // common local operations (`Repository::open`/`init`), not an edge case. FQN-exact, not a
368 // `::open`/`::write`/`::read` suffix — those verbs are common enough elsewhere (`Odb::reader`
369 // isn't `::read`, `TreeBuilder`/`Index`/`PackBuilder::write` share the name with plenty of pure
370 // builder setters on other types) that a bare suffix would either miss these or over-charge.
371 if path == "git2::Config::add_file"
372 || path == "git2::Config::open"
373 || path == "git2::Config::open_default"
374 || path == "git2::Index::add_all"
375 || path == "git2::Index::add_path"
376 || path == "git2::Index::read"
377 || path == "git2::Index::write"
378 || path == "git2::Index::write_tree"
379 || path == "git2::Index::write_tree_to"
380 || path == "git2::Odb::read"
381 || path == "git2::Odb::reader"
382 || path == "git2::Odb::write"
383 || path == "git2::Odb::writer"
384 || path == "git2::PackBuilder::write"
385 || path == "git2::Reference::delete"
386 || path == "git2::Reference::set_target"
387 || path == "git2::Repository::blob_path"
388 || path == "git2::Repository::checkout_head"
389 || path == "git2::Repository::checkout_index"
390 || path == "git2::Repository::checkout_tree"
391 || path == "git2::Repository::commit"
392 || path == "git2::Repository::discover"
393 || path == "git2::Repository::discover_path"
394 || path == "git2::Repository::init"
395 || path == "git2::Repository::init_bare"
396 || path == "git2::Repository::init_opts"
397 || path == "git2::Repository::open"
398 || path == "git2::Repository::open_bare"
399 || path == "git2::Repository::open_ext"
400 || path == "git2::Repository::open_from_env"
401 || path == "git2::Repository::reference"
402 || path == "git2::Repository::tag"
403 || path == "git2::TreeBuilder::write"
404 {
405 return Some("Fs");
406 }
407 // `Cred::credential_helper` (cred.rs:121) calls `CredentialHelper::execute` (cred.rs:326),
408 // which — on every code path — spawns a REAL `sh -c "<helper> get"` (or, if `sh` itself fails to
409 // spawn, the helper binary directly) via `std::process::Command::spawn` to resolve
410 // `credential.helper`-configured auth (cred.rs:370-390): a real subprocess launch a caller
411 // authenticating against a private remote is one call away from (`Cred::credential_helper(cfg,
412 // url, user)` is the crate's own documented way to honor `credential.helper`), not a lower-level
413 // implementation detail. FQN-exact: `execute` alone is far too generic to key on blindly.
414 if path == "git2::Cred::credential_helper" || path == "git2::CredentialHelper::execute" {
415 return Some("Exec");
416 }
417 return None;
418 }
419 // libc — raw syscalls via FFI. The FFI-thin tier (nix, and the syscall layer beneath rusqlite/git2)
420 // is invisible to a name classifier unless we model libc directly: a 35-crate calibration
421 // (eval/calibration) showed nix reporting ZERO library effects because every wrapper bottoms out in
422 // an unrecognised `libc::*` call. Classify by syscall name, but ONLY the UNAMBIGUOUS ones — the
423 // socket family is Net, path/dir syscalls are Fs, spawn/exec/wait is Exec, SysV/pipe IPC is Ipc,
424 // env/clock/entropy each their own. We deliberately SKIP the generic file-descriptor ops
425 // (read/write/close/lseek/dup/fcntl/ioctl/poll/select/epoll*/mmap): they operate on ANY fd — file,
426 // socket, or pipe — so a fixed label would mis-categorise as often as it helps. An honest
427 // no-classify (under-report) beats emitting the WRONG effect. Pure conversions (htons/inet_pton/
428 // gmtime) are also skipped.
429 //
430 // `nix` (the idiomatic SAFE libc wrapper, in ~every Rust systems/CLI crate) is routed through the
431 // SAME table: its functions keep the syscall leaf name (`nix::fcntl::open`, `nix::sys::socket::connect`,
432 // `nix::unistd::execvp`). Without this, a CONSUMER of nix analysed without nix's own source (the
433 // stable scanner, single-crate) sees `nix::*` cross-crate and under-reports — serialport-rs opens its
434 // device via `nix::fcntl::open` and reported ZERO Fs. The nightly lint reaches `libc::*` THROUGH nix's
435 // body; this gives the scanner the same coverage directly. (Found sweeping serialport-rs.)
436 // `rustix` is the same shape as nix but does RAW syscalls (no libc underneath), so its functions MUST
437 // be classified directly. Its leaf names are the syscall names too (`rustix::time::clock_settime`,
438 // `rustix::fs::mkfifoat`/`symlink`/`stat`, `rustix::net::connect`) — route it through the same table.
439 // The rustix-specific `*at`/variant leaves it doesn't share with libc just under-report (the safe
440 // direction). VALIDATED, not speculative: coreutils' `date` reads/sets the clock via
441 // `rustix::time::clock_getres`/`clock_settime` and reported Clock=0; the file I/O that goes through
442 // std::fs was already correct, which is why only the rustix-only effects (Clock/Ipc) were missing.
443 if crate_name == "libc" || crate_name == "nix" || crate_name == "rustix" {
444 let f = path.rsplit("::").next().unwrap_or(path);
445 // path / directory / metadata syscalls (incl. *64 and *at variants)
446 const FS: &[&str] = &[
447 "open", "open64", "openat", "openat2", "creat", "creat64", "stat", "stat64", "lstat",
448 "lstat64", "fstatat", "fstatat64", "newfstatat", "statx", "access", "faccessat",
449 "faccessat2", "mkdir", "mkdirat", "rmdir", "unlink", "unlinkat", "rename", "renameat",
450 "renameat2", "link", "linkat", "symlink", "symlinkat", "readlink", "readlinkat", "chmod",
451 "fchmodat", "chown", "lchown", "fchownat", "truncate", "truncate64", "ftruncate",
452 "ftruncate64", "opendir", "fdopendir", "readdir", "readdir64", "readdir_r", "closedir",
453 "rewinddir", "seekdir", "telldir", "scandir", "mkstemp", "mkstemps", "mkostemp", "mkdtemp",
454 "mknod", "mknodat", "chdir", "fchdir", "getcwd", "get_current_dir_name", "chroot",
455 "pivot_root", "statfs", "statfs64", "fstatfs", "fstatfs64", "statvfs", "fstatvfs", "mount",
456 "umount", "umount2", "fsync", "fdatasync", "sync", "syncfs", "sync_file_range", "fallocate",
457 "posix_fallocate", "posix_fadvise", "sendfile", "sendfile64", "copy_file_range", "flock",
458 "getdents", "getdents64", "utime", "utimes", "lutimes", "futimens", "utimensat", "futimesat",
459 "realpath",
460 ];
461 // socket family — these operate only on sockets, so Net is unambiguous (AF_UNIX domain isn't
462 // visible at the call, so a Unix socket reads as Net rather than Ipc; acceptable over-general).
463 const NET: &[&str] = &[
464 "socket", "setsockopt", "getsockopt", "bind", "listen", "accept", "accept4", "connect",
465 "shutdown", "send", "sendto", "sendmsg", "sendmmsg", "recv", "recvfrom", "recvmsg",
466 "recvmmsg", "getpeername", "getsockname", "getaddrinfo", "freeaddrinfo", "getnameinfo",
467 ];
468 // process creation / replacement / reaping
469 const EXEC: &[&str] = &[
470 "fork", "vfork", "clone", "clone3", "execl", "execlp", "execle", "execv", "execvp",
471 "execvpe", "execve", "execveat", "fexecve", "posix_spawn", "posix_spawnp", "system",
472 "popen", "pclose", "wait", "waitpid", "wait3", "wait4", "waitid",
473 ];
474 // pipes / FIFOs / SysV + POSIX message queues, semaphores, shared memory; socketpair (AF_UNIX)
475 const IPC: &[&str] = &[
476 "pipe", "pipe2", "mkfifo", "mkfifoat", "socketpair", "msgget", "msgsnd", "msgrcv", "msgctl",
477 "semget", "semop", "semtimedop", "semctl", "shmget", "shmat", "shmdt", "shmctl", "mq_open",
478 "mq_send", "mq_receive", "mq_timedsend", "mq_timedreceive", "mq_close", "mq_unlink",
479 ];
480 const ENV: &[&str] = &["getenv", "secure_getenv", "setenv", "putenv", "unsetenv", "clearenv"];
481 const CLOCK: &[&str] = &[
482 "time", "gettimeofday", "clock_gettime", "clock_getres", "nanosleep", "clock_nanosleep",
483 // SETTING the system clock is a clock effect too (was unclassified — found on coreutils `date`,
484 // which sets it via `clock_settime`).
485 "clock_settime", "settimeofday", "stime", "adjtime", "adjtimex", "clock_adjtime",
486 ];
487 const RAND: &[&str] = &["getrandom", "getentropy", "arc4random", "arc4random_buf", "arc4random_uniform"];
488 if FS.contains(&f) {
489 return Some("Fs");
490 }
491 if NET.contains(&f) {
492 return Some("Net");
493 }
494 if EXEC.contains(&f) {
495 return Some("Exec");
496 }
497 if IPC.contains(&f) {
498 return Some("Ipc");
499 }
500 if ENV.contains(&f) {
501 return Some("Env");
502 }
503 if CLOCK.contains(&f) {
504 return Some("Clock");
505 }
506 if RAND.contains(&f) {
507 return Some("Rand");
508 }
509 return None;
510 }
511 // C-library FFI bindings: libsqlite3 (under rusqlite) and libgit2 (under git2). Like the libc tier,
512 // these crates are thin Rust over a C library, so their real I/O is invisible until the C entry
513 // points are named. Match by the DISTINCTIVE C function name (`sqlite3_*` / `git_*`) via the call's
514 // LEAF — independent of the binding crate's alias: rusqlite calls `ffi::sqlite3_step`, git2 calls
515 // `raw::git_remote_fetch`, and the nightly lint resolves the same to `libsqlite3_sys`/`libgit2_sys`;
516 // all spellings share the leaf. Only the I/O-performing entry points are listed — the in-memory
517 // accessors (`sqlite3_bind_*`/`sqlite3_column_*`, `git_*_oid`/strarray/options builders) stay pure,
518 // so a non-listed `sqlite3_`/`git_` leaf returns None (under-report, never a wrong effect). Calibrated
519 // + validated against rusqlite 0.39 / git2 0.20 source (eval/calibration).
520 {
521 let leaf = path.rsplit("::").next().unwrap_or(path);
522 if let Some(rest) = leaf.strip_prefix("sqlite3_") {
523 let _ = rest;
524 // SQLite C API operations that touch the database (open/exec/step/prepare/backup/blob/wal).
525 const DB: &[&str] = &[
526 "sqlite3_open", "sqlite3_open_v2", "sqlite3_open16", "sqlite3_close", "sqlite3_close_v2",
527 "sqlite3_exec", "sqlite3_step", "sqlite3_prepare", "sqlite3_prepare_v2",
528 "sqlite3_prepare_v3", "sqlite3_prepare16", "sqlite3_prepare16_v2", "sqlite3_prepare16_v3",
529 "sqlite3_get_table", "sqlite3_backup_init", "sqlite3_backup_step", "sqlite3_backup_finish",
530 "sqlite3_blob_open", "sqlite3_blob_read", "sqlite3_blob_write", "sqlite3_blob_reopen",
531 "sqlite3_load_extension", "sqlite3_wal_checkpoint", "sqlite3_wal_checkpoint_v2",
532 ];
533 return DB.contains(&leaf).then_some("Db");
534 }
535 if leaf.starts_with("git_") {
536 // libgit2: remote/transport operations contact the network … (incl. submodule clone/update,
537 // which `git_clone`/fetch the subrepo over its remote — `allow_fetch` defaults on; an A/B on
538 // git2 0.20 caught `Submodule::update`/`clone` reporting no `Net`).
539 const NET: &[&str] = &[
540 "git_clone", "git_remote_connect", "git_remote_connect_ext", "git_remote_fetch",
541 "git_remote_download", "git_remote_upload", "git_remote_push", "git_remote_ls",
542 "git_submodule_clone", "git_submodule_update",
543 ];
544 // … and repository/index/odb/checkout/ref/config operations touch the on-disk .git store.
545 const FS: &[&str] = &[
546 "git_repository_open", "git_repository_open_ext", "git_repository_open_bare",
547 "git_repository_init", "git_repository_init_ext", "git_repository_discover",
548 "git_checkout_tree", "git_checkout_head", "git_checkout_index", "git_index_read",
549 "git_index_write", "git_index_write_tree", "git_index_write_tree_to",
550 "git_index_add_bypath", "git_index_add_all", "git_odb_open", "git_odb_read",
551 "git_odb_write", "git_odb_open_wstream", "git_odb_open_rstream",
552 "git_blob_create_fromdisk", "git_blob_create_fromworkdir", "git_blob_create_from_disk",
553 "git_blob_create_from_workdir", "git_blob_create_from_stream", "git_commit_create",
554 "git_commit_create_v", "git_reference_create", "git_reference_set_target",
555 "git_reference_delete", "git_config_open_default", "git_config_open_ondisk",
556 "git_config_add_file_ondisk", "git_tag_create", "git_treebuilder_write",
557 "git_packbuilder_write",
558 ];
559 if NET.contains(&leaf) {
560 return Some("Net");
561 }
562 if FS.contains(&leaf) {
563 return Some("Fs");
564 }
565 return None;
566 }
567 if leaf.starts_with("curl_") {
568 // libcurl (under the `curl` crate, called `curl_sys::curl_*`). Only the entry points that
569 // PERFORM network I/O: the blocking transfer (`curl_easy_perform`), raw socket send/recv,
570 // the HTTP/2 keepalive PING (`upkeep`), and the multi-interface transfer pumps. The large
571 // pure surface (setopt/init/cleanup/reset/getinfo/escape/multi_add_handle/fdset/info_read)
572 // stays unclassified, as do `curl_multi_wait`/`poll` (readiness WAIT on sockets, no payload —
573 // the loop's `perform` is the tagged boundary, per the I/O-boundary principle). An A/B on
574 // curl 0.4 caught the whole crate reporting ZERO Net (`Easy::perform` read as pure).
575 const NET: &[&str] = &[
576 "curl_easy_perform", "curl_easy_send", "curl_easy_recv", "curl_easy_upkeep",
577 "curl_multi_perform", "curl_multi_socket_action",
578 ];
579 return NET.contains(&leaf).then_some("Net");
580 }
581 if let Some(op) = leaf.strip_prefix("SSL_") {
582 // OpenSSL (libssl, under the `openssl`/`native-tls` crates, called `ffi::SSL_*`). The TLS
583 // handshake and record I/O run over the peer socket -> Net. Unlike libc read/write, an SSL_*
584 // op is ~always over a network BIO (the rare memory-BIO/sans-IO case is the honest exception
585 // we accept). The crypto surface (EVP_*/SHA*/AES*) and pure setup (SSL_CTX_new/SSL_set_fd) are
586 // NOT here; `BIO_*` is skipped (a BIO may be memory or socket). Validated vs openssl 0.9 source.
587 const SSL_NET: &[&str] = &[
588 "connect", "accept", "do_handshake", "read", "read_ex", "write", "write_ex", "peek",
589 "peek_ex", "shutdown",
590 ];
591 return SSL_NET.contains(&op).then_some("Net");
592 }
593 }
594 // HTTP clients use the same builder pattern as the AWS SDK: only the dispatch is
595 // I/O. (Found by the eval: ebman's reqwest calls to the Anthropic API + webhooks
596 // were silently classified network-free because reqwest wasn't recognized.)
597 if crate_name == "reqwest" || crate_name == "isahc" {
598 // The dispatch (`::send`/`::execute`) is the I/O. PLUS the one-shot CONVENIENCE functions
599 // `reqwest::get` / `reqwest::blocking::get` / `isahc::get`, which send immediately — they're
600 // an EXACT match (not `Client::get`, the builder) to avoid false-positiving the builder path.
601 // (Found running on `xh`: a one-shot `reqwest::get(url)` was classified network-free.)
602 if path.ends_with("::send")
603 || path.ends_with("::execute")
604 || path == "reqwest::get"
605 || path == "reqwest::blocking::get"
606 || path == "isahc::get"
607 {
608 return Some("Net");
609 }
610 // THE URL-BEARING BUILDER METHODS: `Client::{get,post,put,delete,patch,head,request}(URL)`.
611 // Real code almost never uses `reqwest::get(url)`; the DOMINANT idiom is the builder chain
612 // `Client::new().post(url).send()` / `Client::builder().build()?.post(url).send()`. The `.send()`
613 // already classifies `Net` — but the URL literal rides the `.post(url)` call, NOT `.send()`, so
614 // without classifying the URL-naming step `Net` the endpoint is NEVER captured and the `Llm`
615 // host refinement can't fire (ebman's `api.anthropic.com` call read as bare Net, undisclosed as
616 // Llm — the dogfood silent under-report). Classifying these `Net` (idempotent with the eventual
617 // `.send()`) makes the scanner capture the URL from their string arg. `request(method, url)`'s
618 // url is its SECOND arg — the scanner's first-string-literal capture still gets it when the
619 // method is a literal string, and misses it (honest under-report) when the method is an
620 // expression. The pure builder surface (`::header`, `::json`, `::body`, `::query`, …) stays None.
621 if path.ends_with("::get")
622 || path.ends_with("::post")
623 || path.ends_with("::put")
624 || path.ends_with("::delete")
625 || path.ends_with("::patch")
626 || path.ends_with("::head")
627 || path.ends_with("::request")
628 {
629 return Some("Net");
630 }
631 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against isahc 2.0.1 — isahc-only, since
632 // reqwest has no equivalent free-function async family:
633 //
634 // `get_async`/`head_async`/`post_async`/`put_async`/`delete_async`/`send_async` (lib.rs:357-500)
635 // are the crate's async one-shot convenience functions — `HttpClient::shared().get_async(uri)`
636 // etc., the exact async mirror of the already-covered `isahc::get`, dispatching immediately.
637 // `HttpClient::new`/`HttpClientBuilder::build` (client.rs:629,444) are genuinely NOT lazy like
638 // reqwest's `Client::new`: `build()` unconditionally calls `agent_builder.spawn()`
639 // (agent/mod.rs:63), which spawns a background OS thread running `AgentContext::run` — curl's
640 // multi-handle event loop that DOES perform the real socket reads/writes for every request later
641 // sent through this client. Confirmed via self-scan on isahc's own source: `AgentBuilder::spawn`
642 // reaches `Net` through that same call chain, not a guess.
643 if crate_name == "isahc"
644 && (path == "isahc::get_async"
645 || path == "isahc::head_async"
646 || path == "isahc::post_async"
647 || path == "isahc::put_async"
648 || path == "isahc::delete_async"
649 || path == "isahc::send_async"
650 || path.ends_with("HttpClient::new")
651 || path.ends_with("HttpClientBuilder::build"))
652 {
653 return Some("Net");
654 }
655 // `reqwest::multipart::Part::file` (blocking/multipart.rs:223) opens the given path directly
656 // (`std::fs::File::open`) to attach it as a form part — genuinely Fs, not Net (the eventual
657 // upload is charged at `.send()`, already covered above).
658 //
659 // NOT modelled: `h3_client::pool::PoolClient::send_request` (async_impl/h3_client/pool.rs:207)
660 // — `mod async_impl;` is PRIVATE in reqwest's lib.rs (only `self::async_impl::multipart` is
661 // selectively re-exported), so `PoolClient` is unreachable from outside the crate; the real
662 // HTTP/3 dispatch a consumer can actually reach is the already-covered `::send`/`::execute`.
663 // Removed from open.tsv rather than guessed.
664 if crate_name == "reqwest" && path.ends_with("::Part::file") {
665 return Some("Fs");
666 }
667 return None;
668 }
669 if crate_name == "ureq" && path.ends_with("::call") {
670 return Some("Net");
671 }
672 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against ureq 2.12.1 (the version this ratchet's
673 // rows were generated from — 3.x, also cached locally, rewrote this surface beyond recognition and
674 // is a different question for a future pass). `::call` above is the no-body dispatch terminal;
675 // these are its siblings:
676 //
677 // `Request::send`/`send_bytes`/`send_form`/`send_json`/`send_string` (request.rs:78-296) are the
678 // WITH-BODY dispatch terminals — `do_call(Payload::..)`, the exact same terminal `call` reaches with
679 // an empty payload. Missing them left every `agent.post(url).send_json(&body)` (the dominant idiom
680 // for a JSON API call) silently pure.
681 // `ureq::agent`/`request`/`request_url` (lib.rs:513,539,561) are real gaps in a stranger way: each
682 // one's own body contains a REACHABLE (if `is_test(true)` was ever called — itself a `pub fn` a
683 // consumer or another dependency could invoke) call to `testserver::test_agent()`, which binds a
684 // real listening `TcpStream` (see `TestServer::new` below) — self-scan's whole-body reachability
685 // treats that as real, and per this project's over-approximate-rather-than-miss discipline (a
686 // toggleable global flag routing traffic through an internal listener is exactly the kind of path a
687 // security-conscious caller wants disclosed, not silently pure), so does this rule. `agent()` is also
688 // the crate's OWN base for `request`/`request_url` (`agent().request(..)`), and `request`/
689 // `request_url` additionally carry the URL argument — matching the reqwest/isahc precedent above of
690 // classifying the URL-bearing constructor `Net` too (idempotent with the eventual `.call()`/`.send()`)
691 // so the destination is captured even if a caller never re-touches the returned `Request`.
692 if crate_name == "ureq"
693 && (path.ends_with("::send")
694 || path.ends_with("::send_bytes")
695 || path.ends_with("::send_form")
696 || path.ends_with("::send_json")
697 || path.ends_with("::send_string")
698 || path == "ureq::agent"
699 || path == "ureq::request"
700 || path == "ureq::request_url")
701 {
702 return Some("Net");
703 }
704 // NOT modelled (both removed from open.tsv, not guessed):
705 // `ureq::Arc::connect` — the ratchet's generator strips generic arguments from an impl header, so
706 // `impl TlsConnector for Arc<rustls::ClientConfig>` (rtls.rs:88) produced the type name "Arc" — a
707 // bogus path (`ureq::Arc` names nothing; `Arc` is std's, not ureq's) that no consumer could ever
708 // write. The trait method itself is real (ureq's own internal TLS dispatch, invoked by `.call()`,
709 // already covered), but no FQN a real caller would spell resolves through this guess.
710 // `ureq::TestServer::new` — `TestServer` (testserver.rs:62) lives in `mod testserver;` (lib.rs:444),
711 // PRIVATE with no re-export, so `ureq::TestServer` is unreachable from outside the crate (the
712 // listener it binds is real, but only `agent()`'s internal `is_test` branch above can reach it).
713 // The `curl` crate (libcurl's safe binding — cargo's own HTTP client): the dispatch verbs are
714 // `perform` (Easy/Easy2/Transfer/Multi), raw-socket `send`/`recv`, the keepalive `upkeep`, and the
715 // multi-interface `action` (socket_action). The big setopt-style builder surface stays pure.
716 // `Multi::timeout` is deliberately NOT matched: `Easy::timeout` is a pure CURLOPT_TIMEOUT setter
717 // sharing the leaf — an under-report on the rare event-loop kick beats mis-tagging every consumer
718 // that sets a timeout. (Consumer-side companion to the curl_* FFI tier, same A/B finding.)
719 if crate_name == "curl"
720 && (path.ends_with("::perform")
721 || path.ends_with("::send")
722 || path.ends_with("::recv")
723 || path.ends_with("::upkeep")
724 || path.ends_with("::action"))
725 {
726 return Some("Net");
727 }
728 // The modern async-HTTP / TLS / QUIC / DNS stack — the LAYER reqwest/ureq/isahc build on, and that
729 // crates use DIRECTLY. Found by the independent-method differential on `oha` (2026-06-17): candor
730 // honestly DISCLOSED these as blind but never CLASSIFIED them, leaving real Net reaches uncovered.
731 // Verb-keyed (the pure type/builder/codec surface stays None) and CRATE-GATED, so generic verbs
732 // (request/connect/get/read/write/accept) never fabricate across unrelated crates. Same precision
733 // discipline as the reqwest/curl rules above; complements the scan_builder_entry_effect entries.
734 match crate_name {
735 // hyper 1.x client connection I/O (the builder/Body/Request types stay pure).
736 "hyper" if path.ends_with("::send_request") || path.ends_with("::handshake") => return Some("Net"),
737 // hyper-util's pooled legacy Client + its TCP connectors.
738 "hyper_util" if path.ends_with("::request") || path.ends_with("::connect") => return Some("Net"),
739 // hickory (trust-dns) resolver — issues DNS queries over the network.
740 "hickory_resolver"
741 if path.ends_with("::lookup_ip") || path.ends_with("::lookup") || path.ends_with("_lookup")
742 || path.ends_with("::resolve") => return Some("Net"),
743 // HTTP/3 over QUIC.
744 "h3" if path.ends_with("::send_request") || path.ends_with("::recv_data")
745 || path.ends_with("::recv_response") || path.ends_with("::send_data") => return Some("Net"),
746 // QUIC transport (UDP socket send/recv): connection setup, datagrams, AND the stream byte I/O
747 // (`RecvStream::read*` / `SendStream::write*` / `finish`). Opening a stream is caught above, but a
748 // fn that only HOLDS a stream and reads/writes it would otherwise read silent-pure (review: a Net
749 // under-report). Crate-gated to quinn, where these verbs are unambiguously the socket I/O.
750 "quinn" if path.ends_with("::connect") || path.ends_with("::accept") || path.ends_with("::open_bi")
751 || path.ends_with("::open_uni") || path.ends_with("::accept_bi") || path.ends_with("::accept_uni")
752 || path.ends_with("::send_datagram") || path.ends_with("::read_datagram")
753 || path.ends_with("::read") || path.ends_with("::read_chunk") || path.ends_with("::read_chunks")
754 || path.ends_with("::read_to_end") || path.ends_with("::write") || path.ends_with("::write_all")
755 || path.ends_with("::write_chunk") || path.ends_with("::write_chunks")
756 || path.ends_with("::finish") => return Some("Net"),
757 // TLS-over-TCP stream adapters — the actual socket handshake/I/O (the config/cert types stay pure).
758 "tokio_rustls" | "native_tls"
759 if path.ends_with("::connect") || path.ends_with("::accept") || path.ends_with("::handshake") =>
760 return Some("Net"),
761 // AF_VSOCK host<->guest sockets — inter-process / VM comms.
762 "tokio_vsock" if path.ends_with("::connect") || path.ends_with("::bind") || path.ends_with("::accept") =>
763 return Some("Ipc"),
764 // Loads the OS trust store from disk (cert files / keychain).
765 "rustls_native_certs" if path.ends_with("::load_native_certs") => return Some("Fs"),
766 // `rlimit` reads/mutates the process's kernel resource limits — the closest bucket is Env (host/
767 // process config); no dedicated process-state bucket exists, so getrlimit (read) and setrlimit
768 // (mutate) share it. NOTE: `num_cpus::get`/`get_physical` are deliberately NOT modeled — asking the
769 // OS for the CPU count is a near-pure topology query, and std's equivalent `thread::
770 // available_parallelism` classifies pure; modeling it as Env would spray Env over every thread-pool
771 // constructor (review: a high-noise over-report) for no capability a reviewer cares about.
772 "rlimit" if path.ends_with("::getrlimit") || path.ends_with("::setrlimit")
773 || path.ends_with("::increase_nofile_limit") => return Some("Env"),
774 // rustls — the SYNC TLS core (tokio_rustls/native_tls above are the async/system adapters). The
775 // record-layer I/O is `read_tls`/`write_tls` (pull/push raw bytes through a held `io::Read`/`Write`)
776 // and `complete_io` (loops them until the handshake/buffers drain). The config/cert/builder types
777 // (`ClientConfig`/`ServerConfig`/`ConfigBuilder`) are PURE. `process_new_packets` is deliberately
778 // EXCLUDED — it only decrypts ALREADY-buffered bytes (no socket touch; docs say call it AFTER
779 // read_tls), so flagging it would over-report Net on the pure decrypt step.
780 "rustls" if path.ends_with("::read_tls") || path.ends_with("::write_tls")
781 || path.ends_with("::complete_io") => return Some("Net"),
782 // native-tls under its alternate crate name + the tokio async wrapper (the `native_tls` arm above
783 // is the common name). The TLS handshake over a TcpStream is Net; the builder/cert types are pure.
784 "native_tls_crate" | "tokio_native_tls"
785 if path.ends_with("::connect") || path.ends_with("::accept")
786 || path.ends_with("::handshake") => return Some("Net"),
787 _ => {}
788 }
789 // THE COVERAGE-GATE SWEEP (2026-08-27) — two crate-root CONSTRUCTORS the verb-keyed match above
790 // never reached because neither ends in connect/accept/handshake, verified against their real
791 // per-platform impl (native-tls 0.2.18, rustls 0.23.43):
792 //
793 // `native_tls_crate::TlsConnector::new` (lib.rs:481, the PUBLIC newtype wrapper around the private
794 // per-platform `imp::TlsConnector`) reads the system trust store — on the openssl backend it loads
795 // the probed cert file/dir from disk before any socket exists. `Identity::from_pkcs8` (lib.rs:178,
796 // wrapping `imp::Identity::from_pkcs8`) on the security-framework (macOS) backend creates a real
797 // temporary keychain file on disk to import the PKCS8 key into.
798 // `rustls::KeyLogFile::new` (key_log_file.rs:88) reads `$SSLKEYLOGFILE` and, if set, opens (creating
799 // if needed) that file in append mode — a real, if opt-in, disk write path independent of the sync
800 // TLS record-layer I/O the `rustls` arm above already covers.
801 if crate_name == "native_tls_crate"
802 && (path.ends_with("TlsConnector::new") || path.ends_with("Identity::from_pkcs8"))
803 {
804 return Some("Fs");
805 }
806 if crate_name == "rustls" && path.ends_with("KeyLogFile::new") {
807 return Some("Fs");
808 }
809 // Message-queue clients fully encapsulate the socket (the underlying tokio::net lives
810 // inside the crate, unseen), so a user's connect/publish/consume calls ARE the I/O
811 // boundary — to a remote broker, hence Net. Match the broker round-trip verbs (snake_case
812 // methods); the CamelCase option/property builders stay pure. (Found hardening on consumer
813 // apps: lapin `basic_publish`/`queue_declare` and async-nats `publish`/`subscribe` were
814 // classified pure — a message-queue client reporting no I/O.)
815 if crate_name == "async_nats" {
816 if path.ends_with("::connect")
817 || path.contains("::publish")
818 || path.ends_with("::subscribe")
819 || path.ends_with("::queue_subscribe")
820 || path.contains("::request")
821 || path.ends_with("::flush")
822 {
823 return Some("Net");
824 }
825 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against async-nats 0.35.1 (the version this
826 // ratchet's rows were generated from — 0.50.0, also cached locally, removed/relocated this
827 // exact surface, a separate question for a future pass):
828 //
829 // `connect_with_options` (lib.rs:891) is the crate's OWN real entry point `connect()` calls one
830 // hop down — the actual handshake, missing because the verb list above matches the bare
831 // `::connect` suffix, not the `_with_options` sibling.
832 // `ConnectOptions::credentials_file`/`with_credentials_file` (options.rs:417,438) load a real
833 // credentials file off disk via `auth_utils::load_creds` before authenticating — Fs, not Net
834 // (the network round-trip these feed happens later, at `connect`).
835 // `ServerAddr::socket_addrs` (lib.rs:1473) does `tokio::net::lookup_host` — a real DNS query.
836 if path == "async_nats::connect_with_options" {
837 return Some("Net");
838 }
839 if path == "async_nats::ConnectOptions::credentials_file"
840 || path == "async_nats::ConnectOptions::with_credentials_file"
841 {
842 return Some("Fs");
843 }
844 if path == "async_nats::ServerAddr::socket_addrs" {
845 return Some("Net");
846 }
847 return None;
848 }
849 if crate_name == "lapin" {
850 if path.ends_with("::connect")
851 || path.ends_with("::create_channel")
852 || path.contains("::basic_")
853 || path.contains("::queue_")
854 || path.contains("::exchange_")
855 || path.contains("::tx_")
856 || path.ends_with("::confirm_select")
857 || path.ends_with("::close")
858 {
859 return Some("Net");
860 }
861 return None;
862 }
863 // SMTP email — lettre's `Transport::send` is the network dispatch; Message building is
864 // pure. (Found hardening on a lettre consumer: `mailer.send(&email)` classified pure.)
865 //
866 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against lettre 0.11.23 source — the TLS-setup and
867 // connection-establishment family this crate's own `send` rule never reached:
868 //
869 // `TlsParameters::new`/`new_rustls` (client/tls.rs:598,617) and `TlsParametersBuilder::build`/
870 // `build_rustls` (tls.rs:325,446) — `build_rustls` calls `rustls_native_certs::load_native_certs()`
871 // (a real OS-trust-store read) plus `tracing::debug!`; `build()` dispatches to it under the
872 // (textually-visible, cfg-gated) `rustls` feature. `SmtpTransport`/`AsyncSmtpTransport::{from_url,
873 // relay,starttls_relay}` (transport.rs:90,114,243; async_transport.rs:135,169,306) each construct a
874 // `TlsParameters` directly (`relay`/`starttls_relay`) or via the crate's OWN `pub(crate)
875 // from_connection_url` (`from_url`) — real TLS setup a consumer never spells `TlsParameters` to
876 // reach. `FileTransport::read` (transport/file/mod.rs:216) calls `std::fs::read` directly.
877 if crate_name == "lettre" {
878 if path.ends_with("::send") || path.ends_with("::send_raw") {
879 return Some("Net");
880 }
881 if path == "lettre::TlsParametersBuilder::build"
882 || path == "lettre::transport::smtp::client::TlsParametersBuilder::build"
883 || path == "lettre::TlsParametersBuilder::build_rustls"
884 || path == "lettre::transport::smtp::client::TlsParametersBuilder::build_rustls"
885 || path == "lettre::TlsParameters::new"
886 || path == "lettre::transport::smtp::client::TlsParameters::new"
887 || path == "lettre::TlsParameters::new_rustls"
888 || path == "lettre::transport::smtp::client::TlsParameters::new_rustls"
889 || path == "lettre::SmtpTransport::from_url"
890 || path == "lettre::SmtpTransport::relay"
891 || path == "lettre::SmtpTransport::starttls_relay"
892 || path == "lettre::AsyncSmtpTransport::from_url"
893 || path == "lettre::AsyncSmtpTransport::relay"
894 || path == "lettre::AsyncSmtpTransport::starttls_relay"
895 || path == "lettre::FileTransport::read"
896 {
897 return Some("Fs");
898 }
899 // `AsyncStd1Executor::{connect,fs_read,fs_write}` (executor.rs:223,260,265, the sealed
900 // `Executor` trait impl — `#[doc(hidden)]` in lettre's own source, so this is a narrower, less-
901 // visited surface than the others above, but genuinely reachable: both the trait and the type
902 // are `pub`, re-exported at the crate root) dial a real connection / read+write real files.
903 // `AsyncSmtpConnection`/`AsyncNetworkStream::connect_asyncstd1` (client/async_connection.rs:121,
904 // client/async_net.rs:207) dial a real `async-std` TCP connection — reachable only via the
905 // module-qualified path (`transport::smtp::client::`), since neither type is re-exported at the
906 // crate root (unlike `TlsParameters`'s sibling forms, which at least reach `client::` — the dual
907 // spellings above, same shape as rusqlite's `Blob`/`Backup`).
908 if path == "lettre::AsyncStd1Executor::connect"
909 || path == "lettre::AsyncSmtpConnection::connect_asyncstd1"
910 || path == "lettre::transport::smtp::client::AsyncSmtpConnection::connect_asyncstd1"
911 || path == "lettre::AsyncNetworkStream::connect_asyncstd1"
912 || path == "lettre::transport::smtp::client::AsyncNetworkStream::connect_asyncstd1"
913 {
914 return Some("Net");
915 }
916 if path == "lettre::AsyncStd1Executor::fs_read" || path == "lettre::AsyncStd1Executor::fs_write" {
917 return Some("Fs");
918 }
919 // NOT included: `NetworkStream::{shutdown,set_read_timeout,set_write_timeout}` (client/net.rs)
920 // LOOK like three more hits (self-scan flags them too, propagating from the coarse
921 // `std::net::TcpStream` whole-handle rule) but `mod net;` (client/mod.rs:50) is PRIVATE and
922 // `NetworkStream` is never re-exported (only imported crate-internally via a bare `use
923 // self::net::NetworkStream;`, mod.rs:35) — no external consumer can ever name the type, so no
924 // rule was added despite self-scan flagging it (the same `InnerConnection`/`RawStatement` shape
925 // as the rusqlite fix elsewhere in this file).
926 return None;
927 }
928 // WebSockets — tungstenite (the modern successor to the old `websocket` crate). connect
929 // and the socket read/write/send are network; Message constructors are pure. (Found on a
930 // tungstenite consumer: connect + send + read classified pure.)
931 //
932 // THE FIX: `connect` only names the URL-dialing client entry point. tungstenite ALSO ships the
933 // stream-first client/server handshake free functions — `client`/`client_with_config` (client.rs:176,
934 // 152: "use this if you need a nonblocking handshake ... or a custom stream") and
935 // `accept`/`accept_with_config`/`accept_hdr`/`accept_hdr_with_config` (server.rs:23-63) — each of
936 // which does `{Client,Server}Handshake::start(stream, ..).handshake()`, the REAL WS upgrade
937 // handshake read/write over an already-open stream. These are the documented way to run tungstenite
938 // over a caller-managed TCP/TLS/mio stream (exactly the shape most async runtimes use, since
939 // tungstenite itself is sync/transport-agnostic) — missing from a verb list keyed only on the
940 // dial-it-yourself `connect` spelling. Same vein as `git2::Submodule::clone`/`ignore::Walk::new`: an
941 // established effect's SIBLING entry point absent from the allowlist.
942 if crate_name == "tungstenite" {
943 if path.ends_with("::connect")
944 || path.ends_with("::connect_with_config")
945 || path.ends_with("::client")
946 || path.ends_with("::client_with_config")
947 || path.ends_with("::client_tls")
948 || path.ends_with("::client_tls_with_config")
949 || path.ends_with("::accept")
950 || path.ends_with("::accept_with_config")
951 || path.ends_with("::accept_hdr")
952 || path.ends_with("::accept_hdr_with_config")
953 || path.ends_with("::read")
954 || path.ends_with("::write")
955 || path.ends_with("::send")
956 || path.ends_with("::close")
957 || path.ends_with("::flush")
958 || path.ends_with("::read_message")
959 || path.ends_with("::write_message")
960 {
961 return Some("Net");
962 }
963 return None;
964 }
965 // elasticsearch: request builders are pure; only the `.send()` dispatch is HTTP I/O
966 // (same shape as reqwest / the AWS SDK). (Found on an elasticsearch consumer.)
967 if crate_name == "elasticsearch" && path.ends_with("::send") {
968 return Some("Net");
969 }
970 // gRPC — tonic. The transport connect and the Grpc client RPC dispatch are network;
971 // codecs and request/response wrappers are pure. (connect repro-confirmed on a consumer;
972 // the unary/streaming RPC verbs are from the tonic::client::Grpc API.)
973 //
974 // THE COVERAGE-GATE FIX: the rule above only ever covered the CLIENT half. `Router::serve`/
975 // `::serve_with_shutdown` (transport/server/mod.rs:783,811) are the SERVER's own most common entry
976 // points — a tonic quickstart's last line — and both bind a real listening socket via
977 // `TcpIncoming::new` (incoming.rs:197: `StdTcpListener::bind(addr)`), the crate's other bare-`pub`
978 // constructor (`Server<L>::serve_with_shutdown` at mod.rs:530 is a DIFFERENT, `pub(crate)` method on
979 // a different receiver — not reachable, and not this one). A `deny Net` on a gRPC SERVER app missed
980 // its own listen call entirely.
981 if crate_name == "tonic" {
982 if path.ends_with("::connect")
983 || path.ends_with("::unary")
984 || path.ends_with("::server_streaming")
985 || path.ends_with("::client_streaming")
986 || path.ends_with("::streaming")
987 || path.ends_with("::Router::serve")
988 || path.ends_with("::Router::serve_with_shutdown")
989 || path == "tonic::TcpIncoming::new"
990 || path == "tonic::transport::server::TcpIncoming::new"
991 {
992 return Some("Net");
993 }
994 return None;
995 }
996 // Kafka — rdkafka (FFI to librdkafka). Producer send + consumer poll/recv/subscribe/
997 // commit are network round-trips to the brokers. (API-calibrated + unit-tested; a real
998 // repro needs librdkafka/cmake, deferred.)
999 if crate_name == "rdkafka" {
1000 if path.ends_with("::send")
1001 || path.ends_with("::send_result")
1002 || path.ends_with("::recv")
1003 || path.ends_with("::poll")
1004 || path.ends_with("::subscribe")
1005 || path.ends_with("::commit")
1006 || path.ends_with("::commit_message")
1007 || path.ends_with("::commit_consumer_state")
1008 || path.ends_with("::store_offset")
1009 || path.ends_with("::seek")
1010 || path.ends_with("::fetch_metadata")
1011 || path.ends_with("::fetch_watermarks")
1012 || path.ends_with("::flush")
1013 {
1014 return Some("Net");
1015 }
1016 return None;
1017 }
1018 // cap-std: capability-oriented std. I/O goes *through* a held capability handle
1019 // (Dir/Pool/Clock/...), so these calls ARE the effect. Recognising them means a
1020 // cap-std project's real I/O is detected and matches the capability it declared
1021 // (via `declared_caps`/`capstd_cap`) — conformance against unforgeable capabilities.
1022 if crate_name.starts_with("cap_") {
1023 if path.contains("::net::Unix") || path.contains("::os::") {
1024 return Some("Ipc");
1025 }
1026 if path.contains("::net") {
1027 return Some("Net");
1028 }
1029 if path.contains("::time") {
1030 return Some("Clock");
1031 }
1032 if path.contains("::fs") || crate_name == "cap_tempfile" || crate_name == "cap_directories" {
1033 return Some("Fs");
1034 }
1035 return None;
1036 }
1037 // Local IPC (Unix-domain sockets) is I/O but not *network* — keep it distinct so
1038 // CANDOR_NO_AMBIENT and audits don't conflate it with internet access. async-std puts its
1039 // Unix sockets under `os::unix::net` (mirroring std); async-net (smol's net layer) under
1040 // `unix`.
1041 if path.starts_with("tokio::net::Unix")
1042 || path.starts_with("std::os::unix::net")
1043 || path.starts_with("async_std::os::unix::net")
1044 || path.starts_with("async_net::unix")
1045 {
1046 return Some("Ipc");
1047 }
1048 // Raw packet capture / raw sockets — libpnet (the dominant low-level networking crate; powers
1049 // bandwhich, sniffers, custom-protocol tools). `datalink::channel` opens an L2 socket and
1050 // `transport::transport_channel` an L3/L4 raw socket — both ARE network I/O. Packet construction
1051 // (pnet_packet / pnet_base, MacAddr, Ethernet frames…) is pure and stays unclassified. The actual
1052 // frame read/write happens via methods on the returned Sender/Receiver (trait-object dispatch the
1053 // syntactic backend can't resolve), so the channel-open call is the precise Net boundary. (Found
1054 // scanning bandwhich — a packet sniffer — which reported Net 0.)
1055 if crate_name == "pnet" || crate_name == "pnet_datalink" || crate_name == "pnet_transport" {
1056 if path.ends_with("::channel") || path.ends_with("::transport_channel") {
1057 return Some("Net");
1058 }
1059 return None;
1060 }
1061 // Directory traversal — `ignore` (BurntSushi's gitignore-aware walker; powers ripgrep, fd). The walk
1062 // EXECUTORS read the directory tree from disk = Fs. Type-precise on purpose: the configuration builders
1063 // (`OverrideBuilder::build`, `GitignoreBuilder::build`, the `WalkBuilder` setters) and `DirEntry`
1064 // accessors are PURE — only `WalkBuilder::build`/`build_parallel` (which kick off the walk) and
1065 // `WalkParallel::run` (which drives it) touch the filesystem. A bare `build` would wrongly flag the
1066 // config builders. (Found scanning fd — a file finder — which reported Fs 2: its own `fs::read_dir`
1067 // was caught, but the `ignore`-based traversal that IS fd was invisible cross-crate.)
1068 //
1069 // `Walk::new`/`Walk::from_iter` are the crate's own documented convenience constructors — literally
1070 // `WalkBuilder::new(path).build()` / `WalkBuilder::from_iter(paths).build()` in `ignore`'s own source
1071 // (walk.rs:1128-1146) — but a verb list keyed only on `WalkBuilder::build`/`build_parallel`/
1072 // `WalkParallel::run`/`add_ignore` never sees `Walk::new(root)` (the crate's own top-level doc example
1073 // and the SAME shape as the already-fixed `walkdir::WalkDir::new`/`git2::Repository::clone`: a
1074 // documented public entry point that reaches the modelled effect through a wrapper the allowlist
1075 // didn't name). `deny Fs` over `for e in Walk::new(root) { .. }` exited 0. Charged at the SAME
1076 // construction site as `WalkBuilder::build` — no receiver typing needed, it's a plain `Expr::Call` on
1077 // `ignore::Walk::new`/`ignore::Walk::from_iter`, crate-gated so an unrelated `Walk::new` (a different
1078 // crate, or a local type) cannot match this arm.
1079 if crate_name == "ignore" {
1080 if path == "ignore::WalkBuilder::build"
1081 || path == "ignore::WalkBuilder::build_parallel"
1082 || path.ends_with("::WalkParallel::run")
1083 // `add_ignore(path)` LOOKS like a config setter but reads that ignore file from disk at call
1084 // time (it returns the read error) — unlike the pure `add_custom_ignore_filename(name)` which
1085 // only stores a filename string. The lone Fs-touching builder method in the otherwise-pure setter
1086 // surface, so it was silently pure under the covered-crate floor.
1087 || path == "ignore::WalkBuilder::add_ignore"
1088 || path == "ignore::Walk::new"
1089 || path == "ignore::Walk::from_iter"
1090 {
1091 return Some("Fs");
1092 }
1093 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against ignore 0.4.33 — the gitignore-matcher
1094 // construction family, a DIFFERENT public surface from the walk-builder family above:
1095 //
1096 // `Gitignore::new` (gitignore.rs:103) opens and reads the given file directly via
1097 // `GitignoreBuilder::add`. `Gitignore::global` (gitignore.rs:141) reads `$PWD` then delegates to
1098 // `GitignoreBuilder::new(cwd).build_global()`. `GitignoreBuilder::add` (gitignore.rs:405) is the
1099 // crate's own per-file loader — `File::open` + line-by-line read — the same shape `add_ignore`
1100 // above was fixed for, on the SIBLING type. `GitignoreBuilder::build_global`/
1101 // `gitconfig_excludes_path` (gitignore.rs:377,583) resolve git's global excludesfile: read
1102 // `GIT_CONFIG_GLOBAL`/`XDG_CONFIG_HOME`/`HOME` env vars, then open and parse whichever gitconfig
1103 // file they point at. `WalkBuilder::build_matchers` (walk.rs:675) is the incremental-walk
1104 // sibling of the already-covered `build_parallel` — same `self.build_ignore()` call, different
1105 // caller-facing verb. `IncrementalIgnore::matched`/`matched_with_errors` (incremental.rs:194,213)
1106 // are the newer per-directory incremental-walk API: each lazily loads that directory's ignore
1107 // files (and, when `max_filesize` is set, `stat`s the candidate file) as the walk descends.
1108 //
1109 // `Gitignore`/`GitignoreBuilder`/`gitconfig_excludes_path` live in `pub mod gitignore;`
1110 // (lib.rs:59) but — unlike `Walk`/`WalkBuilder` two lines up (`pub use crate::walk::{..}`,
1111 // lib.rs:52) — are NOT re-exported at the crate root, so the only real, compilable spelling is
1112 // the module-qualified one (proven by a consumer fixture: `ignore::Gitignore::new` does not
1113 // exist, `ignore::gitignore::Gitignore::new` does). `IncrementalIgnore` IS re-exported at the
1114 // root (`pub use crate::incremental::{..}`, lib.rs:51), so the bare form is correct there.
1115 if path == "ignore::gitignore::Gitignore::new"
1116 || path == "ignore::gitignore::Gitignore::global"
1117 || path == "ignore::gitignore::GitignoreBuilder::add"
1118 || path == "ignore::gitignore::GitignoreBuilder::build_global"
1119 || path == "ignore::gitignore::gitconfig_excludes_path"
1120 || path == "ignore::WalkBuilder::build_matchers"
1121 || path == "ignore::IncrementalIgnore::matched"
1122 || path == "ignore::IncrementalIgnore::matched_with_errors"
1123 {
1124 return Some("Fs");
1125 }
1126 return None;
1127 }
1128 // Filesystem watching — `notify` (the de-facto fs-watch crate: watchexec, cargo-watch, mdbook). A
1129 // watcher opens an OS notification handle (inotify / FSEvents / kqueue / ReadDirectoryChanges) and
1130 // registers paths — observing filesystem state changes = Fs. The lifecycle boundary: any
1131 // `*Watcher::new` constructor (RecommendedWatcher/PollWatcher/INotifyWatcher/FsEventWatcher/…), the
1132 // `recommended_watcher` convenience fn, and the `watch`/`unwatch` registration verbs. `Config`/`Event`/
1133 // `EventKind` data types stay pure. (Found scanning watchexec: its watcher-`create` read Fs 0.)
1134 if crate_name == "notify" {
1135 if path.ends_with("Watcher::new")
1136 || path.ends_with("::recommended_watcher")
1137 || path.ends_with("::watch")
1138 || path.ends_with("::unwatch")
1139 // `ReadDirectoryChangesWatcher::create` (windows.rs:475, `pub use windows::
1140 // ReadDirectoryChangesWatcher` at the crate root) is a SECOND, directly-callable
1141 // constructor distinct from the `Watcher::new` trait method above — it takes a raw
1142 // meta-event channel `Watcher::new`'s impl builds internally by calling THIS same
1143 // `create`, then spawns the real ReadDirectoryChangesW watch server thread.
1144 || path.ends_with("ReadDirectoryChangesWatcher::create")
1145 {
1146 return Some("Fs");
1147 }
1148 return None;
1149 }
1150 // std DNS resolution — `("host", 80).to_socket_addrs()` / `std::net::lookup_host("host")` perform a
1151 // real getaddrinfo query (Net), but the classify table covered only the socket I/O *types*, so they
1152 // floored silently (sweep [37]; the syntactic engine modelled DNS only at the libc layer).
1153 if path.ends_with("::to_socket_addrs")
1154 || path == "std::net::lookup_host"
1155 || path.ends_with("ToSocketAddrs::to_socket_addrs")
1156 {
1157 return Some("Net");
1158 }
1159 // Raw sockets. Match the I/O *types* only — `std::net` also holds pure data types
1160 // (SocketAddr, IpAddr, …) whose construction must NOT be flagged.
1161 if path.starts_with("std::net::TcpStream")
1162 || path.starts_with("std::net::TcpListener")
1163 || path.starts_with("std::net::UdpSocket")
1164 || path.starts_with("tokio::net::")
1165 {
1166 // …but the PURE accessors read back local/option state — no network I/O — so the whole-type Net
1167 // rule fabricated Net on them (sweep [24], the precision failure; mirrors the arboard/memmap2 accessor
1168 // carve-outs). local_addr/peer_addr return bound/connected addresses; nodelay/ttl/take_error read
1169 // socket options/state. Every genuine verb (connect/read/write/send/recv/accept) stays Net.
1170 if path.ends_with("::local_addr")
1171 || path.ends_with("::peer_addr")
1172 || path.ends_with("::nodelay")
1173 || path.ends_with("::ttl")
1174 || path.ends_with("::take_error")
1175 {
1176 return None;
1177 }
1178 return Some("Net");
1179 }
1180 // Legacy tokio 0.1 socket crates — `tokio_tcp`/`tokio_udp` are *entirely* networking
1181 // (no pure types to over-flag), so the whole crate is Net. (Found hardening on websocat,
1182 // which is still on tokio 0.1: its `tokio_tcp::TcpStream::connect` was classified
1183 // network-free — a network tool confidently reporting 0 Net.)
1184 if matches!(crate_name, "tokio_tcp" | "tokio_udp") {
1185 return Some("Net");
1186 }
1187 // The other async runtimes mirror tokio's module layout, and their `net` modules hold only
1188 // socket I/O types (the pure `SocketAddr`/`IpAddr` are re-exports that resolve to `std::net`,
1189 // so they're excluded by def-path). `mio` is the low-level non-blocking-socket layer under
1190 // tokio/others; `async_net` is smol's net crate. Closes the async-std/smol/mio gap the
1191 // tokio_tcp note flagged. (Calibrated by module structure — these crates ARE networking — not
1192 // a live repro; the TCP/UDP types are defined in-crate so the def-path prefix is exact.)
1193 if path.starts_with("async_std::net::")
1194 || path.starts_with("mio::net::")
1195 || crate_name == "async_net"
1196 {
1197 return Some("Net");
1198 }
1199 // Database clients. Like the AWS/HTTP builders, only the execution verbs are I/O;
1200 // query *construction* is pure. Best-effort across crates (tune via CANDOR_CONFIG).
1201 // Note: bare `::query` is deliberately omitted — it executes in postgres/rusqlite but
1202 // only *builds* in sqlx, so including it would false-positive sqlx's `query()` builder.
1203 if DB_CRATES.contains(&crate_name) {
1204 // Postgres / SQLite-family clients: `query`/`batch_execute`/`prepare`/etc. ARE the
1205 // execution (round-trips to the server). sqlx is the outlier where bare `query()`
1206 // only BUILDS — it keeps the narrow set below. (Found by running on a real
1207 // tokio-postgres app, pgman: candor had reported only 4 of ~20 DB call sites.)
1208 if matches!(crate_name, "postgres" | "tokio_postgres" | "deadpool_postgres" | "rusqlite") {
1209 const PG: [&str; 20] = [
1210 "::query", "::query_one", "::query_opt", "::query_raw", "::execute",
1211 "::batch_execute", "::simple_query", "::prepare", "::prepare_typed",
1212 "::copy_in", "::copy_out", "::transaction", "::connect",
1213 // `Config::connect_raw` (tokio-postgres config.rs:739) does the SAME protocol handshake
1214 // as `Config::connect` over a caller-supplied stream (a Unix socket, a TLS-terminated
1215 // proxy) instead of dialing one itself — real Db/Net I/O, missing because the verb
1216 // doesn't end in the plain `connect` spelling.
1217 "::connect_raw",
1218 // rusqlite's dialect of the same verbs (a verb-probe found the CANONICAL rusqlite
1219 // consumer API classifying pure): `query_row` is the one-row read, `query_map`/
1220 // `query_and_then` the many-row reads, `execute_batch` is rusqlite's name for
1221 // batch_execute, `prepare_cached` round-trips like prepare. `query_typed` is
1222 // tokio_postgres 0.7.10+.
1223 "::query_row", "::query_map", "::query_and_then", "::execute_batch",
1224 "::prepare_cached", "::query_typed",
1225 ];
1226 if PG.iter().any(|v| path.ends_with(v)) {
1227 return Some("Db");
1228 }
1229 // THE COVERAGE-GATE SWEEP (2026-08-27): `CancelToken::cancel_query` (tokio-postgres
1230 // cancel_token.rs:34) opens a BRAND NEW connection to the server to send a raw CancelRequest
1231 // packet — real socket I/O, but not a query round-trip on an existing connection like the PG
1232 // verb list above, so bucketed `Net` rather than `Db`.
1233 if crate_name == "tokio_postgres" && path.ends_with("CancelToken::cancel_query") {
1234 return Some("Net");
1235 }
1236 // rusqlite only: opening the database IS the connection establishment (`Connection::
1237 // open`/`open_in_memory`/`open_with_flags` — the embedded analog of `::connect`).
1238 //
1239 // THE FIX: rusqlite 0.32's `Connection` type has SIX `open*` constructors, not three —
1240 // `open_with_flags_and_vfs` and `open_in_memory_with_flags_and_vfs` call the real
1241 // `InnerConnection::open_with_flags` (the sqlite3_open_v2 FFI) directly (lib.rs:492,530), and
1242 // `open_in_memory_with_flags` (lib.rs:515) calls `Connection::open_with_flags(":memory:", ..)`
1243 // — but an exact-suffix allowlist keyed on the THREE simplest names never matches any of
1244 // them (`"open_in_memory_with_flags".ends_with("::open_with_flags")` is false: the suffix
1245 // check needs the string to END in that literal tail, and `_and_vfs`/`_with_flags` add
1246 // characters AFTER it). The same shape as `ignore::Walk::new`: a sibling spelling of an
1247 // already-modelled constructor missing from the allowlist, silently pure because rusqlite is
1248 // calibrated. Match on the `Connection::open` PREFIX rather than enumerating every suffix —
1249 // every current and future `Connection::open*` constructor opens a real handle. Scoped to the
1250 // `Connection::` segment specifically (not a bare leaf prefix) so an unrelated same-crate
1251 // `open`-prefixed method on a DIFFERENT type (e.g. the private `pragma::Sql::open_brace`,
1252 // which pushes one char to a string buffer — no I/O) cannot be swept in by accident.
1253 //
1254 // Same sighting, different constructor: `Connection::blob_open` and `Blob::reopen` call
1255 // `ffi::sqlite3_blob_open`/`sqlite3_blob_reopen` directly (blob/mod.rs:218,251) — genuinely
1256 // Db, and `sqlite3_blob_open`/`sqlite3_blob_reopen` are already in this file's own FFI-leaf DB
1257 // table above (matched only when a caller names the raw `ffi::` leaf directly) — but rusqlite's
1258 // own documented safe wrapper, the incremental-BLOB-I/O API every rusqlite blob consumer
1259 // actually calls, carried no rule of its own.
1260 if crate_name == "rusqlite"
1261 && (path.contains("::Connection::open")
1262 || path.ends_with("::Connection::blob_open")
1263 || path.ends_with("::Blob::reopen"))
1264 {
1265 return Some("Db");
1266 }
1267 // THE COVERAGE-GATE SWEEP (2026-08-27): same shape again, and the SAME root cause as
1268 // `git2`'s — this `if crate_name == "rusqlite"` block returns unconditionally, so it never
1269 // reaches this file's own `sqlite3_*` FFI-leaf table below even though every one of these
1270 // calls a leaf ALREADY listed there (`sqlite3_backup_init`/`_step`, `sqlite3_blob_read`/
1271 // `_write`). Verified against rusqlite 0.32.1 source. `Backup::new`/`new_with_names` are the
1272 // crate's own online-backup constructors (backup.rs:187,200); `step`/`run_to_completion`
1273 // drive it; `Connection::backup`/`restore` (backup.rs:61,99) are the one-call convenience
1274 // wrappers around the same API and need their OWN rule since a consumer calling THEM never
1275 // names `Backup` at all. `Blob::{read,write}_at[_exact]`/`raw_read_at[_exact]`/
1276 // `write_all_at` (blob/pos_io.rs) are the incremental-BLOB positional-I/O methods, each
1277 // calling `ffi::sqlite3_blob_read`/`_write` directly. `Connection::from_handle`/
1278 // `_owned`/`extension_init2` and the free fn `init_auto_extension` (lib.rs:950,999,966;
1279 // auto_extension.rs:26) wrap a caller-supplied raw `*mut ffi::sqlite3` into a live
1280 // `Connection` — the loadable-extension entry points, same "produces a live connection"
1281 // effect as `Connection::open`, just over a handle the caller already has instead of one
1282 // this call opens itself (the `tokio_postgres::connect_raw`/`sea_orm::connect_proxy` shape).
1283 //
1284 // NOT included: `InnerConnection::*` and `RawStatement::step` LOOK like six more hits (self-
1285 // scan flags them too) but `mod inner_connection;`/`mod raw_statement;` (lib.rs:119,127) are
1286 // PRIVATE — despite `pub struct InnerConnection`/`RawStatement`, neither type has any public
1287 // path a real consumer could ever name or obtain a value of, so no rule was added for them
1288 // (a rule would be dead weight, never reachable). `Context::get_connection` (functions.rs)
1289 // is ALSO left out, deliberately: it returns a `ConnectionRef` accessor to an
1290 // ALREADY-established connection (`ffi::sqlite3_context_db_handle`, not an I/O verb) — the
1291 // same "handle accessor, not a syscall" shape as `TcpStream::local_addr`, not confirmed
1292 // enough to charge without risking the over-charge this file's history has repeatedly warned
1293 // against. THE COVERAGE-GATE SWEEP (2026-08-27) resolved this from "left in the ratchet"
1294 // to a formal call: `REVIEWED_PURE_ENTRIES` now carries `("rusqlite",
1295 // "rusqlite::Context::get_connection")` so the gate stops re-flagging it every refresh.
1296 //
1297 // TWO SPELLINGS EACH for `Backup`/`Blob`/`init_auto_extension`: none of the three is
1298 // re-exported at rusqlite's crate root (unlike `Connection`, declared directly in lib.rs), so
1299 // the ONLY real spelling a consumer can write is the module-qualified one
1300 // (`rusqlite::backup::Backup::new`, `rusqlite::blob::Blob::read_at`,
1301 // `rusqlite::auto_extension::init_auto_extension`) — but the coverage-gate ratchet recorded
1302 // the SHORT, technically-unreachable guess (`generate.py` always adds a bare
1303 // `{crate}::{Type}::{fn}` guess as a possible root-alias, without confirming a `pub use`
1304 // actually exists for it). Both are listed: the short form so this fix closes the exact row
1305 // the ratchet carries, the long form so it fires for what a real consumer's source (proven
1306 // against a compiling fixture) actually contains — dropping the long form would leave the
1307 // real gap open while the bookkeeping said closed.
1308 if crate_name == "rusqlite"
1309 && (path == "rusqlite::Backup::new"
1310 || path == "rusqlite::backup::Backup::new"
1311 || path == "rusqlite::Backup::new_with_names"
1312 || path == "rusqlite::backup::Backup::new_with_names"
1313 || path == "rusqlite::Backup::step"
1314 || path == "rusqlite::backup::Backup::step"
1315 || path == "rusqlite::Backup::run_to_completion"
1316 || path == "rusqlite::backup::Backup::run_to_completion"
1317 || path == "rusqlite::Connection::backup"
1318 || path == "rusqlite::Connection::restore"
1319 || path == "rusqlite::Blob::read_at"
1320 || path == "rusqlite::blob::Blob::read_at"
1321 || path == "rusqlite::Blob::read_at_exact"
1322 || path == "rusqlite::blob::Blob::read_at_exact"
1323 || path == "rusqlite::Blob::raw_read_at"
1324 || path == "rusqlite::blob::Blob::raw_read_at"
1325 || path == "rusqlite::Blob::raw_read_at_exact"
1326 || path == "rusqlite::blob::Blob::raw_read_at_exact"
1327 || path == "rusqlite::Blob::write_at"
1328 || path == "rusqlite::blob::Blob::write_at"
1329 || path == "rusqlite::Blob::write_all_at"
1330 || path == "rusqlite::blob::Blob::write_all_at"
1331 || path == "rusqlite::Connection::from_handle"
1332 || path == "rusqlite::Connection::from_handle_owned"
1333 || path == "rusqlite::Connection::extension_init2"
1334 || path == "rusqlite::init_auto_extension"
1335 || path == "rusqlite::auto_extension::init_auto_extension")
1336 {
1337 return Some("Db");
1338 }
1339 return None;
1340 }
1341 // redis: the way redis is ACTUALLY used is the high-level `Commands`/`AsyncCommands`
1342 // traits (`con.get`/`set`/`hset`/`lpush`/…) — every method is a round-trip — plus
1343 // connection establishment. The shared VERBS below only catch the low-level
1344 // `cmd("GET").query(con)`, so without this a normal redis user's calls classify as
1345 // PURE. (Found hardening on redis-rs: a fn doing `con.get`/`set` reported no effects.)
1346 if crate_name == "redis"
1347 && (path.contains("Commands::")
1348 // THE COVERAGE-GATE SWEEP (2026-08-27), a pre-existing over-report found in passing:
1349 // `path.contains("::get_connection")` is a SUBSTRING match, so it also matched
1350 // `Client::get_connection_info` (client.rs:78) — a pure accessor returning an
1351 // already-stored `&ConnectionInfo`, no round-trip at all. Verified against redis 1.6.0.
1352 || (path.contains("::get_connection") && !path.ends_with("::get_connection_info"))
1353 || path.contains("::get_async_connection")
1354 || path.contains("::get_multiplexed_async_connection")
1355 // a live `ConnectionManager` round-trips (Db), but `ConnectionManagerConfig` is a pure
1356 // in-memory builder (set_number_of_retries/set_max_delay) — exclude it (adversarial review).
1357 // `ConnectionManager::clone` is an Arc refcount bump — no Db round-trip (sweep [27]).
1358 || (path.contains("ConnectionManager") && !path.contains("ConnectionManagerConfig")
1359 && !path.ends_with("::clone"))
1360 || path.ends_with("::query")
1361 || path.ends_with("::query_async")
1362 || path.ends_with("::req_command")
1363 || path.ends_with("::req_packed_command")
1364 || path.ends_with("::req_packed_commands"))
1365 {
1366 return Some("Db");
1367 }
1368 // mongodb: a document-store API with none of the SQL verbs — the user calls
1369 // `coll.find_one`/`insert_one`/`aggregate`/… and `Client::with_uri_str`. Without
1370 // these a mongodb user's calls classify PURE. (Found hardening: a fn doing
1371 // `find_one`+`insert_one` reported no effects.) Handle accessors (name/namespace)
1372 // and option/doc builders don't match these verbs, so they stay pure.
1373 //
1374 // THE FIX: `Client::with_options(options)` IS `with_uri_str`'s own body one call down —
1375 // `with_uri_str` (client.rs:179) is literally `ClientOptions::parse(uri).await?;
1376 // Client::with_options(options)`, and `with_options` (client.rs:188) is where the topology/
1377 // monitoring actually spins up. A caller who already holds a `ClientOptions` (built
1378 // programmatically, or via `ClientOptions::parse` called separately) uses `with_options`
1379 // directly — mongodb's OWN alternate entry point for the identical effect, missing from an
1380 // allowlist keyed only on the URI-string spelling. Same shape as `ignore::Walk::new` and
1381 // diesel's `establish`: a sibling constructor of an already-modelled effect. Verified against
1382 // mongodb 3.8.1 source for both the async (`client.rs`) and sync (`sync/client.rs`) `Client`.
1383 if crate_name == "mongodb" {
1384 const MONGO: [&str; 28] = [
1385 "::with_uri_str", "::with_options", "::connect", "::find", "::find_one", "::insert_one",
1386 "::insert_many", "::update_one", "::update_many", "::delete_one",
1387 "::delete_many", "::replace_one", "::aggregate", "::count_documents",
1388 "::estimated_document_count", "::count", "::distinct", "::run_command",
1389 "::find_one_and_update", "::find_one_and_delete", "::find_one_and_replace",
1390 "::list_collections", "::list_collection_names", "::list_databases",
1391 "::list_database_names", "::create_collection", "::create_index", "::watch",
1392 ];
1393 if MONGO.iter().any(|v| path.ends_with(v)) {
1394 return Some("Db");
1395 }
1396 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against mongodb 3.8.1 — the client-side
1397 // field-level encryption (CSFLE) surface, entirely unrelated to the CRUD verb list above.
1398 //
1399 // `ClientEncryption::decrypt` (client/csfle/client_encryption.rs:195) is an ordinary `pub
1400 // async fn` that bottoms out in `CryptExecutor::run_ctx` (client/csfle/state_machine.rs:106),
1401 // a state-machine loop that — depending on state — runs a real `list_collections` query
1402 // against the key-vault database, executes a real command against `mongocryptd` (respawning
1403 // the child process if the connection drops), and fetches data keys from the key vault:
1404 // genuinely Net (and, via mongocryptd respawn, Exec), reached from a manual decrypt call, not
1405 // a guess. Suffix-matched because the real path runs through a re-export hop the ratchet's
1406 // crate-root-alias guess skips: `ClientEncryption` is defined inside `pub(crate) mod csfle;`
1407 // (client.rs:4) and reachable only via `pub use crate::client::csfle::client_encryption;` at
1408 // the crate root (lib.rs:74) — `mongodb::client_encryption::ClientEncryption`, not bare
1409 // `mongodb::ClientEncryption`. The name is specific enough within this crate that a suffix
1410 // match carries no fabrication risk (crate-gated, and `ClientEncryption` exists nowhere else
1411 // in mongodb's own source).
1412 if path.ends_with("ClientEncryption::decrypt") {
1413 return Some("Net");
1414 }
1415 // NOT modelled (removed from open.tsv, not guessed): `CreateDataKey::execute` /
1416 // `Encrypt::execute` — a DIFFERENT false-guess shape from every other row in this pass.
1417 // `CreateDataKey`/`Encrypt` (action/csfle/create_data_key.rs, action/csfle/encrypt.rs) are
1418 // the crate's real, publicly re-exported Action-builder types (`mongodb::action::csfle::
1419 // {CreateDataKey,Encrypt}`) — but the SAME effect self-scan found (the real `run_ctx` call)
1420 // lives in `impl Action for CreateDataKey<'a> { async fn execute(self) -> .. { .. } }`
1421 // (client/csfle/client_encryption/create_data_key.rs:14), which is the INPUT to the
1422 // `#[action_impl]` proc macro, not a real method: the `Action` trait (action.rs:104) declares
1423 // only `optional`, and its own doc comment says the crate's action types are "executed via
1424 // `await` (or `run` if using the sync client)" — the macro consumes this `execute` fn body
1425 // and re-emits it as `IntoFuture::into_future`, so no compiled `CreateDataKey`/`Encrypt`
1426 // value ever has a method literally named `execute` a consumer could call. Self-scan found a
1427 // real function performing a real effect; it is just never reachable under this spelling
1428 // (nor, by construction, under ANY spelling — a consumer's call site is `.await`, not a
1429 // `.execute()` method call, so candor-scan would need to resolve the `IntoFuture` desugaring
1430 // itself to see this at all, a different, deeper question than a classify() rule can answer).
1431 return None;
1432 }
1433 // mysql / mysql_async: the `query`/`exec` families + `get_conn`/`ping` execute
1434 // immediately — no build-then-execute split like sqlx, so matching `::query` is safe
1435 // here. Same DB-verb-dialect gap class as redis/mongodb; calibrated from the Queryable
1436 // API (unit-tested; a real-app repro is the remaining confirmation).
1437 //
1438 // THE FIX: `Conn::new(opts)` is each crate's OWN primary connection constructor — not a
1439 // pool helper like `get_conn`, but the raw handle. `mysql::Conn::new` (conn/mod.rs:342) calls
1440 // `conn.connect_stream()?; conn.connect()?` directly; `mysql_async::Conn::new` (conn/mod.rs:921)
1441 // returns a future that does the same handshake. Real connection establishment, same shape as
1442 // diesel's `establish`/rusqlite's `open*` — but `::new` never appeared in this verb list, so a
1443 // bare `Conn::new(opts)?` (the crate's OWN first doctest example) read pure. Scoped to the
1444 // `Conn::` segment specifically (`::new` alone is far too generic a suffix to key on blindly —
1445 // `Opts`/`Pool`/query-result types in the same two crates have their own pure `new`s).
1446 if matches!(crate_name, "mysql" | "mysql_async") {
1447 if path.ends_with("::Conn::new") {
1448 return Some("Db");
1449 }
1450 const MY: [&str; 16] = [
1451 "::query", "::query_first", "::query_iter", "::query_map", "::query_fold",
1452 "::query_drop", "::exec", "::exec_first", "::exec_iter", "::exec_map",
1453 "::exec_fold", "::exec_drop", "::exec_batch", "::prep", "::ping", "::get_conn",
1454 ];
1455 if MY.iter().any(|v| path.ends_with(v)) {
1456 return Some("Db");
1457 }
1458 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against mysql_async 0.37.0:
1459 // `Conn::from_url` (conn/mod.rs:1059) is `Conn::new(Opts::from_str(url)?)` one hop down —
1460 // the crate's OWN alternate entry point for the identical connect effect `Conn::new` above
1461 // already carries, same shape as `ignore::Walk::new`/diesel's `establish`.
1462 // `WhiteListFsHandler::handle` (local_infile_handler/builtin.rs:58, the `GlobalHandler` impl
1463 // for `LOAD DATA LOCAL INFILE`) opens a real file off the caller-supplied whitelist —
1464 // reachable at the crate root (`pub use self::local_infile_handler::{builtin::
1465 // WhiteListFsHandler, ..}`, lib.rs:500).
1466 if path == "mysql_async::Conn::from_url" {
1467 return Some("Db");
1468 }
1469 if path.ends_with("WhiteListFsHandler::handle") {
1470 return Some("Fs");
1471 }
1472 // NOT modelled (all three removed from open.tsv, not guessed): `mysql::MyTcpBuilder::connect`/
1473 // `Stream::connect_tcp`/`Stream::make_secure` (mysql, not mysql_async — io/tcp.rs, io/mod.rs,
1474 // io/tls/native_tls_io.rs). `mod io;` is PRIVATE in mysql's lib.rs with no re-export of
1475 // `MyTcpBuilder`/`Stream`, so none of the three is a path any external consumer can name —
1476 // the crate's real, externally-reachable connect effect is `Conn::new` above (already
1477 // covered), which calls into this private `io` module internally. Also NOT modelled:
1478 // `mysql_async::PathOrBuf::read` (opts/mod.rs) — `PathOrBuf` is declared in the same private
1479 // `mod opts;` whose OTHER types (`Opts`, `OptsBuilder`, …) ARE individually `pub use`-
1480 // re-exported at the crate root (lib.rs:493) but `PathOrBuf` specifically is not, so it too
1481 // is unreachable — the same private-module shape as tempfile's `imp::` functions above.
1482 return None;
1483 }
1484 // sea_orm: an ORM whose execution is split from building (like sqlx). The query
1485 // BUILDERS (`Entity::find`, `Entity::insert`) are pure; execution happens at `.all`/
1486 // `.one`/`.count`/`.stream` and `Insert/Update/Delete::exec`. The write path via an
1487 // ActiveModel (`model.insert(db)`) executes too — distinguished from the `EntityTrait`
1488 // builder by the trait in the path (`ActiveModelTrait::`). (Found hardening on a
1489 // sea_orm consumer app: `.all(db)` reads and `ActiveModel::insert` writes were pure.)
1490 if crate_name == "sea_orm" {
1491 // sea_orm RE-EXPORTS sea_query (`sea_orm::sea_query::…`), whose builder algebra collides with
1492 // the execution verbs: `Func::count(col)` builds a COUNT() expr, `Condition::all()` AND-groups
1493 // filters, `Expr::count(…)` — all PURE, none touch a db. The `::all`/`::count`/`::one` execution
1494 // rule fabricated Db on them (sweep [5]). sea_query is pure query construction end-to-end, so
1495 // exclude the whole re-exported namespace first.
1496 if path.contains("sea_query") {
1497 return None;
1498 }
1499 if path.ends_with("::all")
1500 || path.ends_with("::one")
1501 || path.ends_with("::count")
1502 || path.ends_with("::stream")
1503 || path.ends_with("::exec")
1504 || path.ends_with("::exec_with_returning")
1505 || path.ends_with("::exec_without_returning")
1506 || path.ends_with("::connect")
1507 // `Database::connect_proxy` (database/mod.rs:139, behind the `proxy` feature) is
1508 // `Database::connect`'s sibling for a caller-supplied `ProxyDatabaseTrait` backend —
1509 // same effect (produces a live `DatabaseConnection`), different verb, missing from an
1510 // allowlist keyed on the plain `connect` spelling.
1511 || path.ends_with("::connect_proxy")
1512 || path.ends_with("::execute")
1513 || path.ends_with("::execute_unprepared")
1514 || path.ends_with("::query_one")
1515 || path.ends_with("::query_all")
1516 || path.ends_with("::fetch_page")
1517 || path.ends_with("::num_items")
1518 || path.contains("ActiveModelTrait::")
1519 {
1520 return Some("Db");
1521 }
1522 // THE COVERAGE-GATE SWEEP (2026-08-27): sea_orm's TRANSACTION and PAGINATION families,
1523 // verified against sea-orm 1.1.20 source — each of these was found effectful by self-scan
1524 // under no guessed spelling this allowlist already covered.
1525 //
1526 // `DatabaseConnection::transaction`/`transaction_with_config` (db_connection.rs:302,345, the
1527 // `TransactionTrait` impl) is the crate's documented callback-transaction API
1528 // (`db.transaction(|txn| ...)`) — every sqlx-backed match arm dispatches to the
1529 // `SqlxXxxPoolConnection::transaction` methods FQN-listed below, a real BEGIN. FQN-exact
1530 // (not a bare `::transaction` suffix) because `MockDatabaseConnection`/`ProxyDatabaseConnection`
1531 // (driver/mock.rs, driver/proxy.rs, both feature-gated) have their OWN `begin`/`commit`/
1532 // `rollback`/`ping`/`transaction` methods sharing every one of these verb names — a mock
1533 // backend performs no real I/O by construction, and a caller-supplied proxy is a callback
1534 // boundary, not a provable effect; a bare suffix would fabricate Db on both.
1535 if path == "sea_orm::DatabaseConnection::transaction"
1536 || path == "sea_orm::DatabaseConnection::transaction_with_config"
1537 // BONUS, found while proving the above reachable with a real consumer fixture (not in
1538 // the original coverage-gate ratchet — self-scan's own reachability pass didn't flag
1539 // these, apparently because it doesn't track an enum match arm's payload binding as a
1540 // typed receiver, but they dispatch through the identical match-on-`self` shape as
1541 // `transaction` two lines up): `DatabaseConnection::ping` (db_connection.rs:462) and the
1542 // `TransactionTrait::begin`/`begin_with_config` impl (db_connection.rs:246,268) — a fixture
1543 // proved `ping` silently read pure with no rule at all.
1544 || path == "sea_orm::DatabaseConnection::ping"
1545 || path == "sea_orm::DatabaseConnection::begin"
1546 || path == "sea_orm::DatabaseConnection::begin_with_config"
1547 // `DatabaseTransaction::commit`/`rollback` (transaction.rs:119,159) call
1548 // `<sqlx::X as sqlx::Database>::TransactionManager::commit`/`rollback` directly — real
1549 // COMMIT/ROLLBACK, not sea_orm's OWN `::begin`/`::run` (both `pub(crate)`, unreachable).
1550 || path == "sea_orm::DatabaseTransaction::commit"
1551 || path == "sea_orm::DatabaseTransaction::rollback"
1552 // The three sqlx-backed pool connections' OWN `begin`/`ping`/`transaction` (driver/
1553 // sqlx_{mysql,postgres,sqlite}.rs) — `begin`/`transaction` acquire a pool connection and
1554 // start a real transaction, `ping` round-trips `conn.ping()` to the server.
1555 || path == "sea_orm::SqlxMySqlPoolConnection::begin"
1556 || path == "sea_orm::SqlxMySqlPoolConnection::ping"
1557 || path == "sea_orm::SqlxMySqlPoolConnection::transaction"
1558 || path == "sea_orm::SqlxPostgresPoolConnection::begin"
1559 || path == "sea_orm::SqlxPostgresPoolConnection::ping"
1560 || path == "sea_orm::SqlxPostgresPoolConnection::transaction"
1561 || path == "sea_orm::SqlxSqlitePoolConnection::begin"
1562 || path == "sea_orm::SqlxSqlitePoolConnection::ping"
1563 || path == "sea_orm::SqlxSqlitePoolConnection::transaction"
1564 // `Paginator::fetch`/`fetch_and_next`/`into_stream`/`num_pages`/`num_items_and_pages`
1565 // (executor/paginator.rs) all transitively reach `fetch_page`/`num_items` — already-
1566 // covered verbs — but each is ALSO a documented entry point a real consumer calls
1567 // directly (`cake::Entity::find().paginate(db, 50).into_stream()`, the crate's own
1568 // pagination doc example) and needs its own rule for the same reason
1569 // `Connection::backup` needed one beside `Backup::new` in rusqlite.
1570 || path == "sea_orm::Paginator::fetch"
1571 || path == "sea_orm::Paginator::fetch_and_next"
1572 || path == "sea_orm::Paginator::into_stream"
1573 || path == "sea_orm::Paginator::num_pages"
1574 || path == "sea_orm::Paginator::num_items_and_pages"
1575 // `Insert`/`Inserter`/`TryInsert::exec_with_returning_keys`/`_many` (executor/insert.rs)
1576 // are sibling spellings of the already-covered `::exec_with_returning` (same shape as
1577 // rusqlite's `open_with_flags_and_vfs` beside `open_with_flags`): each calls through to
1578 // the same underlying `exec_with_returning_keys`/`_many` executor, a real INSERT.
1579 || path.ends_with("::exec_with_returning_keys")
1580 || path.ends_with("::exec_with_returning_many")
1581 {
1582 return Some("Db");
1583 }
1584 return None;
1585 }
1586 // (Reached by sqlx + diesel — the build-vs-execute-split crates.) `first` is diesel's
1587 // LIMIT-1 round trip and `load_iter` its 2.x streaming execution; `fetch_many` is sqlx's
1588 // multi-result stream. All crate-gated, so a std `Vec::first` never resolves here.
1589 //
1590 // `establish` is diesel's OWN name for `::connect` — `Connection::establish(url)`
1591 // (connection/mod.rs:243), implemented by opening the real backend handle in
1592 // `SqliteConnection`/`PgConnection`/`MysqlConnection::establish` (sqlite/connection/mod.rs:230,
1593 // pg/connection/mod.rs:176, mysql/connection/mod.rs:158 — each does the real file-open/socket-
1594 // connect). It is diesel's canonical, and by far most common, connection entry point — every
1595 // diesel quickstart opens with it — yet it shares no verb spelling with `::connect`, so it fell
1596 // through this allowlist to `None`, and because `diesel` IS a CALIBRATED_CRATES entry the miss
1597 // produced NO `coverage.uncovered` disclosure either (that ledger is crate-level: a calibrated
1598 // crate's unmatched path reads as reviewed-pure, not as a gap) — the same silent-purity shape as
1599 // `ignore::Walk::new` above, one call away from `establish_test_transaction`/`establish_inner`,
1600 // which are diesel's own private plumbing and stay unmatched (no `::` in the allowed suffix keeps
1601 // this exact-verb, not a substring match).
1602 const VERBS: [&str; 20] = [
1603 "::execute", "::query_row", "::query_map", "::query_one", "::fetch_one",
1604 "::fetch_all", "::fetch_optional", "::fetch", "::fetch_many", "::connect",
1605 "::acquire", "::begin", "::commit", "::rollback", "::load", "::load_iter",
1606 "::first", "::get_result", "::get_results", "::establish",
1607 ];
1608 if VERBS.iter().any(|v| path.ends_with(v)) {
1609 return Some("Db");
1610 }
1611 return None;
1612 }
1613 // std::path::Path / PathBuf STAT-family methods hit the filesystem (each is a stat/readlink/
1614 // readdir syscall) — unlike the rest of the std::path surface, which is pure string manipulation
1615 // (join/file_name/extension/parent/…). Verb-precise so the scanner's receiver inference can safely
1616 // route a `path.symlink_metadata()` method call here. (A blackout screen caught gix-dir — an entire
1617 // directory WALKER — reporting ZERO Fs because all its I/O is Path-method calls; same class as
1618 // fd's residual `Path::symlink_metadata` under-report.)
1619 if let Some(m) = path
1620 .strip_prefix("std::path::Path::")
1621 .or_else(|| path.strip_prefix("std::path::PathBuf::"))
1622 {
1623 const STAT: &[&str] = &[
1624 "metadata", "symlink_metadata", "canonicalize", "read_link", "read_dir", "exists",
1625 "try_exists", "is_file", "is_dir", "is_symlink",
1626 ];
1627 return STAT.contains(&m).then_some("Fs");
1628 }
1629 // OPTION-BUILDERS, the shape SPEC §1 ⟨0.32⟩ separates from an invocation object: "option-builders for
1630 // other effects (`OpenOptions`, request builders) stay pure because their resource arrives at the
1631 // terminal verb, which is charged at its own call site". An `OpenOptions` holds a handful of bools and
1632 // names no file; `DirBuilder` holds one bool and a mode. Both sit under the coarse `std::fs::` prefix
1633 // below, which charged Fs for `OpenOptions::new()` and for every `o.read(true)` — MEASURED: a
1634 // `let o = OpenOptions::new().read(true);` with no `open` ANYWHERE reported `Fs`.
1635 //
1636 // A DENYLIST, and keyed on the TYPE. Only the provably-pure setters/ctors are subtracted; anything
1637 // else under the type keeps its effect, so the terminal verb (`OpenOptions::open`, `DirBuilder::create`)
1638 // stays Fs and a std addition we have not read about fails in the safe direction. The type key is not
1639 // decoration: `create` is a pure flag SETTER on `OpenOptions` and the mkdir SYSCALL on `DirBuilder` —
1640 // one leaf, opposite answers. (candor-java keys the same carve-out by DESCRIPTOR, because its collision
1641 // is an overload — `command()` reads back, `command(List)` sets. Rust has no overloads, so the type IS
1642 // the discriminator; a bare-name denylist would silence `DirBuilder::create`.)
1643 if let Some(m) = path.strip_prefix("std::fs::OpenOptions::") {
1644 const PURE: &[&str] = &[
1645 // the builder itself + the std flag setters
1646 "new", "read", "write", "append", "truncate", "create", "create_new",
1647 // the platform extension setters (`OpenOptionsExt`, unix + windows)
1648 "mode", "custom_flags", "security_qos_flags", "access_mode", "share_mode", "attributes",
1649 // derives
1650 "clone", "default", "fmt", "eq", "ne", "hash",
1651 ];
1652 return (!PURE.contains(&m)).then_some("Fs");
1653 }
1654 if let Some(m) = path.strip_prefix("std::fs::DirBuilder::") {
1655 const PURE: &[&str] = &["new", "recursive", "mode", "clone", "default", "fmt"];
1656 return (!PURE.contains(&m)).then_some("Fs");
1657 }
1658 // Filesystem. `tokio::fs`/`async_std::fs` are the async mirrors of `std::fs`; `async_fs` is
1659 // smol's fs crate; `fs_err` is a drop-in `std::fs` wrapper (its whole surface is fs I/O).
1660 if path.starts_with("std::fs::")
1661 || path.starts_with("tokio::fs::")
1662 || path.starts_with("async_std::fs::")
1663 || crate_name == "async_fs"
1664 || crate_name == "fs_err"
1665 {
1666 return Some("Fs");
1667 }
1668 // memmap2: only `MmapOptions::map*` (and the in-place `Mmap::flush`/`make_*` protection
1669 // changes / `remap`) actually issue the mmap/msync/mprotect/mremap syscall = Fs. The rest of the
1670 // crate is PURE: `MmapOptions::new`/setters BUILD the request, and once a region is mapped, reads
1671 // over it (`Mmap::len`/`is_empty`/`as_ptr`/`as_mut_ptr`/`deref` into the byte slice) are plain
1672 // memory access with no syscall. Whole-crate Fs fabricated Fs on those reads (a `m.len()` the
1673 // scanner's receiver inference routes to `memmap2::Mmap::len`). Match the syscall-issuing verbs;
1674 // everything else returns None (pure). `map*` covers `map`/`map_mut`/`map_exec`/`map_copy`/
1675 // `map_copy_read_only`/`map_raw`/`map_raw_read_only`/`map_anon`.
1676 if crate_name == "memmap2" {
1677 let m = path.rsplit("::").next().unwrap_or(path);
1678 if m.starts_with("map")
1679 || m == "flush"
1680 || m == "flush_async"
1681 || m == "flush_range"
1682 || m == "flush_async_range"
1683 || m == "remap"
1684 || m.starts_with("make_")
1685 || m == "advise"
1686 || m == "advise_range"
1687 || m == "lock"
1688 || m == "unlock"
1689 {
1690 return Some("Fs");
1691 }
1692 return None;
1693 }
1694 // tempfile: creating a temp file/dir touches the disk. Match the create/persist verbs (the
1695 // `Builder` setters — prefix/suffix/rand_bytes — stay pure). `persist`/`keep` rename/retain
1696 // the file on disk; `close` removes it.
1697 if crate_name == "tempfile"
1698 && (path.ends_with("::tempfile")
1699 || path.ends_with("::tempfile_in")
1700 || path.ends_with("::tempdir")
1701 || path.ends_with("::tempdir_in")
1702 || path.ends_with("NamedTempFile::new")
1703 || path.ends_with("NamedTempFile::new_in")
1704 || path.ends_with("TempDir::new")
1705 || path.ends_with("TempDir::new_in")
1706 || path.ends_with("::persist")
1707 || path.ends_with("::persist_noclobber")
1708 || path.ends_with("::keep"))
1709 {
1710 return Some("Fs");
1711 }
1712 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against tempfile 3.27.0 source — the SIBLING
1713 // constructors/closers the create/persist verb list above never reached:
1714 //
1715 // `Builder::make`/`make_in` (lib.rs:716,739) are the crate's OWN documented escape hatch for a
1716 // caller-supplied factory (`Builder::new().make_in(dir, |path| UnixListener::bind(path))`) — real
1717 // disk creation via `util::create_helper`, same mechanism as `Builder::tempfile`/`tempfile_in`
1718 // two lines up, just a different entry verb.
1719 // `NamedTempFile::with_prefix`/`with_prefix_in`/`with_suffix`/`with_suffix_in` (file/mod.rs:630-677)
1720 // are one-call convenience wrappers documented as equivalent to `Builder::new().prefix(..).tempfile()`
1721 // — real creation, missing because the verb list matched `NamedTempFile::new`/`new_in` but not these.
1722 // `NamedTempFile::reopen` (file/mod.rs:951) calls the platform `imp::reopen`, a real `open`/`fstat`
1723 // pair (found the original file was replaced, or hands back a fresh handle to it).
1724 // `TempPath::close` (file/mod.rs:161) and `TempDir::close` (dir/mod.rs:470) `fs::remove_file`/
1725 // `remove_dir_all` the real path — `NamedTempFile::close` (file/mod.rs:727) is a thin `self.path.close()`
1726 // delegate to the same `TempPath::close`, so matching the `::close` suffix (crate-gated, and the ONLY
1727 // three `pub fn close` in this crate) catches it too without a separate rule.
1728 // `SpooledTempFile::set_len` (spooled.rs:134) calls `File::set_len` on the real disk file once the
1729 // in-memory buffer has spilled past `max_size` — the crate's own roll-over threshold, not a corner case.
1730 //
1731 // NOT included: `tempfile::create`/`create_named`/`reopen` (dir/imp/any.rs, file/imp/windows.rs,
1732 // file/imp/unix.rs) — despite being `pub fn`, `mod dir;`/`mod file;` are PRIVATE at the crate root
1733 // (lib.rs:202,204) with no re-export of the `imp` submodules, so no external consumer can ever name
1734 // these paths; the ratchet's generator guesses a crate-root alias for every candidate regardless of
1735 // whether one is real (the same "second, related shape" gap the previous pass's rusqlite/lettre
1736 // private-module findings already documented — restricted_types()/is_bare_pub check a type's or fn's
1737 // OWN visibility keyword, not its enclosing module chain). Removed from open.tsv rather than guessed.
1738 if crate_name == "tempfile"
1739 && (path == "tempfile::Builder::make"
1740 || path == "tempfile::Builder::make_in"
1741 || path == "tempfile::NamedTempFile::with_prefix"
1742 || path == "tempfile::NamedTempFile::with_prefix_in"
1743 || path == "tempfile::NamedTempFile::with_suffix"
1744 || path == "tempfile::NamedTempFile::with_suffix_in"
1745 || path == "tempfile::NamedTempFile::reopen"
1746 || path == "tempfile::SpooledTempFile::set_len"
1747 || path.ends_with("::close"))
1748 {
1749 return Some("Fs");
1750 }
1751 // glob: walks the filesystem to expand a pattern (the returned iterator reads directories).
1752 // `Pattern::matches` is pure string matching — match only the directory-walking entry points.
1753 if crate_name == "glob" && (path.ends_with("::glob") || path.ends_with("::glob_with")) {
1754 return Some("Fs");
1755 }
1756 // Password-hashing / KDF crates — the entropy tier (the TS engine's CTA lesson: an invisible
1757 // argon2 landed on exactly the call a security review cares about). In this engine's
1758 // verb-precise style the ENTROPY is the salt mint: `SaltString::generate(OsRng)` in the
1759 // password-hash API family, and bcrypt's `hash`/`hash_with_result` (salt minted internally).
1760 // Verification and explicit-salt hashing are deterministic recomputation — pure. `rand_core`
1761 // carries the OsRng source itself (otherwise the most common salt mint is invisible).
1762 if matches!(crate_name, "argon2" | "scrypt" | "pbkdf2" | "password_hash") {
1763 if path.contains("SaltString::generate") {
1764 return Some("Rand");
1765 }
1766 return None;
1767 }
1768 if crate_name == "bcrypt" {
1769 if path.ends_with("::hash") || path.ends_with("::hash_with_result") {
1770 return Some("Rand");
1771 }
1772 return None;
1773 }
1774 if crate_name == "rand_core" {
1775 if path.contains("OsRng")
1776 || path.ends_with("::next_u32")
1777 || path.ends_with("::next_u64")
1778 || path.ends_with("::fill_bytes")
1779 {
1780 return Some("Rand");
1781 }
1782 return None;
1783 }
1784 // Randomness / entropy. `getrandom`/`fastrand` are effectful end-to-end. `rand` is NOT — it
1785 // mixes entropy/generation (effectful) with *pure* distribution constructors (`Uniform::new`,
1786 // `Normal::new`) and deterministic-seed constructors (`seed_from_u64`). Flagging the whole crate
1787 // over-reported those as `Rand`; match only the calls that actually consume randomness — the
1788 // entropy sources (`OsRng`, `thread_rng`/`rng`, `from_entropy`/`from_os_rng`) and the generation
1789 // verbs (`gen*`/`random*`/`fill*`/`sample*`/`next_u*`). A `Uniform::new` is now correctly pure.
1790 if crate_name == "getrandom" {
1791 return Some("Rand");
1792 }
1793 // fastrand: like `rand`, it mixes entropy-consuming generation (effectful) with PURE deterministic
1794 // pieces. `Rng::with_seed(42)` is a DETERMINISTIC seeded constructor (consumes no entropy — the same
1795 // seed gives the same stream), and `Rng::fork`/`Rng::clone` just split/copy existing state. Those are
1796 // PURE; whole-crate Rand fabricated Rand on them. The effect is the value-drawing methods (`u32`/
1797 // `usize`/`bool`/`f64`/`char`/`alphanumeric`/`choice`/`choose_multiple`/`shuffle`/`fill`/the range
1798 // forms) AND the entropy-seeded entry points: bare `Rng::new()` (seeds from the global entropy-backed
1799 // generator), `fastrand::seed`, and the top-level `fastrand::u32(..)` free functions (which draw from
1800 // the thread-local generator). `with_seed` is exempted explicitly; any other method on an `Rng`
1801 // (i.e. a value draw) is Rand.
1802 if crate_name == "fastrand" {
1803 let m = path.rsplit("::").next().unwrap_or(path);
1804 // Provably pure: deterministic seeded ctor + state split/copy.
1805 if m == "with_seed" || m == "fork" || m == "clone" {
1806 return None;
1807 }
1808 // Everything else fastrand exposes either draws a value or seeds from entropy → Rand. (The crate
1809 // has no pure data types beyond the `Rng` handle itself, so a non-draw stray would have to be a
1810 // method we don't recognise — keep the effect, the safe direction.)
1811 return Some("Rand");
1812 }
1813 if crate_name == "rand" {
1814 let rng_verb = path.ends_with("::gen")
1815 || path.ends_with("::gen_range")
1816 || path.ends_with("::gen_bool")
1817 || path.ends_with("::gen_ratio")
1818 || path.ends_with("::random")
1819 || path.ends_with("::random_range")
1820 || path.ends_with("::random_bool")
1821 || path.ends_with("::random_ratio")
1822 || path.ends_with("::random_iter") // rand 0.9 iterator generator
1823 || path.ends_with("::gen_iter")
1824 || path.ends_with("::fill")
1825 || path.ends_with("::fill_bytes")
1826 || path.ends_with("::try_fill")
1827 || path.ends_with("::try_fill_bytes")
1828 || path.ends_with("::sample")
1829 || path.ends_with("::sample_iter")
1830 || path.ends_with("::next_u32")
1831 || path.ends_with("::next_u64")
1832 || path.ends_with("::thread_rng")
1833 || path.ends_with("::rng")
1834 || path.ends_with("::from_entropy")
1835 || path.ends_with("::from_os_rng");
1836 // `OsRng` is the OS entropy SOURCE, but `clone`/`fork`/`default` just copy or construct the
1837 // (zero-sized) handle and draw no entropy — pure, exactly like the `fastrand` arm's clone/fork
1838 // exemption above. The actual draws (`fill_bytes`/`next_u*`/…) are caught by `rng_verb`. Without
1839 // this exemption the blanket `contains("OsRng")` fabricated `Rand` on `OsRng::clone` (adversarial
1840 // review: OsRng is a unit struct, cloning consumes nothing).
1841 let m = path.rsplit("::").next().unwrap_or(path);
1842 let os_rng = path.contains("OsRng") && !matches!(m, "clone" | "fork" | "default");
1843 if rng_verb || os_rng {
1844 return Some("Rand");
1845 }
1846 return None;
1847 }
1848 // Subprocess spawning. `tokio::process` is the async mirror of `std::process` — it exists
1849 // only to spawn/control subprocesses (`Command`/`Child`, no pure data types like std's
1850 // `Stdio`/`ExitStatus`/`exit`), so spawning through it is Exec just the same. Without this an
1851 // async app's `tokio::process::Command::new(..).spawn()` classified pure — a silent under-report
1852 // of subprocess execution, the dangerous direction (mirrors the tokio::fs/tokio::net coverage).
1853 if path.starts_with("std::process::Command")
1854 || path.starts_with("std::process::Child")
1855 || path.starts_with("tokio::process::Command")
1856 || path.starts_with("tokio::process::Child")
1857 || path.starts_with("async_std::process::Command")
1858 || path.starts_with("async_std::process::Child")
1859 {
1860 // PURE read-backs of the builder's stored fields / the cached pid — no spawn, no syscall — so the
1861 // whole-type Exec rule fabricated Exec on them (sweep [23]; mirrors the portable_pty getter carve-
1862 // out just below). get_program/get_args/get_envs/get_current_dir read the Command; Child::id reads
1863 // the cached pid. Every genuine verb (new/spawn/output/status/wait/kill) stays Exec.
1864 if path.ends_with("::get_program")
1865 || path.ends_with("::get_args")
1866 || path.ends_with("::get_envs")
1867 || path.ends_with("::get_current_dir")
1868 || path.ends_with("Child::id")
1869 {
1870 return None;
1871 }
1872 return Some("Exec");
1873 }
1874 // portable_pty / async_process are whole-crate Exec EXCEPT for the proven-pure surface they expose:
1875 // the `CommandBuilder` GETTERS (`get_argv`/`get_cwd`/`get_env`/`as_unix_command_line`…) read back
1876 // configuration, and the PURE DATA types (`PtySize::default`, `ExitStatus`/`Stdio`/`CommandBuilder`
1877 // construction/setters). The earlier `is_cmd_naming_method` fix stopped the head-refinement LEAK, but
1878 // the BASE Exec still fabricated on these accessors (a `cmd.get_cwd()` the scanner routes to
1879 // `portable_pty::CommandBuilder::get_cwd`). Subtract the read-back getters and the obvious pure
1880 // ctors/setters; the spawn/wait/exec surface (`spawn_command`/`openpty`/`wait`/`kill`/`exec`…) keeps
1881 // Exec. SUBTRACT only what is provably pure — when unrecognised, KEEP Exec (the safe direction).
1882 if crate_name == "async_process" || crate_name == "portable_pty" {
1883 let m = path.rsplit("::").next().unwrap_or(path);
1884 // THE COVERAGE-GATE SWEEP (2026-08-27): `CommandBuilder::get_shell` (cmdbuilder.rs:544, unix)
1885 // LOOKS like a config read-back getter — same `get_` naming as the pure getters below — but its
1886 // unix body calls `nix::unistd::access(shell, X_OK)`, a real filesystem-access syscall, before
1887 // falling back to a password-database lookup if `$SHELL` isn't set or isn't executable. Carved
1888 // out of the `get_` prefix exemption BEFORE it, not folded into it — the one getter this crate
1889 // exposes that is not actually pure. Verified against portable-pty 0.9.0.
1890 if m == "get_shell" {
1891 return Some("Fs");
1892 }
1893 // configuration read-back getters — pure (no spawn).
1894 if m.starts_with("get_") || m == "as_unix_command_line" {
1895 return None;
1896 }
1897 // pure data-type ctors/setters/derives that NAME no program and spawn nothing.
1898 if matches!(
1899 m,
1900 "default" | "new" | "piped" | "null" | "inherit" | "from_raw_fd"
1901 | "arg" | "args" | "arg0" | "env" | "envs" | "env_clear" | "env_remove"
1902 | "cwd" | "current_dir" | "rows" | "cols"
1903 | "clone" | "fmt" | "eq" | "ne" | "hash"
1904 ) {
1905 return None;
1906 }
1907 return Some("Exec");
1908 }
1909 // duct: a subprocess-orchestration crate. `cmd()`/`cmd!` only *build* an Expression; the
1910 // spawn/wait happens at `run`/`read`/`start`. Match the execution verbs, not the builder.
1911 if crate_name == "duct"
1912 && (path.ends_with("::run")
1913 || path.ends_with("::read")
1914 || path.ends_with("::start")
1915 || path.ends_with("::read_chars")
1916 // `Expression::reader` (lib.rs:357) calls the already-covered `.start()` internally to
1917 // launch the child and hands back a streaming `ReaderHandle` — a distinct public entry
1918 // point from `start` itself, so the suffix match above never reached it.
1919 || path.ends_with("Expression::reader"))
1920 {
1921 return Some("Exec");
1922 }
1923 if path.starts_with("std::env::") {
1924 return Some("Env");
1925 }
1926 // dotenvy / dotenv: load environment variables (reading a `.env` file and mutating the process
1927 // environment). Match the load/read entry points; `Error`/builder types stay pure.
1928 if matches!(crate_name, "dotenvy" | "dotenv")
1929 && (path.ends_with("::dotenv")
1930 || path.ends_with("::dotenv_override")
1931 || path.ends_with("::from_path")
1932 || path.ends_with("::from_path_override")
1933 || path.ends_with("::from_filename")
1934 || path.ends_with("::from_filename_override")
1935 || path.ends_with("::from_read")
1936 || path.ends_with("::from_read_override")
1937 || path.ends_with("::load")
1938 || path.ends_with("::var")
1939 || path.ends_with("::vars"))
1940 {
1941 return Some("Env");
1942 }
1943 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against dotenv 0.15.0 and dotenvy 0.15.7
1944 // (byte-identical shape in both crates) — the ITERATOR-returning siblings of the load verbs above,
1945 // which return the parsed pairs instead of mutating the environment but still touch disk to get
1946 // them: `dotenv_iter`/`from_filename_iter` (lib.rs) resolve and open a `.env`-shaped file exactly
1947 // like `dotenv`/`from_filename` two lines up (same `Finder::new().find()` call); `from_path_iter`
1948 // opens the given path directly. Bucketed `Env` for consistency with the rest of this family rather
1949 // than `Fs` (self-scan's raw signal): the crate's OWN purpose is env-var loading, and splitting the
1950 // iterator variants into a different effect bucket from their non-iterator siblings would be an
1951 // arbitrary distinction a reviewer gains nothing from.
1952 //
1953 // NOT included: `Finder::find` / the free fn `find` (find.rs) — both are used only via a PRIVATE
1954 // `use crate::find::Finder;` (no `pub use`) inside a private `mod find;` (lib.rs), so neither
1955 // `dotenv(y)::Finder` nor `dotenv(y)::find` is a path any external consumer can compile a call to;
1956 // the ratchet's generator still guesses a crate-root alias for every candidate (the same private-
1957 // module shape as tempfile's `imp::` functions above). Removed from open.tsv rather than guessed.
1958 if matches!(crate_name, "dotenvy" | "dotenv")
1959 && (path.ends_with("::dotenv_iter")
1960 || path.ends_with("::from_filename_iter")
1961 || path.ends_with("::from_path_iter"))
1962 {
1963 return Some("Env");
1964 }
1965 // Wall-clock reads. Match the `now` accessor precisely (ends_with), not any path
1966 // containing the substring "now". The `time` crate (distinct from `std::time`/`chrono`)
1967 // reads the clock via `now_utc`/`now_local` (and the deprecated `Instant::now`).
1968 if (crate_name == "chrono" || path.starts_with("std::time::")) && path.ends_with("::now") {
1969 return Some("Clock");
1970 }
1971 if crate_name == "time"
1972 && (path.ends_with("::now_utc") || path.ends_with("::now_local") || path.ends_with("::now"))
1973 {
1974 return Some("Clock");
1975 }
1976 // `tracing`: same principle as the `log` facade below — the crate's TYPES are pure data, so match
1977 // the emit, not the whole crate. The actual program output is the macro-expanded
1978 // `Subscriber::event`/`event!`/`Span::*enter*` dispatch and the `Span::new*`/`Span::record`
1979 // recording path that drives the subscriber. The data-type accessors — `Level::as_str`,
1980 // `Span::is_disabled`/`metadata`/`id`, and constructing/reading `Level`/`LevelFilter`/`Span`/
1981 // `Event`/`Metadata`/`Field`/`FieldSet`/`Id` — are PURE (no output is produced), so whole-crate Log
1982 // fabricated Log on them. Match the emit verbs; everything else returns None.
1983 if crate_name == "tracing" {
1984 let m = path.rsplit("::").next().unwrap_or(path);
1985 // The user-facing emit MACROS (`tracing::info!`/`warn!`/…) — candor-scan is pre-expansion, so it
1986 // sees the raw macro path `tracing::info`, not the expanded `__tracing`/`Subscriber::event` the
1987 // deep (post-expansion) engine sees. Only the macro names; the pure DATA types (Level/Span/Event)
1988 // have other tails and stay None.
1989 if m == "trace" || m == "debug" || m == "info" || m == "warn" || m == "error"
1990 || m == "trace_span" || m == "debug_span" || m == "info_span" || m == "warn_span"
1991 || m == "error_span" || m == "span"
1992 || m == "event"
1993 || m == "new_span"
1994 || m == "record"
1995 || m == "record_follows_from"
1996 || m == "enter"
1997 || m == "exit"
1998 || m == "in_scope"
1999 || m == "entered"
2000 || path.contains("::__macro_support")
2001 || path.contains("::__tracing")
2002 || path.contains("Subscriber::event")
2003 || path.contains("Subscriber::new_span")
2004 || path.contains("Subscriber::enter")
2005 || path.contains("Subscriber::exit")
2006 {
2007 return Some("Log");
2008 }
2009 return None;
2010 }
2011 // The `log` facade: its macros route through `log::__private_api`; the crate's types
2012 // (`Level`, `LevelFilter`) are pure, so match the logging entry, not the whole crate.
2013 if crate_name == "log" {
2014 // Expanded macro form (deep engine) OR the raw user-facing macro names (candor-scan, pre-expansion).
2015 // `log::Level`/`LevelFilter`/`Record`/`Metadata` have other tails, so the type surface stays pure.
2016 let m = path.rsplit("::").next().unwrap_or(path);
2017 if path.contains("::__private_api")
2018 || m == "error" || m == "warn" || m == "info" || m == "debug" || m == "trace" || m == "log"
2019 {
2020 return Some("Log");
2021 }
2022 }
2023 // Compiler diagnostic emission — the ONE genuinely effectful operation in the otherwise-pure
2024 // rustc_* surface (a dylint lint's actual OUTPUT: it writes warnings/errors to the compiler's
2025 // diagnostic sink). Classified `Log` (same family as `tracing`/`log` — program output). Match the
2026 // emission verbs precisely; rustc_lint/rustc_errors are mostly pure types (Lint, LintId, the Diag
2027 // BUILDERS), and only the terminal `emit`/`emit_span_lint` actually produces output.
2028 if crate_name == "rustc_lint"
2029 && (path.ends_with("::emit_span_lint")
2030 || path.ends_with("::span_lint")
2031 || path.ends_with("::span_lint_hir"))
2032 {
2033 return Some("Log");
2034 }
2035 if crate_name == "rustc_errors"
2036 && (path.ends_with("::emit")
2037 || path.ends_with("::emit_diagnostic")
2038 || path.ends_with("::emit_now"))
2039 {
2040 return Some("Log");
2041 }
2042 // arboard: the effectful surface is the `Clipboard` handle's read/write verbs (each talks to the
2043 // OS clipboard / X11/Wayland/Win32/NSPasteboard server). The data types — chiefly `arboard::Error`
2044 // (whose `Display`/`to_string` formatting is pure) and the `ImageData`/`GetExtLinux`/`SetExtLinux`
2045 // option types — are PURE, so whole-crate Clipboard fabricated Clipboard on e.g. an error
2046 // `to_string()`. Match the handle verbs; everything else returns None. `Clipboard::new` opens the
2047 // connection to the clipboard server, so it's an effect too; `get`/`set` return the
2048 // builder-then-read `Get`/`Set` cursors whose `text`/`image`/`html` terminals do the I/O.
2049 if crate_name == "arboard" {
2050 let m = path.rsplit("::").next().unwrap_or(path);
2051 if m == "new"
2052 || m == "get"
2053 || m == "set"
2054 || m == "clear"
2055 || m == "get_text"
2056 || m == "set_text"
2057 || m == "set_html"
2058 || m == "get_image"
2059 || m == "set_image"
2060 || m == "text"
2061 || m == "image"
2062 || m == "html"
2063 {
2064 return Some("Clipboard");
2065 }
2066 return None;
2067 }
2068 // ── Coverage-differential additions (calibrated against each crate's real API; see the per-crate
2069 // notes). All verb-keyed + crate-gated, with the pure builder/config/data surface returning None.
2070
2071 // `etcetera` — XDG/known-folder base+app directory resolution. Each dir ACCESSOR reads the
2072 // environment at call time (`$HOME`/`$XDG_*` on Unix, `%APPDATA%`/`%LOCALAPPDATA%` on Windows), and
2073 // the `choose_*`/`home_dir` entry points read `$HOME`. The `AppStrategyArgs` data struct and the
2074 // strategy types themselves are PURE. (Found DISCLOSED-but-unmodeled in 3/4 differential projects.)
2075 if crate_name == "etcetera" {
2076 let m = path.rsplit("::").next().unwrap_or(path);
2077 if m == "home_dir"
2078 || m == "choose_base_strategy" || m == "choose_native_strategy" || m == "choose_app_strategy"
2079 || m == "config_dir" || m == "data_dir" || m == "cache_dir"
2080 || m == "state_dir" || m == "runtime_dir" || m == "data_local_dir"
2081 {
2082 return Some("Env");
2083 }
2084 return None;
2085 }
2086 // `sqlx-core` (crate `sqlx_core`) — the execution terminals under the sqlx core (the `sqlx` builder
2087 // table maps `sqlx::query*`; here it's the core `Executor`/`Connection`/`Pool` round-trips). Opening
2088 // the connection is the network boundary (Net); the query/transaction round-trips are Db. The
2089 // `*Options`/query-builder/row data types are PURE. Crate-gated so the generic verbs never spread.
2090 if crate_name == "sqlx_core" {
2091 if path.ends_with("::connect") || path.ends_with("::connect_with") {
2092 return Some("Net");
2093 }
2094 if path.ends_with("::fetch") || path.ends_with("::fetch_all") || path.ends_with("::fetch_one")
2095 || path.ends_with("::fetch_optional") || path.ends_with("::fetch_many")
2096 || path.ends_with("::execute") || path.ends_with("::execute_many")
2097 || path.ends_with("::prepare") || path.ends_with("::prepare_with")
2098 || path.ends_with("::acquire") || path.ends_with("::begin") || path.ends_with("::ping")
2099 {
2100 return Some("Db");
2101 }
2102 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against sqlx-core 0.8.6/0.9.0 (identical
2103 // shape in both) — the free-function surface under `pub mod fs`/`pub mod net::tls`/
2104 // `pub mod migrate`, none of which end in the connect/fetch/execute verbs above:
2105 //
2106 // `fs::{read,read_to_string,create_dir_all,remove_file,remove_dir,remove_dir_all,read_dir}`
2107 // (fs.rs) are direct `std::fs::*` calls off-loaded to `spawn_blocking` — sqlx's OWN async
2108 // filesystem shim, used by the migrator to read `.sql` files and by SQLite to manage on-disk
2109 // databases. `net::tls::handshake` (net/tls/mod.rs:69) is the real TLS handshake dispatch
2110 // reqwest/ureq-style crates already model — sqlx has its OWN copy because it drives the
2111 // handshake over its own `Socket` trait rather than a `TcpStream` directly.
2112 // `migrate::resolve_blocking`/`resolve_blocking_with_config` (migrate/source.rs:151,156) are
2113 // `#[doc(hidden)]` but still `pub use`-exported at `sqlx_core::migrate` (mod.rs:16) — real,
2114 // callable, and reads the migrations directory off disk (`fs::read_dir` + per-file `canonicalize`).
2115 //
2116 // NOT included: `net::tls::tls_rustls::RustlsSocket::poll_{flush,read_ready,write_ready,
2117 // shutdown}` — `RustlsSocket` is declared inside `mod tls_rustls;` (net/tls/mod.rs), which is
2118 // PRIVATE with no re-export, so no external consumer can ever name it (the same private-module
2119 // shape as tempfile's `imp::` functions above). Removed from open.tsv rather than guessed.
2120 if path == "sqlx_core::fs::read"
2121 || path == "sqlx_core::fs::read_to_string"
2122 || path == "sqlx_core::fs::create_dir_all"
2123 || path == "sqlx_core::fs::remove_file"
2124 || path == "sqlx_core::fs::remove_dir"
2125 || path == "sqlx_core::fs::remove_dir_all"
2126 || path == "sqlx_core::fs::read_dir"
2127 || path.ends_with("::migrate::resolve_blocking")
2128 || path.ends_with("::migrate::resolve_blocking_with_config")
2129 {
2130 return Some("Fs");
2131 }
2132 if path.ends_with("::net::tls::handshake") {
2133 return Some("Net");
2134 }
2135 return None;
2136 }
2137 // `walkdir` — recursive directory traversal. Charged at `WalkDir::new` (construction), the SAME
2138 // point `ignore::WalkBuilder::build`/`glob::glob` are charged, and NOT at `IntoIter::next` (driving
2139 // the iterator) as this rule originally read. The disk read (`read_dir`+`stat`) is technically lazy
2140 // — nothing happens until the value is iterated — but `.next()` is reached ONLY through a receiver
2141 // typed as `walkdir::IntoIter`, and candor-scan's receiver-typing (`ctor_type`/`resolve_recv_type`)
2142 // hard-blocks the `.into_iter()` verb (a guard against fabricating onto a DIFFERENT std type, e.g.
2143 // `Vec::into_iter()` -> `std::vec::IntoIter`) with no per-crate exception for the SAME-crate case.
2144 // `WalkDir::into_iter()` returns `walkdir::IntoIter` (same crate as `WalkDir` itself, exactly the
2145 // shape the blocklist exists to protect, not the shape it should be blocking) — so every idiomatic
2146 // form (`for e in WalkDir::new(p)`, `.into_iter().count()`, `.into_iter().filter_map(..)`, an
2147 // untyped `let it = ...into_iter(); it.next()`) never reaches a typed `IntoIter::next` receiver and
2148 // read silent-pure under the OLD rule, with `deny Fs` exiting 0 over code that walks the filesystem.
2149 // Charging at `WalkDir::new` needs no receiver typing at all — it's a plain `Expr::Call`, robust to
2150 // however the iterator is later consumed. `max_depth`/`follow_links`/`sort_by`/`into_iter` (the
2151 // BUILDER chain, no I/O until pulled) and the cached `DirEntry::path`/`file_name`/`file_type`/`depth`
2152 // accessors (`file_type` makes NO syscall) stay PURE — matched by nothing here, so they fall through.
2153 // The `IntoIter::next`/`DirEntry::metadata` rule is KEPT, not dead: it still fires whenever the
2154 // concrete `walkdir::IntoIter` type reaches `.next()` by a route the blocklist doesn't gate — an
2155 // EXPLICIT type annotation (`let it: walkdir::IntoIter = ...`), a struct field declared with that
2156 // type, or a local fn's declared return type — real, if rarer, shapes than the inline-chain idiom.
2157 // (Companion to the already-modeled `ignore`.)
2158 if crate_name == "walkdir" {
2159 if path.ends_with("::WalkDir::new")
2160 || path.ends_with("::IntoIter::next")
2161 || path.ends_with("::DirEntry::metadata")
2162 {
2163 return Some("Fs");
2164 }
2165 return None;
2166 }
2167 // `filetime` — file-timestamp mutation. The `set_*` free fns issue utimes/utimensat/futimens (Fs).
2168 // `FileTime::now` reads the system clock (Clock). The `FileTime::from_*`/`zero` value constructors
2169 // (incl. `from_last_modification_time(&Metadata)` etc., which read an ALREADY-loaded `&Metadata`, not
2170 // the disk) and the `seconds`/`nanoseconds` accessors are PURE.
2171 if crate_name == "filetime" {
2172 if path.ends_with("::set_file_mtime") || path.ends_with("::set_file_atime")
2173 || path.ends_with("::set_file_times") || path.ends_with("::set_symlink_file_times")
2174 || path.ends_with("::set_file_handle_times")
2175 {
2176 return Some("Fs");
2177 }
2178 if path.ends_with("::FileTime::now") {
2179 return Some("Clock");
2180 }
2181 return None;
2182 }
2183 // `execute` — the `Execute` trait that extends `std::process::Command` with run helpers. The
2184 // `execute*` verbs SPAWN a child process (Exec). The `execute::command`/`shell` free fns and the
2185 // `command!`/`command_args!` macros only BUILD a Command (no spawn) and stay PURE.
2186 if crate_name == "execute" {
2187 if path.contains("::execute") {
2188 return Some("Exec");
2189 }
2190 return None;
2191 }
2192 // `ctrlc` — installs an OS signal handler (Unix SIGINT/SIGTERM/SIGHUP, Windows CTRL_C_EVENT) and
2193 // spawns its handler thread. Signals are an inter-process control channel, so the closest bucket is
2194 // Ipc (candor has no dedicated Signal effect; same judgment as routing SysV/pipe IPC to Ipc).
2195 if crate_name == "ctrlc" {
2196 if path.ends_with("::set_handler") || path.ends_with("::try_set_handler") {
2197 return Some("Ipc");
2198 }
2199 return None;
2200 }
2201 // `clap` — argument parsing. ONLY the terminals that read `std::env::args_os` at call time are an
2202 // effect (Env): `get_matches`/`get_matches_mut`/`try_get_matches` and the derive `parse`/`try_parse`.
2203 // clap is MOSTLY PURE: the ENTIRE builder surface (`Command::new`/`arg`/`about`/`Arg::new`) stays
2204 // None, and crucially the `*_from`/`*_parse_from` variants take an EXPLICIT iterator (they do NOT
2205 // read argv) so they stay pure too. (`Arg::env` reads an env var at builder time but bare `::env` is
2206 // too generic to gate safely, so it's left unmodeled — under-report over fabrication.)
2207 if crate_name == "clap" {
2208 if path.ends_with("::get_matches") || path.ends_with("::get_matches_mut")
2209 || path.ends_with("::try_get_matches")
2210 || path.ends_with("::parse") || path.ends_with("::try_parse")
2211 {
2212 return Some("Env");
2213 }
2214 return None;
2215 }
2216 // `jiff` — date/time. `Timestamp::now`/`Zoned::now`/`Zoned::now_with` read the wall clock (Clock).
2217 // `tz::TimeZone::system`/`get` and `tz::db().get` read the system tzdb files from disk
2218 // (`/etc/localtime`, `/usr/share/zoneinfo`; `system` is also `$TZ`-overridable — Fs is the dominant
2219 // op, modeled as Fs). The `Span`/`civil` date math and `Timestamp`/`Zoned` arithmetic are PURE.
2220 if crate_name == "jiff" {
2221 if path.ends_with("::now") || path.ends_with("::now_with") {
2222 return Some("Clock");
2223 }
2224 if path.ends_with("::TimeZone::system") || path.ends_with("::TimeZone::get")
2225 || path.ends_with("::TimeZoneDatabase::get")
2226 // `TimeZone::try_system` (tz/timezone.rs:391) is `TimeZone::system`'s fallible sibling —
2227 // same `crate::tz::system::get(crate::tz::db())` call, real localtime/zoneinfo disk read.
2228 || path.ends_with("::TimeZone::try_system")
2229 {
2230 return Some("Fs");
2231 }
2232 return None;
2233 }
2234 // `env_logger` — installs the global logger and emits to stderr; reads `RUST_LOG`/`RUST_LOG_STYLE`.
2235 // The init terminals are the effect (Log — program output, same family as `log`/`tracing`). The
2236 // `Builder::new`/`build` and the format/filter/target config setters are PURE.
2237 if crate_name == "env_logger" {
2238 if path.ends_with("::init") || path.ends_with("::try_init")
2239 || path.ends_with("::init_from_env") || path.ends_with("::try_init_from_env")
2240 {
2241 return Some("Log");
2242 }
2243 return None;
2244 }
2245 // `dialoguer` — interactive terminal prompts. The `interact*` verbs read stdin + write the tty (a
2246 // console dialogue with the user — Ipc, like the other local-channel effects). The
2247 // `with_prompt`/`default`/`items`/`validate_with` BUILDERS are PURE.
2248 if crate_name == "dialoguer" {
2249 if path.ends_with("::interact") || path.ends_with("::interact_on")
2250 || path.ends_with("::interact_text") || path.ends_with("::interact_text_on")
2251 || path.ends_with("::interact_opt") || path.ends_with("::interact_on_opt")
2252 {
2253 return Some("Ipc");
2254 }
2255 // `Editor::edit` (edit.rs:93) writes the caller's string to a real tempfile, spawns
2256 // `$VISUAL`/`$EDITOR` on it via `process::Command::spawn().wait()`, then reads the result back
2257 // — a real subprocess launch, not a terminal-dialogue read/write like the verbs above.
2258 if path.ends_with("Editor::edit") {
2259 return Some("Exec");
2260 }
2261 return None;
2262 }
2263 // `tracing_subscriber` — the subscriber that gives `tracing` somewhere to go. TWO effects, and the
2264 // filing said "Log/Fs": VERIFIED against 0.3.23, the Fs half is WRONG.
2265 //
2266 // Log — `fmt/fmt_layer.rs:749` defaults `make_writer: io::stdout`, so the fmt INIT terminals install
2267 // a subscriber that writes program output. Same family as `log`/`tracing`/`env_logger`.
2268 // Env — `fmt/mod.rs:1219` reads `RUST_LOG` on the `init()` path, `fmt_layer.rs` reads `NO_COLOR`,
2269 // and `filter/env/builder.rs:189,203` read `env::var(self.env_var_name())`.
2270 //
2271 // NOT Fs. The only `std::fs` in the crate is `impl MakeWriter for std::fs::File` — the crate ACCEPTING
2272 // a caller-supplied File, not opening one. The caller's `File::create` is classified on the caller, so
2273 // charging Fs here would double-count, exactly the `serde_json::from_reader` caveat one crate over.
2274 //
2275 // The builders (`fmt()`, `layer()`, `with_writer`, `with_target`, `EnvFilter::new`) are PURE: they
2276 // describe a subscriber. Only the INIT terminals install one, and only the from-env constructors read.
2277 if crate_name == "tracing_subscriber" {
2278 if path.ends_with("::init") || path.ends_with("::try_init") {
2279 return Some("Log");
2280 }
2281 if path.ends_with("::from_default_env") || path.ends_with("::try_from_default_env")
2282 || path.ends_with("::from_env") || path.ends_with("::from_env_lossy")
2283 || path.ends_with("::try_from_env")
2284 {
2285 return Some("Env");
2286 }
2287 return None;
2288 }
2289 // `crossterm` — the terminal driver. The tty is a USER DIALOGUE CHANNEL, so this is Ipc, matching the
2290 // ruling `dialoguer`/`console`/`terminal_colorsaurus` already carry rather than a new one.
2291 //
2292 // VERIFIED against crossterm-0.28.1 rather than assumed: `command.rs` `execute`/`queue` end in
2293 // `self.flush()?` on the writer (real code, not a doc example), `event::read`/`poll` read tty input,
2294 // and `terminal::{enable,disable}_raw_mode` + `size`/`window_size` talk to the device.
2295 //
2296 // `size`/`window_size`/`is_raw_mode_enabled` ARE classified, and that is deliberate: once a crate is
2297 // CALIBRATED every unmatched path becomes a PURITY CLAIM rather than a disclosed blind spot, so a tty
2298 // ioctl left to fall through would be claimed pure. The genuinely pure surface — the Command VALUE
2299 // types (`Print`, `MoveTo`, `SetForegroundColor`), the style/event data types — carries none of these
2300 // tails and stays pure correctly.
2301 if crate_name == "crossterm" {
2302 if path.ends_with("::execute") || path.ends_with("::queue")
2303 || path.ends_with("::event::read") || path.ends_with("::event::poll")
2304 || path.ends_with("::enable_raw_mode") || path.ends_with("::disable_raw_mode")
2305 || path.ends_with("::size") || path.ends_with("::window_size")
2306 || path.ends_with("::is_raw_mode_enabled")
2307 {
2308 return Some("Ipc");
2309 }
2310 // `terminal::supports_keyboard_enhancement` (terminal/sys/unix.rs:188, re-exported `pub use
2311 // sys::supports_keyboard_enhancement` in terminal.rs) drives the SAME channel as
2312 // `event::read`/`enable_raw_mode` above (it toggles raw mode, then reads/polls terminal events
2313 // to detect the kitty keyboard protocol) — bucketed `Ipc` for consistency with the rest of this
2314 // tty-dialogue family rather than the `Fs` self-scan's raw signal reports (that signal traces
2315 // through the crate's internal event source opening `/dev/tty` as a fallback fd, the same
2316 // primitive `tty_fd` below wraps — not a distinct effect from the terminal channel itself).
2317 //
2318 // NOT modelled: `tty_fd` (terminal/sys/file_descriptor.rs:124) — declared inside `pub(crate) mod
2319 // sys;` (terminal.rs:99), so despite being `pub fn` it is unreachable from outside the crate
2320 // (only used internally by the event source). Removed from open.tsv rather than guessed.
2321 if path.ends_with("::supports_keyboard_enhancement") {
2322 return Some("Ipc");
2323 }
2324 return None;
2325 }
2326 // `ratatui` — the TUI renderer, and the single loudest source of disclosed-blind calls measured in the
2327 // 2026-07-14 four-ecosystem sweep (3,345 across three real repos). The backlog filed it as
2328 // "mark reviewed-pure"; VERIFYING against ratatui-0.29.0 REFUTES that for part of the surface:
2329 // `terminal/terminal.rs` `draw`/`flush`/`clear`/`autoresize`/`hide_cursor`/`show_cursor` end in a
2330 // backend flush, and `backend/` writes to the terminal. Marking the whole crate pure would have
2331 // claimed purity over the one API that actually writes.
2332 //
2333 // So the split is where the sweep's noise actually is: the BULK of those 3,345 calls are widget,
2334 // layout, buffer, style and text constructors — genuinely pure, and now covered rather than disclosed.
2335 // The Terminal/backend verbs are Ipc, same channel as crossterm underneath them.
2336 if crate_name == "ratatui" {
2337 // CARVE-OUT FIRST: `widgets::canvas` is an IN-MEMORY grid. `Context::draw(&shape)` sets
2338 // `self.dirty` and paints into a `Painter` — no terminal, no writer, provably pure — but it ends
2339 // in `::draw` and the tails below would have charged it `Ipc`. MEASURED as a live fabrication on a
2340 // fixture (`plot(ctx) -> ['Ipc']`) before this line existed, and it is a HOT path: a TUI drawing
2341 // charts or maps calls it per shape per frame.
2342 //
2343 // A DENYLIST (carve out the proven-pure module) rather than an allowlist of `Terminal::`, per the
2344 // family rule: an allowlist silently under-reports whatever it forgot, and the write surface here
2345 // is Terminal AND the backends (`CrosstermBackend::flush`), so pinning to `Terminal::` would drop
2346 // a direct backend call. Reading the crate, canvas is the only module whose methods collide with
2347 // these tails.
2348 if path.contains("::canvas::") {
2349 return None;
2350 }
2351 if path.ends_with("::draw") || path.ends_with("::try_draw") || path.ends_with("::flush")
2352 || path.ends_with("::autoresize") || path.ends_with("::clear")
2353 || path.ends_with("::hide_cursor") || path.ends_with("::show_cursor")
2354 || path.ends_with("::insert_before")
2355 || path.ends_with("::set_cursor_position") || path.ends_with("::get_cursor_position")
2356 {
2357 return Some("Ipc");
2358 }
2359 return None;
2360 }
2361 // `console` — terminal handle + styling. The `Term` read/write verbs do tty I/O (Ipc, the user
2362 // dialogue channel; note there is NO `write_str` — `Term` impls `io::Write`). The free-fn terminal
2363 // detection (`colors_enabled`/`user_attended`) reads `CLICOLOR`/`CLICOLOR_FORCE` (Env). The `Style`
2364 // color/format methods and the text utils (`strip_ansi_codes`/`pad_str`/`measure_text_width`) are PURE.
2365 if crate_name == "console" {
2366 if path.ends_with("::write_line") || path.ends_with("::read_line")
2367 || path.ends_with("::read_line_initial_text") || path.ends_with("::read_char")
2368 || path.ends_with("::read_key") || path.ends_with("::read_key_raw")
2369 || path.ends_with("::read_secure_line")
2370 {
2371 return Some("Ipc");
2372 }
2373 if path.ends_with("::colors_enabled") || path.ends_with("::colors_enabled_stderr")
2374 || path.ends_with("::user_attended") || path.ends_with("::user_attended_stderr")
2375 {
2376 return Some("Env");
2377 }
2378 return None;
2379 }
2380 // `terminal_colorsaurus` — queries the terminal's colours by writing OSC 10/11 escapes and reading the
2381 // reply (bidirectional tty dialogue — Ipc, consistent with dialoguer/console). Nothing else is I/O.
2382 if crate_name == "terminal_colorsaurus" {
2383 if path.ends_with("::background_color") || path.ends_with("::foreground_color")
2384 || path.ends_with("::color_palette") || path.ends_with("::theme_mode")
2385 {
2386 return Some("Ipc");
2387 }
2388 return None;
2389 }
2390 // `backoff` — retry-with-backoff. `retry`/`retry_notify` consult the clock and `thread::sleep`
2391 // between attempts (Clock). The `ExponentialBackoff`/builder config is PURE. (The user closure's own
2392 // effects are out of scope here — we model only backoff's own Clock effect.)
2393 if crate_name == "backoff" {
2394 if path.ends_with("::retry") || path.ends_with("::retry_notify") {
2395 return Some("Clock");
2396 }
2397 return None;
2398 }
2399 // `lscolors` — LS_COLORS parsing. ONLY `from_env` reads the environment (Env). `from_string`/
2400 // `style_for_path`/`style_for*` and the `Style` type take explicit input and are PURE.
2401 if crate_name == "lscolors" {
2402 if path.ends_with("::from_env") {
2403 return Some("Env");
2404 }
2405 return None;
2406 }
2407 // `wild` — argv with glob expansion. `args`/`args_os` read `std::env::args(_os)` (Env). Nothing else.
2408 if crate_name == "wild" {
2409 if path.ends_with("::args") || path.ends_with("::args_os") {
2410 return Some("Env");
2411 }
2412 return None;
2413 }
2414 // `grep_cli` — only the firm effect is modeled: `CommandReaderBuilder::build` spawns a child process
2415 // (Exec). The `is_readable_stdin`/`is_tty_*` fd probes (isatty/fstat on the std descriptors) are
2416 // deliberately NOT modeled — candor doesn't classify `IsTerminal`/isatty as an effect anywhere, and
2417 // they read no data; flagging them would be an inconsistent over-report.
2418 if crate_name == "grep_cli" {
2419 if path.ends_with("::build") {
2420 return Some("Exec");
2421 }
2422 // THE COVERAGE-GATE SWEEP (2026-08-27), verified against grep-cli 0.1.12 — the sibling entry
2423 // points to `::build` above, none of which end in that suffix:
2424 //
2425 // `CommandReader::new` (process.rs:195) is `CommandReaderBuilder::new().build(cmd)` — the same
2426 // spawn, reached through the type's own one-call constructor instead of the builder.
2427 // `CommandReader::close` (process.rs:218) drops the child's stdout handle then calls
2428 // `self.child.wait()` — `std::process::Child::wait` is ALREADY classified `Exec` elsewhere in
2429 // this file (reaping/blocking on a spawned child is part of the subprocess lifecycle this
2430 // project tracks), and self-scan resolves `self.child`'s concrete `process::Child` type to
2431 // confirm it, not a guess.
2432 // `DecompressionMatcher::command` (decompress.rs:179) builds a REAL `Command::new(&decomp_cmd.bin)`
2433 // naming an actual decompressor binary (gzip/xz/bzip2/…) and returns it un-spawned — the same
2434 // "constructs a real argv, caller spawns it" shape `Cred::credential_helper` was classified for
2435 // in git2 (see the FQN-exact rule near this file's top).
2436 // `DecompressionReader::new` (decompress.rs:352) is `DecompressionReaderBuilder::new().build(path)`,
2437 // which calls the already-covered `CommandReaderBuilder::build` two hops down.
2438 // `patterns_from_path` (pattern.rs:82) opens and reads the given file directly (`std::fs::File::
2439 // open`) — unrelated to the Exec family above, genuinely Fs.
2440 if path == "grep_cli::CommandReader::new"
2441 || path == "grep_cli::CommandReader::close"
2442 || path == "grep_cli::DecompressionMatcher::command"
2443 || path == "grep_cli::DecompressionReader::new"
2444 {
2445 return Some("Exec");
2446 }
2447 if path == "grep_cli::patterns_from_path" {
2448 return Some("Fs");
2449 }
2450 return None;
2451 }
2452 // `clircle` — detects whether two handles are the same file (cycle protection). `Identifier::try_from`
2453 // (File/Stdio) issues an `fstat`, and `surely_conflicts_with` does an `lseek` (`stream_position`) — both
2454 // Fs. The `PartialEq`/`Hash` comparisons read stored dev/ino and are PURE. (The named methods
2455 // `are_identical`/`same_file` do NOT exist in the crate — not modeled.)
2456 if crate_name == "clircle" {
2457 if path.ends_with("::try_from") || path.ends_with("::surely_conflicts_with") {
2458 return Some("Fs");
2459 }
2460 return None;
2461 }
2462 None
2463}
2464
2465pub fn cap_from_name(name: &str) -> Option<&'static str> {
2466 EFFECTS.iter().copied().find(|e| *e == name)
2467}
2468
2469/// Refine the `Exec` cliff (spec §4 ⟨0.5⟩): the effects a *literal, statically-known* subprocess
2470/// head implies, matched by basename (`/usr/bin/curl` → `curl`). The head's effects are ADDED to a
2471/// caller that already carries `Exec` (a subprocess is still spawned — `Exec` is never dropped); an
2472/// unrecognised or dynamically-built head returns `&[]` and keeps the bare cliff (never guess). A
2473/// **candor engine** reads `Fs`/`Env` only — spec §7 item 12 (the analyzer self-boundary) guarantees
2474/// that, so that case is spec-supplied, not curation. The rest is a small curated table under the
2475/// same under-report rule as the crate classifier. INVARIANT: every head here is an external tool
2476/// that does NOT run the analysed project's own code (so `make`/`npm`/`cargo` are deliberately
2477/// absent — they stay the cliff). The reference engines share this table so the `Exec` boundary —
2478/// the one boundary every engine hits — refines identically (the §4-consistency argument).
2479/// SPEC §2 `fs` — for a call ALREADY classified `Fs`, the read/write direction its path implies.
2480/// `["read"]`, `["write"]`, `["read","write"]`, or `[]` when the verb does not say.
2481///
2482/// THE EMPTY CASE IS THE DISCIPLINE. §2: *"when `Fs` is reached but its kind is unknown … the field MUST
2483/// be omitted rather than guessed. An empty or partial `fs` would be read as a positive claim ('reads but
2484/// never writes'), which is the §4 trust contract's forbidden direction."* So an unrecognised verb
2485/// contributes nothing and the field stays absent; absence means "kind undetermined", never "read-only".
2486///
2487/// A syntactic refinement of an effect already proved, NOT a soundness claim. Deliberately the same
2488/// vocabulary as candor-java's `fsKind`, candor-swift's and candor-ts's — the surface is spec'd four-way,
2489/// and four engines inventing four verb tables for one field is how a shared field stops meaning one thing.
2490pub fn fs_kind(path: &str) -> &'static [&'static str] {
2491 // the terminal segment is the verb (`std::fs::write`, `File::create`, `f.read_to_string`)
2492 let leaf = path.rsplit("::").next().unwrap_or(path);
2493 // `OpenOptions::open` is the one verb that does NOT carry its own direction — the direction was set
2494 // by the BUILDER chain (`.read(true)`/`.write(true)`), which this function cannot see. The READ list
2495 // below holds a bare `open` for `File::open`, unambiguously a read; letting an `OpenOptions` receiver
2496 // reach it publishes `fs: ["read"]` for a builder configured `write(true)`, and §2 reads that as the
2497 // positive claim "reads but never writes" — the forbidden direction. The paragraph under the READ
2498 // list has always SAID this; nothing implemented it, and it only became REACHABLE when
2499 // `OpenOptions::open` started classifying on its own — before, the `Fs` came from the
2500 // `OpenOptions::new` constructor, whose leaf claims nothing, so the field was simply absent.
2501 // MEASURED on this commit's own fix before this guard: `OpenOptions::new().read(true).open(p)` went
2502 // from `fs` ABSENT to `fs: ["read"]`, i.e. the fix minted a claim the engine cannot support. Keyed on
2503 // the TYPE segment, so it holds for the `fs_err`/`tokio::fs`/`async_std::fs` spellings too.
2504 if path.rsplit("::").nth(1) == Some("OpenOptions") {
2505 return &[];
2506 }
2507 // Reads the source AND writes the destination in one call.
2508 if matches!(leaf, "copy" | "rename" | "hard_link" | "soft_link" | "symlink") {
2509 return &["read", "write"];
2510 }
2511 const WRITE: &[&str] = &[
2512 "write", "write_all", "write_fmt", "write_vectored", "create", "create_new", "create_dir",
2513 "create_dir_all", "remove_file", "remove_dir", "remove_dir_all", "set_permissions",
2514 "set_len", "set_modified", "set_times", "append", "flush", "sync_all", "sync_data",
2515 "write_at", "truncate",
2516 ];
2517 const READ: &[&str] = &[
2518 "read", "read_to_string", "read_to_end", "read_exact", "read_dir", "read_link",
2519 "metadata", "symlink_metadata", "exists", "try_exists", "canonicalize", "open",
2520 "read_at", "file_type", "permissions", "modified", "accessed", "created", "len",
2521 ];
2522 if WRITE.contains(&leaf) { return &["write"]; }
2523 if READ.contains(&leaf) { return &["read"]; }
2524 // `OpenOptions` carries the direction in its BUILDER chain, not its terminal verb, so `.open()` on one
2525 // says nothing here and is deliberately left to the READ arm above only when it is `File::open`
2526 // (unambiguously a read). Anything else: no claim.
2527 if leaf.starts_with("write") || leaf.starts_with("append") { return &["write"]; }
2528 if leaf.starts_with("read") { return &["read"]; }
2529 &[]
2530}
2531
2532pub fn classify_command_head(cmd: &str) -> &'static [&'static str] {
2533 // Only UNAMBIGUOUS single-effect tools belong here. A multi-modal head (`git status` is local,
2534 // `git push` is Net; `rsync` local-vs-remote) would FABRICATE the effect for its common case —
2535 // the under-report rule forbids it, so such heads keep the bare cliff.
2536 match cmd.rsplit(['/', '\\']).next().unwrap_or(cmd) {
2537 "curl" | "wget" | "http" | "ssh" | "scp" | "sftp" | "ftp" | "telnet" => &["Net"],
2538 "psql" | "mysql" | "sqlite3" | "mongosh" | "mongo" | "redis-cli" | "cqlsh" | "influx" => &["Db"],
2539 // candor engines — Fs/Env only, guaranteed by spec §7 item 12 (the analyzer self-boundary)
2540 "candor" | "candor-run.sh" | "candor-scan" | "candor-query" | "candor-java"
2541 | "candor-classify" | "candor-report" | "cargo-candor" => &["Env", "Fs"],
2542 _ => &[],
2543 }
2544}
2545
2546/// Known machine-learning MODEL-provider hosts — the SPEC §1 ⟨0.13⟩ `Llm` host-literal refinement:
2547/// a statically-known `Net` request to one of these classifies `Llm` IN ADDITION to `Net` (Net is
2548/// never dropped — a model call IS network I/O, exactly as an `Exec`-refined subprocess keeps `Exec`),
2549/// just as a jdbc URL classifies `Db`. Matched by host, case-insensitive; a SUBDOMAIN of a listed host
2550/// counts. The reference engines share this table VERBATIM with candor-java's `Literals.MODEL_HOSTS`
2551/// (the analog of `classify_command_head`) so the `Net` boundary refines to `Llm` identically. An
2552/// UNKNOWN host stays bare `Net` — never guessed. Curated STARTER set; the §7 coverage ledger
2553/// discloses an uncovered provider like any other.
2554pub const MODEL_HOSTS: &[&str] = &[
2555 "api.openai.com",
2556 "api.anthropic.com",
2557 "generativelanguage.googleapis.com",
2558 "api.mistral.ai",
2559 "api.cohere.ai",
2560 "api.cohere.com",
2561 "api.groq.com",
2562 "api.together.xyz",
2563 "api.perplexity.ai",
2564 "openrouter.ai",
2565];
2566
2567/// Whether an endpoint HOST literal is a known model provider (case-insensitive; a subdomain of a
2568/// `MODEL_HOSTS` entry counts). Strips a `:port` suffix first. Two special forms carry their own rule,
2569/// matching candor-java's `Literals.isModelHost` exactly: any host whose port is `11434` is a local
2570/// Ollama endpoint (a LOOPBACK host — `localhost`/`127.0.0.1`/`::1` — on port 11434); and an AWS Bedrock
2571/// runtime host (the model-inference service label `bedrock-runtime`/`bedrock-agent-runtime`).
2572pub fn is_model_host(host_literal: &str) -> bool {
2573 // Strip any `:port` (via the shared host_part) and lowercase for the name comparisons.
2574 let host = policy::host_part(host_literal).to_ascii_lowercase();
2575 // Ollama is a LOCAL endpoint: :11434 → Llm ONLY on a loopback host (max-review r3 parity fix — "any
2576 // host on :11434" fabricated Llm on unrelated internal services on that port).
2577 if let Some((_, port)) = host_literal.rsplit_once(':') {
2578 if port == "11434" {
2579 return matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1");
2580 }
2581 }
2582 if MODEL_HOSTS.contains(&host.as_str()) {
2583 return true;
2584 }
2585 // A subdomain of a known model host counts (`eu.api.openai.com` → api.openai.com).
2586 if MODEL_HOSTS.iter().any(|m| host.ends_with(&format!(".{m}"))) {
2587 return true;
2588 }
2589 // AWS Bedrock runtime: the FIRST label is the model-inference service (`bedrock-runtime.<region>.
2590 // amazonaws.com`), NOT the substring "bedrock" (which caught `bedrock-backups.s3.amazonaws.com`, an
2591 // S3 bucket) and NOT the control-plane `bedrock.<region>.amazonaws.com`.
2592 host.ends_with(".amazonaws.com")
2593 && matches!(host.split('.').next(), Some("bedrock-runtime") | Some("bedrock-agent-runtime"))
2594}
2595
2596/// ⟨0.20⟩ Curated telemetry / analytics / APM hosts — the `Net` destination-class `known-telemetry` set
2597/// (NET-DESTINATION-CLASS-DESIGN.md), shared VERBATIM with candor-java's `Literals.TELEMETRY_HOSTS` (like
2598/// `MODEL_HOSTS`). A benign observability endpoint. Matched by host, case-insensitive; a SUBDOMAIN of a
2599/// listed host counts. Tight, high-precision STARTER set — mis-including an exfil-capable host would
2600/// under-gate `deny Net[unknown-host]`.
2601pub const TELEMETRY_HOSTS: &[&str] = &[
2602 "sentry.io",
2603 "bugsnag.com",
2604 "rollbar.com",
2605 "segment.io",
2606 "segment.com",
2607 "mixpanel.com",
2608 "amplitude.com",
2609 "google-analytics.com",
2610 "analytics.google.com",
2611 "datadoghq.com",
2612 "datadoghq.eu",
2613 "newrelic.com",
2614 "nr-data.net",
2615 "honeycomb.io",
2616 "logtail.com",
2617 // ⟨0.20.1⟩ corpus-grown (a real-repo dogfood): more single-purpose analytics / session-replay / RUM
2618 // providers — vendor-specific product domains only (no general-purpose host), so no under-gate risk.
2619 "posthog.com",
2620 "plausible.io",
2621 "usefathom.com",
2622 "heapanalytics.com",
2623 "fullstory.com",
2624 "hotjar.com",
2625 "logrocket.com",
2626 "cloudflareinsights.com",
2627];
2628
2629/// Whether an endpoint HOST literal is in `set` (case-insensitive; a subdomain of a listed host counts).
2630/// Strips a `:port` suffix first via `host_part`. The shared membership test for `TELEMETRY_HOSTS` and the
2631/// config-declared partner set (mirrors candor-java's `Literals.hostInSet`).
2632pub fn host_in_set(host_literal: &str, set: &[&str]) -> bool {
2633 let host = policy::host_part(host_literal).to_ascii_lowercase();
2634 set.contains(&host.as_str()) || set.iter().any(|e| host.ends_with(&format!(".{e}")))
2635}
2636
2637/// Whether an endpoint HOST literal is a known telemetry/analytics/APM host (`TELEMETRY_HOSTS`).
2638pub fn is_telemetry_host(host_literal: &str) -> bool {
2639 host_in_set(host_literal, TELEMETRY_HOSTS)
2640}
2641
2642/// ⟨0.20⟩ The `Net` DESTINATION CLASS of a host literal (NET-DESTINATION-CLASS-DESIGN.md): `known-telemetry`
2643/// (curated), `known-partner` (config `net-partner` OR a model host — a declared-ish external API), else
2644/// `unknown-host` — the HONEST default (candor makes no claim; the security gate bites this). A partner set
2645/// is per-project (config-declared). Never fabricated onto a safe class: an unresolved host is unknown-host.
2646/// Mirrors candor-java's `Literals.netDestClass`.
2647pub fn net_dest_class(host_literal: &str, partners: &std::collections::BTreeSet<String>) -> &'static str {
2648 if is_telemetry_host(host_literal) {
2649 return "known-telemetry";
2650 }
2651 if partner_for(host_literal, partners).is_some() || is_model_host(host_literal) {
2652 return "known-partner";
2653 }
2654 "unknown-host"
2655}
2656
2657/// ⟨0.31⟩ WHICH declared partner a host matched, or `None` — the SAME match `net_dest_class` decides on,
2658/// extracted so the DISCLOSURE and the DECISION cannot use different rules.
2659///
2660/// That is not a stylistic preference. The reverted first attempt at the `net-partner` disclosure
2661/// re-implemented this match against a normaliser that KEEPS the port, so an observed
2662/// `partner.example:443` never equalled a declared `partner.example` and the disclosure came back
2663/// SILENTLY EMPTY on every real run — while the verdicts it was reporting on had flipped. A disclosure
2664/// normalised differently from the decision it reports can only be wrong, and one function with two
2665/// callers is what makes writing one impossible.
2666pub fn partner_for(
2667 host_literal: &str,
2668 partners: &std::collections::BTreeSet<String>,
2669) -> Option<String> {
2670 if partners.is_empty() {
2671 return None;
2672 }
2673 let host = policy::host_part(host_literal).to_ascii_lowercase();
2674 if partners.contains(&host) {
2675 return Some(host);
2676 }
2677 partners
2678 .iter()
2679 .find(|p| host.ends_with(&format!(".{p}")))
2680 .cloned()
2681}
2682
2683/// ⟨0.20⟩ The closed `Net` destination-class vocabulary, for the `deny Net[<dest…>]` policy filter.
2684pub const NET_DEST_CLASSES: &[&str] = &["known-telemetry", "known-partner", "unknown-host"];
2685
2686/// Curated Rust model-provider SDK crates — the SPEC §1 ⟨0.13⟩ `Llm` model-SDK surface, the Rust analog
2687/// of candor-java's `Rules.MODEL_SDK_PACKAGES`. A resolved call into one of these crates classifies
2688/// `Llm` + `Net` (the caller adds both — a model dispatch IS network I/O). NO method-name gating: these
2689/// are single-purpose provider clients, so ANY call into the crate is a model dispatch (matches the java
2690/// reference's judgment call). Curated STARTER list; the §7 coverage ledger discloses the rest.
2691pub const MODEL_SDK_CRATES: &[&str] = &[
2692 "async_openai", // async-openai — the de-facto OpenAI client
2693 "anthropic_sdk", // anthropic-sdk
2694 "anthropic", // anthropic (community client crate)
2695 "aws_sdk_bedrockruntime", // AWS Bedrock runtime (invoke/converse) — the model surface of the aws-sdk family
2696 "ollama_rs", // ollama-rs — local Ollama client
2697 "langchain_rust", // langchain-rust — the LangChain invoke surfaces
2698 "mistralai", // mistralai (Mistral client)
2699 "genai", // genai — a multi-provider model client
2700];
2701
2702/// Whether a resolved call's CRATE is a curated model-provider SDK (`MODEL_SDK_CRATES`) → the SPEC §1
2703/// ⟨0.13⟩ `Llm` model-SDK classification (the caller adds both `Llm` and `Net`). Crate-level, no
2704/// method gating — a single-purpose client, matching candor-java's `isModelSdkOwner`.
2705pub fn is_model_sdk_crate(crate_name: &str) -> bool {
2706 MODEL_SDK_CRATES.contains(&crate_name)
2707}
2708
2709/// Whether a subprocess-builder method only MODIFIES the command (`.arg`, `.env`, `.current_dir`)
2710/// rather than NAMING the program (`Command::new`, `duct::cmd`). A WHOLE-CRATE-Exec crate
2711/// (`portable_pty`, `duct`, `async_process`) classifies *every* method as `Exec`, so the
2712/// head-refinement must skip these: an arg or env-var-name literal that happened to match a head
2713/// (`.env("psql", …)`, `.arg("curl")`) would FABRICATE that effect — the §1 under-report rule. The
2714/// method is the call path's last segment.
2715pub fn is_cmd_builder_method(method: &str) -> bool {
2716 matches!(
2717 method,
2718 "arg" | "args" | "arg0" | "env" | "envs" | "env_clear" | "env_remove" | "current_dir"
2719 | "cwd" | "stdin" | "stdout" | "stderr" | "pre_exec" | "creation_flags" | "uid" | "gid"
2720 | "groups" | "process_group"
2721 )
2722}
2723
2724/// Whether a subprocess method NAMES the program (so its first string literal IS the command head to
2725/// refine): `Command::new("curl")`, `duct::cmd("curl", …)`. The head-refinement must fire ONLY here —
2726/// an ALLOWLIST, not "any method except known modifiers". A whole-crate-Exec crate classifies EVERY
2727/// method as `Exec`, so a denylist leaked NON-naming methods that aren't modifiers — a getter like
2728/// `CommandBuilder::get_env("psql")` (reading back an env-var KEY, not a program) fed `"psql"` to the
2729/// head classifier and FABRICATED `Db` (review find). Only `new`/`cmd` name a program; everything else
2730/// (modifiers, getters `get_*`, custom builder methods) keeps the bare `Exec` cliff — under-refine
2731/// (safe) rather than fabricate. `std::process::Command` is verb-precise so getters never fire `Exec`
2732/// there anyway; the allowlist makes the whole-crate-Exec crates safe too.
2733pub fn is_cmd_naming_method(method: &str) -> bool {
2734 matches!(method, "new" | "cmd")
2735}
2736
2737/// The masking guard (AS-EFF-008): a Net call whose method takes the HOST/URL as an argument is
2738/// "establishing" — a classified Net call here with no captured host literal leaves the endpoint
2739/// structurally INVISIBLE (a runtime-built host), so the surface is incomplete and the gate must fail
2740/// closed (else a benign sibling literal masks the runtime endpoint). An ALLOWLIST of connection-
2741/// establishing verbs — the SAFE direction: a USE-verb on an already-connected socket
2742/// (`stream.write`/`read`/`flush`, `socket.send`/`recv`) is NOT here, so a missing literal there (the
2743/// host was fixed at `connect`) never false-positives. Under-catching an unusual establishing verb is a
2744/// missed mask (sound-with-disclosure), never a broken gate. The arg is the method (path's last segment).
2745pub fn is_net_establishing(method: &str) -> bool {
2746 matches!(
2747 method,
2748 "connect"
2749 | "connect_timeout"
2750 | "get"
2751 | "post"
2752 | "put"
2753 | "patch"
2754 | "delete"
2755 | "head"
2756 | "request"
2757 | "send_to"
2758 | "lookup_host"
2759 | "to_socket_addrs"
2760 )
2761}
2762
2763/// ⟨0.29⟩ A LOCAL BIND/LISTEN VERB — the address it names is where the process LISTENS, never a
2764/// destination it reaches.
2765///
2766/// **MEASURED, and it is a false all-clear rather than a naming quibble.** `UdpSocket::bind("0.0.0.0:0")`
2767/// put `0.0.0.0:0` into `hosts`, the DESTINATION surface `allow Net` gates on (§2), and — because a
2768/// literal had been captured — nothing marked the surface incomplete. So:
2769///
2770/// ```text
2771/// let s = UdpSocket::bind("0.0.0.0:0")?; // local
2772/// s.send_to(b"secrets", dst); // destination is a RUNTIME value
2773/// allow Net 0.0.0.0 -> policy ✓, exit 0
2774/// ```
2775///
2776/// A local listen address certified a send to an endpoint nobody can see. That is the masking evasion
2777/// AS-EFF-008 exists to close, reached through a verb whose literal is not a destination at all.
2778///
2779/// **A BIND CANNOT BE CERTIFIED, EVER**, which is why this marks the surface incomplete rather than
2780/// merely withholding the literal: a server that binds and accepts talks to whoever connects, so its
2781/// destination set is not statically knowable even in principle. candor-java already behaves this way —
2782/// it publishes the bind address AND hedges — and matching the reference engine keeps the informative
2783/// half (an operator can still see what the service listens on) while making it non-certifying.
2784/// ⟨0.29⟩ The Net verbs whose LOCATOR is at argument **1**, not 0 — the rust analogue of candor-ts's
2785/// `NET_URL_ARG1_MEMBERS`.
2786///
2787/// **A REGRESSION FOUND IN REVIEW, and the direction matters.** The positional-literal rung replaced
2788/// `first_str_lit` ("the first string literal ANYWHERE in the call") with `positional_str_lit(args, 0)`
2789/// as the UNIVERSAL default. That is right for `Fs`/`Db`/`Exec`, whose locator is always argument 0 —
2790/// but `is_net_establishing` already listed two verbs whose locator is not: `reqwest::Client::request`
2791/// takes `(Method, url)` and `UdpSocket::send_to` takes `(buf, addr)`. MEASURED after the swap:
2792/// `c.request(Method::GET, "https://api.example.com/v1")` published NO `hosts` at all and could not be
2793/// certified, while `c.get("https://api.example.com/v1")` certified normally.
2794///
2795/// ⟨0.29⟩ MEASURED FOR `request` ONLY. `send_to` is a METHOD on a receiver the STABLE syntactic backend
2796/// does not type, so `s.send_to(buf, "203.0.113.9:53")` is not classified at all there and its arg-1
2797/// literal is still uncaptured on the floor; the position is right and the deep engine can use it. Said
2798/// plainly because the first version of this fix's changelog listed both verbs as measured.
2799///
2800/// The direction is SAFE — an uncaptured locator fails closed, it never certifies something invisible —
2801/// which is exactly why it needed a review to find: the gate stayed sound and quietly stopped being
2802/// USABLE for a common shape. candor-ts avoided this by making its resolver verb-aware in the same rung;
2803/// this is that discipline arriving one engine late.
2804pub fn is_net_host_arg1(method: &str) -> bool {
2805 matches!(method, "request" | "send_to")
2806}
2807
2808pub fn is_net_binding(method: &str) -> bool {
2809 matches!(method, "bind" | "listen" | "bind_to_device" | "incoming" | "accept")
2810}
2811
2812/// The masking guard (AS-EFF-008), the `Fs` analog of `is_net_establishing`: whether an `Fs`-classified
2813/// call takes the filesystem PATH as a string argument (so a missing literal leaves the path
2814/// structurally INVISIBLE — a runtime-built path — and the surface is incomplete, fail-closed). An
2815/// ALLOWLIST of the path-NAMING free functions / constructors (`fs::write`/`read`/`File::open`/…), the
2816/// SAFE direction: a path-stat METHOD whose path is the RECEIVER (`p.metadata()`, `p.exists()`) is
2817/// invoked method-form and the caller gates on `!is_method`, so this never sees it; an op on an
2818/// already-opened handle (`file.write_all`, `mmap.flush`, `tempfile()` — a random name, no path arg)
2819/// is not here, so a missing literal there never false-positives. Under-catching an unusual
2820/// path-naming fn is a missed mask (sound-with-disclosure), never a broken gate. The arg is the
2821/// method/fn leaf (the path's last segment).
2822pub fn is_fs_path_arg(leaf: &str) -> bool {
2823 matches!(
2824 leaf,
2825 // std::fs / tokio::fs / async_std::fs / fs_err free functions taking a path argument
2826 "write"
2827 | "read"
2828 | "read_to_string"
2829 | "read_dir"
2830 | "read_link"
2831 | "copy"
2832 | "rename"
2833 | "remove_file"
2834 | "remove_dir"
2835 | "remove_dir_all"
2836 | "create_dir"
2837 | "create_dir_all"
2838 | "hard_link"
2839 | "soft_link"
2840 | "symlink"
2841 | "symlink_file"
2842 | "symlink_dir"
2843 | "symlink_metadata"
2844 | "canonicalize"
2845 | "metadata"
2846 | "set_permissions"
2847 | "exists"
2848 | "try_exists"
2849 // File / OpenOptions constructors taking a path argument
2850 | "open"
2851 | "create"
2852 | "create_new"
2853 )
2854}
2855
2856/// ⟨0.29⟩ HOW MANY LEADING ARGUMENTS OF AN `Fs` PATH-TAKING CALL ARE PATHS.
2857///
2858/// **THE DEFECT THIS EXISTS FOR.** The literal harvester took the first string literal found ANYWHERE in
2859/// the argument list, so `fs::write(user_path, "/tmp/lit")` published `paths: ["/tmp/lit"]` — the BYTES
2860/// BEING WRITTEN — as the destination surface, and `allow Fs /tmp/lit` certified a write to an
2861/// attacker-controlled path at exit 0. Measured on candor-scan and candor-ts; candor-java and
2862/// candor-swift read the path POSITION and fail closed correctly. Removing the sibling literal made the
2863/// same call fail closed, which is what identified the mechanism: any literal in the call, in any
2864/// position, defeated the runtime-path incompleteness marker.
2865///
2866/// So the surface is read from the PATH POSITIONS and nowhere else, and every one of them must be a
2867/// literal for the surface to be complete. Two-path operations are the reason this is an arity and not a
2868/// boolean: `fs::copy("/safe", user_path)` has a literal at position 0 and still writes somewhere
2869/// nobody can see, so requiring only position 0 would leave the identical hole one argument along.
2870pub fn fs_path_arity(leaf: &str) -> usize {
2871 match leaf {
2872 "copy" | "rename" | "hard_link" | "soft_link" | "symlink" | "symlink_file" | "symlink_dir" => 2,
2873 _ => 1,
2874 }
2875}
2876
2877/// The masking guard (AS-EFF-008), the `Db` analog of `is_net_establishing`: whether a `Db`-classified
2878/// call takes the raw SQL QUERY as a string argument (so a missing literal leaves the table
2879/// structurally INVISIBLE — a runtime-built query — and the surface is incomplete, fail-closed). An
2880/// ALLOWLIST of the SQL-string-bearing execution/prepare verbs, the SAFE direction: a
2881/// build-then-execute terminal that takes NO SQL string (sqlx/diesel/sea_orm `fetch*`/`load*`/`first`/
2882/// `all`/`one`/`stream`, the document-store `find*`/`insert*`/…), and a non-query op (`connect`/
2883/// `open`/`acquire`/`begin`/`commit`/`ping`/`get_conn`), are NOT here — their query is built
2884/// structurally (never a maskable string literal) so a missing literal must not false-positive.
2885/// Under-catching an unusual query verb is a missed mask (sound-with-disclosure), never a broken gate.
2886/// The arg is the method leaf (the path's last segment).
2887pub fn is_db_query_arg(leaf: &str) -> bool {
2888 matches!(
2889 leaf,
2890 "execute"
2891 | "execute_batch"
2892 | "execute_unprepared"
2893 | "batch_execute"
2894 | "simple_query"
2895 | "query"
2896 | "query_one"
2897 | "query_opt"
2898 | "query_raw"
2899 | "query_row"
2900 | "query_map"
2901 | "query_and_then"
2902 | "query_typed"
2903 | "query_all"
2904 | "prepare"
2905 | "prepare_typed"
2906 | "prepare_cached"
2907 | "exec"
2908 | "exec_first"
2909 | "exec_iter"
2910 | "exec_map"
2911 | "exec_fold"
2912 | "exec_drop"
2913 | "exec_batch"
2914 | "prep"
2915 | "run_command"
2916 )
2917}
2918
2919/// Map a cap-std capability *type* to the effect it authorises. Holding one of these
2920/// (e.g. `&Dir`) is the real, unforgeable right to perform that effect — so candor
2921/// treats it as a declared capability, exactly like its own `&Fs` token.
2922pub fn capstd_cap(crate_name: &str, type_name: &str) -> Option<&'static str> {
2923 if !crate_name.starts_with("cap_") {
2924 return None;
2925 }
2926 Some(match type_name {
2927 "Dir" => "Fs",
2928 "TcpListener" | "TcpStream" | "UdpSocket" | "Pool" => "Net",
2929 "UnixListener" | "UnixStream" | "UnixDatagram" => "Ipc",
2930 "SystemClock" | "MonotonicClock" => "Clock",
2931 _ => return None,
2932 })
2933}
2934
2935/// Table names a SQL string literal STATICALLY reaches — the `Db` analog of the `Net` host /
2936/// `Exec` command / `Fs` path literal surface (feeds `allow Db in <scope> <table>…`, AS-EFF-008).
2937/// Conservative by construction, because a wrong capture here would FABRICATE: the string must
2938/// open with a SQL statement keyword, and only identifiers in table position are taken —
2939/// `FROM`/`JOIN` anywhere, `INTO` anywhere, statement-leading `UPDATE`/`TRUNCATE`, and
2940/// `TABLE` (create/drop/alter), skipping `ONLY`/`IF NOT EXISTS`. `UPDATE` mid-statement is
2941/// deliberately ignored (`FOR UPDATE SKIP LOCKED` must not yield a table "skip"). A
2942/// dynamically-built query yields nothing — the gate's opaque case — never a guess.
2943/// Output is lower-cased, quote/backtick-stripped, `schema.table` kept qualified, deduped.
2944/// SPEC §2 pins this algorithm token-for-token across engines; the cross-impl vector battery
2945/// (candor-spec conformance/tables/vectors.json, run.sh Part 4b) enforces the JVM/TS mirrors.
2946pub fn tables_in_sql(sql: &str) -> Vec<String> {
2947 const STMT: &[&str] =
2948 &["select", "insert", "update", "delete", "create", "drop", "alter", "truncate", "merge", "replace", "with"];
2949 // Tokens that can FOLLOW a table-introducing keyword without being a table.
2950 const SKIP: &[&str] = &["only", "if", "not", "exists", "table"];
2951 // Identifier-position tokens that are grammar, not a table (subqueries, locking clauses…).
2952 const STOP: &[&str] = &[
2953 "select", "set", "where", "values", "on", "using", "group", "order", "by", "limit",
2954 "returning", "as", "inner", "outer", "left", "right", "cross", "lateral", "natural",
2955 "union", "all", "distinct", "case", "when", "null", "default", "skip", "nowait", "of",
2956 "from", "join", "into", "update", "delete", "insert",
2957 ];
2958 // `,` survives as its OWN token (not a space): it's what lets `FROM t1, t2` continue the table
2959 // list without fabricating from other comma-ridden positions (column lists, ON clauses).
2960 let cleaned: String = sql
2961 .to_lowercase()
2962 .chars()
2963 .flat_map(|c| match c {
2964 '(' | ')' | ';' => vec![' '],
2965 ',' => vec![' ', ',', ' '],
2966 _ => vec![c],
2967 })
2968 .collect();
2969 let toks: Vec<&str> = cleaned.split_whitespace().collect();
2970 let Some(first) = toks.first() else { return Vec::new() };
2971 if !STMT.contains(first) {
2972 return Vec::new(); // not SQL — nothing to certify, nothing fabricated
2973 }
2974 let ident = |t: &str| -> Option<String> {
2975 let t = t.trim_matches(|c| matches!(c, '"' | '`' | '\''));
2976 let mut chars = t.chars();
2977 let ok_first = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
2978 let ok_rest = t.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '$' | '"' | '`'));
2979 (ok_first && ok_rest && !STOP.contains(&t)).then(|| t.replace(['"', '`'], ""))
2980 };
2981 let mut out: Vec<String> = Vec::new();
2982 let mut push = |t: Option<String>| {
2983 if let Some(t) = t {
2984 if !out.contains(&t) {
2985 out.push(t);
2986 }
2987 }
2988 };
2989 for (i, tok) in toks.iter().enumerate() {
2990 let table_pos = match *tok {
2991 "from" | "join" | "into" | "table" => true,
2992 // statement-leading only (see doc comment): `update t set …`, `truncate [table] t`.
2993 "update" | "truncate" => i == 0,
2994 _ => false,
2995 };
2996 if !table_pos {
2997 continue;
2998 }
2999 let mut j = i + 1;
3000 while j < toks.len() && SKIP.contains(&toks[j]) {
3001 j += 1;
3002 }
3003 let Some(next) = toks.get(j) else { continue };
3004 let Some(first) = ident(next) else { continue };
3005 push(Some(first));
3006 // Comma-ADJACENT continuation only: `FROM t1, t2, t3` takes all three, while an alias breaks
3007 // the chain (`FROM t1 a, t2` keeps just t1 — an under-report, never a guess: skipping an
3008 // alias to chase the comma would fabricate tables out of `INSERT INTO t (a, b)`'s column
3009 // list, whose parens are spaces by the time we tokenize).
3010 while j + 2 < toks.len() && toks[j + 1] == "," {
3011 let Some(more) = ident(toks[j + 2]) else { break };
3012 push(Some(more));
3013 j += 2;
3014 }
3015 }
3016 out
3017}
3018
3019#[cfg(test)]
3020mod tests {
3021 #[test]
3022 fn model_host_recognizes_known_providers_and_special_forms() {
3023 use super::is_model_host as m;
3024 // exact known hosts (case-insensitive), with/without a port
3025 assert!(m("api.openai.com"));
3026 assert!(m("API.OpenAI.com"));
3027 assert!(m("api.anthropic.com:443"));
3028 assert!(m("generativelanguage.googleapis.com"));
3029 assert!(m("api.mistral.ai"));
3030 assert!(m("api.cohere.ai"));
3031 assert!(m("api.cohere.com")); // BOTH cohere hosts
3032 assert!(m("api.groq.com"));
3033 assert!(m("api.together.xyz"));
3034 assert!(m("api.perplexity.ai"));
3035 assert!(m("openrouter.ai"));
3036 // a subdomain of a known host counts
3037 assert!(m("eu.api.openai.com"));
3038 // Ollama: :11434 on a LOOPBACK host only (max-review r3 — a remote host on 11434 is not Ollama)
3039 assert!(m("localhost:11434"));
3040 assert!(m("127.0.0.1:11434"));
3041 assert!(!m("ollama.internal:11434")); // a remote/internal service on 11434 is NOT a model host
3042 // Bedrock: the FIRST label is the model-inference service, not the substring "bedrock"
3043 assert!(m("bedrock-runtime.us-east-1.amazonaws.com"));
3044 assert!(m("bedrock-runtime.eu-west-1.amazonaws.com"));
3045 assert!(m("bedrock-agent-runtime.us-east-1.amazonaws.com"));
3046 // NOT model hosts (never guessed)
3047 assert!(!m("example.com"));
3048 assert!(!m("api.stripe.com"));
3049 assert!(!m("localhost:8080")); // a non-Ollama local port
3050 assert!(!m("s3.us-east-1.amazonaws.com")); // amazonaws but not bedrock
3051 assert!(!m("bedrock-backups.s3.amazonaws.com")); // an S3 bucket merely NAMED bedrock — not the runtime
3052 assert!(!m("bedrock.us-east-1.amazonaws.com")); // the Bedrock CONTROL plane — not model inference
3053 assert!(!m("openai.com.evil.com")); // suffix trick — not a subdomain of a known host
3054 }
3055
3056 #[test]
3057 fn model_sdk_crate_is_crate_level_no_method_gating() {
3058 use super::is_model_sdk_crate as s;
3059 assert!(s("async_openai"));
3060 assert!(s("aws_sdk_bedrockruntime"));
3061 assert!(s("ollama_rs"));
3062 assert!(s("langchain_rust"));
3063 assert!(!s("reqwest"));
3064 assert!(!s("aws_sdk_s3"));
3065 }
3066
3067 #[test]
3068 fn sql_table_extraction_is_conservative() {
3069 use super::tables_in_sql as t;
3070 assert_eq!(t("SELECT id FROM users WHERE x = 1"), vec!["users"]);
3071 assert_eq!(t("select * from ledger.entries e join customers c on c.id = e.cid"),
3072 vec!["ledger.entries", "customers"]);
3073 assert_eq!(t("INSERT INTO audit_log (a) VALUES (?1)"), vec!["audit_log"]);
3074 assert_eq!(t("UPDATE accounts SET v = ?"), vec!["accounts"]);
3075 assert_eq!(t("DELETE FROM sessions WHERE id = ?"), vec!["sessions"]);
3076 assert_eq!(t("CREATE TABLE IF NOT EXISTS cache (k TEXT)"), vec!["cache"]);
3077 assert_eq!(t("TRUNCATE TABLE staging"), vec!["staging"]);
3078 // FOR UPDATE locking clause must not yield a phantom table (mid-statement update ignored)
3079 assert_eq!(t("SELECT * FROM jobs FOR UPDATE SKIP LOCKED"), vec!["jobs"]);
3080 // a subquery in FROM position yields nothing for that position
3081 assert_eq!(t("SELECT * FROM (SELECT 1) q"), Vec::<String>::new());
3082 // not SQL -> nothing (never fabricate)
3083 assert_eq!(t("/tmp/some/path"), Vec::<String>::new());
3084 assert_eq!(t("hello world from nowhere"), Vec::<String>::new());
3085 // comma-ADJACENT continuation: a FROM list takes every table in the chain…
3086 assert_eq!(t("SELECT a FROM t1, t2, s.t3 WHERE x = 1"), vec!["t1", "t2", "s.t3"]);
3087 // …but an alias breaks it (under-report, never a guess)…
3088 assert_eq!(t("SELECT a FROM t1 a1, t2 WHERE x = 1"), vec!["t1"]);
3089 // …which is exactly what keeps a column list from fabricating (parens are spaces by now).
3090 assert_eq!(t("INSERT INTO t (a, b) VALUES (1, 2)"), vec!["t"]);
3091 // a subquery after the comma stops the chain too
3092 assert_eq!(t("SELECT a FROM t1, (SELECT 1) q"), vec!["t1"]);
3093 }
3094
3095 use super::*;
3096
3097 /// The routed-handle list's MEMBERSHIP RULE, enforced rather than merely documented.
3098 ///
3099 /// (a) Every routed type must have a whole-type rule that answers an ARBITRARY method on it.
3100 /// If it did not, routing it would hand the scanner a path the classifier drops — motion with no
3101 /// effect, and the silent under-report would survive the fix that was supposed to close it. The
3102 /// probe leaf is deliberately a name no carve-out lists, so it measures the BASE rule.
3103 ///
3104 /// (b) The named exclusions stay excluded. `ReadDir` cannot have a verb-precise rule written for it
3105 /// (its `next` is a syscall and is spelled like every other iterator's), and `Metadata`/`DirEntry`/
3106 /// `Permissions`/`FileType` are pure DATA under the coarse `std::fs::` prefix — routing any of them
3107 /// charges an effect for reading a field. `OpenOptions`/`DirBuilder` LEFT this list by the route the
3108 /// list's own comment prescribed: their type-keyed setter carve-outs were written first, so the
3109 /// whole-type rule under them now answers only for the terminal verb.
3110 #[test]
3111 fn std_effect_handles_have_a_whole_type_rule_and_exclude_the_pure_surfaces() {
3112 for ty in STD_EFFECT_HANDLES {
3113 let probe = format!("{ty}::__candor_probe_verb");
3114 assert!(
3115 classify("std", &probe).is_some(),
3116 "`{ty}` is routed into the classifier by receiver inference, but an arbitrary method \
3117 on it classifies to NOTHING — the routing cannot close any under-report"
3118 );
3119 assert!(is_std_effect_handle(ty), "the predicate must agree with the list");
3120 }
3121 for ty in [
3122 "std::fs::ReadDir",
3123 "std::fs::Metadata", "std::fs::DirEntry", "std::fs::Permissions", "std::fs::FileType",
3124 "std::path::Path", "std::path::PathBuf", "std::vec::Vec",
3125 ] {
3126 assert!(!is_std_effect_handle(ty),
3127 "`{ty}` has a pure surface the coarse prefix rules would charge — it must not be \
3128 routed as a handle (Path/PathBuf route through their own verb-precise carve-out)");
3129 }
3130 }
3131
3132 /// The pure read-backs on the ROUTED types, which is what makes (a) above safe: a whole-type rule
3133 /// answering every method is only acceptable because the genuinely-pure methods were carved out
3134 /// first. Receiver routing is a second door into these rules, so it must not re-fabricate.
3135 #[test]
3136 fn routed_std_handles_keep_their_pure_accessor_carve_outs() {
3137 for p in [
3138 "std::process::Command::get_program", "std::process::Command::get_args",
3139 "std::process::Command::get_envs", "std::process::Command::get_current_dir",
3140 "std::process::Child::id",
3141 "std::net::TcpStream::local_addr", "std::net::TcpStream::peer_addr",
3142 "std::net::TcpStream::nodelay", "std::net::TcpStream::ttl",
3143 "std::net::UdpSocket::take_error",
3144 "std::fs::File::as_raw_fd", "std::fs::File::into_raw_fd",
3145 "std::os::unix::net::UnixStream::as_raw_fd",
3146 // The OPTION-BUILDER setters (SPEC §1 ⟨0.32⟩): flags in a struct, no file named, nothing
3147 // opened. `create` is here as `OpenOptions`' setter and is a VERB on `DirBuilder` below —
3148 // one leaf, two answers, which is why these carve-outs are keyed on the TYPE.
3149 "std::fs::OpenOptions::new", "std::fs::OpenOptions::read", "std::fs::OpenOptions::write",
3150 "std::fs::OpenOptions::append", "std::fs::OpenOptions::truncate",
3151 "std::fs::OpenOptions::create", "std::fs::OpenOptions::create_new",
3152 "std::fs::OpenOptions::mode", "std::fs::OpenOptions::custom_flags",
3153 "std::fs::DirBuilder::new", "std::fs::DirBuilder::recursive", "std::fs::DirBuilder::mode",
3154 ] {
3155 assert_eq!(classify("std", p), None, "`{p}` is a pure read-back");
3156 }
3157 // …and the verbs on the same types still answer, or the carve-outs would have eaten the rule.
3158 for (p, want) in [
3159 ("std::process::Command::spawn", "Exec"),
3160 ("std::process::Child::wait", "Exec"),
3161 ("std::fs::File::write_all", "Fs"),
3162 ("std::net::TcpStream::write_all", "Net"),
3163 ("std::os::unix::net::UnixStream::send", "Ipc"),
3164 // the TERMINAL VERBS of the two option-builders — the whole point of routing them.
3165 ("std::fs::OpenOptions::open", "Fs"),
3166 ("std::fs::DirBuilder::create", "Fs"),
3167 ] {
3168 assert_eq!(classify("std", p), Some(want), "`{p}` must stay {want}");
3169 }
3170 }
3171
3172 #[test]
3173 fn db_crates_are_calibrated() {
3174 // The calibrated set must cover every DB client the classifier knows, or the receipt's coverage
3175 // check would flag a recognized crate as a blind spot. (Was nightly-lint-only; now runs on stable.)
3176 for c in DB_CRATES {
3177 assert!(
3178 CALIBRATED_CRATES.contains(&c),
3179 "DB crate `{c}` is matched by classify() but missing from CALIBRATED_CRATES"
3180 );
3181 }
3182 }
3183
3184 /// The two coverage lists mean OPPOSITE things and must stay disjoint.
3185 ///
3186 /// `CALIBRATED_CRATES` = "classify has effect rules here". `REVIEWED_PURE_CRATES` = "read it, it
3187 /// performs nothing". A crate in both would be asserting both at once, and the ledger consults them
3188 /// with an OR — so the contradiction would resolve silently to "covered" and nobody would look again.
3189 #[test]
3190 fn reviewed_pure_and_calibrated_are_disjoint() {
3191 for c in REVIEWED_PURE_CRATES {
3192 assert!(!CALIBRATED_CRATES.contains(&c),
3193 "`{c}` is in BOTH lists — it cannot be rule-covered AND effect-free");
3194 assert!(!PATH_CALIBRATED_CRATES.contains(&c), "`{c}` is in BOTH lists (path-calibrated)");
3195 assert!(!CALIBRATED_PREFIXES.iter().any(|p| c.starts_with(p)),
3196 "`{c}` is covered by a calibrated PREFIX as well as the pure list");
3197 }
3198 }
3199
3200 /// A reviewed-pure crate must actually classify as pure — the mirror of `calibrated_crates_are_live`.
3201 ///
3202 /// The list makes candor BELIEVE these crates rather than disclose them, so if someone later adds a
3203 /// rule for one, the claim "performs no effect of its own" is dead and the entry has to be re-read,
3204 /// not silently outvoted by the rule. Probed with the same tails the liveness test uses, which is a
3205 /// broad sweep of the effectful verb shapes candor knows.
3206 #[test]
3207 fn reviewed_pure_crates_classify_as_pure() {
3208 for c in REVIEWED_PURE_CRATES {
3209 for t in CALIBRATION_PROBE_TAILS {
3210 assert!(classify(c, &format!("{c}{t}")).is_none(),
3211 "`{c}` is listed REVIEWED-PURE but classify() gives it an effect on `{c}{t}` — \
3212 one of the two is wrong, and the list is the claim");
3213 }
3214 }
3215 }
3216
3217 #[test]
3218 fn calibrated_crates_are_live() {
3219 // Conversely, every crate advertised as calibrated must actually be matched by classify() for
3220 // some representative path — a dead entry would silently suppress a real coverage warning.
3221 for c in CALIBRATED_CRATES {
3222 assert!(
3223 CALIBRATION_PROBE_TAILS.iter().any(|t| classify(c, &format!("{c}{t}")).is_some()),
3224 "calibrated crate `{c}` is matched by no path in classify() — dead list entry"
3225 );
3226 }
3227 }
3228
3229 #[test]
3230 fn async_http_stack_classifies() {
3231 // The modern async-HTTP/TLS/QUIC/DNS stack (found by the independent-method differential on oha):
3232 // verb-keyed Net/Ipc/Fs/Env, crate-gated so generic verbs never fabricate across crates.
3233 assert_eq!(classify("hyper", "hyper::client::conn::http1::SendRequest::send_request"), Some("Net"));
3234 assert_eq!(classify("hyper", "hyper::client::conn::http1::handshake"), Some("Net"));
3235 assert_eq!(classify("hyper_util", "hyper_util::client::legacy::Client::request"), Some("Net"));
3236 assert_eq!(classify("hickory_resolver", "hickory_resolver::Resolver::lookup_ip"), Some("Net"));
3237 assert_eq!(classify("quinn", "quinn::Endpoint::connect"), Some("Net"));
3238 assert_eq!(classify("quinn", "quinn::RecvStream::read_to_end"), Some("Net")); // stream byte I/O, not just open
3239 assert_eq!(classify("quinn", "quinn::SendStream::write_all"), Some("Net"));
3240 assert_eq!(classify("tokio_rustls", "tokio_rustls::TlsConnector::connect"), Some("Net"));
3241 assert_eq!(classify("native_tls", "native_tls::TlsConnector::connect"), Some("Net"));
3242 assert_eq!(classify("tokio_vsock", "tokio_vsock::VsockStream::connect"), Some("Ipc"));
3243 assert_eq!(classify("rustls_native_certs", "rustls_native_certs::load_native_certs"), Some("Fs"));
3244 assert_eq!(classify("rlimit", "rlimit::setrlimit"), Some("Env"));
3245 // num_cpus is deliberately PURE (consistency with std::thread::available_parallelism; avoids Env spray)
3246 assert_eq!(classify("num_cpus", "num_cpus::get"), None);
3247 assert_eq!(classify("num_cpus", "num_cpus::get_physical"), None);
3248 // pure surface stays None (no fabrication): builder/type/config paths, and other crates' generic verbs
3249 assert_eq!(classify("hyper", "hyper::Request::builder"), None);
3250 assert_eq!(classify("hyper", "hyper::body::Bytes::new"), None);
3251 assert_eq!(classify("native_tls", "native_tls::TlsConnectorBuilder::min_protocol_version"), None);
3252 assert_eq!(classify("serde", "serde::Deserialize::request"), None); // generic verb, wrong crate
3253 }
3254
3255 #[test]
3256 fn coverage_differential_crates_classify() {
3257 // Crates the coverage differential found DISCLOSED-but-unmodeled. Each rule is verb-keyed +
3258 // crate-gated; the EFFECT verbs map to the right bucket and the PURE surface stays None (a
3259 // wrongly-flagged pure crate is a fabrication, so the negatives matter as much as the positives).
3260
3261 // rustls (sync TLS core) — record I/O is Net; config/cert + the buffered-decrypt step are pure.
3262 assert_eq!(classify("rustls", "rustls::ClientConnection::read_tls"), Some("Net"));
3263 assert_eq!(classify("rustls", "rustls::ConnectionCommon::write_tls"), Some("Net"));
3264 assert_eq!(classify("rustls", "rustls::Connection::complete_io"), Some("Net"));
3265 assert_eq!(classify("rustls", "rustls::ConnectionCommon::process_new_packets"), None); // buffered decrypt, no I/O
3266 assert_eq!(classify("rustls", "rustls::ClientConfig::builder"), None); // pure config
3267
3268 // native-tls variants — handshake is Net; builder is pure.
3269 assert_eq!(classify("native_tls_crate", "native_tls_crate::TlsConnector::connect"), Some("Net"));
3270 assert_eq!(classify("tokio_native_tls", "tokio_native_tls::TlsAcceptor::accept"), Some("Net"));
3271 assert_eq!(classify("native_tls_crate", "native_tls_crate::TlsConnectorBuilder::min_protocol_version"), None);
3272
3273 // etcetera — dir resolution reads env; the args data type is pure.
3274 assert_eq!(classify("etcetera", "etcetera::home_dir"), Some("Env"));
3275 assert_eq!(classify("etcetera", "etcetera::base_strategy::choose_base_strategy"), Some("Env"));
3276 assert_eq!(classify("etcetera", "etcetera::base_strategy::Xdg::config_dir"), Some("Env"));
3277 assert_eq!(classify("etcetera", "etcetera::app_strategy::AppStrategyArgs::new"), None); // pure data
3278
3279 // sqlx-core — connect is Net, execute/fetch round-trips are Db; options/builders pure.
3280 assert_eq!(classify("sqlx_core", "sqlx_core::connection::Connection::connect"), Some("Net"));
3281 assert_eq!(classify("sqlx_core", "sqlx_core::executor::Executor::fetch_one"), Some("Db"));
3282 assert_eq!(classify("sqlx_core", "sqlx_core::executor::Executor::execute"), Some("Db"));
3283 assert_eq!(classify("sqlx_core", "sqlx_core::pool::Pool::acquire"), Some("Db"));
3284 assert_eq!(classify("sqlx_core", "sqlx_core::pool::PoolOptions::max_connections"), None); // pure builder
3285
3286 // walkdir — charged at construction (`WalkDir::new`, mirroring `ignore`/`glob`), NOT solely at
3287 // the lazy `next()`/`metadata()` read: candor-scan's receiver-typing blocklist hard-blocks
3288 // `.into_iter()`, so a typed `IntoIter::next` receiver is unreachable from the idiomatic
3289 // `WalkDir::new(p).into_iter()...` chain — `WalkDir::new` is the only point every real usage
3290 // (for-loop, `.count()`, untyped `.next()`) actually reaches. `IntoIter::next`/`DirEntry::metadata`
3291 // stay Some("Fs") as a secondary rule for the narrower explicit-type-annotation case. Builder
3292 // setters (`into_iter` itself, `max_depth`, …) and cached accessors stay pure.
3293 assert_eq!(classify("walkdir", "walkdir::WalkDir::new"), Some("Fs"));
3294 assert_eq!(classify("walkdir", "walkdir::IntoIter::next"), Some("Fs"));
3295 assert_eq!(classify("walkdir", "walkdir::DirEntry::metadata"), Some("Fs"));
3296 assert_eq!(classify("walkdir", "walkdir::WalkDir::into_iter"), None); // no I/O until pulled
3297 assert_eq!(classify("walkdir", "walkdir::WalkDir::max_depth"), None); // builder setter
3298 assert_eq!(classify("walkdir", "walkdir::DirEntry::file_type"), None); // cached, no syscall
3299
3300 // filetime — set_* are utimes (Fs), now is Clock; from_* constructors pure.
3301 assert_eq!(classify("filetime", "filetime::set_file_mtime"), Some("Fs"));
3302 assert_eq!(classify("filetime", "filetime::set_file_handle_times"), Some("Fs"));
3303 assert_eq!(classify("filetime", "filetime::FileTime::now"), Some("Clock"));
3304 assert_eq!(classify("filetime", "filetime::FileTime::from_unix_time"), None);
3305 assert_eq!(classify("filetime", "filetime::FileTime::from_last_modification_time"), None); // reads &Metadata, not disk
3306
3307 // execute — the execute* verbs spawn (Exec); command/shell builders pure.
3308 assert_eq!(classify("execute", "execute::Execute::execute"), Some("Exec"));
3309 assert_eq!(classify("execute", "execute::Execute::execute_output"), Some("Exec"));
3310 assert_eq!(classify("execute", "execute::Execute::execute_multiple_output"), Some("Exec"));
3311 assert_eq!(classify("execute", "execute::command"), None); // only builds a Command
3312 assert_eq!(classify("execute", "execute::shell"), None);
3313
3314 // ctrlc — install signal handler (Ipc).
3315 assert_eq!(classify("ctrlc", "ctrlc::set_handler"), Some("Ipc"));
3316 assert_eq!(classify("ctrlc", "ctrlc::try_set_handler"), Some("Ipc"));
3317
3318 // clap — only the argv-reading terminals are Env; the whole builder + *_from variants pure.
3319 assert_eq!(classify("clap", "clap::Command::get_matches"), Some("Env"));
3320 assert_eq!(classify("clap", "clap::Command::try_get_matches"), Some("Env"));
3321 assert_eq!(classify("clap", "clap::Parser::parse"), Some("Env"));
3322 assert_eq!(classify("clap", "clap::Command::new"), None); // builder
3323 assert_eq!(classify("clap", "clap::Arg::about"), None); // builder
3324 assert_eq!(classify("clap", "clap::Command::get_matches_from"), None); // explicit args, no argv read
3325
3326 // jiff — now* is Clock; tz lookups read the tzdb (Fs); span/civil math pure.
3327 assert_eq!(classify("jiff", "jiff::Timestamp::now"), Some("Clock"));
3328 assert_eq!(classify("jiff", "jiff::Zoned::now_with"), Some("Clock"));
3329 assert_eq!(classify("jiff", "jiff::tz::TimeZone::system"), Some("Fs"));
3330 assert_eq!(classify("jiff", "jiff::tz::TimeZone::get"), Some("Fs"));
3331 assert_eq!(classify("jiff", "jiff::Span::checked_add"), None); // pure arithmetic
3332
3333 // env_logger — init installs the logger + reads RUST_LOG (Log); config setters pure.
3334 // TUI — the tty is a user dialogue channel (Ipc), the ruling dialoguer/console already carry.
3335 // Each verb below was read off the crate source (crossterm-0.28.1, ratatui-0.29.0), not guessed.
3336 assert_eq!(classify("crossterm", "crossterm::ExecutableCommand::execute"), Some("Ipc"));
3337 assert_eq!(classify("crossterm", "crossterm::QueueableCommand::queue"), Some("Ipc"));
3338 assert_eq!(classify("crossterm", "crossterm::event::read"), Some("Ipc"));
3339 assert_eq!(classify("crossterm", "crossterm::event::poll"), Some("Ipc"));
3340 assert_eq!(classify("crossterm", "crossterm::terminal::enable_raw_mode"), Some("Ipc"));
3341 // a tty IOCTL must not fall through: in a CALIBRATED crate an unmatched path is a purity CLAIM
3342 assert_eq!(classify("crossterm", "crossterm::terminal::size"), Some("Ipc"));
3343 // the Command VALUE types are pure — they describe an action, they do not perform one
3344 assert_eq!(classify("crossterm", "crossterm::style::Print"), None);
3345 assert_eq!(classify("crossterm", "crossterm::cursor::MoveTo"), None);
3346
3347 // ratatui: the backlog said "mark reviewed-pure"; the SOURCE says `Terminal::draw` ends in a
3348 // backend flush, so the write surface is Ipc and only the render surface is pure.
3349 assert_eq!(classify("ratatui", "ratatui::Terminal::draw"), Some("Ipc"));
3350 assert_eq!(classify("ratatui", "ratatui::Terminal::flush"), Some("Ipc"));
3351 assert_eq!(classify("ratatui", "ratatui::Terminal::clear"), Some("Ipc"));
3352 assert_eq!(classify("ratatui", "ratatui::Terminal::hide_cursor"), Some("Ipc"));
3353 // REGRESSION: `widgets::canvas` is an in-memory grid. `Context::draw` ends in `::draw` and was
3354 // FABRICATING Ipc — caught in review, measured on a fixture, and a hot path (per shape, per frame).
3355 assert_eq!(classify("ratatui", "ratatui::widgets::canvas::Context::draw"), None);
3356 assert_eq!(classify("ratatui", "ratatui::widgets::canvas::Context::layer"), None);
3357 // …while the real write surface still classifies, including a DIRECT backend call (which is why
3358 // the carve-out is a denylist on canvas rather than an allowlist on `Terminal::`).
3359 assert_eq!(classify("ratatui", "ratatui::backend::CrosstermBackend::flush"), Some("Ipc"));
3360 // the BULK of the 3,345 disclosed calls — widgets, layout, style — are genuinely pure
3361 assert_eq!(classify("ratatui", "ratatui::widgets::Paragraph::new"), None);
3362 assert_eq!(classify("ratatui", "ratatui::layout::Layout::split"), None);
3363 assert_eq!(classify("ratatui", "ratatui::style::Style::fg"), None);
3364 assert_eq!(classify("ratatui", "ratatui::buffer::Buffer::set_string"), None);
3365
3366 // tracing_subscriber — two effects, both read off 0.3.23. The filing said "Log/Fs"; the Fs half
3367 // is wrong (the crate ACCEPTS a File as a writer, it never opens one).
3368 assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::init"), Some("Log"));
3369 assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::try_init"), Some("Log"));
3370 assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::SubscriberBuilder::init"), Some("Log"));
3371 assert_eq!(classify("tracing_subscriber", "tracing_subscriber::EnvFilter::from_default_env"), Some("Env"));
3372 assert_eq!(classify("tracing_subscriber", "tracing_subscriber::EnvFilter::from_env"), Some("Env"));
3373 // builders DESCRIBE a subscriber; they do not install one
3374 assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::layer"), None);
3375 assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::SubscriberBuilder::with_target"), None);
3376 assert_eq!(classify("tracing_subscriber", "tracing_subscriber::EnvFilter::new"), None);
3377
3378 assert_eq!(classify("env_logger", "env_logger::init"), Some("Log"));
3379 assert_eq!(classify("env_logger", "env_logger::try_init"), Some("Log"));
3380 assert_eq!(classify("env_logger", "env_logger::Builder::init"), Some("Log"));
3381 assert_eq!(classify("env_logger", "env_logger::Builder::format_timestamp"), None); // config
3382 assert_eq!(classify("env_logger", "env_logger::Builder::build"), None); // pure build
3383
3384 // dialoguer — interact* is tty I/O (Ipc); builders pure.
3385 assert_eq!(classify("dialoguer", "dialoguer::Input::interact_text"), Some("Ipc"));
3386 assert_eq!(classify("dialoguer", "dialoguer::Confirm::interact"), Some("Ipc"));
3387 assert_eq!(classify("dialoguer", "dialoguer::Select::interact_opt"), Some("Ipc"));
3388 assert_eq!(classify("dialoguer", "dialoguer::Input::with_prompt"), None); // builder
3389
3390 // console — Term I/O is Ipc, detection is Env, Style is pure.
3391 assert_eq!(classify("console", "console::Term::write_line"), Some("Ipc"));
3392 assert_eq!(classify("console", "console::Term::read_key"), Some("Ipc"));
3393 assert_eq!(classify("console", "console::colors_enabled"), Some("Env"));
3394 assert_eq!(classify("console", "console::Style::cyan"), None); // pure styling
3395 assert_eq!(classify("console", "console::strip_ansi_codes"), None); // pure text util
3396
3397 // terminal_colorsaurus — tty colour query (Ipc).
3398 assert_eq!(classify("terminal_colorsaurus", "terminal_colorsaurus::background_color"), Some("Ipc"));
3399 assert_eq!(classify("terminal_colorsaurus", "terminal_colorsaurus::color_palette"), Some("Ipc"));
3400
3401 // backoff — retry sleeps + reads the clock (Clock); config pure.
3402 assert_eq!(classify("backoff", "backoff::retry"), Some("Clock"));
3403 assert_eq!(classify("backoff", "backoff::retry_notify"), Some("Clock"));
3404 assert_eq!(classify("backoff", "backoff::ExponentialBackoff::default"), None);
3405
3406 // lscolors — ONLY from_env reads the environment; from_string/style_for_path pure.
3407 assert_eq!(classify("lscolors", "lscolors::LsColors::from_env"), Some("Env"));
3408 assert_eq!(classify("lscolors", "lscolors::LsColors::from_string"), None);
3409 assert_eq!(classify("lscolors", "lscolors::LsColors::style_for_path"), None);
3410
3411 // wild — argv readers (Env).
3412 assert_eq!(classify("wild", "wild::args"), Some("Env"));
3413 assert_eq!(classify("wild", "wild::args_os"), Some("Env"));
3414
3415 // grep_cli — only the firm Exec (CommandReader spawn); the isatty probes stay unmodeled.
3416 assert_eq!(classify("grep_cli", "grep_cli::CommandReaderBuilder::build"), Some("Exec"));
3417 assert_eq!(classify("grep_cli", "grep_cli::is_readable_stdin"), None); // isatty/fstat, not modeled
3418 assert_eq!(classify("grep_cli", "grep_cli::is_tty_stdout"), None);
3419
3420 // clircle — same-file detection issues fstat/lseek (Fs); equality is pure.
3421 assert_eq!(classify("clircle", "clircle::Identifier::try_from"), Some("Fs"));
3422 assert_eq!(classify("clircle", "clircle::Clircle::surely_conflicts_with"), Some("Fs"));
3423 }
3424
3425 #[test]
3426 fn log_tracing_emit_macros_classify_pre_expansion() {
3427 // candor-scan is pre-expansion: it sees the raw macro path (`log::info`, `tracing::warn`), not the
3428 // expanded dispatch the deep engine sees. Both the user-facing macro names AND the type surface:
3429 assert_eq!(classify("log", "log::info"), Some("Log"));
3430 assert_eq!(classify("log", "log::error"), Some("Log"));
3431 assert_eq!(classify("tracing", "tracing::warn"), Some("Log"));
3432 assert_eq!(classify("tracing", "tracing::info_span"), Some("Log"));
3433 // pure data-type surface stays None (no fabricated Log)
3434 assert_eq!(classify("log", "log::Level::as_str"), None);
3435 assert_eq!(classify("tracing", "tracing::Level::INFO"), None);
3436 }
3437
3438 #[test]
3439 fn classify_core_effects() {
3440 // A representative smoke test of the classifier's main families, so the published crate is not
3441 // shipped untested (these used to live only in the nightly-only src/lib.rs).
3442 assert_eq!(classify("std", "std::fs::read_to_string"), Some("Fs"));
3443 // std::path stat-family methods are Fs (each is a stat/readdir syscall); the pure
3444 // string-manipulation surface stays unclassified (the blackout screen's gix-dir find).
3445 assert_eq!(classify("std", "std::path::Path::symlink_metadata"), Some("Fs"));
3446 assert_eq!(classify("std", "std::path::PathBuf::read_dir"), Some("Fs"));
3447 assert_eq!(classify("std", "std::path::Path::exists"), Some("Fs"));
3448 assert_eq!(classify("std", "std::path::Path::join"), None); // pure string manipulation
3449 assert_eq!(classify("std", "std::path::PathBuf::file_name"), None);
3450 assert_eq!(classify("std", "std::path::Path::parent"), None);
3451 assert_eq!(classify("std", "std::process::Command::new"), Some("Exec"));
3452 assert_eq!(classify("std", "std::env::var"), Some("Env"));
3453 assert_eq!(classify("reqwest", "reqwest::Client::execute"), Some("Net"));
3454 // one-shot convenience fns send immediately → Net.
3455 assert_eq!(classify("reqwest", "reqwest::get"), Some("Net"));
3456 assert_eq!(classify("reqwest", "reqwest::blocking::get"), Some("Net"));
3457 // the URL-BEARING builder methods classify Net too — the DOMINANT idiom is the builder chain
3458 // `Client::new().post(url).send()`, whose URL literal rides the `.post(url)` step (NOT `.send()`),
3459 // so the endpoint (and the Llm host refinement) only get captured if the URL-naming step is Net.
3460 assert_eq!(classify("reqwest", "reqwest::Client::get"), Some("Net"));
3461 assert_eq!(classify("reqwest", "reqwest::Client::post"), Some("Net"));
3462 assert_eq!(classify("reqwest", "reqwest::Client::put"), Some("Net"));
3463 assert_eq!(classify("reqwest", "reqwest::Client::delete"), Some("Net"));
3464 assert_eq!(classify("reqwest", "reqwest::Client::request"), Some("Net"));
3465 // the PURE builder surface stays None (no URL, no dispatch).
3466 assert_eq!(classify("reqwest", "reqwest::RequestBuilder::header"), None);
3467 assert_eq!(classify("reqwest", "reqwest::RequestBuilder::json"), None);
3468 assert_eq!(classify("reqwest", "reqwest::ClientBuilder::build"), None);
3469 // RAW POSIX SOCKETS — the lowest network tier, pinned as a regression guard (four-way close:
3470 // swift got a raw-socket regression this week from a bare-identifier collision; rust never had
3471 // the gap because it classifies path-QUALIFIED via the syscall-leaf table, but pin it so the
3472 // `socket`/`connect` Net rows can't silently drop). `libc::connect`/`libc::socket` are the direct
3473 // FFI syscalls; `nix::sys::socket::connect` is the safe wrapper; both bottom out in the NET table.
3474 assert_eq!(classify("libc", "libc::connect"), Some("Net"));
3475 assert_eq!(classify("libc", "libc::socket"), Some("Net"));
3476 assert_eq!(classify("libc", "libc::bind"), Some("Net"));
3477 assert_eq!(classify("libc", "libc::accept"), Some("Net"));
3478 // nix routes through the libc syscall table (same leaves): I/O classified, generic fd ops skipped.
3479 assert_eq!(classify("nix", "nix::fcntl::open"), Some("Fs"));
3480 assert_eq!(classify("nix", "nix::sys::socket::connect"), Some("Net"));
3481 assert_eq!(classify("nix", "nix::sys::socket::socket"), Some("Net"));
3482 assert_eq!(classify("nix", "nix::unistd::execvp"), Some("Exec"));
3483 assert_eq!(classify("nix", "nix::unistd::write"), None); // generic fd op — deliberately unclassified
3484 assert_eq!(classify("nix", "nix::unistd::getpid"), None); // not I/O
3485 // rustix does raw syscalls (no libc underneath) → classified directly by leaf, same table.
3486 assert_eq!(classify("rustix", "rustix::time::clock_settime"), Some("Clock"));
3487 assert_eq!(classify("rustix", "rustix::fs::symlink"), Some("Fs"));
3488 assert_eq!(classify("rustix", "rustix::net::connect"), Some("Net"));
3489 assert_eq!(classify("rustix", "rustix::io::read"), None); // generic fd op
3490 // pnet raw packet capture: channel openers are Net, packet construction stays pure.
3491 assert_eq!(classify("pnet", "pnet::datalink::channel"), Some("Net"));
3492 assert_eq!(classify("pnet", "pnet::transport::transport_channel"), Some("Net"));
3493 assert_eq!(classify("pnet_datalink", "pnet_datalink::channel"), Some("Net"));
3494 assert_eq!(classify("pnet", "pnet::packet::ethernet::EthernetPacket::new"), None);
3495 assert_eq!(classify("pnet_base", "pnet_base::MacAddr::new"), None);
3496 // ignore (gitignore-aware walker): walk executors are Fs, config builders stay pure.
3497 assert_eq!(classify("ignore", "ignore::WalkBuilder::build_parallel"), Some("Fs"));
3498 assert_eq!(classify("ignore", "ignore::WalkBuilder::build"), Some("Fs"));
3499 assert_eq!(classify("ignore", "ignore::WalkParallel::run"), Some("Fs"));
3500 assert_eq!(classify("ignore", "ignore::WalkBuilder::add_ignore"), Some("Fs")); // reads the ignore file
3501 assert_eq!(classify("ignore", "ignore::overrides::OverrideBuilder::build"), None); // pure config
3502 assert_eq!(classify("ignore", "ignore::gitignore::GitignoreBuilder::build"), None); // pure config
3503 assert_eq!(classify("ignore", "ignore::DirEntry::path"), None); // pure accessor
3504 // THE FIX: `Walk::new`/`Walk::from_iter` are `WalkBuilder::new(path).build()` /
3505 // `WalkBuilder::from_iter(paths).build()` in ignore's own source — the crate's documented
3506 // convenience constructors, and its own top-level doc example (`for entry in Walk::new(path)`).
3507 // A corpus round caught `deny Fs` exiting 0 over exactly that idiom.
3508 assert_eq!(classify("ignore", "ignore::Walk::new"), Some("Fs"));
3509 assert_eq!(classify("ignore", "ignore::Walk::from_iter"), Some("Fs"));
3510 // NO FABRICATION: a same-named `Walk::new` from an unrelated crate must not gain Fs — crate-gated.
3511 assert_eq!(classify("walkdir", "walkdir::Walk::new"), None);
3512 assert_eq!(classify("some_local_crate", "some_local_crate::Walk::new"), None);
3513 // notify fs-watching: watcher constructors + watch/unwatch are Fs, data types stay pure.
3514 assert_eq!(classify("notify", "notify::RecommendedWatcher::new"), Some("Fs"));
3515 assert_eq!(classify("notify", "notify::PollWatcher::new"), Some("Fs"));
3516 assert_eq!(classify("notify", "notify::recommended_watcher"), Some("Fs"));
3517 assert_eq!(classify("notify", "notify::INotifyWatcher::watch"), Some("Fs"));
3518 assert_eq!(classify("notify", "notify::Config::default"), None); // pure config
3519 assert_eq!(classify("notify", "notify::Event::new"), None); // pure data type
3520 assert_eq!(classify("rusqlite", "rusqlite::Connection::execute"), Some("Db"));
3521 // the rusqlite verb DIALECT (a verb probe found the canonical consumer API classifying pure):
3522 assert_eq!(classify("rusqlite", "rusqlite::Connection::query_row"), Some("Db"));
3523 assert_eq!(classify("rusqlite", "rusqlite::Statement::query_map"), Some("Db"));
3524 assert_eq!(classify("rusqlite", "rusqlite::Connection::execute_batch"), Some("Db"));
3525 assert_eq!(classify("rusqlite", "rusqlite::Connection::prepare_cached"), Some("Db"));
3526 assert_eq!(classify("rusqlite", "rusqlite::Connection::open"), Some("Db"));
3527 assert_eq!(classify("rusqlite", "rusqlite::Connection::open_in_memory"), Some("Db"));
3528 // THE FIX: three more real `Connection::open*` constructors an exact-suffix list missed
3529 // (`open_in_memory_with_flags` doesn't END in `open_with_flags`), plus the documented blob-I/O
3530 // API (`blob_open`/`Blob::reopen`), each calling the real sqlite3 FFI directly.
3531 assert_eq!(classify("rusqlite", "rusqlite::Connection::open_in_memory_with_flags"), Some("Db"));
3532 assert_eq!(classify("rusqlite", "rusqlite::Connection::open_with_flags_and_vfs"), Some("Db"));
3533 assert_eq!(
3534 classify("rusqlite", "rusqlite::Connection::open_in_memory_with_flags_and_vfs"),
3535 Some("Db")
3536 );
3537 assert_eq!(classify("rusqlite", "rusqlite::Connection::blob_open"), Some("Db"));
3538 assert_eq!(classify("rusqlite", "rusqlite::blob::Blob::reopen"), Some("Db"));
3539 // NO FABRICATION: a same-crate, DIFFERENT type's `open`-prefixed method (rusqlite's own private
3540 // `pragma::Sql::open_brace`, which pushes one char to a string buffer) must not gain Db — the
3541 // fix keys on the `Connection::` segment, not a bare leaf prefix.
3542 assert_eq!(classify("rusqlite", "rusqlite::pragma::Sql::open_brace"), None);
3543 // THE COVERAGE-GATE SWEEP FIX: the online-backup API and incremental-BLOB positional I/O,
3544 // verified against rusqlite 0.32.1 (each calls an `ffi::sqlite3_*` leaf already in this file's
3545 // own FFI-leaf DB table, but the `crate_name == "rusqlite"` block above returned before ever
3546 // reaching it — same shape as the git2 fix elsewhere in this file).
3547 assert_eq!(classify("rusqlite", "rusqlite::Backup::new"), Some("Db"));
3548 assert_eq!(classify("rusqlite", "rusqlite::Backup::new_with_names"), Some("Db"));
3549 assert_eq!(classify("rusqlite", "rusqlite::Backup::step"), Some("Db"));
3550 assert_eq!(classify("rusqlite", "rusqlite::Backup::run_to_completion"), Some("Db"));
3551 assert_eq!(classify("rusqlite", "rusqlite::Connection::backup"), Some("Db"));
3552 assert_eq!(classify("rusqlite", "rusqlite::Connection::restore"), Some("Db"));
3553 assert_eq!(classify("rusqlite", "rusqlite::Blob::read_at"), Some("Db"));
3554 assert_eq!(classify("rusqlite", "rusqlite::Blob::read_at_exact"), Some("Db"));
3555 assert_eq!(classify("rusqlite", "rusqlite::Blob::raw_read_at"), Some("Db"));
3556 assert_eq!(classify("rusqlite", "rusqlite::Blob::raw_read_at_exact"), Some("Db"));
3557 assert_eq!(classify("rusqlite", "rusqlite::Blob::write_at"), Some("Db"));
3558 assert_eq!(classify("rusqlite", "rusqlite::Blob::write_all_at"), Some("Db"));
3559 assert_eq!(classify("rusqlite", "rusqlite::Connection::from_handle"), Some("Db"));
3560 assert_eq!(classify("rusqlite", "rusqlite::Connection::from_handle_owned"), Some("Db"));
3561 assert_eq!(classify("rusqlite", "rusqlite::Connection::extension_init2"), Some("Db"));
3562 assert_eq!(classify("rusqlite", "rusqlite::init_auto_extension"), Some("Db"));
3563 // …and the REAL module-qualified spellings (`Backup`/`Blob`/`init_auto_extension` are not
3564 // re-exported at rusqlite's crate root, unlike `Connection` — a real consumer's source, proven
3565 // against a compiling fixture, must use these, not the short forms above).
3566 assert_eq!(classify("rusqlite", "rusqlite::backup::Backup::new"), Some("Db"));
3567 assert_eq!(classify("rusqlite", "rusqlite::blob::Blob::read_at"), Some("Db"));
3568 assert_eq!(classify("rusqlite", "rusqlite::auto_extension::init_auto_extension"), Some("Db"));
3569 // NO FABRICATION: `InnerConnection`/`RawStatement` live in PRIVATE modules (lib.rs `mod
3570 // inner_connection;`/`mod raw_statement;`, no `pub`) — no external consumer can ever name either
3571 // type, so no rule was added despite self-scan flagging them too.
3572 assert_eq!(classify("rusqlite", "rusqlite::InnerConnection::close"), None);
3573 assert_eq!(classify("rusqlite", "rusqlite::RawStatement::step"), None);
3574 // …but `open` stays rusqlite-only (postgres has no open; nothing else may borrow it):
3575 assert_eq!(classify("postgres", "postgres::Client::open"), None);
3576 assert_eq!(classify("tokio_postgres", "tokio_postgres::Client::query_typed"), Some("Db"));
3577 // THE FIX: `Config::connect_raw` does the same protocol handshake as `Config::connect`, over a
3578 // caller-supplied stream instead of one it dials itself.
3579 assert_eq!(classify("tokio_postgres", "tokio_postgres::Config::connect_raw"), Some("Db"));
3580 // diesel's LIMIT-1 + streaming executions; sqlx's multi-result stream:
3581 assert_eq!(classify("diesel", "diesel::RunQueryDsl::first"), Some("Db"));
3582 assert_eq!(classify("diesel", "diesel::RunQueryDsl::load_iter"), Some("Db"));
3583 // THE FIX: `Connection::establish` is diesel's own name for `::connect` — the crate's canonical,
3584 // most-used connection entry point (`SqliteConnection::establish(url)` in every diesel
3585 // quickstart), implemented by really opening the backend handle — but shared no verb spelling
3586 // with `::connect`, so it read pure with zero `coverage.uncovered` disclosure (diesel is
3587 // calibrated, so the miss reads as reviewed-pure, not as a gap).
3588 assert_eq!(classify("diesel", "diesel::sqlite::SqliteConnection::establish"), Some("Db"));
3589 assert_eq!(classify("diesel", "diesel::pg::PgConnection::establish"), Some("Db"));
3590 assert_eq!(classify("diesel", "diesel::mysql::MysqlConnection::establish"), Some("Db"));
3591 assert_eq!(classify("diesel", "diesel::Connection::establish"), Some("Db"));
3592 // NO FABRICATION: diesel's own private plumbing shares the substring but not the exact verb.
3593 assert_eq!(classify("diesel", "diesel::sqlite::SqliteConnection::establish_inner"), None);
3594 assert_eq!(classify("sqlx", "sqlx::query::Query::fetch_many"), Some("Db"));
3595 // sqlx's bare `query()` builder must STAY pure (the original sqlx lesson):
3596 assert_eq!(classify("sqlx", "sqlx::query"), None);
3597 // tracing: the emit/span-lifecycle dispatch is Log; the pure DATA-type accessors are not
3598 // (whole-crate Log fabricated Log on `Level::as_str` / `Span::is_disabled` — the data types are
3599 // pure, same principle as the `log` facade).
3600 assert_eq!(classify("tracing", "tracing::event"), Some("Log"));
3601 assert_eq!(classify("tracing", "tracing::Span::new_span"), Some("Log"));
3602 assert_eq!(classify("tracing", "tracing::Span::record"), Some("Log"));
3603 assert_eq!(classify("tracing", "tracing::Span::enter"), Some("Log"));
3604 assert_eq!(classify("tracing", "tracing::Level::as_str"), None); // pure accessor
3605 assert_eq!(classify("tracing", "tracing::Span::is_disabled"), None); // pure state read
3606 assert_eq!(classify("tracing", "tracing::Span::metadata"), None); // pure accessor
3607 assert_eq!(classify("tracing", "tracing::metadata::Level::TRACE"), None); // pure data type
3608 assert_eq!(classify("tracing", "tracing::field::Field::name"), None); // pure data type
3609 // git2 (high-level Rust API, not the `raw::git_*` FFI tier tested above): remote verbs are Net.
3610 assert_eq!(classify("git2", "git2::Remote::fetch"), Some("Net"));
3611 assert_eq!(classify("git2", "git2::Remote::push"), Some("Net"));
3612 assert_eq!(classify("git2", "git2::Remote::download"), Some("Net"));
3613 assert_eq!(classify("git2", "git2::Remote::connect"), Some("Net"));
3614 assert_eq!(classify("git2", "git2::Remote::connect_auth"), Some("Net"));
3615 assert_eq!(classify("git2", "git2::Remote::ls"), Some("Net"));
3616 assert_eq!(classify("git2", "git2::Remote::upload"), Some("Net"));
3617 // THE COVERAGE-GATE SWEEP FIX: `Repository::open`/`init` (and every other local .git-directory
3618 // operation below) used to read PURE here — the comment this replaced called that "honest, no
3619 // network", but candor-scan self-scanning git2's OWN source (eval/coverage-gate) proved these
3620 // reach a REAL Fs effect (`raw::git_repository_open`/`_init_ext`, already in this file's FFI-tier
3621 // table) that a real consumer's call to the safe wrapper could never surface, because THIS
3622 // block's early `return None` sat in front of that table for every `crate_name == "git2"` call.
3623 // "local, no network" was true and beside the point: Fs was the effect being missed. See MEMORY /
3624 // the commit that added this block for the full sweep (Config/Index/Odb/PackBuilder/Reference/
3625 // Repository/TreeBuilder — every FQN below calls a listed FS FFI leaf; verified against git2
3626 // 0.20.4 source).
3627 assert_eq!(classify("git2", "git2::Repository::open"), Some("Fs"));
3628 assert_eq!(classify("git2", "git2::Repository::init"), Some("Fs"));
3629 assert_eq!(classify("git2", "git2::Repository::open_bare"), Some("Fs"));
3630 assert_eq!(classify("git2", "git2::Repository::open_ext"), Some("Fs"));
3631 assert_eq!(classify("git2", "git2::Repository::open_from_env"), Some("Fs"));
3632 assert_eq!(classify("git2", "git2::Repository::discover"), Some("Fs"));
3633 assert_eq!(classify("git2", "git2::Repository::discover_path"), Some("Fs"));
3634 assert_eq!(classify("git2", "git2::Repository::init_bare"), Some("Fs"));
3635 assert_eq!(classify("git2", "git2::Repository::init_opts"), Some("Fs"));
3636 assert_eq!(classify("git2", "git2::Repository::blob_path"), Some("Fs"));
3637 assert_eq!(classify("git2", "git2::Repository::checkout_head"), Some("Fs"));
3638 assert_eq!(classify("git2", "git2::Repository::checkout_index"), Some("Fs"));
3639 assert_eq!(classify("git2", "git2::Repository::checkout_tree"), Some("Fs"));
3640 assert_eq!(classify("git2", "git2::Repository::commit"), Some("Fs"));
3641 assert_eq!(classify("git2", "git2::Repository::reference"), Some("Fs"));
3642 assert_eq!(classify("git2", "git2::Repository::tag"), Some("Fs"));
3643 assert_eq!(classify("git2", "git2::Config::add_file"), Some("Fs"));
3644 assert_eq!(classify("git2", "git2::Config::open"), Some("Fs"));
3645 assert_eq!(classify("git2", "git2::Config::open_default"), Some("Fs"));
3646 assert_eq!(classify("git2", "git2::Index::add_all"), Some("Fs"));
3647 assert_eq!(classify("git2", "git2::Index::add_path"), Some("Fs"));
3648 assert_eq!(classify("git2", "git2::Index::read"), Some("Fs"));
3649 assert_eq!(classify("git2", "git2::Index::write"), Some("Fs"));
3650 assert_eq!(classify("git2", "git2::Index::write_tree"), Some("Fs"));
3651 assert_eq!(classify("git2", "git2::Index::write_tree_to"), Some("Fs"));
3652 assert_eq!(classify("git2", "git2::Odb::read"), Some("Fs"));
3653 assert_eq!(classify("git2", "git2::Odb::reader"), Some("Fs"));
3654 assert_eq!(classify("git2", "git2::Odb::write"), Some("Fs"));
3655 assert_eq!(classify("git2", "git2::Odb::writer"), Some("Fs"));
3656 assert_eq!(classify("git2", "git2::PackBuilder::write"), Some("Fs"));
3657 assert_eq!(classify("git2", "git2::Reference::delete"), Some("Fs"));
3658 assert_eq!(classify("git2", "git2::Reference::set_target"), Some("Fs"));
3659 assert_eq!(classify("git2", "git2::TreeBuilder::write"), Some("Fs"));
3660 // `Remote::list`/`RemoteConnection::list` call `raw::git_remote_ls` directly — the same
3661 // reference-advertisement Net effect `Remote::ls` was meant to catch, under the name git2 0.20
3662 // actually uses (there is no `ls` method left in the crate at all — that suffix rule is dead
3663 // weight, kept rather than removed since deleting it is a separate, unrelated cleanup).
3664 assert_eq!(classify("git2", "git2::Remote::list"), Some("Net"));
3665 assert_eq!(classify("git2", "git2::RemoteConnection::list"), Some("Net"));
3666 // `Cred::credential_helper` spawns a real `sh -c "<helper> get"` subprocess to resolve
3667 // `credential.helper` config (cred.rs:370-390) — a real consumer's most common way to honor it.
3668 assert_eq!(classify("git2", "git2::Cred::credential_helper"), Some("Exec"));
3669 assert_eq!(classify("git2", "git2::CredentialHelper::execute"), Some("Exec"));
3670 // NO FABRICATION controls: a `write`/`read`/`open` on some OTHER git2 type (not FQN-listed above)
3671 // must stay pure — this fix is FQN-exact, not a suffix re-widen.
3672 assert_eq!(classify("git2", "git2::Repository::state"), None);
3673 assert_eq!(classify("git2", "git2::Blob::content"), None);
3674 // tonic's SERVER half (the coverage-gate sweep found only the client verbs were covered):
3675 // `Router::serve`/`::serve_with_shutdown` bind a real listening socket via `TcpIncoming::new`
3676 // (`StdTcpListener::bind`), verified against tonic 0.12.3 source.
3677 assert_eq!(classify("tonic", "tonic::transport::server::Router::serve"), Some("Net"));
3678 assert_eq!(
3679 classify("tonic", "tonic::transport::server::Router::serve_with_shutdown"),
3680 Some("Net")
3681 );
3682 assert_eq!(classify("tonic", "tonic::TcpIncoming::new"), Some("Net"));
3683 // NO FABRICATION control: `Server<L>::serve_with_shutdown` (mod.rs:530) is a DIFFERENT,
3684 // `pub(crate)` method on a different type — not reachable by any consumer, and this fix does not
3685 // fabricate an effect for it by widening past the `Router::` FQN.
3686 assert_eq!(classify("tonic", "tonic::transport::server::Server::serve_with_shutdown"), None);
3687 // THE FIX: `Repository::clone`/`clone_recurse` and `RepoBuilder::clone` ARE libgit2's real
3688 // network clone — a corpus round found `git2::Repository::clone(url, path)` reporting ZERO
3689 // effects and passing `deny Net` at exit 0, because the bare `::clone` denylist meant to keep
3690 // out `Remote`'s derived `Clone` dup (below) also excluded the one thing the old comment named
3691 // as NOT meant to be excluded.
3692 assert_eq!(classify("git2", "git2::Repository::clone"), Some("Net"));
3693 assert_eq!(classify("git2", "git2::Repository::clone_recurse"), Some("Net"));
3694 assert_eq!(classify("git2", "git2::build::RepoBuilder::clone"), Some("Net"));
3695 // THE CONTROL: `Remote::clone` is the DERIVED `Clone` trait impl (an Arc/handle duplication, no
3696 // libgit2 call) — it must stay pure. Re-fabricating Net here would undo the very over-charge the
3697 // original exclusion existed to prevent. FQN-exact matching (not a bare `::clone` re-widen) keeps
3698 // this pure while catching `Repository::clone` above.
3699 assert_eq!(classify("git2", "git2::Remote::clone"), None);
3700 // THE SWEEP FIX: a fourth+fifth true positive in the SAME gap — `Submodule::clone`/
3701 // `Submodule::update` call `raw::git_submodule_clone`/`git_submodule_update` directly (the exact
3702 // leaves already in this file's FFI-tier NET table), but git2's documented submodule-init idiom
3703 // calls the safe Rust wrapper, which carried no rule of its own.
3704 assert_eq!(classify("git2", "git2::Submodule::clone"), Some("Net"));
3705 assert_eq!(classify("git2", "git2::Submodule::update"), Some("Net"));
3706 // NO FABRICATION: git2's many pure `update_*` setters (CheckoutBuilder/DiffOptions/StatusOptions)
3707 // must not gain Net — FQN-exact, not a bare `::update` substring.
3708 assert_eq!(classify("git2", "git2::build::CheckoutBuilder::update_only"), None);
3709 assert_eq!(classify("git2", "git2::DiffOptions::update_index"), None);
3710 assert_eq!(classify("git2", "git2::Submodule::update_strategy"), None);
3711 // memmap2: only the syscall-issuing map/flush/protect verbs are Fs; reads over an already-mapped
3712 // region (len/as_ptr/is_empty) and the request builder are PURE (whole-crate Fs fabricated Fs).
3713 assert_eq!(classify("memmap2", "memmap2::MmapOptions::map"), Some("Fs"));
3714 assert_eq!(classify("memmap2", "memmap2::MmapOptions::map_mut"), Some("Fs"));
3715 assert_eq!(classify("memmap2", "memmap2::Mmap::flush"), Some("Fs"));
3716 assert_eq!(classify("memmap2", "memmap2::MmapMut::make_read_only"), Some("Fs"));
3717 assert_eq!(classify("memmap2", "memmap2::Mmap::len"), None); // length read — pure
3718 assert_eq!(classify("memmap2", "memmap2::Mmap::is_empty"), None); // pure
3719 assert_eq!(classify("memmap2", "memmap2::Mmap::as_ptr"), None); // pointer — pure
3720 assert_eq!(classify("memmap2", "memmap2::MmapOptions::new"), None); // request builder — pure
3721 // arboard: the Clipboard handle's read/write verbs are Clipboard; `arboard::Error` formatting
3722 // and option data types are PURE (whole-crate Clipboard fabricated Clipboard on `Error::to_string`).
3723 assert_eq!(classify("arboard", "arboard::Clipboard::new"), Some("Clipboard"));
3724 assert_eq!(classify("arboard", "arboard::Clipboard::get_text"), Some("Clipboard"));
3725 assert_eq!(classify("arboard", "arboard::Clipboard::set_text"), Some("Clipboard"));
3726 assert_eq!(classify("arboard", "arboard::Clipboard::clear"), Some("Clipboard"));
3727 assert_eq!(classify("arboard", "arboard::Error::to_string"), None); // error formatting — pure
3728 assert_eq!(classify("arboard", "arboard::Error::fmt"), None); // Display impl — pure
3729 assert_eq!(classify("arboard", "arboard::ImageData::to_owned_img"), None); // pure data type
3730 // fastrand: value draws + entropy-seeded entry points are Rand; the DETERMINISTIC seeded ctor
3731 // `with_seed` and state split/copy (`fork`/`clone`) are PURE (whole-crate Rand fabricated Rand).
3732 assert_eq!(classify("fastrand", "fastrand::u32"), Some("Rand")); // top-level draw
3733 assert_eq!(classify("fastrand", "fastrand::Rng::usize"), Some("Rand"));
3734 assert_eq!(classify("fastrand", "fastrand::Rng::shuffle"), Some("Rand"));
3735 assert_eq!(classify("fastrand", "fastrand::Rng::new"), Some("Rand")); // entropy-seeded
3736 assert_eq!(classify("fastrand", "fastrand::Rng::with_seed"), None); // deterministic ctor — pure
3737 assert_eq!(classify("fastrand", "fastrand::Rng::fork"), None); // state split — pure
3738 assert_eq!(classify("fastrand", "fastrand::Rng::clone"), None); // state copy — pure
3739 // portable_pty / async_process: spawn/wait keep Exec; config GETTERS and pure data ctors/setters
3740 // do NOT (base Exec fabricated on `CommandBuilder::get_cwd` / `PtySize::default` / `Stdio::piped`).
3741 assert_eq!(classify("portable_pty", "portable_pty::PtySystem::openpty"), Some("Exec"));
3742 assert_eq!(classify("portable_pty", "portable_pty::SlavePty::spawn_command"), Some("Exec"));
3743 assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::get_argv"), None); // getter
3744 assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::get_cwd"), None); // getter
3745 assert_eq!(classify("portable_pty", "portable_pty::PtySize::default"), None); // pure data type
3746 assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::new"), None); // builder ctor
3747 assert_eq!(classify("async_process", "async_process::Command::spawn"), Some("Exec"));
3748 assert_eq!(classify("async_process", "async_process::Command::output"), Some("Exec"));
3749 assert_eq!(classify("async_process", "async_process::Stdio::piped"), None); // pure data type
3750 assert_eq!(classify("async_process", "async_process::Stdio::null"), None); // pure data type
3751 // FFI tiers (matched by distinctive leaf, alias-independent)
3752 assert_eq!(classify("libc", "libc::open"), Some("Fs"));
3753 assert_eq!(classify("libc", "libc::connect"), Some("Net"));
3754 assert_eq!(classify("libc", "libc::read"), None); // generic fd op — deliberately unclassified
3755 assert_eq!(classify("ffi", "ffi::sqlite3_step"), Some("Db"));
3756 assert_eq!(classify("raw", "raw::git_remote_fetch"), Some("Net"));
3757 // libgit2 clone + submodule clone/update fetch over the network (an A/B on git2 0.20 caught
3758 // `Submodule::update`/`clone` and `Repository::clone` reporting no Net — the latter because the
3759 // `src/build.rs` module was being dropped as if it were the Cargo build script).
3760 assert_eq!(classify("raw", "raw::git_clone"), Some("Net"));
3761 assert_eq!(classify("raw", "raw::git_submodule_clone"), Some("Net"));
3762 assert_eq!(classify("raw", "raw::git_submodule_update"), Some("Net"));
3763 assert_eq!(classify("raw", "raw::git_submodule_open"), None); // local subrepo open — not Net
3764 // libcurl: the transfer/raw-socket entry points are Net (an A/B on curl 0.4 caught the whole
3765 // crate reporting ZERO Net); the big setopt/init/getinfo surface — and the readiness-wait
3766 // multi_wait/poll — stay unclassified (the loop's perform is the boundary).
3767 assert_eq!(classify("curl_sys", "curl_sys::curl_easy_perform"), Some("Net"));
3768 assert_eq!(classify("curl_sys", "curl_sys::curl_easy_send"), Some("Net"));
3769 assert_eq!(classify("curl_sys", "curl_sys::curl_multi_perform"), Some("Net"));
3770 assert_eq!(classify("curl_sys", "curl_sys::curl_multi_socket_action"), Some("Net"));
3771 assert_eq!(classify("curl_sys", "curl_sys::curl_easy_setopt"), None); // in-memory option write
3772 assert_eq!(classify("curl_sys", "curl_sys::curl_easy_init"), None); // handle alloc
3773 assert_eq!(classify("curl_sys", "curl_sys::curl_multi_wait"), None); // readiness wait, no payload
3774 // consumer-side `curl` crate rule: the dispatch verbs are Net, the setopt builders pure.
3775 assert_eq!(classify("curl", "curl::easy::Easy::perform"), Some("Net"));
3776 assert_eq!(classify("curl", "curl::multi::Multi::perform"), Some("Net"));
3777 assert_eq!(classify("curl", "curl::easy::Easy::send"), Some("Net"));
3778 assert_eq!(classify("curl", "curl::easy::Easy::url"), None); // CURLOPT setter — pure
3779 assert_eq!(classify("curl", "curl::easy::Easy::timeout"), None); // pure setter; Multi::timeout under-reported by design
3780 assert_eq!(classify("ffi", "ffi::SSL_connect"), Some("Net"));
3781 // pure crates stay pure
3782 assert_eq!(classify("serde", "serde::Serialize::serialize"), None);
3783 assert_eq!(classify("std", "std::vec::Vec::push"), None);
3784
3785 // ── sweep 2026-06-17: fabrication carve-outs + DNS coverage (each fails pre-fix) ──
3786 // [24] std::net socket accessors are pure; the I/O verbs stay Net.
3787 assert_eq!(classify("std", "std::net::TcpStream::connect"), Some("Net"));
3788 assert_eq!(classify("std", "std::net::TcpStream::local_addr"), None);
3789 assert_eq!(classify("std", "std::net::TcpStream::nodelay"), None);
3790 assert_eq!(classify("std", "std::net::TcpStream::ttl"), None);
3791 assert_eq!(classify("std", "std::net::UdpSocket::peer_addr"), None);
3792 // [37] std DNS resolution is Net (was floored).
3793 assert_eq!(classify("std", "std::net::lookup_host"), Some("Net"));
3794 assert_eq!(classify("std", "core::net::ToSocketAddrs::to_socket_addrs"), Some("Net"));
3795 // [23] std::process getters are pure; spawn/new stay Exec.
3796 assert_eq!(classify("std", "std::process::Command::get_program"), None);
3797 assert_eq!(classify("std", "std::process::Command::get_args"), None);
3798 assert_eq!(classify("std", "std::process::Child::id"), None);
3799 assert_eq!(classify("std", "std::process::Command::spawn"), Some("Exec"));
3800 // [27] redis ConnectionManager::clone is an Arc bump (pure); a query round-trips.
3801 assert_eq!(classify("redis", "redis::aio::ConnectionManager::clone"), None);
3802 assert_eq!(classify("redis", "redis::aio::ConnectionManager::send_packed_command"), Some("Db"));
3803 // [5] sea_orm re-exported sea_query builder algebra is pure; execution verbs stay Db.
3804 assert_eq!(classify("sea_orm", "sea_orm::sea_query::Func::count"), None);
3805 assert_eq!(classify("sea_orm", "sea_orm::sea_query::Condition::all"), None);
3806 assert_eq!(classify("sea_orm", "sea_orm::Select::all"), Some("Db"));
3807 // THE FIX: `Database::connect_proxy` (the `proxy`-feature sibling of `Database::connect`) opens
3808 // a live `DatabaseConnection` through a caller-supplied `ProxyDatabaseTrait` — same effect,
3809 // missing from an allowlist keyed on the plain `connect` spelling.
3810 assert_eq!(classify("sea_orm", "sea_orm::Database::connect_proxy"), Some("Db"));
3811 // THE COVERAGE-GATE SWEEP: the transaction and pagination families, verified against sea-orm
3812 // 1.1.20 source.
3813 assert_eq!(classify("sea_orm", "sea_orm::DatabaseConnection::transaction"), Some("Db"));
3814 assert_eq!(
3815 classify("sea_orm", "sea_orm::DatabaseConnection::transaction_with_config"),
3816 Some("Db")
3817 );
3818 // BONUS (found proving the above reachable with a real fixture, not in the original ratchet):
3819 assert_eq!(classify("sea_orm", "sea_orm::DatabaseConnection::ping"), Some("Db"));
3820 assert_eq!(classify("sea_orm", "sea_orm::DatabaseConnection::begin"), Some("Db"));
3821 assert_eq!(
3822 classify("sea_orm", "sea_orm::DatabaseConnection::begin_with_config"),
3823 Some("Db")
3824 );
3825 assert_eq!(classify("sea_orm", "sea_orm::DatabaseTransaction::commit"), Some("Db"));
3826 assert_eq!(classify("sea_orm", "sea_orm::DatabaseTransaction::rollback"), Some("Db"));
3827 assert_eq!(classify("sea_orm", "sea_orm::SqlxMySqlPoolConnection::begin"), Some("Db"));
3828 assert_eq!(classify("sea_orm", "sea_orm::SqlxMySqlPoolConnection::ping"), Some("Db"));
3829 assert_eq!(classify("sea_orm", "sea_orm::SqlxMySqlPoolConnection::transaction"), Some("Db"));
3830 assert_eq!(classify("sea_orm", "sea_orm::SqlxPostgresPoolConnection::begin"), Some("Db"));
3831 assert_eq!(classify("sea_orm", "sea_orm::SqlxSqlitePoolConnection::ping"), Some("Db"));
3832 assert_eq!(classify("sea_orm", "sea_orm::Paginator::fetch"), Some("Db"));
3833 assert_eq!(classify("sea_orm", "sea_orm::Paginator::fetch_and_next"), Some("Db"));
3834 assert_eq!(classify("sea_orm", "sea_orm::Paginator::into_stream"), Some("Db"));
3835 assert_eq!(classify("sea_orm", "sea_orm::Paginator::num_pages"), Some("Db"));
3836 assert_eq!(classify("sea_orm", "sea_orm::Paginator::num_items_and_pages"), Some("Db"));
3837 assert_eq!(classify("sea_orm", "sea_orm::Insert::exec_with_returning_keys"), Some("Db"));
3838 assert_eq!(classify("sea_orm", "sea_orm::Inserter::exec_with_returning_many"), Some("Db"));
3839 assert_eq!(classify("sea_orm", "sea_orm::TryInsert::exec_with_returning_keys"), Some("Db"));
3840 // NO FABRICATION: the mock/proxy backends share every one of these verb names but perform no
3841 // real I/O (mock) or dispatch through a caller-supplied trait (proxy) — FQN-exact, not a bare
3842 // `::ping`/`::begin`/`::transaction`/`::commit`/`::rollback` suffix, keeps them pure/unknown.
3843 assert_eq!(classify("sea_orm", "sea_orm::MockDatabaseConnection::ping"), None);
3844 assert_eq!(classify("sea_orm", "sea_orm::MockDatabaseConnection::begin"), None);
3845 assert_eq!(classify("sea_orm", "sea_orm::ProxyDatabaseConnection::commit"), None);
3846 assert_eq!(classify("sea_orm", "sea_orm::ProxyDatabaseConnection::ping"), None);
3847 }
3848
3849 #[test]
3850 fn lettre_tls_setup_and_connection_family_is_covered() {
3851 // THE COVERAGE-GATE SWEEP, verified against lettre 0.11.23 source. `build_rustls` calls
3852 // `rustls_native_certs::load_native_certs()` (a real OS-trust-store read) + `tracing::debug!`.
3853 assert_eq!(
3854 classify("lettre", "lettre::transport::smtp::client::TlsParametersBuilder::build_rustls"),
3855 Some("Fs")
3856 );
3857 assert_eq!(classify("lettre", "lettre::TlsParametersBuilder::build"), Some("Fs"));
3858 assert_eq!(classify("lettre", "lettre::TlsParameters::new"), Some("Fs"));
3859 assert_eq!(classify("lettre", "lettre::TlsParameters::new_rustls"), Some("Fs"));
3860 assert_eq!(classify("lettre", "lettre::SmtpTransport::from_url"), Some("Fs"));
3861 assert_eq!(classify("lettre", "lettre::SmtpTransport::relay"), Some("Fs"));
3862 assert_eq!(classify("lettre", "lettre::SmtpTransport::starttls_relay"), Some("Fs"));
3863 assert_eq!(classify("lettre", "lettre::AsyncSmtpTransport::from_url"), Some("Fs"));
3864 assert_eq!(classify("lettre", "lettre::AsyncSmtpTransport::relay"), Some("Fs"));
3865 assert_eq!(classify("lettre", "lettre::AsyncSmtpTransport::starttls_relay"), Some("Fs"));
3866 assert_eq!(classify("lettre", "lettre::FileTransport::read"), Some("Fs"));
3867 // The sealed `Executor` trait impl — `#[doc(hidden)]`, a narrower surface, but genuinely
3868 // reachable (both the trait and `AsyncStd1Executor` are `pub`, re-exported at the crate root).
3869 assert_eq!(classify("lettre", "lettre::AsyncStd1Executor::connect"), Some("Net"));
3870 assert_eq!(classify("lettre", "lettre::AsyncStd1Executor::fs_read"), Some("Fs"));
3871 assert_eq!(classify("lettre", "lettre::AsyncStd1Executor::fs_write"), Some("Fs"));
3872 // Only reachable via the module-qualified path — neither type is re-exported at the crate root.
3873 assert_eq!(
3874 classify(
3875 "lettre",
3876 "lettre::transport::smtp::client::AsyncSmtpConnection::connect_asyncstd1"
3877 ),
3878 Some("Net")
3879 );
3880 assert_eq!(
3881 classify(
3882 "lettre",
3883 "lettre::transport::smtp::client::AsyncNetworkStream::connect_asyncstd1"
3884 ),
3885 Some("Net")
3886 );
3887 // NO FABRICATION: `NetworkStream` lives in a PRIVATE module (`mod net;`, never re-exported) — no
3888 // external consumer can ever name it, so no rule was added despite self-scan flagging its
3889 // `shutdown`/`set_read_timeout`/`set_write_timeout` (propagated from the coarse
3890 // `std::net::TcpStream` whole-handle rule).
3891 assert_eq!(classify("lettre", "lettre::NetworkStream::shutdown"), None);
3892 assert_eq!(classify("lettre", "lettre::NetworkStream::set_read_timeout"), None);
3893 }
3894
3895 #[test]
3896 fn rand_osrng_handle_ops_are_pure_but_draws_are_rand() {
3897 // Adversarial-review fabrication: the blanket `contains("OsRng")` tagged `OsRng::clone` Rand,
3898 // but OsRng is a unit struct — clone/fork/default draw no entropy. The real draws still fire.
3899 assert_eq!(classify("rand", "rand::rngs::OsRng::clone"), None);
3900 assert_eq!(classify("rand", "rand::rngs::OsRng::default"), None);
3901 assert_eq!(classify("rand", "rand::rngs::OsRng::fill_bytes"), Some("Rand")); // a real draw
3902 assert_eq!(classify("rand", "rand::rngs::OsRng::next_u32"), Some("Rand"));
3903 assert_eq!(classify("rand", "rand::Rng::gen"), Some("Rand")); // verb path unaffected
3904 assert_eq!(classify("rand", "rand::distributions::Uniform::new"), None); // pure ctor still pure
3905 }
3906
3907 #[test]
3908 fn redis_connection_manager_config_builder_is_pure() {
3909 // Adversarial-review fabrication: `contains("ConnectionManager")` hit the pure *Config* builder.
3910 assert_eq!(classify("redis", "redis::aio::ConnectionManagerConfig::new"), None);
3911 assert_eq!(classify("redis", "redis::aio::ConnectionManagerConfig::set_max_delay"), None);
3912 // the LIVE manager still round-trips (Db).
3913 assert_eq!(classify("redis", "redis::aio::ConnectionManager::new"), Some("Db"));
3914 assert_eq!(classify("redis", "redis::Commands::get"), Some("Db"));
3915 }
3916
3917 #[test]
3918 fn redis_get_connection_info_is_a_pure_accessor_not_swept_by_the_substring() {
3919 // coverage-gate sweep (2026-08-27): `contains("::get_connection")` also matched
3920 // `Client::get_connection_info` (a pure accessor over an already-stored field) because it is a
3921 // literal substring of that name — the real connection-getters stay caught.
3922 assert_eq!(classify("redis", "redis::Client::get_connection_info"), None);
3923 assert_eq!(classify("redis", "redis::Client::get_connection"), Some("Db"));
3924 assert_eq!(classify("redis", "redis::Client::get_async_connection"), Some("Db"));
3925 assert_eq!(
3926 classify("redis", "redis::Client::get_multiplexed_async_connection"),
3927 Some("Db")
3928 );
3929 }
3930
3931 #[test]
3932 fn aws_config_load_from_env_is_loads_own_convenience_wrapper() {
3933 // THE FIX: `load_from_env()` is the crate's OWN "convenience wrapper" (its doc comment's words)
3934 // around `from_env().load().await` — the exact already-modelled effect one call down — but
3935 // "load_from_env".ends_with("::load") is false.
3936 assert_eq!(classify("aws_config", "aws_config::load"), Some("Net"));
3937 assert_eq!(classify("aws_config", "aws_config::load_defaults"), Some("Net"));
3938 assert_eq!(classify("aws_config", "aws_config::load_from_env"), Some("Net"));
3939 assert_eq!(classify("aws_config", "aws_config::from_env"), None); // pure builder
3940 }
3941
3942 #[test]
3943 fn tungstenite_stream_first_handshake_entry_points_are_net() {
3944 // THE FIX: `client`/`client_with_config`/`client_tls`/`client_tls_with_config` (client.rs:176,
3945 // 159; tls.rs:163,179) and `accept`/`accept_with_config`/`accept_hdr`/`accept_hdr_with_config`
3946 // (server.rs:23-63) each run `{Client,Server}Handshake::start(stream, ..).handshake()` — the real
3947 // WS upgrade I/O over an already-open stream. tungstenite's OWN documented way to run over a
3948 // caller-managed TCP/TLS/mio stream, missing from a verb list keyed only on `connect`.
3949 assert_eq!(classify("tungstenite", "tungstenite::client::client"), Some("Net"));
3950 assert_eq!(classify("tungstenite", "tungstenite::client::client_with_config"), Some("Net"));
3951 assert_eq!(classify("tungstenite", "tungstenite::tls::client_tls"), Some("Net"));
3952 assert_eq!(classify("tungstenite", "tungstenite::tls::client_tls_with_config"), Some("Net"));
3953 assert_eq!(classify("tungstenite", "tungstenite::server::accept"), Some("Net"));
3954 assert_eq!(classify("tungstenite", "tungstenite::server::accept_with_config"), Some("Net"));
3955 assert_eq!(classify("tungstenite", "tungstenite::server::accept_hdr"), Some("Net"));
3956 assert_eq!(classify("tungstenite", "tungstenite::server::accept_hdr_with_config"), Some("Net"));
3957 assert_eq!(classify("tungstenite", "tungstenite::client::connect_with_config"), Some("Net"));
3958 }
3959
3960 #[test]
3961 fn mysql_conn_new_is_the_real_connect() {
3962 // THE FIX: `mysql::Conn::new` (conn/mod.rs:342) calls `connect_stream()?; connect()?` directly;
3963 // `mysql_async::Conn::new` (conn/mod.rs:921) is the async equivalent. Each crate's own primary
3964 // connection constructor, the crate's own first doctest example — but `::new` never appeared in
3965 // the verb list.
3966 assert_eq!(classify("mysql", "mysql::Conn::new"), Some("Db"));
3967 assert_eq!(classify("mysql_async", "mysql_async::Conn::new"), Some("Db"));
3968 // NO FABRICATION: the SAME two crates' unrelated pure `new`s (Opts/PoolConstraints/TxOpts/…)
3969 // must not gain Db — scoped to the `Conn::` segment, not a bare `::new` suffix.
3970 assert_eq!(classify("mysql", "mysql::Opts::new"), None);
3971 assert_eq!(classify("mysql", "mysql::PoolConstraints::new"), None);
3972 assert_eq!(classify("mysql_async", "mysql_async::TxOpts::new"), None);
3973 }
3974
3975 #[test]
3976 fn mongodb_with_options_is_the_same_effect_as_with_uri_str() {
3977 // THE FIX: `with_uri_str` (client.rs:179) is `ClientOptions::parse(uri).await?;
3978 // Client::with_options(options)` one call down — `with_options` (client.rs:188) is where the
3979 // topology/monitoring actually spins up, and mongodb's own doc calls it the entry point for a
3980 // caller who already holds parsed `ClientOptions`. Verified against mongodb 3.8.1 (both the
3981 // async `client.rs` and sync `sync/client.rs` `Client`).
3982 assert_eq!(classify("mongodb", "mongodb::Client::with_uri_str"), Some("Db"));
3983 assert_eq!(classify("mongodb", "mongodb::Client::with_options"), Some("Db"));
3984 assert_eq!(classify("mongodb", "mongodb::sync::Client::with_options"), Some("Db"));
3985 assert_eq!(classify("mongodb", "mongodb::Collection::find_one"), Some("Db"));
3986 // NO FABRICATION: an unrelated crate's `with_options` builder must not gain Db — crate-gated.
3987 assert_eq!(classify("some_other_crate", "some_other_crate::Widget::with_options"), None);
3988 }
3989
3990 #[test]
3991 fn pure_fd_transfer_is_not_an_effect() {
3992 // ADOPTING / EXTRACTING / BORROWING an already-open descriptor (or unwrapping an async type back
3993 // to its std type) issues NO syscall — it must be PURE even though it hangs off a std I/O type
3994 // whose prefix rule would otherwise fire Net/Fs/Ipc. (Real tokio sweep: `into_std`, `from_raw_fd`,
3995 // `as_raw_fd` all fabricated effects.)
3996 assert_eq!(classify("std", "std::net::TcpStream::from_raw_fd"), None);
3997 assert_eq!(classify("std", "std::net::TcpStream::into_raw_fd"), None);
3998 assert_eq!(classify("std", "std::net::TcpStream::as_raw_fd"), None);
3999 assert_eq!(classify("std", "std::net::TcpListener::from_raw_fd"), None);
4000 assert_eq!(classify("std", "std::net::UdpSocket::from_raw_socket"), None);
4001 assert_eq!(classify("std", "std::fs::File::from_raw_fd"), None);
4002 assert_eq!(classify("std", "std::fs::File::into_raw_fd"), None);
4003 assert_eq!(classify("std", "std::fs::File::as_raw_handle"), None);
4004 assert_eq!(classify("std", "std::os::unix::net::UnixStream::from_raw_fd"), None);
4005 // `SocketAddr::from_pathname` builds an address struct, opens no socket — pure. (socket2 sweep.)
4006 assert_eq!(classify("std", "std::os::unix::net::SocketAddr::from_pathname"), None);
4007 assert_eq!(classify("tokio", "tokio::net::TcpStream::from_raw_fd"), None);
4008 assert_eq!(classify("tokio", "tokio::net::TcpStream::into_std"), None); // unwrap → std type, pure
4009 assert_eq!(classify("tokio", "tokio::fs::File::into_std"), None);
4010 // …but a REAL open/connect on the SAME types still fires the effect — the carve-out is leaf-precise.
4011 assert_eq!(classify("std", "std::net::TcpStream::connect"), Some("Net"));
4012 assert_eq!(classify("std", "std::fs::File::open"), Some("Fs"));
4013 assert_eq!(classify("std", "std::fs::read"), Some("Fs"));
4014 assert_eq!(classify("std", "std::os::unix::net::UnixStream::connect"), Some("Ipc"));
4015 assert_eq!(classify("tokio", "tokio::net::TcpStream::connect"), Some("Net"));
4016 }
4017
4018 #[test]
4019 fn command_head_refines_the_exec_cliff() {
4020 use super::classify_command_head as h;
4021 // unambiguous external tools classify by basename (spec §4 ⟨0.5⟩)
4022 assert_eq!(h("curl"), &["Net"]);
4023 assert_eq!(h("telnet"), &["Net"]);
4024 assert_eq!(h("sftp"), &["Net"]);
4025 assert_eq!(h("/usr/local/bin/psql"), &["Db"]); // basename match strips the path
4026 assert_eq!(h("mongo"), &["Db"]);
4027 assert_eq!(h("cqlsh"), &["Db"]);
4028 // a candor engine is Fs/Env — spec-SUPPLIED by §7 item 12, not curation
4029 assert_eq!(h("candor-scan"), &["Env", "Fs"]);
4030 assert_eq!(h("candor-run.sh"), &["Env", "Fs"]);
4031 // an unrecognised head adds nothing — the bare Exec cliff stands (never guess). `make`/`npm`
4032 // run the project's own code; `git`/`rsync` are multi-modal (local vs remote) — all keep the
4033 // cliff rather than fabricate an effect for the common case.
4034 assert_eq!(h("some-unknown-tool"), &[] as &[&str]);
4035 assert_eq!(h("make"), &[] as &[&str]);
4036 assert_eq!(h("npm"), &[] as &[&str]);
4037 assert_eq!(h("git"), &[] as &[&str]);
4038 assert_eq!(h("rsync"), &[] as &[&str]);
4039 // a builder MODIFIER (`.arg`/`.env`) names no program — its literal must NOT refine (a
4040 // whole-crate-Exec crate classifies every method; `.env("psql",..)` must not fabricate Db).
4041 assert!(is_cmd_builder_method("env") && is_cmd_builder_method("arg") && is_cmd_builder_method("current_dir"));
4042 assert!(!is_cmd_builder_method("new")); // Command::new NAMES the program
4043 assert!(!is_cmd_builder_method("cmd")); // duct::cmd NAMES the program
4044 // The gate that ADMITS a literal to classify_command_head is an ALLOWLIST of program-NAMING
4045 // methods, not the builder denylist. Inversion matters: a whole-crate-Exec crate (portable_pty)
4046 // classifies EVERY method as Exec, so a getter like `cmd.get_env("psql")` — absent from the
4047 // builder denylist — would have leaked "psql" to the head and FABRICATED Db. Only `new`/`cmd`
4048 // name a program, so only they may refine.
4049 assert!(is_cmd_naming_method("new") && is_cmd_naming_method("cmd"));
4050 assert!(!is_cmd_naming_method("get_env")); // a GETTER, not a namer — the leak this closes
4051 assert!(!is_cmd_naming_method("arg") && !is_cmd_naming_method("env") && !is_cmd_naming_method("current_dir"));
4052 }
4053
4054 #[test]
4055 fn net_establishing_allowlist() {
4056 // sweep [3]/[7]: the masking guard's establishing-verb allowlist — host-bearing connect/request
4057 // verbs establish (a runtime host there is invisible); USE-verbs on a connected socket do NOT.
4058 assert!(is_net_establishing("connect") && is_net_establishing("connect_timeout"));
4059 assert!(is_net_establishing("get") && is_net_establishing("post") && is_net_establishing("request"));
4060 assert!(is_net_establishing("send_to") && is_net_establishing("to_socket_addrs"));
4061 // use-verbs (host fixed at connect) must NOT be establishing — else `connect("h").write()` flags.
4062 assert!(!is_net_establishing("write") && !is_net_establishing("read") && !is_net_establishing("send"));
4063 assert!(!is_net_establishing("flush") && !is_net_establishing("recv") && !is_net_establishing("peek"));
4064 }
4065
4066 #[test]
4067 fn fs_path_arg_allowlist() {
4068 // The Fs masking guard's path-naming-fn allowlist — free fns / constructors take the path as a
4069 // string arg (a runtime path there is invisible to the gate). Stat methods (path on the receiver)
4070 // and handle ops carry no path arg and must NOT flag — but they're caught by the caller's
4071 // `!is_method` gate; the allowlist itself just enumerates the path-NAMING leaves.
4072 assert!(is_fs_path_arg("write") && is_fs_path_arg("read") && is_fs_path_arg("read_to_string"));
4073 assert!(is_fs_path_arg("open") && is_fs_path_arg("create") && is_fs_path_arg("create_new"));
4074 assert!(is_fs_path_arg("remove_file") && is_fs_path_arg("rename") && is_fs_path_arg("copy"));
4075 assert!(is_fs_path_arg("create_dir_all") && is_fs_path_arg("canonicalize") && is_fs_path_arg("metadata"));
4076 // handle ops / pure builders take NO path arg — never path-naming.
4077 assert!(!is_fs_path_arg("write_all") && !is_fs_path_arg("flush") && !is_fs_path_arg("read_exact"));
4078 assert!(!is_fs_path_arg("new") && !is_fs_path_arg("sync_all") && !is_fs_path_arg("set_len"));
4079 }
4080
4081 #[test]
4082 fn db_query_arg_allowlist() {
4083 // The Db masking guard's query-bearing-verb allowlist — these take the raw SQL as a string arg
4084 // (a runtime query there is invisible to the gate). Build-then-execute terminals and non-query
4085 // ops carry no SQL string and must NOT flag.
4086 assert!(is_db_query_arg("execute") && is_db_query_arg("query") && is_db_query_arg("query_one"));
4087 assert!(is_db_query_arg("prepare") && is_db_query_arg("batch_execute") && is_db_query_arg("execute_batch"));
4088 assert!(is_db_query_arg("query_row") && is_db_query_arg("query_map") && is_db_query_arg("exec"));
4089 // build-then-execute terminals (query built structurally, no SQL string) must NOT flag.
4090 assert!(!is_db_query_arg("fetch_all") && !is_db_query_arg("load") && !is_db_query_arg("first"));
4091 assert!(!is_db_query_arg("all") && !is_db_query_arg("one") && !is_db_query_arg("stream"));
4092 // connection / lifecycle ops take no SQL — must NOT flag.
4093 assert!(!is_db_query_arg("connect") && !is_db_query_arg("open") && !is_db_query_arg("begin"));
4094 assert!(!is_db_query_arg("commit") && !is_db_query_arg("ping") && !is_db_query_arg("get_conn"));
4095 }
4096}
4097
4098#[cfg(test)]
4099mod fs_kind_tests {
4100 use super::fs_kind;
4101
4102 /// SPEC §2 `fs`. Most of what these assert is what the classifier REFUSES to say: §2 requires the
4103 /// field be "omitted rather than guessed", because an empty or partial `fs` reads as a positive claim
4104 /// ("reads but never writes") — the §4 trust contract's forbidden direction.
4105 #[test]
4106 fn write_verbs() {
4107 for p in ["std::fs::write", "std::fs::create_dir_all", "std::fs::remove_file",
4108 "File::create", "std::fs::set_permissions", "f::write_all"] {
4109 assert_eq!(fs_kind(p), &["write"], "{p} mutates the disk");
4110 }
4111 }
4112
4113 #[test]
4114 fn read_verbs() {
4115 for p in ["std::fs::read_to_string", "std::fs::read_dir", "std::fs::metadata",
4116 "File::open", "std::fs::canonicalize", "f::read_to_end"] {
4117 assert_eq!(fs_kind(p), &["read"], "{p} observes without mutating");
4118 }
4119 }
4120
4121 /// A copy/rename reads the source AND writes the destination — one call, both kinds.
4122 #[test]
4123 fn two_locator_verbs_are_both() {
4124 for p in ["std::fs::copy", "std::fs::rename", "std::fs::hard_link"] {
4125 assert_eq!(fs_kind(p), &["read", "write"], "{p}");
4126 }
4127 }
4128
4129 /// THE LOAD-BEARING CASE. A verb that does not reveal direction must contribute NOTHING — not a
4130 /// default, not a guess. Anything here returning a kind would let a function claim "reads but never
4131 /// writes" on the strength of a verb that said neither.
4132 #[test]
4133 fn unrevealing_verbs_make_no_claim() {
4134 for p in ["std::fs::OpenOptions", "some_crate::do_thing", "std::fs::File", "f::seek"] {
4135 assert!(fs_kind(p).is_empty(), "{p} must not claim a direction it did not reveal");
4136 }
4137 }
4138
4139 /// `OpenOptions::open` is `File::open`'s leaf and NOT its verb: the direction lives in the builder
4140 /// chain (`.read(true)`/`.write(true)`), which `fs_kind` cannot see. A `write(true)` builder claiming
4141 /// `["read"]` is the forbidden direction, so the type wins over the leaf here. `File::open` — where
4142 /// `open` DOES mean read — is unaffected, which is the whole reason this is keyed on the type.
4143 #[test]
4144 fn openoptions_open_claims_no_direction_but_file_open_still_reads() {
4145 for p in ["std::fs::OpenOptions::open", "fs_err::OpenOptions::open",
4146 "tokio::fs::OpenOptions::open"] {
4147 assert!(fs_kind(p).is_empty(),
4148 "{p}'s direction was set by the builder chain, which this function cannot see");
4149 }
4150 assert_eq!(fs_kind("std::fs::File::open"), &["read"], "`File::open` is unambiguously a read");
4151 }
4152}