Skip to main content

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/// Representative path tails (each appended to a crate name) that the `calibrated_crates_are_live`
128/// liveness test probes: at least one must match for every `CALIBRATED_CRATES` entry, else the entry is
129/// dead. Exported as ONE source of truth because the nightly lint crate (`src/lib.rs`) runs the SAME
130/// liveness test — when the two probe lists were duplicated they drifted, and a rule keyed on a
131/// distinctive tail (pnet `::datalink::channel`, ignore `::WalkBuilder::build_parallel`, notify
132/// `::RecommendedWatcher::new`) added to only one list silently broke the other crate's `cargo test`.
133pub const CALIBRATION_PROBE_TAILS: &[&str] = &[
134    "::X::send", "::X::execute", "::X::call", "::X::query", "::X::fetch_one", "::Remote::fetch",
135    "::datalink::channel", "::WalkBuilder::build_parallel", "::RecommendedWatcher::new",
136    "::X::connect", "::Utc::now", "::X::load", "::__private_api::log", "::tempfile", "::glob",
137    "::X::run", "::dotenv", "::random", "::emit", "::X::emit_span_lint", "::X::anything",
138    "::X::draw",
139    "::SaltString::generate", "::hash", "::OsRng::fill_bytes",
140    // verb-precise crates whose whole-crate rules were narrowed to the effectful surface (the pure
141    // accessors/ctors/data-types now return None), so the liveness probe must name an EFFECTFUL path:
142    "::Mmap::map", "::event", "::u32", "::Clipboard::get_text", "::spawn_command",
143    // coverage-differential crates (each needs ≥1 effectful tail; existing tails already cover
144    // native_tls_crate/tokio_native_tls/sqlx_core via ::X::connect, execute via ::X::execute, jiff via ::now):
145    "::read_tls", "::home_dir", "::args", "::from_env", "::IntoIter::next", "::set_file_mtime",
146    "::surely_conflicts_with", "::set_handler", "::get_matches", "::init", "::interact",
147    "::write_line", "::background_color", "::retry", "::build",
148];
149
150/// Database client crates whose execution verbs are I/O (see the DB branch in `classify`).
151/// Module-level so `db_crates_are_calibrated` can enforce `DB_CRATES ⊆ CALIBRATED_CRATES`.
152pub const DB_CRATES: [&str; 11] = [
153    "sqlx", "rusqlite", "postgres", "tokio_postgres", "diesel", "redis", "mongodb",
154    "mysql", "mysql_async", "sea_orm", "deadpool_postgres",
155];
156
157/// Pure file-descriptor *ownership-transfer* leaves. These ADOPT an already-open descriptor
158/// (`from_raw_fd`/`from_raw_socket`/`from_raw_handle`), EXTRACT/BORROW one
159/// (`into_raw_fd`/`into_raw_socket`/`into_raw_handle`, `as_raw_fd`/`as_raw_socket`/`as_raw_handle`),
160/// or UNWRAP an async wrapper back to its std type (`into_std`) — none of them issue a syscall or
161/// 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
162/// coarse std-type PREFIX rules (`std::net::TcpStream`/`std::fs::File`/`std::os::unix::net` → Net/Fs/Ipc)
163/// even though the descriptor was opened ELSEWHERE. The portable_pty/async_process Exec rule already
164/// exempts `from_raw_fd`; this generalises the same carve-out across the net/fs/ipc prefix rules.
165/// (Found by a real-world sweep of tokio: `TcpStream::into_std`, `*::from_raw_fd`, `*::as_raw_fd` all
166/// fabricated Net/Fs/Ipc.)
167const PURE_FD_TRANSFER: &[&str] = &[
168    "from_raw_fd", "from_raw_socket", "from_raw_handle",
169    "into_raw_fd", "into_raw_socket", "into_raw_handle",
170    "as_raw_fd", "as_raw_socket", "as_raw_handle",
171    "into_std",
172    // `SocketAddr::from_pathname` (std/async-std unix net) builds an address STRUCT from a path —
173    // it opens no socket. The `std::os::unix::net` prefix rule below would otherwise fabricate Ipc
174    // on it. (Found sweeping socket2: `SockAddr::as_unix` → `from_pathname` reported Ipc.)
175    "from_pathname",
176];
177
178/// The std I/O **handle** types whose methods the scanner's receiver inference may route into
179/// `classify` as `Type::method`.
180///
181/// WHY A LIST AND NOT "std". The std rules in `classify` are a MIXTURE: a few are type-precise with
182/// reviewed pure-accessor carve-outs (`std::net::TcpStream` → Net minus `local_addr`/`peer_addr`/…;
183/// `std::process::Command` → Exec minus `get_program`/`get_args`/…), but most are coarse MODULE
184/// prefixes (`std::fs::` → Fs) written for free functions and constructors. Applied to arbitrary
185/// method calls, a coarse prefix charges an effect for reading a struct field: `std::fs::Metadata`,
186/// `DirEntry`, `Permissions` and `FileType` all sit under `std::fs::` and are pure DATA. So the
187/// scanner used to skip EVERY std-rooted receiver — which shut the door on the pure data types and on
188/// the real handles alike, and `fn run(cmd: &mut Command) { cmd.spawn(); }` certified PURE while it
189/// spawned a process (a silent false all-clear; the `cc` crate's `command_helpers::spawn` was a live
190/// instance). This list re-opens the door for the handles ONLY.
191///
192/// MEMBERSHIP RULE, and the reason it is safe to apply a whole-type rule to an inferred method call:
193/// a type belongs here only when EVERY method on it is either the effect itself or an already-carved-
194/// out pure read-back. That holds for an open descriptor's handle — a `File`/`TcpStream`/`Child` does
195/// nothing but I/O on the thing it owns — and the `PURE_FD_TRANSFER` guard at the top of `classify`
196/// exempts the `as_raw_fd`/`into_std` family for all of them before any prefix rule runs.
197///
198/// THE OPTION-BUILDERS, `OpenOptions` and `DirBuilder`, were absent for exactly this rule and now
199/// satisfy it: each has a type-keyed carve-out in `classify` subtracting its pure setters, so only its
200/// TERMINAL VERB (`open` / `create`) is Fs. The exclusion was written as "a narrow residual, not the
201/// reported hole" and MEASURED otherwise — `fn load(o: &OpenOptions, p: &Path) { o.open(p) }` opened a
202/// file and reported NOTHING, the same silent false all-clear as the `Command` parameter, while a
203/// setter-only `OpenOptions::new().read(true)` reported `Fs` for opening nothing. The order the old
204/// comment prescribed is the order taken: the verb-precise rules went in FIRST, and this list widened
205/// onto them. `ReadDir` stays out — its `next` IS a syscall, but nothing distinguishes it from any
206/// other iterator's `next`, so no verb-precise rule can be written and the honest miss stands.
207///
208/// `std::path::Path`/`PathBuf` are NOT here: they are pure data with an effectful STAT sub-surface —
209/// the exact inverse shape — and they route through their own verb-precise carve-out in the scanner.
210const STD_EFFECT_HANDLES: &[&str] = &[
211    // Exec — the whole type is the subprocess boundary (`new` names a program, `arg`/`env` build the
212    // invocation, `spawn`/`output`/`status` run it); the read-back getters are already carved out.
213    "std::process::Command",
214    "std::process::Child",
215    // Net — an open socket. `connect`/`accept`/`read`/`write`/`shutdown`/`set_*` are socket syscalls;
216    // `local_addr`/`peer_addr`/`nodelay`/`ttl`/`take_error` are carved out.
217    "std::net::TcpStream",
218    "std::net::TcpListener",
219    "std::net::UdpSocket",
220    // Ipc — a Unix-domain socket, the same shape as the TCP/UDP handles above.
221    "std::os::unix::net::UnixStream",
222    "std::os::unix::net::UnixListener",
223    "std::os::unix::net::UnixDatagram",
224    // Fs — an open file. Every method reads, writes, syncs, truncates or stats it; the descriptor
225    // conversions are carved out by `PURE_FD_TRANSFER`.
226    "std::fs::File",
227    // Fs — the option-builders, whose PURE setters are subtracted by the type-keyed carve-outs in
228    // `classify` so that only the terminal verb (`OpenOptions::open`, `DirBuilder::create`) routes to Fs.
229    "std::fs::OpenOptions",
230    "std::fs::DirBuilder",
231];
232
233/// Is `ty` a std I/O handle type whose methods may be routed into `classify` by the scanner's
234/// receiver inference? See `STD_EFFECT_HANDLES` for the membership rule and the named exclusions.
235pub fn is_std_effect_handle(ty: &str) -> bool {
236    STD_EFFECT_HANDLES.contains(&ty)
237}
238
239/// Classify a resolved callee by the crate it belongs to and its full path.
240pub fn classify(crate_name: &str, path: &str) -> Option<&'static str> {
241    // Pure fd ownership-transfer/extraction leaves are never an effect, regardless of which std I/O
242    // type they hang off — exempt them BEFORE the coarse prefix rules can fabricate Net/Fs/Ipc.
243    if PURE_FD_TRANSFER.contains(&path.rsplit("::").next().unwrap_or(path)) {
244        return None;
245    }
246    if crate_name.starts_with("aws_sdk_") || crate_name.starts_with("aws_smithy") {
247        // Only request dispatch is network I/O; builder setters/accessors are pure.
248        if path.ends_with("::send") || path.ends_with("::send_with") {
249            return Some("Net");
250        }
251        return None;
252    }
253    // aws-config resolves credentials/region on `.load()` — it reaches the IMDS metadata
254    // endpoint / STS over the network (and reads ~/.aws + env). Builders (`defaults()`,
255    // `SdkConfig::builder()`, `BehaviorVersion::latest()`) are pure; the `load` is the I/O.
256    // (Found hardening on a real app, ebman: `builder.load().await` was classified pure.)
257    if crate_name == "aws_config" {
258        if path.ends_with("::load") || path.ends_with("::load_defaults") {
259            return Some("Net");
260        }
261        return None;
262    }
263    // git2 (libgit2 FFI): remote operations contact the network; everything else is local
264    // to the .git directory. Match the remote verbs precisely — NOT bare `::clone`, which is
265    // the `Clone`-trait dup of a `Remote` handle (pure), not `Repository::clone`. (Found
266    // hardening on gitui: `remote.fetch`/`remote.push` were classified network-free — a git
267    // client reporting it makes no network calls.)
268    if crate_name == "git2" {
269        if path.ends_with("::fetch")
270            || path.ends_with("::push")
271            || path.ends_with("::download")
272            || path.ends_with("::connect")
273            || path.ends_with("::connect_auth")
274            || path.ends_with("::ls")
275            || path.ends_with("::upload")
276        {
277            return Some("Net");
278        }
279        return None;
280    }
281    // libc — raw syscalls via FFI. The FFI-thin tier (nix, and the syscall layer beneath rusqlite/git2)
282    // is invisible to a name classifier unless we model libc directly: a 35-crate calibration
283    // (eval/calibration) showed nix reporting ZERO library effects because every wrapper bottoms out in
284    // an unrecognised `libc::*` call. Classify by syscall name, but ONLY the UNAMBIGUOUS ones — the
285    // socket family is Net, path/dir syscalls are Fs, spawn/exec/wait is Exec, SysV/pipe IPC is Ipc,
286    // env/clock/entropy each their own. We deliberately SKIP the generic file-descriptor ops
287    // (read/write/close/lseek/dup/fcntl/ioctl/poll/select/epoll*/mmap): they operate on ANY fd — file,
288    // socket, or pipe — so a fixed label would mis-categorise as often as it helps. An honest
289    // no-classify (under-report) beats emitting the WRONG effect. Pure conversions (htons/inet_pton/
290    // gmtime) are also skipped.
291    //
292    // `nix` (the idiomatic SAFE libc wrapper, in ~every Rust systems/CLI crate) is routed through the
293    // SAME table: its functions keep the syscall leaf name (`nix::fcntl::open`, `nix::sys::socket::connect`,
294    // `nix::unistd::execvp`). Without this, a CONSUMER of nix analysed without nix's own source (the
295    // stable scanner, single-crate) sees `nix::*` cross-crate and under-reports — serialport-rs opens its
296    // device via `nix::fcntl::open` and reported ZERO Fs. The nightly lint reaches `libc::*` THROUGH nix's
297    // body; this gives the scanner the same coverage directly. (Found sweeping serialport-rs.)
298    // `rustix` is the same shape as nix but does RAW syscalls (no libc underneath), so its functions MUST
299    // be classified directly. Its leaf names are the syscall names too (`rustix::time::clock_settime`,
300    // `rustix::fs::mkfifoat`/`symlink`/`stat`, `rustix::net::connect`) — route it through the same table.
301    // The rustix-specific `*at`/variant leaves it doesn't share with libc just under-report (the safe
302    // direction). VALIDATED, not speculative: coreutils' `date` reads/sets the clock via
303    // `rustix::time::clock_getres`/`clock_settime` and reported Clock=0; the file I/O that goes through
304    // std::fs was already correct, which is why only the rustix-only effects (Clock/Ipc) were missing.
305    if crate_name == "libc" || crate_name == "nix" || crate_name == "rustix" {
306        let f = path.rsplit("::").next().unwrap_or(path);
307        // path / directory / metadata syscalls (incl. *64 and *at variants)
308        const FS: &[&str] = &[
309            "open", "open64", "openat", "openat2", "creat", "creat64", "stat", "stat64", "lstat",
310            "lstat64", "fstatat", "fstatat64", "newfstatat", "statx", "access", "faccessat",
311            "faccessat2", "mkdir", "mkdirat", "rmdir", "unlink", "unlinkat", "rename", "renameat",
312            "renameat2", "link", "linkat", "symlink", "symlinkat", "readlink", "readlinkat", "chmod",
313            "fchmodat", "chown", "lchown", "fchownat", "truncate", "truncate64", "ftruncate",
314            "ftruncate64", "opendir", "fdopendir", "readdir", "readdir64", "readdir_r", "closedir",
315            "rewinddir", "seekdir", "telldir", "scandir", "mkstemp", "mkstemps", "mkostemp", "mkdtemp",
316            "mknod", "mknodat", "chdir", "fchdir", "getcwd", "get_current_dir_name", "chroot",
317            "pivot_root", "statfs", "statfs64", "fstatfs", "fstatfs64", "statvfs", "fstatvfs", "mount",
318            "umount", "umount2", "fsync", "fdatasync", "sync", "syncfs", "sync_file_range", "fallocate",
319            "posix_fallocate", "posix_fadvise", "sendfile", "sendfile64", "copy_file_range", "flock",
320            "getdents", "getdents64", "utime", "utimes", "lutimes", "futimens", "utimensat", "futimesat",
321            "realpath",
322        ];
323        // socket family — these operate only on sockets, so Net is unambiguous (AF_UNIX domain isn't
324        // visible at the call, so a Unix socket reads as Net rather than Ipc; acceptable over-general).
325        const NET: &[&str] = &[
326            "socket", "setsockopt", "getsockopt", "bind", "listen", "accept", "accept4", "connect",
327            "shutdown", "send", "sendto", "sendmsg", "sendmmsg", "recv", "recvfrom", "recvmsg",
328            "recvmmsg", "getpeername", "getsockname", "getaddrinfo", "freeaddrinfo", "getnameinfo",
329        ];
330        // process creation / replacement / reaping
331        const EXEC: &[&str] = &[
332            "fork", "vfork", "clone", "clone3", "execl", "execlp", "execle", "execv", "execvp",
333            "execvpe", "execve", "execveat", "fexecve", "posix_spawn", "posix_spawnp", "system",
334            "popen", "pclose", "wait", "waitpid", "wait3", "wait4", "waitid",
335        ];
336        // pipes / FIFOs / SysV + POSIX message queues, semaphores, shared memory; socketpair (AF_UNIX)
337        const IPC: &[&str] = &[
338            "pipe", "pipe2", "mkfifo", "mkfifoat", "socketpair", "msgget", "msgsnd", "msgrcv", "msgctl",
339            "semget", "semop", "semtimedop", "semctl", "shmget", "shmat", "shmdt", "shmctl", "mq_open",
340            "mq_send", "mq_receive", "mq_timedsend", "mq_timedreceive", "mq_close", "mq_unlink",
341        ];
342        const ENV: &[&str] = &["getenv", "secure_getenv", "setenv", "putenv", "unsetenv", "clearenv"];
343        const CLOCK: &[&str] = &[
344            "time", "gettimeofday", "clock_gettime", "clock_getres", "nanosleep", "clock_nanosleep",
345            // SETTING the system clock is a clock effect too (was unclassified — found on coreutils `date`,
346            // which sets it via `clock_settime`).
347            "clock_settime", "settimeofday", "stime", "adjtime", "adjtimex", "clock_adjtime",
348        ];
349        const RAND: &[&str] = &["getrandom", "getentropy", "arc4random", "arc4random_buf", "arc4random_uniform"];
350        if FS.contains(&f) {
351            return Some("Fs");
352        }
353        if NET.contains(&f) {
354            return Some("Net");
355        }
356        if EXEC.contains(&f) {
357            return Some("Exec");
358        }
359        if IPC.contains(&f) {
360            return Some("Ipc");
361        }
362        if ENV.contains(&f) {
363            return Some("Env");
364        }
365        if CLOCK.contains(&f) {
366            return Some("Clock");
367        }
368        if RAND.contains(&f) {
369            return Some("Rand");
370        }
371        return None;
372    }
373    // C-library FFI bindings: libsqlite3 (under rusqlite) and libgit2 (under git2). Like the libc tier,
374    // these crates are thin Rust over a C library, so their real I/O is invisible until the C entry
375    // points are named. Match by the DISTINCTIVE C function name (`sqlite3_*` / `git_*`) via the call's
376    // LEAF — independent of the binding crate's alias: rusqlite calls `ffi::sqlite3_step`, git2 calls
377    // `raw::git_remote_fetch`, and the nightly lint resolves the same to `libsqlite3_sys`/`libgit2_sys`;
378    // all spellings share the leaf. Only the I/O-performing entry points are listed — the in-memory
379    // accessors (`sqlite3_bind_*`/`sqlite3_column_*`, `git_*_oid`/strarray/options builders) stay pure,
380    // so a non-listed `sqlite3_`/`git_` leaf returns None (under-report, never a wrong effect). Calibrated
381    // + validated against rusqlite 0.39 / git2 0.20 source (eval/calibration).
382    {
383        let leaf = path.rsplit("::").next().unwrap_or(path);
384        if let Some(rest) = leaf.strip_prefix("sqlite3_") {
385            let _ = rest;
386            // SQLite C API operations that touch the database (open/exec/step/prepare/backup/blob/wal).
387            const DB: &[&str] = &[
388                "sqlite3_open", "sqlite3_open_v2", "sqlite3_open16", "sqlite3_close", "sqlite3_close_v2",
389                "sqlite3_exec", "sqlite3_step", "sqlite3_prepare", "sqlite3_prepare_v2",
390                "sqlite3_prepare_v3", "sqlite3_prepare16", "sqlite3_prepare16_v2", "sqlite3_prepare16_v3",
391                "sqlite3_get_table", "sqlite3_backup_init", "sqlite3_backup_step", "sqlite3_backup_finish",
392                "sqlite3_blob_open", "sqlite3_blob_read", "sqlite3_blob_write", "sqlite3_blob_reopen",
393                "sqlite3_load_extension", "sqlite3_wal_checkpoint", "sqlite3_wal_checkpoint_v2",
394            ];
395            return DB.contains(&leaf).then_some("Db");
396        }
397        if leaf.starts_with("git_") {
398            // libgit2: remote/transport operations contact the network … (incl. submodule clone/update,
399            // which `git_clone`/fetch the subrepo over its remote — `allow_fetch` defaults on; an A/B on
400            // git2 0.20 caught `Submodule::update`/`clone` reporting no `Net`).
401            const NET: &[&str] = &[
402                "git_clone", "git_remote_connect", "git_remote_connect_ext", "git_remote_fetch",
403                "git_remote_download", "git_remote_upload", "git_remote_push", "git_remote_ls",
404                "git_submodule_clone", "git_submodule_update",
405            ];
406            // … and repository/index/odb/checkout/ref/config operations touch the on-disk .git store.
407            const FS: &[&str] = &[
408                "git_repository_open", "git_repository_open_ext", "git_repository_open_bare",
409                "git_repository_init", "git_repository_init_ext", "git_repository_discover",
410                "git_checkout_tree", "git_checkout_head", "git_checkout_index", "git_index_read",
411                "git_index_write", "git_index_write_tree", "git_index_write_tree_to",
412                "git_index_add_bypath", "git_index_add_all", "git_odb_open", "git_odb_read",
413                "git_odb_write", "git_odb_open_wstream", "git_odb_open_rstream",
414                "git_blob_create_fromdisk", "git_blob_create_fromworkdir", "git_blob_create_from_disk",
415                "git_blob_create_from_workdir", "git_blob_create_from_stream", "git_commit_create",
416                "git_commit_create_v", "git_reference_create", "git_reference_set_target",
417                "git_reference_delete", "git_config_open_default", "git_config_open_ondisk",
418                "git_config_add_file_ondisk", "git_tag_create", "git_treebuilder_write",
419                "git_packbuilder_write",
420            ];
421            if NET.contains(&leaf) {
422                return Some("Net");
423            }
424            if FS.contains(&leaf) {
425                return Some("Fs");
426            }
427            return None;
428        }
429        if leaf.starts_with("curl_") {
430            // libcurl (under the `curl` crate, called `curl_sys::curl_*`). Only the entry points that
431            // PERFORM network I/O: the blocking transfer (`curl_easy_perform`), raw socket send/recv,
432            // the HTTP/2 keepalive PING (`upkeep`), and the multi-interface transfer pumps. The large
433            // pure surface (setopt/init/cleanup/reset/getinfo/escape/multi_add_handle/fdset/info_read)
434            // stays unclassified, as do `curl_multi_wait`/`poll` (readiness WAIT on sockets, no payload —
435            // the loop's `perform` is the tagged boundary, per the I/O-boundary principle). An A/B on
436            // curl 0.4 caught the whole crate reporting ZERO Net (`Easy::perform` read as pure).
437            const NET: &[&str] = &[
438                "curl_easy_perform", "curl_easy_send", "curl_easy_recv", "curl_easy_upkeep",
439                "curl_multi_perform", "curl_multi_socket_action",
440            ];
441            return NET.contains(&leaf).then_some("Net");
442        }
443        if let Some(op) = leaf.strip_prefix("SSL_") {
444            // OpenSSL (libssl, under the `openssl`/`native-tls` crates, called `ffi::SSL_*`). The TLS
445            // handshake and record I/O run over the peer socket -> Net. Unlike libc read/write, an SSL_*
446            // op is ~always over a network BIO (the rare memory-BIO/sans-IO case is the honest exception
447            // we accept). The crypto surface (EVP_*/SHA*/AES*) and pure setup (SSL_CTX_new/SSL_set_fd) are
448            // NOT here; `BIO_*` is skipped (a BIO may be memory or socket). Validated vs openssl 0.9 source.
449            const SSL_NET: &[&str] = &[
450                "connect", "accept", "do_handshake", "read", "read_ex", "write", "write_ex", "peek",
451                "peek_ex", "shutdown",
452            ];
453            return SSL_NET.contains(&op).then_some("Net");
454        }
455    }
456    // HTTP clients use the same builder pattern as the AWS SDK: only the dispatch is
457    // I/O. (Found by the eval: ebman's reqwest calls to the Anthropic API + webhooks
458    // were silently classified network-free because reqwest wasn't recognized.)
459    if crate_name == "reqwest" || crate_name == "isahc" {
460        // The dispatch (`::send`/`::execute`) is the I/O. PLUS the one-shot CONVENIENCE functions
461        // `reqwest::get` / `reqwest::blocking::get` / `isahc::get`, which send immediately — they're
462        // an EXACT match (not `Client::get`, the builder) to avoid false-positiving the builder path.
463        // (Found running on `xh`: a one-shot `reqwest::get(url)` was classified network-free.)
464        if path.ends_with("::send")
465            || path.ends_with("::execute")
466            || path == "reqwest::get"
467            || path == "reqwest::blocking::get"
468            || path == "isahc::get"
469        {
470            return Some("Net");
471        }
472        // THE URL-BEARING BUILDER METHODS: `Client::{get,post,put,delete,patch,head,request}(URL)`.
473        // Real code almost never uses `reqwest::get(url)`; the DOMINANT idiom is the builder chain
474        // `Client::new().post(url).send()` / `Client::builder().build()?.post(url).send()`. The `.send()`
475        // already classifies `Net` — but the URL literal rides the `.post(url)` call, NOT `.send()`, so
476        // without classifying the URL-naming step `Net` the endpoint is NEVER captured and the `Llm`
477        // host refinement can't fire (ebman's `api.anthropic.com` call read as bare Net, undisclosed as
478        // Llm — the dogfood silent under-report). Classifying these `Net` (idempotent with the eventual
479        // `.send()`) makes the scanner capture the URL from their string arg. `request(method, url)`'s
480        // url is its SECOND arg — the scanner's first-string-literal capture still gets it when the
481        // method is a literal string, and misses it (honest under-report) when the method is an
482        // expression. The pure builder surface (`::header`, `::json`, `::body`, `::query`, …) stays None.
483        if path.ends_with("::get")
484            || path.ends_with("::post")
485            || path.ends_with("::put")
486            || path.ends_with("::delete")
487            || path.ends_with("::patch")
488            || path.ends_with("::head")
489            || path.ends_with("::request")
490        {
491            return Some("Net");
492        }
493        return None;
494    }
495    if crate_name == "ureq" && path.ends_with("::call") {
496        return Some("Net");
497    }
498    // The `curl` crate (libcurl's safe binding — cargo's own HTTP client): the dispatch verbs are
499    // `perform` (Easy/Easy2/Transfer/Multi), raw-socket `send`/`recv`, the keepalive `upkeep`, and the
500    // multi-interface `action` (socket_action). The big setopt-style builder surface stays pure.
501    // `Multi::timeout` is deliberately NOT matched: `Easy::timeout` is a pure CURLOPT_TIMEOUT setter
502    // sharing the leaf — an under-report on the rare event-loop kick beats mis-tagging every consumer
503    // that sets a timeout. (Consumer-side companion to the curl_* FFI tier, same A/B finding.)
504    if crate_name == "curl"
505        && (path.ends_with("::perform")
506            || path.ends_with("::send")
507            || path.ends_with("::recv")
508            || path.ends_with("::upkeep")
509            || path.ends_with("::action"))
510    {
511        return Some("Net");
512    }
513    // The modern async-HTTP / TLS / QUIC / DNS stack — the LAYER reqwest/ureq/isahc build on, and that
514    // crates use DIRECTLY. Found by the independent-method differential on `oha` (2026-06-17): candor
515    // honestly DISCLOSED these as blind but never CLASSIFIED them, leaving real Net reaches uncovered.
516    // Verb-keyed (the pure type/builder/codec surface stays None) and CRATE-GATED, so generic verbs
517    // (request/connect/get/read/write/accept) never fabricate across unrelated crates. Same precision
518    // discipline as the reqwest/curl rules above; complements the scan_builder_entry_effect entries.
519    match crate_name {
520        // hyper 1.x client connection I/O (the builder/Body/Request types stay pure).
521        "hyper" if path.ends_with("::send_request") || path.ends_with("::handshake") => return Some("Net"),
522        // hyper-util's pooled legacy Client + its TCP connectors.
523        "hyper_util" if path.ends_with("::request") || path.ends_with("::connect") => return Some("Net"),
524        // hickory (trust-dns) resolver — issues DNS queries over the network.
525        "hickory_resolver"
526            if path.ends_with("::lookup_ip") || path.ends_with("::lookup") || path.ends_with("_lookup")
527                || path.ends_with("::resolve") => return Some("Net"),
528        // HTTP/3 over QUIC.
529        "h3" if path.ends_with("::send_request") || path.ends_with("::recv_data")
530            || path.ends_with("::recv_response") || path.ends_with("::send_data") => return Some("Net"),
531        // QUIC transport (UDP socket send/recv): connection setup, datagrams, AND the stream byte I/O
532        // (`RecvStream::read*` / `SendStream::write*` / `finish`). Opening a stream is caught above, but a
533        // fn that only HOLDS a stream and reads/writes it would otherwise read silent-pure (review: a Net
534        // under-report). Crate-gated to quinn, where these verbs are unambiguously the socket I/O.
535        "quinn" if path.ends_with("::connect") || path.ends_with("::accept") || path.ends_with("::open_bi")
536            || path.ends_with("::open_uni") || path.ends_with("::accept_bi") || path.ends_with("::accept_uni")
537            || path.ends_with("::send_datagram") || path.ends_with("::read_datagram")
538            || path.ends_with("::read") || path.ends_with("::read_chunk") || path.ends_with("::read_chunks")
539            || path.ends_with("::read_to_end") || path.ends_with("::write") || path.ends_with("::write_all")
540            || path.ends_with("::write_chunk") || path.ends_with("::write_chunks")
541            || path.ends_with("::finish") => return Some("Net"),
542        // TLS-over-TCP stream adapters — the actual socket handshake/I/O (the config/cert types stay pure).
543        "tokio_rustls" | "native_tls"
544            if path.ends_with("::connect") || path.ends_with("::accept") || path.ends_with("::handshake") =>
545            return Some("Net"),
546        // AF_VSOCK host<->guest sockets — inter-process / VM comms.
547        "tokio_vsock" if path.ends_with("::connect") || path.ends_with("::bind") || path.ends_with("::accept") =>
548            return Some("Ipc"),
549        // Loads the OS trust store from disk (cert files / keychain).
550        "rustls_native_certs" if path.ends_with("::load_native_certs") => return Some("Fs"),
551        // `rlimit` reads/mutates the process's kernel resource limits — the closest bucket is Env (host/
552        // process config); no dedicated process-state bucket exists, so getrlimit (read) and setrlimit
553        // (mutate) share it. NOTE: `num_cpus::get`/`get_physical` are deliberately NOT modeled — asking the
554        // OS for the CPU count is a near-pure topology query, and std's equivalent `thread::
555        // available_parallelism` classifies pure; modeling it as Env would spray Env over every thread-pool
556        // constructor (review: a high-noise over-report) for no capability a reviewer cares about.
557        "rlimit" if path.ends_with("::getrlimit") || path.ends_with("::setrlimit")
558            || path.ends_with("::increase_nofile_limit") => return Some("Env"),
559        // rustls — the SYNC TLS core (tokio_rustls/native_tls above are the async/system adapters). The
560        // record-layer I/O is `read_tls`/`write_tls` (pull/push raw bytes through a held `io::Read`/`Write`)
561        // and `complete_io` (loops them until the handshake/buffers drain). The config/cert/builder types
562        // (`ClientConfig`/`ServerConfig`/`ConfigBuilder`) are PURE. `process_new_packets` is deliberately
563        // EXCLUDED — it only decrypts ALREADY-buffered bytes (no socket touch; docs say call it AFTER
564        // read_tls), so flagging it would over-report Net on the pure decrypt step.
565        "rustls" if path.ends_with("::read_tls") || path.ends_with("::write_tls")
566            || path.ends_with("::complete_io") => return Some("Net"),
567        // native-tls under its alternate crate name + the tokio async wrapper (the `native_tls` arm above
568        // is the common name). The TLS handshake over a TcpStream is Net; the builder/cert types are pure.
569        "native_tls_crate" | "tokio_native_tls"
570            if path.ends_with("::connect") || path.ends_with("::accept")
571                || path.ends_with("::handshake") => return Some("Net"),
572        _ => {}
573    }
574    // Message-queue clients fully encapsulate the socket (the underlying tokio::net lives
575    // inside the crate, unseen), so a user's connect/publish/consume calls ARE the I/O
576    // boundary — to a remote broker, hence Net. Match the broker round-trip verbs (snake_case
577    // methods); the CamelCase option/property builders stay pure. (Found hardening on consumer
578    // apps: lapin `basic_publish`/`queue_declare` and async-nats `publish`/`subscribe` were
579    // classified pure — a message-queue client reporting no I/O.)
580    if crate_name == "async_nats" {
581        if path.ends_with("::connect")
582            || path.contains("::publish")
583            || path.ends_with("::subscribe")
584            || path.ends_with("::queue_subscribe")
585            || path.contains("::request")
586            || path.ends_with("::flush")
587        {
588            return Some("Net");
589        }
590        return None;
591    }
592    if crate_name == "lapin" {
593        if path.ends_with("::connect")
594            || path.ends_with("::create_channel")
595            || path.contains("::basic_")
596            || path.contains("::queue_")
597            || path.contains("::exchange_")
598            || path.contains("::tx_")
599            || path.ends_with("::confirm_select")
600            || path.ends_with("::close")
601        {
602            return Some("Net");
603        }
604        return None;
605    }
606    // SMTP email — lettre's `Transport::send` is the network dispatch; Message building is
607    // pure. (Found hardening on a lettre consumer: `mailer.send(&email)` classified pure.)
608    if crate_name == "lettre" {
609        if path.ends_with("::send") || path.ends_with("::send_raw") {
610            return Some("Net");
611        }
612        return None;
613    }
614    // WebSockets — tungstenite (the modern successor to the old `websocket` crate). connect
615    // and the socket read/write/send are network; Message constructors are pure. (Found on a
616    // tungstenite consumer: connect + send + read classified pure.)
617    if crate_name == "tungstenite" {
618        if path.ends_with("::connect")
619            || path.ends_with("::read")
620            || path.ends_with("::write")
621            || path.ends_with("::send")
622            || path.ends_with("::close")
623            || path.ends_with("::flush")
624            || path.ends_with("::read_message")
625            || path.ends_with("::write_message")
626        {
627            return Some("Net");
628        }
629        return None;
630    }
631    // elasticsearch: request builders are pure; only the `.send()` dispatch is HTTP I/O
632    // (same shape as reqwest / the AWS SDK). (Found on an elasticsearch consumer.)
633    if crate_name == "elasticsearch" && path.ends_with("::send") {
634        return Some("Net");
635    }
636    // gRPC — tonic. The transport connect and the Grpc client RPC dispatch are network;
637    // codecs and request/response wrappers are pure. (connect repro-confirmed on a consumer;
638    // the unary/streaming RPC verbs are from the tonic::client::Grpc API.)
639    if crate_name == "tonic" {
640        if path.ends_with("::connect")
641            || path.ends_with("::unary")
642            || path.ends_with("::server_streaming")
643            || path.ends_with("::client_streaming")
644            || path.ends_with("::streaming")
645        {
646            return Some("Net");
647        }
648        return None;
649    }
650    // Kafka — rdkafka (FFI to librdkafka). Producer send + consumer poll/recv/subscribe/
651    // commit are network round-trips to the brokers. (API-calibrated + unit-tested; a real
652    // repro needs librdkafka/cmake, deferred.)
653    if crate_name == "rdkafka" {
654        if path.ends_with("::send")
655            || path.ends_with("::send_result")
656            || path.ends_with("::recv")
657            || path.ends_with("::poll")
658            || path.ends_with("::subscribe")
659            || path.ends_with("::commit")
660            || path.ends_with("::commit_message")
661            || path.ends_with("::commit_consumer_state")
662            || path.ends_with("::store_offset")
663            || path.ends_with("::seek")
664            || path.ends_with("::fetch_metadata")
665            || path.ends_with("::fetch_watermarks")
666            || path.ends_with("::flush")
667        {
668            return Some("Net");
669        }
670        return None;
671    }
672    // cap-std: capability-oriented std. I/O goes *through* a held capability handle
673    // (Dir/Pool/Clock/...), so these calls ARE the effect. Recognising them means a
674    // cap-std project's real I/O is detected and matches the capability it declared
675    // (via `declared_caps`/`capstd_cap`) — conformance against unforgeable capabilities.
676    if crate_name.starts_with("cap_") {
677        if path.contains("::net::Unix") || path.contains("::os::") {
678            return Some("Ipc");
679        }
680        if path.contains("::net") {
681            return Some("Net");
682        }
683        if path.contains("::time") {
684            return Some("Clock");
685        }
686        if path.contains("::fs") || crate_name == "cap_tempfile" || crate_name == "cap_directories" {
687            return Some("Fs");
688        }
689        return None;
690    }
691    // Local IPC (Unix-domain sockets) is I/O but not *network* — keep it distinct so
692    // CANDOR_NO_AMBIENT and audits don't conflate it with internet access. async-std puts its
693    // Unix sockets under `os::unix::net` (mirroring std); async-net (smol's net layer) under
694    // `unix`.
695    if path.starts_with("tokio::net::Unix")
696        || path.starts_with("std::os::unix::net")
697        || path.starts_with("async_std::os::unix::net")
698        || path.starts_with("async_net::unix")
699    {
700        return Some("Ipc");
701    }
702    // Raw packet capture / raw sockets — libpnet (the dominant low-level networking crate; powers
703    // bandwhich, sniffers, custom-protocol tools). `datalink::channel` opens an L2 socket and
704    // `transport::transport_channel` an L3/L4 raw socket — both ARE network I/O. Packet construction
705    // (pnet_packet / pnet_base, MacAddr, Ethernet frames…) is pure and stays unclassified. The actual
706    // frame read/write happens via methods on the returned Sender/Receiver (trait-object dispatch the
707    // syntactic backend can't resolve), so the channel-open call is the precise Net boundary. (Found
708    // scanning bandwhich — a packet sniffer — which reported Net 0.)
709    if crate_name == "pnet" || crate_name == "pnet_datalink" || crate_name == "pnet_transport" {
710        if path.ends_with("::channel") || path.ends_with("::transport_channel") {
711            return Some("Net");
712        }
713        return None;
714    }
715    // Directory traversal — `ignore` (BurntSushi's gitignore-aware walker; powers ripgrep, fd). The walk
716    // EXECUTORS read the directory tree from disk = Fs. Type-precise on purpose: the configuration builders
717    // (`OverrideBuilder::build`, `GitignoreBuilder::build`, the `WalkBuilder` setters) and `DirEntry`
718    // accessors are PURE — only `WalkBuilder::build`/`build_parallel` (which kick off the walk) and
719    // `WalkParallel::run` (which drives it) touch the filesystem. A bare `build` would wrongly flag the
720    // config builders. (Found scanning fd — a file finder — which reported Fs 2: its own `fs::read_dir`
721    // was caught, but the `ignore`-based traversal that IS fd was invisible cross-crate.)
722    if crate_name == "ignore" {
723        if path == "ignore::WalkBuilder::build"
724            || path == "ignore::WalkBuilder::build_parallel"
725            || path.ends_with("::WalkParallel::run")
726            // `add_ignore(path)` LOOKS like a config setter but reads that ignore file from disk at call
727            // time (it returns the read error) — unlike the pure `add_custom_ignore_filename(name)` which
728            // only stores a filename string. The lone Fs-touching builder method in the otherwise-pure setter
729            // surface, so it was silently pure under the covered-crate floor.
730            || path == "ignore::WalkBuilder::add_ignore"
731        {
732            return Some("Fs");
733        }
734        return None;
735    }
736    // Filesystem watching — `notify` (the de-facto fs-watch crate: watchexec, cargo-watch, mdbook). A
737    // watcher opens an OS notification handle (inotify / FSEvents / kqueue / ReadDirectoryChanges) and
738    // registers paths — observing filesystem state changes = Fs. The lifecycle boundary: any
739    // `*Watcher::new` constructor (RecommendedWatcher/PollWatcher/INotifyWatcher/FsEventWatcher/…), the
740    // `recommended_watcher` convenience fn, and the `watch`/`unwatch` registration verbs. `Config`/`Event`/
741    // `EventKind` data types stay pure. (Found scanning watchexec: its watcher-`create` read Fs 0.)
742    if crate_name == "notify" {
743        if path.ends_with("Watcher::new")
744            || path.ends_with("::recommended_watcher")
745            || path.ends_with("::watch")
746            || path.ends_with("::unwatch")
747        {
748            return Some("Fs");
749        }
750        return None;
751    }
752    // std DNS resolution — `("host", 80).to_socket_addrs()` / `std::net::lookup_host("host")` perform a
753    // real getaddrinfo query (Net), but the classify table covered only the socket I/O *types*, so they
754    // floored silently (sweep [37]; the syntactic engine modelled DNS only at the libc layer).
755    if path.ends_with("::to_socket_addrs")
756        || path == "std::net::lookup_host"
757        || path.ends_with("ToSocketAddrs::to_socket_addrs")
758    {
759        return Some("Net");
760    }
761    // Raw sockets. Match the I/O *types* only — `std::net` also holds pure data types
762    // (SocketAddr, IpAddr, …) whose construction must NOT be flagged.
763    if path.starts_with("std::net::TcpStream")
764        || path.starts_with("std::net::TcpListener")
765        || path.starts_with("std::net::UdpSocket")
766        || path.starts_with("tokio::net::")
767    {
768        // …but the PURE accessors read back local/option state — no network I/O — so the whole-type Net
769        // rule fabricated Net on them (sweep [24], the precision failure; mirrors the arboard/memmap2 accessor
770        // carve-outs). local_addr/peer_addr return bound/connected addresses; nodelay/ttl/take_error read
771        // socket options/state. Every genuine verb (connect/read/write/send/recv/accept) stays Net.
772        if path.ends_with("::local_addr")
773            || path.ends_with("::peer_addr")
774            || path.ends_with("::nodelay")
775            || path.ends_with("::ttl")
776            || path.ends_with("::take_error")
777        {
778            return None;
779        }
780        return Some("Net");
781    }
782    // Legacy tokio 0.1 socket crates — `tokio_tcp`/`tokio_udp` are *entirely* networking
783    // (no pure types to over-flag), so the whole crate is Net. (Found hardening on websocat,
784    // which is still on tokio 0.1: its `tokio_tcp::TcpStream::connect` was classified
785    // network-free — a network tool confidently reporting 0 Net.)
786    if matches!(crate_name, "tokio_tcp" | "tokio_udp") {
787        return Some("Net");
788    }
789    // The other async runtimes mirror tokio's module layout, and their `net` modules hold only
790    // socket I/O types (the pure `SocketAddr`/`IpAddr` are re-exports that resolve to `std::net`,
791    // so they're excluded by def-path). `mio` is the low-level non-blocking-socket layer under
792    // tokio/others; `async_net` is smol's net crate. Closes the async-std/smol/mio gap the
793    // tokio_tcp note flagged. (Calibrated by module structure — these crates ARE networking — not
794    // a live repro; the TCP/UDP types are defined in-crate so the def-path prefix is exact.)
795    if path.starts_with("async_std::net::")
796        || path.starts_with("mio::net::")
797        || crate_name == "async_net"
798    {
799        return Some("Net");
800    }
801    // Database clients. Like the AWS/HTTP builders, only the execution verbs are I/O;
802    // query *construction* is pure. Best-effort across crates (tune via CANDOR_CONFIG).
803    // Note: bare `::query` is deliberately omitted — it executes in postgres/rusqlite but
804    // only *builds* in sqlx, so including it would false-positive sqlx's `query()` builder.
805    if DB_CRATES.contains(&crate_name) {
806        // Postgres / SQLite-family clients: `query`/`batch_execute`/`prepare`/etc. ARE the
807        // execution (round-trips to the server). sqlx is the outlier where bare `query()`
808        // only BUILDS — it keeps the narrow set below. (Found by running on a real
809        // tokio-postgres app, pgman: candor had reported only 4 of ~20 DB call sites.)
810        if matches!(crate_name, "postgres" | "tokio_postgres" | "deadpool_postgres" | "rusqlite") {
811            const PG: [&str; 19] = [
812                "::query", "::query_one", "::query_opt", "::query_raw", "::execute",
813                "::batch_execute", "::simple_query", "::prepare", "::prepare_typed",
814                "::copy_in", "::copy_out", "::transaction", "::connect",
815                // rusqlite's dialect of the same verbs (a verb-probe found the CANONICAL rusqlite
816                // consumer API classifying pure): `query_row` is the one-row read, `query_map`/
817                // `query_and_then` the many-row reads, `execute_batch` is rusqlite's name for
818                // batch_execute, `prepare_cached` round-trips like prepare. `query_typed` is
819                // tokio_postgres 0.7.10+.
820                "::query_row", "::query_map", "::query_and_then", "::execute_batch",
821                "::prepare_cached", "::query_typed",
822            ];
823            if PG.iter().any(|v| path.ends_with(v)) {
824                return Some("Db");
825            }
826            // rusqlite only: opening the database IS the connection establishment (`Connection::
827            // open`/`open_in_memory`/`open_with_flags` — the embedded analog of `::connect`).
828            if crate_name == "rusqlite"
829                && (path.ends_with("::open")
830                    || path.ends_with("::open_in_memory")
831                    || path.ends_with("::open_with_flags"))
832            {
833                return Some("Db");
834            }
835            return None;
836        }
837        // redis: the way redis is ACTUALLY used is the high-level `Commands`/`AsyncCommands`
838        // traits (`con.get`/`set`/`hset`/`lpush`/…) — every method is a round-trip — plus
839        // connection establishment. The shared VERBS below only catch the low-level
840        // `cmd("GET").query(con)`, so without this a normal redis user's calls classify as
841        // PURE. (Found hardening on redis-rs: a fn doing `con.get`/`set` reported no effects.)
842        if crate_name == "redis"
843            && (path.contains("Commands::")
844                || path.contains("::get_connection")
845                || path.contains("::get_async_connection")
846                || path.contains("::get_multiplexed_async_connection")
847                // a live `ConnectionManager` round-trips (Db), but `ConnectionManagerConfig` is a pure
848                // in-memory builder (set_number_of_retries/set_max_delay) — exclude it (adversarial review).
849                // `ConnectionManager::clone` is an Arc refcount bump — no Db round-trip (sweep [27]).
850                || (path.contains("ConnectionManager") && !path.contains("ConnectionManagerConfig")
851                    && !path.ends_with("::clone"))
852                || path.ends_with("::query")
853                || path.ends_with("::query_async")
854                || path.ends_with("::req_command")
855                || path.ends_with("::req_packed_command")
856                || path.ends_with("::req_packed_commands"))
857        {
858            return Some("Db");
859        }
860        // mongodb: a document-store API with none of the SQL verbs — the user calls
861        // `coll.find_one`/`insert_one`/`aggregate`/… and `Client::with_uri_str`. Without
862        // these a mongodb user's calls classify PURE. (Found hardening: a fn doing
863        // `find_one`+`insert_one` reported no effects.) Handle accessors (name/namespace)
864        // and option/doc builders don't match these verbs, so they stay pure.
865        if crate_name == "mongodb" {
866            const MONGO: [&str; 27] = [
867                "::with_uri_str", "::connect", "::find", "::find_one", "::insert_one",
868                "::insert_many", "::update_one", "::update_many", "::delete_one",
869                "::delete_many", "::replace_one", "::aggregate", "::count_documents",
870                "::estimated_document_count", "::count", "::distinct", "::run_command",
871                "::find_one_and_update", "::find_one_and_delete", "::find_one_and_replace",
872                "::list_collections", "::list_collection_names", "::list_databases",
873                "::list_database_names", "::create_collection", "::create_index", "::watch",
874            ];
875            if MONGO.iter().any(|v| path.ends_with(v)) {
876                return Some("Db");
877            }
878            return None;
879        }
880        // mysql / mysql_async: the `query`/`exec` families + `get_conn`/`ping` execute
881        // immediately — no build-then-execute split like sqlx, so matching `::query` is safe
882        // here. Same DB-verb-dialect gap class as redis/mongodb; calibrated from the Queryable
883        // API (unit-tested; a real-app repro is the remaining confirmation).
884        if matches!(crate_name, "mysql" | "mysql_async") {
885            const MY: [&str; 16] = [
886                "::query", "::query_first", "::query_iter", "::query_map", "::query_fold",
887                "::query_drop", "::exec", "::exec_first", "::exec_iter", "::exec_map",
888                "::exec_fold", "::exec_drop", "::exec_batch", "::prep", "::ping", "::get_conn",
889            ];
890            if MY.iter().any(|v| path.ends_with(v)) {
891                return Some("Db");
892            }
893            return None;
894        }
895        // sea_orm: an ORM whose execution is split from building (like sqlx). The query
896        // BUILDERS (`Entity::find`, `Entity::insert`) are pure; execution happens at `.all`/
897        // `.one`/`.count`/`.stream` and `Insert/Update/Delete::exec`. The write path via an
898        // ActiveModel (`model.insert(db)`) executes too — distinguished from the `EntityTrait`
899        // builder by the trait in the path (`ActiveModelTrait::`). (Found hardening on a
900        // sea_orm consumer app: `.all(db)` reads and `ActiveModel::insert` writes were pure.)
901        if crate_name == "sea_orm" {
902            // sea_orm RE-EXPORTS sea_query (`sea_orm::sea_query::…`), whose builder algebra collides with
903            // the execution verbs: `Func::count(col)` builds a COUNT() expr, `Condition::all()` AND-groups
904            // filters, `Expr::count(…)` — all PURE, none touch a db. The `::all`/`::count`/`::one` execution
905            // rule fabricated Db on them (sweep [5]). sea_query is pure query construction end-to-end, so
906            // exclude the whole re-exported namespace first.
907            if path.contains("sea_query") {
908                return None;
909            }
910            if path.ends_with("::all")
911                || path.ends_with("::one")
912                || path.ends_with("::count")
913                || path.ends_with("::stream")
914                || path.ends_with("::exec")
915                || path.ends_with("::exec_with_returning")
916                || path.ends_with("::exec_without_returning")
917                || path.ends_with("::connect")
918                || path.ends_with("::execute")
919                || path.ends_with("::execute_unprepared")
920                || path.ends_with("::query_one")
921                || path.ends_with("::query_all")
922                || path.ends_with("::fetch_page")
923                || path.ends_with("::num_items")
924                || path.contains("ActiveModelTrait::")
925            {
926                return Some("Db");
927            }
928            return None;
929        }
930        // (Reached by sqlx + diesel — the build-vs-execute-split crates.) `first` is diesel's
931        // LIMIT-1 round trip and `load_iter` its 2.x streaming execution; `fetch_many` is sqlx's
932        // multi-result stream. All crate-gated, so a std `Vec::first` never resolves here.
933        const VERBS: [&str; 19] = [
934            "::execute", "::query_row", "::query_map", "::query_one", "::fetch_one",
935            "::fetch_all", "::fetch_optional", "::fetch", "::fetch_many", "::connect",
936            "::acquire", "::begin", "::commit", "::rollback", "::load", "::load_iter",
937            "::first", "::get_result", "::get_results",
938        ];
939        if VERBS.iter().any(|v| path.ends_with(v)) {
940            return Some("Db");
941        }
942        return None;
943    }
944    // std::path::Path / PathBuf STAT-family methods hit the filesystem (each is a stat/readlink/
945    // readdir syscall) — unlike the rest of the std::path surface, which is pure string manipulation
946    // (join/file_name/extension/parent/…). Verb-precise so the scanner's receiver inference can safely
947    // route a `path.symlink_metadata()` method call here. (A blackout screen caught gix-dir — an entire
948    // directory WALKER — reporting ZERO Fs because all its I/O is Path-method calls; same class as
949    // fd's residual `Path::symlink_metadata` under-report.)
950    if let Some(m) = path
951        .strip_prefix("std::path::Path::")
952        .or_else(|| path.strip_prefix("std::path::PathBuf::"))
953    {
954        const STAT: &[&str] = &[
955            "metadata", "symlink_metadata", "canonicalize", "read_link", "read_dir", "exists",
956            "try_exists", "is_file", "is_dir", "is_symlink",
957        ];
958        return STAT.contains(&m).then_some("Fs");
959    }
960    // OPTION-BUILDERS, the shape SPEC §1 ⟨0.32⟩ separates from an invocation object: "option-builders for
961    // other effects (`OpenOptions`, request builders) stay pure because their resource arrives at the
962    // terminal verb, which is charged at its own call site". An `OpenOptions` holds a handful of bools and
963    // names no file; `DirBuilder` holds one bool and a mode. Both sit under the coarse `std::fs::` prefix
964    // below, which charged Fs for `OpenOptions::new()` and for every `o.read(true)` — MEASURED: a
965    // `let o = OpenOptions::new().read(true);` with no `open` ANYWHERE reported `Fs`.
966    //
967    // A DENYLIST, and keyed on the TYPE. Only the provably-pure setters/ctors are subtracted; anything
968    // else under the type keeps its effect, so the terminal verb (`OpenOptions::open`, `DirBuilder::create`)
969    // stays Fs and a std addition we have not read about fails in the safe direction. The type key is not
970    // decoration: `create` is a pure flag SETTER on `OpenOptions` and the mkdir SYSCALL on `DirBuilder` —
971    // one leaf, opposite answers. (candor-java keys the same carve-out by DESCRIPTOR, because its collision
972    // is an overload — `command()` reads back, `command(List)` sets. Rust has no overloads, so the type IS
973    // the discriminator; a bare-name denylist would silence `DirBuilder::create`.)
974    if let Some(m) = path.strip_prefix("std::fs::OpenOptions::") {
975        const PURE: &[&str] = &[
976            // the builder itself + the std flag setters
977            "new", "read", "write", "append", "truncate", "create", "create_new",
978            // the platform extension setters (`OpenOptionsExt`, unix + windows)
979            "mode", "custom_flags", "security_qos_flags", "access_mode", "share_mode", "attributes",
980            // derives
981            "clone", "default", "fmt", "eq", "ne", "hash",
982        ];
983        return (!PURE.contains(&m)).then_some("Fs");
984    }
985    if let Some(m) = path.strip_prefix("std::fs::DirBuilder::") {
986        const PURE: &[&str] = &["new", "recursive", "mode", "clone", "default", "fmt"];
987        return (!PURE.contains(&m)).then_some("Fs");
988    }
989    // Filesystem. `tokio::fs`/`async_std::fs` are the async mirrors of `std::fs`; `async_fs` is
990    // smol's fs crate; `fs_err` is a drop-in `std::fs` wrapper (its whole surface is fs I/O).
991    if path.starts_with("std::fs::")
992        || path.starts_with("tokio::fs::")
993        || path.starts_with("async_std::fs::")
994        || crate_name == "async_fs"
995        || crate_name == "fs_err"
996    {
997        return Some("Fs");
998    }
999    // memmap2: only `MmapOptions::map*` (and the in-place `Mmap::flush`/`make_*` protection
1000    // changes / `remap`) actually issue the mmap/msync/mprotect/mremap syscall = Fs. The rest of the
1001    // crate is PURE: `MmapOptions::new`/setters BUILD the request, and once a region is mapped, reads
1002    // over it (`Mmap::len`/`is_empty`/`as_ptr`/`as_mut_ptr`/`deref` into the byte slice) are plain
1003    // memory access with no syscall. Whole-crate Fs fabricated Fs on those reads (a `m.len()` the
1004    // scanner's receiver inference routes to `memmap2::Mmap::len`). Match the syscall-issuing verbs;
1005    // everything else returns None (pure). `map*` covers `map`/`map_mut`/`map_exec`/`map_copy`/
1006    // `map_copy_read_only`/`map_raw`/`map_raw_read_only`/`map_anon`.
1007    if crate_name == "memmap2" {
1008        let m = path.rsplit("::").next().unwrap_or(path);
1009        if m.starts_with("map")
1010            || m == "flush"
1011            || m == "flush_async"
1012            || m == "flush_range"
1013            || m == "flush_async_range"
1014            || m == "remap"
1015            || m.starts_with("make_")
1016            || m == "advise"
1017            || m == "advise_range"
1018            || m == "lock"
1019            || m == "unlock"
1020        {
1021            return Some("Fs");
1022        }
1023        return None;
1024    }
1025    // tempfile: creating a temp file/dir touches the disk. Match the create/persist verbs (the
1026    // `Builder` setters — prefix/suffix/rand_bytes — stay pure). `persist`/`keep` rename/retain
1027    // the file on disk; `close` removes it.
1028    if crate_name == "tempfile"
1029        && (path.ends_with("::tempfile")
1030            || path.ends_with("::tempfile_in")
1031            || path.ends_with("::tempdir")
1032            || path.ends_with("::tempdir_in")
1033            || path.ends_with("NamedTempFile::new")
1034            || path.ends_with("NamedTempFile::new_in")
1035            || path.ends_with("TempDir::new")
1036            || path.ends_with("TempDir::new_in")
1037            || path.ends_with("::persist")
1038            || path.ends_with("::persist_noclobber")
1039            || path.ends_with("::keep"))
1040    {
1041        return Some("Fs");
1042    }
1043    // glob: walks the filesystem to expand a pattern (the returned iterator reads directories).
1044    // `Pattern::matches` is pure string matching — match only the directory-walking entry points.
1045    if crate_name == "glob" && (path.ends_with("::glob") || path.ends_with("::glob_with")) {
1046        return Some("Fs");
1047    }
1048    // Password-hashing / KDF crates — the entropy tier (the TS engine's CTA lesson: an invisible
1049    // argon2 landed on exactly the call a security review cares about). In this engine's
1050    // verb-precise style the ENTROPY is the salt mint: `SaltString::generate(OsRng)` in the
1051    // password-hash API family, and bcrypt's `hash`/`hash_with_result` (salt minted internally).
1052    // Verification and explicit-salt hashing are deterministic recomputation — pure. `rand_core`
1053    // carries the OsRng source itself (otherwise the most common salt mint is invisible).
1054    if matches!(crate_name, "argon2" | "scrypt" | "pbkdf2" | "password_hash") {
1055        if path.contains("SaltString::generate") {
1056            return Some("Rand");
1057        }
1058        return None;
1059    }
1060    if crate_name == "bcrypt" {
1061        if path.ends_with("::hash") || path.ends_with("::hash_with_result") {
1062            return Some("Rand");
1063        }
1064        return None;
1065    }
1066    if crate_name == "rand_core" {
1067        if path.contains("OsRng")
1068            || path.ends_with("::next_u32")
1069            || path.ends_with("::next_u64")
1070            || path.ends_with("::fill_bytes")
1071        {
1072            return Some("Rand");
1073        }
1074        return None;
1075    }
1076    // Randomness / entropy. `getrandom`/`fastrand` are effectful end-to-end. `rand` is NOT — it
1077    // mixes entropy/generation (effectful) with *pure* distribution constructors (`Uniform::new`,
1078    // `Normal::new`) and deterministic-seed constructors (`seed_from_u64`). Flagging the whole crate
1079    // over-reported those as `Rand`; match only the calls that actually consume randomness — the
1080    // entropy sources (`OsRng`, `thread_rng`/`rng`, `from_entropy`/`from_os_rng`) and the generation
1081    // verbs (`gen*`/`random*`/`fill*`/`sample*`/`next_u*`). A `Uniform::new` is now correctly pure.
1082    if crate_name == "getrandom" {
1083        return Some("Rand");
1084    }
1085    // fastrand: like `rand`, it mixes entropy-consuming generation (effectful) with PURE deterministic
1086    // pieces. `Rng::with_seed(42)` is a DETERMINISTIC seeded constructor (consumes no entropy — the same
1087    // seed gives the same stream), and `Rng::fork`/`Rng::clone` just split/copy existing state. Those are
1088    // PURE; whole-crate Rand fabricated Rand on them. The effect is the value-drawing methods (`u32`/
1089    // `usize`/`bool`/`f64`/`char`/`alphanumeric`/`choice`/`choose_multiple`/`shuffle`/`fill`/the range
1090    // forms) AND the entropy-seeded entry points: bare `Rng::new()` (seeds from the global entropy-backed
1091    // generator), `fastrand::seed`, and the top-level `fastrand::u32(..)` free functions (which draw from
1092    // the thread-local generator). `with_seed` is exempted explicitly; any other method on an `Rng`
1093    // (i.e. a value draw) is Rand.
1094    if crate_name == "fastrand" {
1095        let m = path.rsplit("::").next().unwrap_or(path);
1096        // Provably pure: deterministic seeded ctor + state split/copy.
1097        if m == "with_seed" || m == "fork" || m == "clone" {
1098            return None;
1099        }
1100        // Everything else fastrand exposes either draws a value or seeds from entropy → Rand. (The crate
1101        // has no pure data types beyond the `Rng` handle itself, so a non-draw stray would have to be a
1102        // method we don't recognise — keep the effect, the safe direction.)
1103        return Some("Rand");
1104    }
1105    if crate_name == "rand" {
1106        let rng_verb = path.ends_with("::gen")
1107            || path.ends_with("::gen_range")
1108            || path.ends_with("::gen_bool")
1109            || path.ends_with("::gen_ratio")
1110            || path.ends_with("::random")
1111            || path.ends_with("::random_range")
1112            || path.ends_with("::random_bool")
1113            || path.ends_with("::random_ratio")
1114            || path.ends_with("::random_iter") // rand 0.9 iterator generator
1115            || path.ends_with("::gen_iter")
1116            || path.ends_with("::fill")
1117            || path.ends_with("::fill_bytes")
1118            || path.ends_with("::try_fill")
1119            || path.ends_with("::try_fill_bytes")
1120            || path.ends_with("::sample")
1121            || path.ends_with("::sample_iter")
1122            || path.ends_with("::next_u32")
1123            || path.ends_with("::next_u64")
1124            || path.ends_with("::thread_rng")
1125            || path.ends_with("::rng")
1126            || path.ends_with("::from_entropy")
1127            || path.ends_with("::from_os_rng");
1128        // `OsRng` is the OS entropy SOURCE, but `clone`/`fork`/`default` just copy or construct the
1129        // (zero-sized) handle and draw no entropy — pure, exactly like the `fastrand` arm's clone/fork
1130        // exemption above. The actual draws (`fill_bytes`/`next_u*`/…) are caught by `rng_verb`. Without
1131        // this exemption the blanket `contains("OsRng")` fabricated `Rand` on `OsRng::clone` (adversarial
1132        // review: OsRng is a unit struct, cloning consumes nothing).
1133        let m = path.rsplit("::").next().unwrap_or(path);
1134        let os_rng = path.contains("OsRng") && !matches!(m, "clone" | "fork" | "default");
1135        if rng_verb || os_rng {
1136            return Some("Rand");
1137        }
1138        return None;
1139    }
1140    // Subprocess spawning. `tokio::process` is the async mirror of `std::process` — it exists
1141    // only to spawn/control subprocesses (`Command`/`Child`, no pure data types like std's
1142    // `Stdio`/`ExitStatus`/`exit`), so spawning through it is Exec just the same. Without this an
1143    // async app's `tokio::process::Command::new(..).spawn()` classified pure — a silent under-report
1144    // of subprocess execution, the dangerous direction (mirrors the tokio::fs/tokio::net coverage).
1145    if path.starts_with("std::process::Command")
1146        || path.starts_with("std::process::Child")
1147        || path.starts_with("tokio::process::Command")
1148        || path.starts_with("tokio::process::Child")
1149        || path.starts_with("async_std::process::Command")
1150        || path.starts_with("async_std::process::Child")
1151    {
1152        // PURE read-backs of the builder's stored fields / the cached pid — no spawn, no syscall — so the
1153        // whole-type Exec rule fabricated Exec on them (sweep [23]; mirrors the portable_pty getter carve-
1154        // out just below). get_program/get_args/get_envs/get_current_dir read the Command; Child::id reads
1155        // the cached pid. Every genuine verb (new/spawn/output/status/wait/kill) stays Exec.
1156        if path.ends_with("::get_program")
1157            || path.ends_with("::get_args")
1158            || path.ends_with("::get_envs")
1159            || path.ends_with("::get_current_dir")
1160            || path.ends_with("Child::id")
1161        {
1162            return None;
1163        }
1164        return Some("Exec");
1165    }
1166    // portable_pty / async_process are whole-crate Exec EXCEPT for the proven-pure surface they expose:
1167    // the `CommandBuilder` GETTERS (`get_argv`/`get_cwd`/`get_env`/`as_unix_command_line`…) read back
1168    // configuration, and the PURE DATA types (`PtySize::default`, `ExitStatus`/`Stdio`/`CommandBuilder`
1169    // construction/setters). The earlier `is_cmd_naming_method` fix stopped the head-refinement LEAK, but
1170    // the BASE Exec still fabricated on these accessors (a `cmd.get_cwd()` the scanner routes to
1171    // `portable_pty::CommandBuilder::get_cwd`). Subtract the read-back getters and the obvious pure
1172    // ctors/setters; the spawn/wait/exec surface (`spawn_command`/`openpty`/`wait`/`kill`/`exec`…) keeps
1173    // Exec. SUBTRACT only what is provably pure — when unrecognised, KEEP Exec (the safe direction).
1174    if crate_name == "async_process" || crate_name == "portable_pty" {
1175        let m = path.rsplit("::").next().unwrap_or(path);
1176        // configuration read-back getters — pure (no spawn).
1177        if m.starts_with("get_") || m == "as_unix_command_line" {
1178            return None;
1179        }
1180        // pure data-type ctors/setters/derives that NAME no program and spawn nothing.
1181        if matches!(
1182            m,
1183            "default" | "new" | "piped" | "null" | "inherit" | "from_raw_fd"
1184                | "arg" | "args" | "arg0" | "env" | "envs" | "env_clear" | "env_remove"
1185                | "cwd" | "current_dir" | "rows" | "cols"
1186                | "clone" | "fmt" | "eq" | "ne" | "hash"
1187        ) {
1188            return None;
1189        }
1190        return Some("Exec");
1191    }
1192    // duct: a subprocess-orchestration crate. `cmd()`/`cmd!` only *build* an Expression; the
1193    // spawn/wait happens at `run`/`read`/`start`. Match the execution verbs, not the builder.
1194    if crate_name == "duct"
1195        && (path.ends_with("::run")
1196            || path.ends_with("::read")
1197            || path.ends_with("::start")
1198            || path.ends_with("::read_chars"))
1199    {
1200        return Some("Exec");
1201    }
1202    if path.starts_with("std::env::") {
1203        return Some("Env");
1204    }
1205    // dotenvy / dotenv: load environment variables (reading a `.env` file and mutating the process
1206    // environment). Match the load/read entry points; `Error`/builder types stay pure.
1207    if matches!(crate_name, "dotenvy" | "dotenv")
1208        && (path.ends_with("::dotenv")
1209            || path.ends_with("::dotenv_override")
1210            || path.ends_with("::from_path")
1211            || path.ends_with("::from_path_override")
1212            || path.ends_with("::from_filename")
1213            || path.ends_with("::from_filename_override")
1214            || path.ends_with("::from_read")
1215            || path.ends_with("::from_read_override")
1216            || path.ends_with("::load")
1217            || path.ends_with("::var")
1218            || path.ends_with("::vars"))
1219    {
1220        return Some("Env");
1221    }
1222    // Wall-clock reads. Match the `now` accessor precisely (ends_with), not any path
1223    // containing the substring "now". The `time` crate (distinct from `std::time`/`chrono`)
1224    // reads the clock via `now_utc`/`now_local` (and the deprecated `Instant::now`).
1225    if (crate_name == "chrono" || path.starts_with("std::time::")) && path.ends_with("::now") {
1226        return Some("Clock");
1227    }
1228    if crate_name == "time"
1229        && (path.ends_with("::now_utc") || path.ends_with("::now_local") || path.ends_with("::now"))
1230    {
1231        return Some("Clock");
1232    }
1233    // `tracing`: same principle as the `log` facade below — the crate's TYPES are pure data, so match
1234    // the emit, not the whole crate. The actual program output is the macro-expanded
1235    // `Subscriber::event`/`event!`/`Span::*enter*` dispatch and the `Span::new*`/`Span::record`
1236    // recording path that drives the subscriber. The data-type accessors — `Level::as_str`,
1237    // `Span::is_disabled`/`metadata`/`id`, and constructing/reading `Level`/`LevelFilter`/`Span`/
1238    // `Event`/`Metadata`/`Field`/`FieldSet`/`Id` — are PURE (no output is produced), so whole-crate Log
1239    // fabricated Log on them. Match the emit verbs; everything else returns None.
1240    if crate_name == "tracing" {
1241        let m = path.rsplit("::").next().unwrap_or(path);
1242        // The user-facing emit MACROS (`tracing::info!`/`warn!`/…) — candor-scan is pre-expansion, so it
1243        // sees the raw macro path `tracing::info`, not the expanded `__tracing`/`Subscriber::event` the
1244        // deep (post-expansion) engine sees. Only the macro names; the pure DATA types (Level/Span/Event)
1245        // have other tails and stay None.
1246        if m == "trace" || m == "debug" || m == "info" || m == "warn" || m == "error"
1247            || m == "trace_span" || m == "debug_span" || m == "info_span" || m == "warn_span"
1248            || m == "error_span" || m == "span"
1249            || m == "event"
1250            || m == "new_span"
1251            || m == "record"
1252            || m == "record_follows_from"
1253            || m == "enter"
1254            || m == "exit"
1255            || m == "in_scope"
1256            || m == "entered"
1257            || path.contains("::__macro_support")
1258            || path.contains("::__tracing")
1259            || path.contains("Subscriber::event")
1260            || path.contains("Subscriber::new_span")
1261            || path.contains("Subscriber::enter")
1262            || path.contains("Subscriber::exit")
1263        {
1264            return Some("Log");
1265        }
1266        return None;
1267    }
1268    // The `log` facade: its macros route through `log::__private_api`; the crate's types
1269    // (`Level`, `LevelFilter`) are pure, so match the logging entry, not the whole crate.
1270    if crate_name == "log" {
1271        // Expanded macro form (deep engine) OR the raw user-facing macro names (candor-scan, pre-expansion).
1272        // `log::Level`/`LevelFilter`/`Record`/`Metadata` have other tails, so the type surface stays pure.
1273        let m = path.rsplit("::").next().unwrap_or(path);
1274        if path.contains("::__private_api")
1275            || m == "error" || m == "warn" || m == "info" || m == "debug" || m == "trace" || m == "log"
1276        {
1277            return Some("Log");
1278        }
1279    }
1280    // Compiler diagnostic emission — the ONE genuinely effectful operation in the otherwise-pure
1281    // rustc_* surface (a dylint lint's actual OUTPUT: it writes warnings/errors to the compiler's
1282    // diagnostic sink). Classified `Log` (same family as `tracing`/`log` — program output). Match the
1283    // emission verbs precisely; rustc_lint/rustc_errors are mostly pure types (Lint, LintId, the Diag
1284    // BUILDERS), and only the terminal `emit`/`emit_span_lint` actually produces output.
1285    if crate_name == "rustc_lint"
1286        && (path.ends_with("::emit_span_lint")
1287            || path.ends_with("::span_lint")
1288            || path.ends_with("::span_lint_hir"))
1289    {
1290        return Some("Log");
1291    }
1292    if crate_name == "rustc_errors"
1293        && (path.ends_with("::emit")
1294            || path.ends_with("::emit_diagnostic")
1295            || path.ends_with("::emit_now"))
1296    {
1297        return Some("Log");
1298    }
1299    // arboard: the effectful surface is the `Clipboard` handle's read/write verbs (each talks to the
1300    // OS clipboard / X11/Wayland/Win32/NSPasteboard server). The data types — chiefly `arboard::Error`
1301    // (whose `Display`/`to_string` formatting is pure) and the `ImageData`/`GetExtLinux`/`SetExtLinux`
1302    // option types — are PURE, so whole-crate Clipboard fabricated Clipboard on e.g. an error
1303    // `to_string()`. Match the handle verbs; everything else returns None. `Clipboard::new` opens the
1304    // connection to the clipboard server, so it's an effect too; `get`/`set` return the
1305    // builder-then-read `Get`/`Set` cursors whose `text`/`image`/`html` terminals do the I/O.
1306    if crate_name == "arboard" {
1307        let m = path.rsplit("::").next().unwrap_or(path);
1308        if m == "new"
1309            || m == "get"
1310            || m == "set"
1311            || m == "clear"
1312            || m == "get_text"
1313            || m == "set_text"
1314            || m == "set_html"
1315            || m == "get_image"
1316            || m == "set_image"
1317            || m == "text"
1318            || m == "image"
1319            || m == "html"
1320        {
1321            return Some("Clipboard");
1322        }
1323        return None;
1324    }
1325    // ── Coverage-differential additions (calibrated against each crate's real API; see the per-crate
1326    //    notes). All verb-keyed + crate-gated, with the pure builder/config/data surface returning None.
1327
1328    // `etcetera` — XDG/known-folder base+app directory resolution. Each dir ACCESSOR reads the
1329    // environment at call time (`$HOME`/`$XDG_*` on Unix, `%APPDATA%`/`%LOCALAPPDATA%` on Windows), and
1330    // the `choose_*`/`home_dir` entry points read `$HOME`. The `AppStrategyArgs` data struct and the
1331    // strategy types themselves are PURE. (Found DISCLOSED-but-unmodeled in 3/4 differential projects.)
1332    if crate_name == "etcetera" {
1333        let m = path.rsplit("::").next().unwrap_or(path);
1334        if m == "home_dir"
1335            || m == "choose_base_strategy" || m == "choose_native_strategy" || m == "choose_app_strategy"
1336            || m == "config_dir" || m == "data_dir" || m == "cache_dir"
1337            || m == "state_dir" || m == "runtime_dir" || m == "data_local_dir"
1338        {
1339            return Some("Env");
1340        }
1341        return None;
1342    }
1343    // `sqlx-core` (crate `sqlx_core`) — the execution terminals under the sqlx core (the `sqlx` builder
1344    // table maps `sqlx::query*`; here it's the core `Executor`/`Connection`/`Pool` round-trips). Opening
1345    // the connection is the network boundary (Net); the query/transaction round-trips are Db. The
1346    // `*Options`/query-builder/row data types are PURE. Crate-gated so the generic verbs never spread.
1347    if crate_name == "sqlx_core" {
1348        if path.ends_with("::connect") || path.ends_with("::connect_with") {
1349            return Some("Net");
1350        }
1351        if path.ends_with("::fetch") || path.ends_with("::fetch_all") || path.ends_with("::fetch_one")
1352            || path.ends_with("::fetch_optional") || path.ends_with("::fetch_many")
1353            || path.ends_with("::execute") || path.ends_with("::execute_many")
1354            || path.ends_with("::prepare") || path.ends_with("::prepare_with")
1355            || path.ends_with("::acquire") || path.ends_with("::begin") || path.ends_with("::ping")
1356        {
1357            return Some("Db");
1358        }
1359        return None;
1360    }
1361    // `walkdir` — recursive directory traversal. The disk read (`read_dir` + `stat`) happens lazily in
1362    // `IntoIter::next` (driving the iterator), and `DirEntry::metadata` issues a `stat`. The
1363    // `WalkDir::new`/`max_depth`/`follow_links`/`sort_by` BUILDERS, `WalkDir::into_iter` (constructs the
1364    // iterator, no I/O until pulled), and the cached `DirEntry::path`/`file_name`/`file_type`/`depth`
1365    // accessors (`file_type` makes NO syscall) are PURE. (Companion to the already-modeled `ignore`.)
1366    if crate_name == "walkdir" {
1367        if path.ends_with("::IntoIter::next") || path.ends_with("::DirEntry::metadata") {
1368            return Some("Fs");
1369        }
1370        return None;
1371    }
1372    // `filetime` — file-timestamp mutation. The `set_*` free fns issue utimes/utimensat/futimens (Fs).
1373    // `FileTime::now` reads the system clock (Clock). The `FileTime::from_*`/`zero` value constructors
1374    // (incl. `from_last_modification_time(&Metadata)` etc., which read an ALREADY-loaded `&Metadata`, not
1375    // the disk) and the `seconds`/`nanoseconds` accessors are PURE.
1376    if crate_name == "filetime" {
1377        if path.ends_with("::set_file_mtime") || path.ends_with("::set_file_atime")
1378            || path.ends_with("::set_file_times") || path.ends_with("::set_symlink_file_times")
1379            || path.ends_with("::set_file_handle_times")
1380        {
1381            return Some("Fs");
1382        }
1383        if path.ends_with("::FileTime::now") {
1384            return Some("Clock");
1385        }
1386        return None;
1387    }
1388    // `execute` — the `Execute` trait that extends `std::process::Command` with run helpers. The
1389    // `execute*` verbs SPAWN a child process (Exec). The `execute::command`/`shell` free fns and the
1390    // `command!`/`command_args!` macros only BUILD a Command (no spawn) and stay PURE.
1391    if crate_name == "execute" {
1392        if path.contains("::execute") {
1393            return Some("Exec");
1394        }
1395        return None;
1396    }
1397    // `ctrlc` — installs an OS signal handler (Unix SIGINT/SIGTERM/SIGHUP, Windows CTRL_C_EVENT) and
1398    // spawns its handler thread. Signals are an inter-process control channel, so the closest bucket is
1399    // Ipc (candor has no dedicated Signal effect; same judgment as routing SysV/pipe IPC to Ipc).
1400    if crate_name == "ctrlc" {
1401        if path.ends_with("::set_handler") || path.ends_with("::try_set_handler") {
1402            return Some("Ipc");
1403        }
1404        return None;
1405    }
1406    // `clap` — argument parsing. ONLY the terminals that read `std::env::args_os` at call time are an
1407    // effect (Env): `get_matches`/`get_matches_mut`/`try_get_matches` and the derive `parse`/`try_parse`.
1408    // clap is MOSTLY PURE: the ENTIRE builder surface (`Command::new`/`arg`/`about`/`Arg::new`) stays
1409    // None, and crucially the `*_from`/`*_parse_from` variants take an EXPLICIT iterator (they do NOT
1410    // read argv) so they stay pure too. (`Arg::env` reads an env var at builder time but bare `::env` is
1411    // too generic to gate safely, so it's left unmodeled — under-report over fabrication.)
1412    if crate_name == "clap" {
1413        if path.ends_with("::get_matches") || path.ends_with("::get_matches_mut")
1414            || path.ends_with("::try_get_matches")
1415            || path.ends_with("::parse") || path.ends_with("::try_parse")
1416        {
1417            return Some("Env");
1418        }
1419        return None;
1420    }
1421    // `jiff` — date/time. `Timestamp::now`/`Zoned::now`/`Zoned::now_with` read the wall clock (Clock).
1422    // `tz::TimeZone::system`/`get` and `tz::db().get` read the system tzdb files from disk
1423    // (`/etc/localtime`, `/usr/share/zoneinfo`; `system` is also `$TZ`-overridable — Fs is the dominant
1424    // op, modeled as Fs). The `Span`/`civil` date math and `Timestamp`/`Zoned` arithmetic are PURE.
1425    if crate_name == "jiff" {
1426        if path.ends_with("::now") || path.ends_with("::now_with") {
1427            return Some("Clock");
1428        }
1429        if path.ends_with("::TimeZone::system") || path.ends_with("::TimeZone::get")
1430            || path.ends_with("::TimeZoneDatabase::get")
1431        {
1432            return Some("Fs");
1433        }
1434        return None;
1435    }
1436    // `env_logger` — installs the global logger and emits to stderr; reads `RUST_LOG`/`RUST_LOG_STYLE`.
1437    // The init terminals are the effect (Log — program output, same family as `log`/`tracing`). The
1438    // `Builder::new`/`build` and the format/filter/target config setters are PURE.
1439    if crate_name == "env_logger" {
1440        if path.ends_with("::init") || path.ends_with("::try_init")
1441            || path.ends_with("::init_from_env") || path.ends_with("::try_init_from_env")
1442        {
1443            return Some("Log");
1444        }
1445        return None;
1446    }
1447    // `dialoguer` — interactive terminal prompts. The `interact*` verbs read stdin + write the tty (a
1448    // console dialogue with the user — Ipc, like the other local-channel effects). The
1449    // `with_prompt`/`default`/`items`/`validate_with` BUILDERS are PURE.
1450    if crate_name == "dialoguer" {
1451        if path.ends_with("::interact") || path.ends_with("::interact_on")
1452            || path.ends_with("::interact_text") || path.ends_with("::interact_text_on")
1453            || path.ends_with("::interact_opt") || path.ends_with("::interact_on_opt")
1454        {
1455            return Some("Ipc");
1456        }
1457        return None;
1458    }
1459    // `tracing_subscriber` — the subscriber that gives `tracing` somewhere to go. TWO effects, and the
1460    // filing said "Log/Fs": VERIFIED against 0.3.23, the Fs half is WRONG.
1461    //
1462    //   Log — `fmt/fmt_layer.rs:749` defaults `make_writer: io::stdout`, so the fmt INIT terminals install
1463    //         a subscriber that writes program output. Same family as `log`/`tracing`/`env_logger`.
1464    //   Env — `fmt/mod.rs:1219` reads `RUST_LOG` on the `init()` path, `fmt_layer.rs` reads `NO_COLOR`,
1465    //         and `filter/env/builder.rs:189,203` read `env::var(self.env_var_name())`.
1466    //
1467    // NOT Fs. The only `std::fs` in the crate is `impl MakeWriter for std::fs::File` — the crate ACCEPTING
1468    // a caller-supplied File, not opening one. The caller's `File::create` is classified on the caller, so
1469    // charging Fs here would double-count, exactly the `serde_json::from_reader` caveat one crate over.
1470    //
1471    // The builders (`fmt()`, `layer()`, `with_writer`, `with_target`, `EnvFilter::new`) are PURE: they
1472    // describe a subscriber. Only the INIT terminals install one, and only the from-env constructors read.
1473    if crate_name == "tracing_subscriber" {
1474        if path.ends_with("::init") || path.ends_with("::try_init") {
1475            return Some("Log");
1476        }
1477        if path.ends_with("::from_default_env") || path.ends_with("::try_from_default_env")
1478            || path.ends_with("::from_env") || path.ends_with("::from_env_lossy")
1479            || path.ends_with("::try_from_env")
1480        {
1481            return Some("Env");
1482        }
1483        return None;
1484    }
1485    // `crossterm` — the terminal driver. The tty is a USER DIALOGUE CHANNEL, so this is Ipc, matching the
1486    // ruling `dialoguer`/`console`/`terminal_colorsaurus` already carry rather than a new one.
1487    //
1488    // VERIFIED against crossterm-0.28.1 rather than assumed: `command.rs` `execute`/`queue` end in
1489    // `self.flush()?` on the writer (real code, not a doc example), `event::read`/`poll` read tty input,
1490    // and `terminal::{enable,disable}_raw_mode` + `size`/`window_size` talk to the device.
1491    //
1492    // `size`/`window_size`/`is_raw_mode_enabled` ARE classified, and that is deliberate: once a crate is
1493    // CALIBRATED every unmatched path becomes a PURITY CLAIM rather than a disclosed blind spot, so a tty
1494    // ioctl left to fall through would be claimed pure. The genuinely pure surface — the Command VALUE
1495    // types (`Print`, `MoveTo`, `SetForegroundColor`), the style/event data types — carries none of these
1496    // tails and stays pure correctly.
1497    if crate_name == "crossterm" {
1498        if path.ends_with("::execute") || path.ends_with("::queue")
1499            || path.ends_with("::event::read") || path.ends_with("::event::poll")
1500            || path.ends_with("::enable_raw_mode") || path.ends_with("::disable_raw_mode")
1501            || path.ends_with("::size") || path.ends_with("::window_size")
1502            || path.ends_with("::is_raw_mode_enabled")
1503        {
1504            return Some("Ipc");
1505        }
1506        return None;
1507    }
1508    // `ratatui` — the TUI renderer, and the single loudest source of disclosed-blind calls measured in the
1509    // 2026-07-14 four-ecosystem sweep (3,345 across three real repos). The backlog filed it as
1510    // "mark reviewed-pure"; VERIFYING against ratatui-0.29.0 REFUTES that for part of the surface:
1511    // `terminal/terminal.rs` `draw`/`flush`/`clear`/`autoresize`/`hide_cursor`/`show_cursor` end in a
1512    // backend flush, and `backend/` writes to the terminal. Marking the whole crate pure would have
1513    // claimed purity over the one API that actually writes.
1514    //
1515    // So the split is where the sweep's noise actually is: the BULK of those 3,345 calls are widget,
1516    // layout, buffer, style and text constructors — genuinely pure, and now covered rather than disclosed.
1517    // The Terminal/backend verbs are Ipc, same channel as crossterm underneath them.
1518    if crate_name == "ratatui" {
1519        // CARVE-OUT FIRST: `widgets::canvas` is an IN-MEMORY grid. `Context::draw(&shape)` sets
1520        // `self.dirty` and paints into a `Painter` — no terminal, no writer, provably pure — but it ends
1521        // in `::draw` and the tails below would have charged it `Ipc`. MEASURED as a live fabrication on a
1522        // fixture (`plot(ctx) -> ['Ipc']`) before this line existed, and it is a HOT path: a TUI drawing
1523        // charts or maps calls it per shape per frame.
1524        //
1525        // A DENYLIST (carve out the proven-pure module) rather than an allowlist of `Terminal::`, per the
1526        // family rule: an allowlist silently under-reports whatever it forgot, and the write surface here
1527        // is Terminal AND the backends (`CrosstermBackend::flush`), so pinning to `Terminal::` would drop
1528        // a direct backend call. Reading the crate, canvas is the only module whose methods collide with
1529        // these tails.
1530        if path.contains("::canvas::") {
1531            return None;
1532        }
1533        if path.ends_with("::draw") || path.ends_with("::try_draw") || path.ends_with("::flush")
1534            || path.ends_with("::autoresize") || path.ends_with("::clear")
1535            || path.ends_with("::hide_cursor") || path.ends_with("::show_cursor")
1536            || path.ends_with("::insert_before")
1537            || path.ends_with("::set_cursor_position") || path.ends_with("::get_cursor_position")
1538        {
1539            return Some("Ipc");
1540        }
1541        return None;
1542    }
1543    // `console` — terminal handle + styling. The `Term` read/write verbs do tty I/O (Ipc, the user
1544    // dialogue channel; note there is NO `write_str` — `Term` impls `io::Write`). The free-fn terminal
1545    // detection (`colors_enabled`/`user_attended`) reads `CLICOLOR`/`CLICOLOR_FORCE` (Env). The `Style`
1546    // color/format methods and the text utils (`strip_ansi_codes`/`pad_str`/`measure_text_width`) are PURE.
1547    if crate_name == "console" {
1548        if path.ends_with("::write_line") || path.ends_with("::read_line")
1549            || path.ends_with("::read_line_initial_text") || path.ends_with("::read_char")
1550            || path.ends_with("::read_key") || path.ends_with("::read_key_raw")
1551            || path.ends_with("::read_secure_line")
1552        {
1553            return Some("Ipc");
1554        }
1555        if path.ends_with("::colors_enabled") || path.ends_with("::colors_enabled_stderr")
1556            || path.ends_with("::user_attended") || path.ends_with("::user_attended_stderr")
1557        {
1558            return Some("Env");
1559        }
1560        return None;
1561    }
1562    // `terminal_colorsaurus` — queries the terminal's colours by writing OSC 10/11 escapes and reading the
1563    // reply (bidirectional tty dialogue — Ipc, consistent with dialoguer/console). Nothing else is I/O.
1564    if crate_name == "terminal_colorsaurus" {
1565        if path.ends_with("::background_color") || path.ends_with("::foreground_color")
1566            || path.ends_with("::color_palette") || path.ends_with("::theme_mode")
1567        {
1568            return Some("Ipc");
1569        }
1570        return None;
1571    }
1572    // `backoff` — retry-with-backoff. `retry`/`retry_notify` consult the clock and `thread::sleep`
1573    // between attempts (Clock). The `ExponentialBackoff`/builder config is PURE. (The user closure's own
1574    // effects are out of scope here — we model only backoff's own Clock effect.)
1575    if crate_name == "backoff" {
1576        if path.ends_with("::retry") || path.ends_with("::retry_notify") {
1577            return Some("Clock");
1578        }
1579        return None;
1580    }
1581    // `lscolors` — LS_COLORS parsing. ONLY `from_env` reads the environment (Env). `from_string`/
1582    // `style_for_path`/`style_for*` and the `Style` type take explicit input and are PURE.
1583    if crate_name == "lscolors" {
1584        if path.ends_with("::from_env") {
1585            return Some("Env");
1586        }
1587        return None;
1588    }
1589    // `wild` — argv with glob expansion. `args`/`args_os` read `std::env::args(_os)` (Env). Nothing else.
1590    if crate_name == "wild" {
1591        if path.ends_with("::args") || path.ends_with("::args_os") {
1592            return Some("Env");
1593        }
1594        return None;
1595    }
1596    // `grep_cli` — only the firm effect is modeled: `CommandReaderBuilder::build` spawns a child process
1597    // (Exec). The `is_readable_stdin`/`is_tty_*` fd probes (isatty/fstat on the std descriptors) are
1598    // deliberately NOT modeled — candor doesn't classify `IsTerminal`/isatty as an effect anywhere, and
1599    // they read no data; flagging them would be an inconsistent over-report.
1600    if crate_name == "grep_cli" {
1601        if path.ends_with("::build") {
1602            return Some("Exec");
1603        }
1604        return None;
1605    }
1606    // `clircle` — detects whether two handles are the same file (cycle protection). `Identifier::try_from`
1607    // (File/Stdio) issues an `fstat`, and `surely_conflicts_with` does an `lseek` (`stream_position`) — both
1608    // Fs. The `PartialEq`/`Hash` comparisons read stored dev/ino and are PURE. (The named methods
1609    // `are_identical`/`same_file` do NOT exist in the crate — not modeled.)
1610    if crate_name == "clircle" {
1611        if path.ends_with("::try_from") || path.ends_with("::surely_conflicts_with") {
1612            return Some("Fs");
1613        }
1614        return None;
1615    }
1616    None
1617}
1618
1619pub fn cap_from_name(name: &str) -> Option<&'static str> {
1620    EFFECTS.iter().copied().find(|e| *e == name)
1621}
1622
1623/// Refine the `Exec` cliff (spec §4 ⟨0.5⟩): the effects a *literal, statically-known* subprocess
1624/// head implies, matched by basename (`/usr/bin/curl` → `curl`). The head's effects are ADDED to a
1625/// caller that already carries `Exec` (a subprocess is still spawned — `Exec` is never dropped); an
1626/// unrecognised or dynamically-built head returns `&[]` and keeps the bare cliff (never guess). A
1627/// **candor engine** reads `Fs`/`Env` only — spec §7 item 12 (the analyzer self-boundary) guarantees
1628/// that, so that case is spec-supplied, not curation. The rest is a small curated table under the
1629/// same under-report rule as the crate classifier. INVARIANT: every head here is an external tool
1630/// that does NOT run the analysed project's own code (so `make`/`npm`/`cargo` are deliberately
1631/// absent — they stay the cliff). The reference engines share this table so the `Exec` boundary —
1632/// the one boundary every engine hits — refines identically (the §4-consistency argument).
1633/// SPEC §2 `fs` — for a call ALREADY classified `Fs`, the read/write direction its path implies.
1634/// `["read"]`, `["write"]`, `["read","write"]`, or `[]` when the verb does not say.
1635///
1636/// THE EMPTY CASE IS THE DISCIPLINE. §2: *"when `Fs` is reached but its kind is unknown … the field MUST
1637/// be omitted rather than guessed. An empty or partial `fs` would be read as a positive claim ('reads but
1638/// never writes'), which is the §4 trust contract's forbidden direction."* So an unrecognised verb
1639/// contributes nothing and the field stays absent; absence means "kind undetermined", never "read-only".
1640///
1641/// A syntactic refinement of an effect already proved, NOT a soundness claim. Deliberately the same
1642/// vocabulary as candor-java's `fsKind`, candor-swift's and candor-ts's — the surface is spec'd four-way,
1643/// and four engines inventing four verb tables for one field is how a shared field stops meaning one thing.
1644pub fn fs_kind(path: &str) -> &'static [&'static str] {
1645    // the terminal segment is the verb (`std::fs::write`, `File::create`, `f.read_to_string`)
1646    let leaf = path.rsplit("::").next().unwrap_or(path);
1647    // `OpenOptions::open` is the one verb that does NOT carry its own direction — the direction was set
1648    // by the BUILDER chain (`.read(true)`/`.write(true)`), which this function cannot see. The READ list
1649    // below holds a bare `open` for `File::open`, unambiguously a read; letting an `OpenOptions` receiver
1650    // reach it publishes `fs: ["read"]` for a builder configured `write(true)`, and §2 reads that as the
1651    // positive claim "reads but never writes" — the forbidden direction. The paragraph under the READ
1652    // list has always SAID this; nothing implemented it, and it only became REACHABLE when
1653    // `OpenOptions::open` started classifying on its own — before, the `Fs` came from the
1654    // `OpenOptions::new` constructor, whose leaf claims nothing, so the field was simply absent.
1655    // MEASURED on this commit's own fix before this guard: `OpenOptions::new().read(true).open(p)` went
1656    // from `fs` ABSENT to `fs: ["read"]`, i.e. the fix minted a claim the engine cannot support. Keyed on
1657    // the TYPE segment, so it holds for the `fs_err`/`tokio::fs`/`async_std::fs` spellings too.
1658    if path.rsplit("::").nth(1) == Some("OpenOptions") {
1659        return &[];
1660    }
1661    // Reads the source AND writes the destination in one call.
1662    if matches!(leaf, "copy" | "rename" | "hard_link" | "soft_link" | "symlink") {
1663        return &["read", "write"];
1664    }
1665    const WRITE: &[&str] = &[
1666        "write", "write_all", "write_fmt", "write_vectored", "create", "create_new", "create_dir",
1667        "create_dir_all", "remove_file", "remove_dir", "remove_dir_all", "set_permissions",
1668        "set_len", "set_modified", "set_times", "append", "flush", "sync_all", "sync_data",
1669        "write_at", "truncate",
1670    ];
1671    const READ: &[&str] = &[
1672        "read", "read_to_string", "read_to_end", "read_exact", "read_dir", "read_link",
1673        "metadata", "symlink_metadata", "exists", "try_exists", "canonicalize", "open",
1674        "read_at", "file_type", "permissions", "modified", "accessed", "created", "len",
1675    ];
1676    if WRITE.contains(&leaf) { return &["write"]; }
1677    if READ.contains(&leaf) { return &["read"]; }
1678    // `OpenOptions` carries the direction in its BUILDER chain, not its terminal verb, so `.open()` on one
1679    // says nothing here and is deliberately left to the READ arm above only when it is `File::open`
1680    // (unambiguously a read). Anything else: no claim.
1681    if leaf.starts_with("write") || leaf.starts_with("append") { return &["write"]; }
1682    if leaf.starts_with("read") { return &["read"]; }
1683    &[]
1684}
1685
1686pub fn classify_command_head(cmd: &str) -> &'static [&'static str] {
1687    // Only UNAMBIGUOUS single-effect tools belong here. A multi-modal head (`git status` is local,
1688    // `git push` is Net; `rsync` local-vs-remote) would FABRICATE the effect for its common case —
1689    // the under-report rule forbids it, so such heads keep the bare cliff.
1690    match cmd.rsplit(['/', '\\']).next().unwrap_or(cmd) {
1691        "curl" | "wget" | "http" | "ssh" | "scp" | "sftp" | "ftp" | "telnet" => &["Net"],
1692        "psql" | "mysql" | "sqlite3" | "mongosh" | "mongo" | "redis-cli" | "cqlsh" | "influx" => &["Db"],
1693        // candor engines — Fs/Env only, guaranteed by spec §7 item 12 (the analyzer self-boundary)
1694        "candor" | "candor-run.sh" | "candor-scan" | "candor-query" | "candor-java"
1695        | "candor-classify" | "candor-report" | "cargo-candor" => &["Env", "Fs"],
1696        _ => &[],
1697    }
1698}
1699
1700/// Known machine-learning MODEL-provider hosts — the SPEC §1 ⟨0.13⟩ `Llm` host-literal refinement:
1701/// a statically-known `Net` request to one of these classifies `Llm` IN ADDITION to `Net` (Net is
1702/// never dropped — a model call IS network I/O, exactly as an `Exec`-refined subprocess keeps `Exec`),
1703/// just as a jdbc URL classifies `Db`. Matched by host, case-insensitive; a SUBDOMAIN of a listed host
1704/// counts. The reference engines share this table VERBATIM with candor-java's `Literals.MODEL_HOSTS`
1705/// (the analog of `classify_command_head`) so the `Net` boundary refines to `Llm` identically. An
1706/// UNKNOWN host stays bare `Net` — never guessed. Curated STARTER set; the §7 coverage ledger
1707/// discloses an uncovered provider like any other.
1708pub const MODEL_HOSTS: &[&str] = &[
1709    "api.openai.com",
1710    "api.anthropic.com",
1711    "generativelanguage.googleapis.com",
1712    "api.mistral.ai",
1713    "api.cohere.ai",
1714    "api.cohere.com",
1715    "api.groq.com",
1716    "api.together.xyz",
1717    "api.perplexity.ai",
1718    "openrouter.ai",
1719];
1720
1721/// Whether an endpoint HOST literal is a known model provider (case-insensitive; a subdomain of a
1722/// `MODEL_HOSTS` entry counts). Strips a `:port` suffix first. Two special forms carry their own rule,
1723/// matching candor-java's `Literals.isModelHost` exactly: any host whose port is `11434` is a local
1724/// Ollama endpoint (a LOOPBACK host — `localhost`/`127.0.0.1`/`::1` — on port 11434); and an AWS Bedrock
1725/// runtime host (the model-inference service label `bedrock-runtime`/`bedrock-agent-runtime`).
1726pub fn is_model_host(host_literal: &str) -> bool {
1727    // Strip any `:port` (via the shared host_part) and lowercase for the name comparisons.
1728    let host = policy::host_part(host_literal).to_ascii_lowercase();
1729    // Ollama is a LOCAL endpoint: :11434 → Llm ONLY on a loopback host (max-review r3 parity fix — "any
1730    // host on :11434" fabricated Llm on unrelated internal services on that port).
1731    if let Some((_, port)) = host_literal.rsplit_once(':') {
1732        if port == "11434" {
1733            return matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1");
1734        }
1735    }
1736    if MODEL_HOSTS.contains(&host.as_str()) {
1737        return true;
1738    }
1739    // A subdomain of a known model host counts (`eu.api.openai.com` → api.openai.com).
1740    if MODEL_HOSTS.iter().any(|m| host.ends_with(&format!(".{m}"))) {
1741        return true;
1742    }
1743    // AWS Bedrock runtime: the FIRST label is the model-inference service (`bedrock-runtime.<region>.
1744    // amazonaws.com`), NOT the substring "bedrock" (which caught `bedrock-backups.s3.amazonaws.com`, an
1745    // S3 bucket) and NOT the control-plane `bedrock.<region>.amazonaws.com`.
1746    host.ends_with(".amazonaws.com")
1747        && matches!(host.split('.').next(), Some("bedrock-runtime") | Some("bedrock-agent-runtime"))
1748}
1749
1750/// ⟨0.20⟩ Curated telemetry / analytics / APM hosts — the `Net` destination-class `known-telemetry` set
1751/// (NET-DESTINATION-CLASS-DESIGN.md), shared VERBATIM with candor-java's `Literals.TELEMETRY_HOSTS` (like
1752/// `MODEL_HOSTS`). A benign observability endpoint. Matched by host, case-insensitive; a SUBDOMAIN of a
1753/// listed host counts. Tight, high-precision STARTER set — mis-including an exfil-capable host would
1754/// under-gate `deny Net[unknown-host]`.
1755pub const TELEMETRY_HOSTS: &[&str] = &[
1756    "sentry.io",
1757    "bugsnag.com",
1758    "rollbar.com",
1759    "segment.io",
1760    "segment.com",
1761    "mixpanel.com",
1762    "amplitude.com",
1763    "google-analytics.com",
1764    "analytics.google.com",
1765    "datadoghq.com",
1766    "datadoghq.eu",
1767    "newrelic.com",
1768    "nr-data.net",
1769    "honeycomb.io",
1770    "logtail.com",
1771    // ⟨0.20.1⟩ corpus-grown (a real-repo dogfood): more single-purpose analytics / session-replay / RUM
1772    // providers — vendor-specific product domains only (no general-purpose host), so no under-gate risk.
1773    "posthog.com",
1774    "plausible.io",
1775    "usefathom.com",
1776    "heapanalytics.com",
1777    "fullstory.com",
1778    "hotjar.com",
1779    "logrocket.com",
1780    "cloudflareinsights.com",
1781];
1782
1783/// Whether an endpoint HOST literal is in `set` (case-insensitive; a subdomain of a listed host counts).
1784/// Strips a `:port` suffix first via `host_part`. The shared membership test for `TELEMETRY_HOSTS` and the
1785/// config-declared partner set (mirrors candor-java's `Literals.hostInSet`).
1786pub fn host_in_set(host_literal: &str, set: &[&str]) -> bool {
1787    let host = policy::host_part(host_literal).to_ascii_lowercase();
1788    set.contains(&host.as_str()) || set.iter().any(|e| host.ends_with(&format!(".{e}")))
1789}
1790
1791/// Whether an endpoint HOST literal is a known telemetry/analytics/APM host (`TELEMETRY_HOSTS`).
1792pub fn is_telemetry_host(host_literal: &str) -> bool {
1793    host_in_set(host_literal, TELEMETRY_HOSTS)
1794}
1795
1796/// ⟨0.20⟩ The `Net` DESTINATION CLASS of a host literal (NET-DESTINATION-CLASS-DESIGN.md): `known-telemetry`
1797/// (curated), `known-partner` (config `net-partner` OR a model host — a declared-ish external API), else
1798/// `unknown-host` — the HONEST default (candor makes no claim; the security gate bites this). A partner set
1799/// is per-project (config-declared). Never fabricated onto a safe class: an unresolved host is unknown-host.
1800/// Mirrors candor-java's `Literals.netDestClass`.
1801pub fn net_dest_class(host_literal: &str, partners: &std::collections::BTreeSet<String>) -> &'static str {
1802    if is_telemetry_host(host_literal) {
1803        return "known-telemetry";
1804    }
1805    if partner_for(host_literal, partners).is_some() || is_model_host(host_literal) {
1806        return "known-partner";
1807    }
1808    "unknown-host"
1809}
1810
1811/// ⟨0.31⟩ WHICH declared partner a host matched, or `None` — the SAME match `net_dest_class` decides on,
1812/// extracted so the DISCLOSURE and the DECISION cannot use different rules.
1813///
1814/// That is not a stylistic preference. The reverted first attempt at the `net-partner` disclosure
1815/// re-implemented this match against a normaliser that KEEPS the port, so an observed
1816/// `partner.example:443` never equalled a declared `partner.example` and the disclosure came back
1817/// SILENTLY EMPTY on every real run — while the verdicts it was reporting on had flipped. A disclosure
1818/// normalised differently from the decision it reports can only be wrong, and one function with two
1819/// callers is what makes writing one impossible.
1820pub fn partner_for(
1821    host_literal: &str,
1822    partners: &std::collections::BTreeSet<String>,
1823) -> Option<String> {
1824    if partners.is_empty() {
1825        return None;
1826    }
1827    let host = policy::host_part(host_literal).to_ascii_lowercase();
1828    if partners.contains(&host) {
1829        return Some(host);
1830    }
1831    partners
1832        .iter()
1833        .find(|p| host.ends_with(&format!(".{p}")))
1834        .cloned()
1835}
1836
1837/// ⟨0.20⟩ The closed `Net` destination-class vocabulary, for the `deny Net[<dest…>]` policy filter.
1838pub const NET_DEST_CLASSES: &[&str] = &["known-telemetry", "known-partner", "unknown-host"];
1839
1840/// Curated Rust model-provider SDK crates — the SPEC §1 ⟨0.13⟩ `Llm` model-SDK surface, the Rust analog
1841/// of candor-java's `Rules.MODEL_SDK_PACKAGES`. A resolved call into one of these crates classifies
1842/// `Llm` + `Net` (the caller adds both — a model dispatch IS network I/O). NO method-name gating: these
1843/// are single-purpose provider clients, so ANY call into the crate is a model dispatch (matches the java
1844/// reference's judgment call). Curated STARTER list; the §7 coverage ledger discloses the rest.
1845pub const MODEL_SDK_CRATES: &[&str] = &[
1846    "async_openai",           // async-openai — the de-facto OpenAI client
1847    "anthropic_sdk",          // anthropic-sdk
1848    "anthropic",              // anthropic (community client crate)
1849    "aws_sdk_bedrockruntime", // AWS Bedrock runtime (invoke/converse) — the model surface of the aws-sdk family
1850    "ollama_rs",              // ollama-rs — local Ollama client
1851    "langchain_rust",         // langchain-rust — the LangChain invoke surfaces
1852    "mistralai",              // mistralai (Mistral client)
1853    "genai",                  // genai — a multi-provider model client
1854];
1855
1856/// Whether a resolved call's CRATE is a curated model-provider SDK (`MODEL_SDK_CRATES`) → the SPEC §1
1857/// ⟨0.13⟩ `Llm` model-SDK classification (the caller adds both `Llm` and `Net`). Crate-level, no
1858/// method gating — a single-purpose client, matching candor-java's `isModelSdkOwner`.
1859pub fn is_model_sdk_crate(crate_name: &str) -> bool {
1860    MODEL_SDK_CRATES.contains(&crate_name)
1861}
1862
1863/// Whether a subprocess-builder method only MODIFIES the command (`.arg`, `.env`, `.current_dir`)
1864/// rather than NAMING the program (`Command::new`, `duct::cmd`). A WHOLE-CRATE-Exec crate
1865/// (`portable_pty`, `duct`, `async_process`) classifies *every* method as `Exec`, so the
1866/// head-refinement must skip these: an arg or env-var-name literal that happened to match a head
1867/// (`.env("psql", …)`, `.arg("curl")`) would FABRICATE that effect — the §1 under-report rule. The
1868/// method is the call path's last segment.
1869pub fn is_cmd_builder_method(method: &str) -> bool {
1870    matches!(
1871        method,
1872        "arg" | "args" | "arg0" | "env" | "envs" | "env_clear" | "env_remove" | "current_dir"
1873            | "cwd" | "stdin" | "stdout" | "stderr" | "pre_exec" | "creation_flags" | "uid" | "gid"
1874            | "groups" | "process_group"
1875    )
1876}
1877
1878/// Whether a subprocess method NAMES the program (so its first string literal IS the command head to
1879/// refine): `Command::new("curl")`, `duct::cmd("curl", …)`. The head-refinement must fire ONLY here —
1880/// an ALLOWLIST, not "any method except known modifiers". A whole-crate-Exec crate classifies EVERY
1881/// method as `Exec`, so a denylist leaked NON-naming methods that aren't modifiers — a getter like
1882/// `CommandBuilder::get_env("psql")` (reading back an env-var KEY, not a program) fed `"psql"` to the
1883/// head classifier and FABRICATED `Db` (review find). Only `new`/`cmd` name a program; everything else
1884/// (modifiers, getters `get_*`, custom builder methods) keeps the bare `Exec` cliff — under-refine
1885/// (safe) rather than fabricate. `std::process::Command` is verb-precise so getters never fire `Exec`
1886/// there anyway; the allowlist makes the whole-crate-Exec crates safe too.
1887pub fn is_cmd_naming_method(method: &str) -> bool {
1888    matches!(method, "new" | "cmd")
1889}
1890
1891/// The masking guard (AS-EFF-008): a Net call whose method takes the HOST/URL as an argument is
1892/// "establishing" — a classified Net call here with no captured host literal leaves the endpoint
1893/// structurally INVISIBLE (a runtime-built host), so the surface is incomplete and the gate must fail
1894/// closed (else a benign sibling literal masks the runtime endpoint). An ALLOWLIST of connection-
1895/// establishing verbs — the SAFE direction: a USE-verb on an already-connected socket
1896/// (`stream.write`/`read`/`flush`, `socket.send`/`recv`) is NOT here, so a missing literal there (the
1897/// host was fixed at `connect`) never false-positives. Under-catching an unusual establishing verb is a
1898/// missed mask (sound-with-disclosure), never a broken gate. The arg is the method (path's last segment).
1899pub fn is_net_establishing(method: &str) -> bool {
1900    matches!(
1901        method,
1902        "connect"
1903            | "connect_timeout"
1904            | "get"
1905            | "post"
1906            | "put"
1907            | "patch"
1908            | "delete"
1909            | "head"
1910            | "request"
1911            | "send_to"
1912            | "lookup_host"
1913            | "to_socket_addrs"
1914    )
1915}
1916
1917/// ⟨0.29⟩ A LOCAL BIND/LISTEN VERB — the address it names is where the process LISTENS, never a
1918/// destination it reaches.
1919///
1920/// **MEASURED, and it is a false all-clear rather than a naming quibble.** `UdpSocket::bind("0.0.0.0:0")`
1921/// put `0.0.0.0:0` into `hosts`, the DESTINATION surface `allow Net` gates on (§2), and — because a
1922/// literal had been captured — nothing marked the surface incomplete. So:
1923///
1924/// ```text
1925/// let s = UdpSocket::bind("0.0.0.0:0")?;   // local
1926/// s.send_to(b"secrets", dst);              // destination is a RUNTIME value
1927/// allow Net 0.0.0.0   ->   policy ✓, exit 0
1928/// ```
1929///
1930/// A local listen address certified a send to an endpoint nobody can see. That is the masking evasion
1931/// AS-EFF-008 exists to close, reached through a verb whose literal is not a destination at all.
1932///
1933/// **A BIND CANNOT BE CERTIFIED, EVER**, which is why this marks the surface incomplete rather than
1934/// merely withholding the literal: a server that binds and accepts talks to whoever connects, so its
1935/// destination set is not statically knowable even in principle. candor-java already behaves this way —
1936/// it publishes the bind address AND hedges — and matching the reference engine keeps the informative
1937/// half (an operator can still see what the service listens on) while making it non-certifying.
1938/// ⟨0.29⟩ The Net verbs whose LOCATOR is at argument **1**, not 0 — the rust analogue of candor-ts's
1939/// `NET_URL_ARG1_MEMBERS`.
1940///
1941/// **A REGRESSION FOUND IN REVIEW, and the direction matters.** The positional-literal rung replaced
1942/// `first_str_lit` ("the first string literal ANYWHERE in the call") with `positional_str_lit(args, 0)`
1943/// as the UNIVERSAL default. That is right for `Fs`/`Db`/`Exec`, whose locator is always argument 0 —
1944/// but `is_net_establishing` already listed two verbs whose locator is not: `reqwest::Client::request`
1945/// takes `(Method, url)` and `UdpSocket::send_to` takes `(buf, addr)`. MEASURED after the swap:
1946/// `c.request(Method::GET, "https://api.example.com/v1")` published NO `hosts` at all and could not be
1947/// certified, while `c.get("https://api.example.com/v1")` certified normally.
1948///
1949/// ⟨0.29⟩ MEASURED FOR `request` ONLY. `send_to` is a METHOD on a receiver the STABLE syntactic backend
1950/// does not type, so `s.send_to(buf, "203.0.113.9:53")` is not classified at all there and its arg-1
1951/// literal is still uncaptured on the floor; the position is right and the deep engine can use it. Said
1952/// plainly because the first version of this fix's changelog listed both verbs as measured.
1953///
1954/// The direction is SAFE — an uncaptured locator fails closed, it never certifies something invisible —
1955/// which is exactly why it needed a review to find: the gate stayed sound and quietly stopped being
1956/// USABLE for a common shape. candor-ts avoided this by making its resolver verb-aware in the same rung;
1957/// this is that discipline arriving one engine late.
1958pub fn is_net_host_arg1(method: &str) -> bool {
1959    matches!(method, "request" | "send_to")
1960}
1961
1962pub fn is_net_binding(method: &str) -> bool {
1963    matches!(method, "bind" | "listen" | "bind_to_device" | "incoming" | "accept")
1964}
1965
1966/// The masking guard (AS-EFF-008), the `Fs` analog of `is_net_establishing`: whether an `Fs`-classified
1967/// call takes the filesystem PATH as a string argument (so a missing literal leaves the path
1968/// structurally INVISIBLE — a runtime-built path — and the surface is incomplete, fail-closed). An
1969/// ALLOWLIST of the path-NAMING free functions / constructors (`fs::write`/`read`/`File::open`/…), the
1970/// SAFE direction: a path-stat METHOD whose path is the RECEIVER (`p.metadata()`, `p.exists()`) is
1971/// invoked method-form and the caller gates on `!is_method`, so this never sees it; an op on an
1972/// already-opened handle (`file.write_all`, `mmap.flush`, `tempfile()` — a random name, no path arg)
1973/// is not here, so a missing literal there never false-positives. Under-catching an unusual
1974/// path-naming fn is a missed mask (sound-with-disclosure), never a broken gate. The arg is the
1975/// method/fn leaf (the path's last segment).
1976pub fn is_fs_path_arg(leaf: &str) -> bool {
1977    matches!(
1978        leaf,
1979        // std::fs / tokio::fs / async_std::fs / fs_err free functions taking a path argument
1980        "write"
1981            | "read"
1982            | "read_to_string"
1983            | "read_dir"
1984            | "read_link"
1985            | "copy"
1986            | "rename"
1987            | "remove_file"
1988            | "remove_dir"
1989            | "remove_dir_all"
1990            | "create_dir"
1991            | "create_dir_all"
1992            | "hard_link"
1993            | "soft_link"
1994            | "symlink"
1995            | "symlink_file"
1996            | "symlink_dir"
1997            | "symlink_metadata"
1998            | "canonicalize"
1999            | "metadata"
2000            | "set_permissions"
2001            | "exists"
2002            | "try_exists"
2003            // File / OpenOptions constructors taking a path argument
2004            | "open"
2005            | "create"
2006            | "create_new"
2007    )
2008}
2009
2010/// ⟨0.29⟩ HOW MANY LEADING ARGUMENTS OF AN `Fs` PATH-TAKING CALL ARE PATHS.
2011///
2012/// **THE DEFECT THIS EXISTS FOR.** The literal harvester took the first string literal found ANYWHERE in
2013/// the argument list, so `fs::write(user_path, "/tmp/lit")` published `paths: ["/tmp/lit"]` — the BYTES
2014/// BEING WRITTEN — as the destination surface, and `allow Fs /tmp/lit` certified a write to an
2015/// attacker-controlled path at exit 0. Measured on candor-scan and candor-ts; candor-java and
2016/// candor-swift read the path POSITION and fail closed correctly. Removing the sibling literal made the
2017/// same call fail closed, which is what identified the mechanism: any literal in the call, in any
2018/// position, defeated the runtime-path incompleteness marker.
2019///
2020/// So the surface is read from the PATH POSITIONS and nowhere else, and every one of them must be a
2021/// literal for the surface to be complete. Two-path operations are the reason this is an arity and not a
2022/// boolean: `fs::copy("/safe", user_path)` has a literal at position 0 and still writes somewhere
2023/// nobody can see, so requiring only position 0 would leave the identical hole one argument along.
2024pub fn fs_path_arity(leaf: &str) -> usize {
2025    match leaf {
2026        "copy" | "rename" | "hard_link" | "soft_link" | "symlink" | "symlink_file" | "symlink_dir" => 2,
2027        _ => 1,
2028    }
2029}
2030
2031/// The masking guard (AS-EFF-008), the `Db` analog of `is_net_establishing`: whether a `Db`-classified
2032/// call takes the raw SQL QUERY as a string argument (so a missing literal leaves the table
2033/// structurally INVISIBLE — a runtime-built query — and the surface is incomplete, fail-closed). An
2034/// ALLOWLIST of the SQL-string-bearing execution/prepare verbs, the SAFE direction: a
2035/// build-then-execute terminal that takes NO SQL string (sqlx/diesel/sea_orm `fetch*`/`load*`/`first`/
2036/// `all`/`one`/`stream`, the document-store `find*`/`insert*`/…), and a non-query op (`connect`/
2037/// `open`/`acquire`/`begin`/`commit`/`ping`/`get_conn`), are NOT here — their query is built
2038/// structurally (never a maskable string literal) so a missing literal must not false-positive.
2039/// Under-catching an unusual query verb is a missed mask (sound-with-disclosure), never a broken gate.
2040/// The arg is the method leaf (the path's last segment).
2041pub fn is_db_query_arg(leaf: &str) -> bool {
2042    matches!(
2043        leaf,
2044        "execute"
2045            | "execute_batch"
2046            | "execute_unprepared"
2047            | "batch_execute"
2048            | "simple_query"
2049            | "query"
2050            | "query_one"
2051            | "query_opt"
2052            | "query_raw"
2053            | "query_row"
2054            | "query_map"
2055            | "query_and_then"
2056            | "query_typed"
2057            | "query_all"
2058            | "prepare"
2059            | "prepare_typed"
2060            | "prepare_cached"
2061            | "exec"
2062            | "exec_first"
2063            | "exec_iter"
2064            | "exec_map"
2065            | "exec_fold"
2066            | "exec_drop"
2067            | "exec_batch"
2068            | "prep"
2069            | "run_command"
2070    )
2071}
2072
2073/// Map a cap-std capability *type* to the effect it authorises. Holding one of these
2074/// (e.g. `&Dir`) is the real, unforgeable right to perform that effect — so candor
2075/// treats it as a declared capability, exactly like its own `&Fs` token.
2076pub fn capstd_cap(crate_name: &str, type_name: &str) -> Option<&'static str> {
2077    if !crate_name.starts_with("cap_") {
2078        return None;
2079    }
2080    Some(match type_name {
2081        "Dir" => "Fs",
2082        "TcpListener" | "TcpStream" | "UdpSocket" | "Pool" => "Net",
2083        "UnixListener" | "UnixStream" | "UnixDatagram" => "Ipc",
2084        "SystemClock" | "MonotonicClock" => "Clock",
2085        _ => return None,
2086    })
2087}
2088
2089/// Table names a SQL string literal STATICALLY reaches — the `Db` analog of the `Net` host /
2090/// `Exec` command / `Fs` path literal surface (feeds `allow Db in <scope> <table>…`, AS-EFF-008).
2091/// Conservative by construction, because a wrong capture here would FABRICATE: the string must
2092/// open with a SQL statement keyword, and only identifiers in table position are taken —
2093/// `FROM`/`JOIN` anywhere, `INTO` anywhere, statement-leading `UPDATE`/`TRUNCATE`, and
2094/// `TABLE` (create/drop/alter), skipping `ONLY`/`IF NOT EXISTS`. `UPDATE` mid-statement is
2095/// deliberately ignored (`FOR UPDATE SKIP LOCKED` must not yield a table "skip"). A
2096/// dynamically-built query yields nothing — the gate's opaque case — never a guess.
2097/// Output is lower-cased, quote/backtick-stripped, `schema.table` kept qualified, deduped.
2098/// SPEC §2 pins this algorithm token-for-token across engines; the cross-impl vector battery
2099/// (candor-spec conformance/tables/vectors.json, run.sh Part 4b) enforces the JVM/TS mirrors.
2100pub fn tables_in_sql(sql: &str) -> Vec<String> {
2101    const STMT: &[&str] =
2102        &["select", "insert", "update", "delete", "create", "drop", "alter", "truncate", "merge", "replace", "with"];
2103    // Tokens that can FOLLOW a table-introducing keyword without being a table.
2104    const SKIP: &[&str] = &["only", "if", "not", "exists", "table"];
2105    // Identifier-position tokens that are grammar, not a table (subqueries, locking clauses…).
2106    const STOP: &[&str] = &[
2107        "select", "set", "where", "values", "on", "using", "group", "order", "by", "limit",
2108        "returning", "as", "inner", "outer", "left", "right", "cross", "lateral", "natural",
2109        "union", "all", "distinct", "case", "when", "null", "default", "skip", "nowait", "of",
2110        "from", "join", "into", "update", "delete", "insert",
2111    ];
2112    // `,` survives as its OWN token (not a space): it's what lets `FROM t1, t2` continue the table
2113    // list without fabricating from other comma-ridden positions (column lists, ON clauses).
2114    let cleaned: String = sql
2115        .to_lowercase()
2116        .chars()
2117        .flat_map(|c| match c {
2118            '(' | ')' | ';' => vec![' '],
2119            ',' => vec![' ', ',', ' '],
2120            _ => vec![c],
2121        })
2122        .collect();
2123    let toks: Vec<&str> = cleaned.split_whitespace().collect();
2124    let Some(first) = toks.first() else { return Vec::new() };
2125    if !STMT.contains(first) {
2126        return Vec::new(); // not SQL — nothing to certify, nothing fabricated
2127    }
2128    let ident = |t: &str| -> Option<String> {
2129        let t = t.trim_matches(|c| matches!(c, '"' | '`' | '\''));
2130        let mut chars = t.chars();
2131        let ok_first = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
2132        let ok_rest = t.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '$' | '"' | '`'));
2133        (ok_first && ok_rest && !STOP.contains(&t)).then(|| t.replace(['"', '`'], ""))
2134    };
2135    let mut out: Vec<String> = Vec::new();
2136    let mut push = |t: Option<String>| {
2137        if let Some(t) = t {
2138            if !out.contains(&t) {
2139                out.push(t);
2140            }
2141        }
2142    };
2143    for (i, tok) in toks.iter().enumerate() {
2144        let table_pos = match *tok {
2145            "from" | "join" | "into" | "table" => true,
2146            // statement-leading only (see doc comment): `update t set …`, `truncate [table] t`.
2147            "update" | "truncate" => i == 0,
2148            _ => false,
2149        };
2150        if !table_pos {
2151            continue;
2152        }
2153        let mut j = i + 1;
2154        while j < toks.len() && SKIP.contains(&toks[j]) {
2155            j += 1;
2156        }
2157        let Some(next) = toks.get(j) else { continue };
2158        let Some(first) = ident(next) else { continue };
2159        push(Some(first));
2160        // Comma-ADJACENT continuation only: `FROM t1, t2, t3` takes all three, while an alias breaks
2161        // the chain (`FROM t1 a, t2` keeps just t1 — an under-report, never a guess: skipping an
2162        // alias to chase the comma would fabricate tables out of `INSERT INTO t (a, b)`'s column
2163        // list, whose parens are spaces by the time we tokenize).
2164        while j + 2 < toks.len() && toks[j + 1] == "," {
2165            let Some(more) = ident(toks[j + 2]) else { break };
2166            push(Some(more));
2167            j += 2;
2168        }
2169    }
2170    out
2171}
2172
2173#[cfg(test)]
2174mod tests {
2175    #[test]
2176    fn model_host_recognizes_known_providers_and_special_forms() {
2177        use super::is_model_host as m;
2178        // exact known hosts (case-insensitive), with/without a port
2179        assert!(m("api.openai.com"));
2180        assert!(m("API.OpenAI.com"));
2181        assert!(m("api.anthropic.com:443"));
2182        assert!(m("generativelanguage.googleapis.com"));
2183        assert!(m("api.mistral.ai"));
2184        assert!(m("api.cohere.ai"));
2185        assert!(m("api.cohere.com")); // BOTH cohere hosts
2186        assert!(m("api.groq.com"));
2187        assert!(m("api.together.xyz"));
2188        assert!(m("api.perplexity.ai"));
2189        assert!(m("openrouter.ai"));
2190        // a subdomain of a known host counts
2191        assert!(m("eu.api.openai.com"));
2192        // Ollama: :11434 on a LOOPBACK host only (max-review r3 — a remote host on 11434 is not Ollama)
2193        assert!(m("localhost:11434"));
2194        assert!(m("127.0.0.1:11434"));
2195        assert!(!m("ollama.internal:11434")); // a remote/internal service on 11434 is NOT a model host
2196        // Bedrock: the FIRST label is the model-inference service, not the substring "bedrock"
2197        assert!(m("bedrock-runtime.us-east-1.amazonaws.com"));
2198        assert!(m("bedrock-runtime.eu-west-1.amazonaws.com"));
2199        assert!(m("bedrock-agent-runtime.us-east-1.amazonaws.com"));
2200        // NOT model hosts (never guessed)
2201        assert!(!m("example.com"));
2202        assert!(!m("api.stripe.com"));
2203        assert!(!m("localhost:8080")); // a non-Ollama local port
2204        assert!(!m("s3.us-east-1.amazonaws.com")); // amazonaws but not bedrock
2205        assert!(!m("bedrock-backups.s3.amazonaws.com")); // an S3 bucket merely NAMED bedrock — not the runtime
2206        assert!(!m("bedrock.us-east-1.amazonaws.com")); // the Bedrock CONTROL plane — not model inference
2207        assert!(!m("openai.com.evil.com")); // suffix trick — not a subdomain of a known host
2208    }
2209
2210    #[test]
2211    fn model_sdk_crate_is_crate_level_no_method_gating() {
2212        use super::is_model_sdk_crate as s;
2213        assert!(s("async_openai"));
2214        assert!(s("aws_sdk_bedrockruntime"));
2215        assert!(s("ollama_rs"));
2216        assert!(s("langchain_rust"));
2217        assert!(!s("reqwest"));
2218        assert!(!s("aws_sdk_s3"));
2219    }
2220
2221    #[test]
2222    fn sql_table_extraction_is_conservative() {
2223        use super::tables_in_sql as t;
2224        assert_eq!(t("SELECT id FROM users WHERE x = 1"), vec!["users"]);
2225        assert_eq!(t("select * from ledger.entries e join customers c on c.id = e.cid"),
2226                   vec!["ledger.entries", "customers"]);
2227        assert_eq!(t("INSERT INTO audit_log (a) VALUES (?1)"), vec!["audit_log"]);
2228        assert_eq!(t("UPDATE accounts SET v = ?"), vec!["accounts"]);
2229        assert_eq!(t("DELETE FROM sessions WHERE id = ?"), vec!["sessions"]);
2230        assert_eq!(t("CREATE TABLE IF NOT EXISTS cache (k TEXT)"), vec!["cache"]);
2231        assert_eq!(t("TRUNCATE TABLE staging"), vec!["staging"]);
2232        // FOR UPDATE locking clause must not yield a phantom table (mid-statement update ignored)
2233        assert_eq!(t("SELECT * FROM jobs FOR UPDATE SKIP LOCKED"), vec!["jobs"]);
2234        // a subquery in FROM position yields nothing for that position
2235        assert_eq!(t("SELECT * FROM (SELECT 1) q"), Vec::<String>::new());
2236        // not SQL -> nothing (never fabricate)
2237        assert_eq!(t("/tmp/some/path"), Vec::<String>::new());
2238        assert_eq!(t("hello world from nowhere"), Vec::<String>::new());
2239        // comma-ADJACENT continuation: a FROM list takes every table in the chain…
2240        assert_eq!(t("SELECT a FROM t1, t2, s.t3 WHERE x = 1"), vec!["t1", "t2", "s.t3"]);
2241        // …but an alias breaks it (under-report, never a guess)…
2242        assert_eq!(t("SELECT a FROM t1 a1, t2 WHERE x = 1"), vec!["t1"]);
2243        // …which is exactly what keeps a column list from fabricating (parens are spaces by now).
2244        assert_eq!(t("INSERT INTO t (a, b) VALUES (1, 2)"), vec!["t"]);
2245        // a subquery after the comma stops the chain too
2246        assert_eq!(t("SELECT a FROM t1, (SELECT 1) q"), vec!["t1"]);
2247    }
2248
2249    use super::*;
2250
2251    /// The routed-handle list's MEMBERSHIP RULE, enforced rather than merely documented.
2252    ///
2253    /// (a) Every routed type must have a whole-type rule that answers an ARBITRARY method on it.
2254    /// If it did not, routing it would hand the scanner a path the classifier drops — motion with no
2255    /// effect, and the silent under-report would survive the fix that was supposed to close it. The
2256    /// probe leaf is deliberately a name no carve-out lists, so it measures the BASE rule.
2257    ///
2258    /// (b) The named exclusions stay excluded. `ReadDir` cannot have a verb-precise rule written for it
2259    /// (its `next` is a syscall and is spelled like every other iterator's), and `Metadata`/`DirEntry`/
2260    /// `Permissions`/`FileType` are pure DATA under the coarse `std::fs::` prefix — routing any of them
2261    /// charges an effect for reading a field. `OpenOptions`/`DirBuilder` LEFT this list by the route the
2262    /// list's own comment prescribed: their type-keyed setter carve-outs were written first, so the
2263    /// whole-type rule under them now answers only for the terminal verb.
2264    #[test]
2265    fn std_effect_handles_have_a_whole_type_rule_and_exclude_the_pure_surfaces() {
2266        for ty in STD_EFFECT_HANDLES {
2267            let probe = format!("{ty}::__candor_probe_verb");
2268            assert!(
2269                classify("std", &probe).is_some(),
2270                "`{ty}` is routed into the classifier by receiver inference, but an arbitrary method \
2271                 on it classifies to NOTHING — the routing cannot close any under-report"
2272            );
2273            assert!(is_std_effect_handle(ty), "the predicate must agree with the list");
2274        }
2275        for ty in [
2276            "std::fs::ReadDir",
2277            "std::fs::Metadata", "std::fs::DirEntry", "std::fs::Permissions", "std::fs::FileType",
2278            "std::path::Path", "std::path::PathBuf", "std::vec::Vec",
2279        ] {
2280            assert!(!is_std_effect_handle(ty),
2281                    "`{ty}` has a pure surface the coarse prefix rules would charge — it must not be \
2282                     routed as a handle (Path/PathBuf route through their own verb-precise carve-out)");
2283        }
2284    }
2285
2286    /// The pure read-backs on the ROUTED types, which is what makes (a) above safe: a whole-type rule
2287    /// answering every method is only acceptable because the genuinely-pure methods were carved out
2288    /// first. Receiver routing is a second door into these rules, so it must not re-fabricate.
2289    #[test]
2290    fn routed_std_handles_keep_their_pure_accessor_carve_outs() {
2291        for p in [
2292            "std::process::Command::get_program", "std::process::Command::get_args",
2293            "std::process::Command::get_envs", "std::process::Command::get_current_dir",
2294            "std::process::Child::id",
2295            "std::net::TcpStream::local_addr", "std::net::TcpStream::peer_addr",
2296            "std::net::TcpStream::nodelay", "std::net::TcpStream::ttl",
2297            "std::net::UdpSocket::take_error",
2298            "std::fs::File::as_raw_fd", "std::fs::File::into_raw_fd",
2299            "std::os::unix::net::UnixStream::as_raw_fd",
2300            // The OPTION-BUILDER setters (SPEC §1 ⟨0.32⟩): flags in a struct, no file named, nothing
2301            // opened. `create` is here as `OpenOptions`' setter and is a VERB on `DirBuilder` below —
2302            // one leaf, two answers, which is why these carve-outs are keyed on the TYPE.
2303            "std::fs::OpenOptions::new", "std::fs::OpenOptions::read", "std::fs::OpenOptions::write",
2304            "std::fs::OpenOptions::append", "std::fs::OpenOptions::truncate",
2305            "std::fs::OpenOptions::create", "std::fs::OpenOptions::create_new",
2306            "std::fs::OpenOptions::mode", "std::fs::OpenOptions::custom_flags",
2307            "std::fs::DirBuilder::new", "std::fs::DirBuilder::recursive", "std::fs::DirBuilder::mode",
2308        ] {
2309            assert_eq!(classify("std", p), None, "`{p}` is a pure read-back");
2310        }
2311        // …and the verbs on the same types still answer, or the carve-outs would have eaten the rule.
2312        for (p, want) in [
2313            ("std::process::Command::spawn", "Exec"),
2314            ("std::process::Child::wait", "Exec"),
2315            ("std::fs::File::write_all", "Fs"),
2316            ("std::net::TcpStream::write_all", "Net"),
2317            ("std::os::unix::net::UnixStream::send", "Ipc"),
2318            // the TERMINAL VERBS of the two option-builders — the whole point of routing them.
2319            ("std::fs::OpenOptions::open", "Fs"),
2320            ("std::fs::DirBuilder::create", "Fs"),
2321        ] {
2322            assert_eq!(classify("std", p), Some(want), "`{p}` must stay {want}");
2323        }
2324    }
2325
2326    #[test]
2327    fn db_crates_are_calibrated() {
2328        // The calibrated set must cover every DB client the classifier knows, or the receipt's coverage
2329        // check would flag a recognized crate as a blind spot. (Was nightly-lint-only; now runs on stable.)
2330        for c in DB_CRATES {
2331            assert!(
2332                CALIBRATED_CRATES.contains(&c),
2333                "DB crate `{c}` is matched by classify() but missing from CALIBRATED_CRATES"
2334            );
2335        }
2336    }
2337
2338    /// The two coverage lists mean OPPOSITE things and must stay disjoint.
2339    ///
2340    /// `CALIBRATED_CRATES` = "classify has effect rules here". `REVIEWED_PURE_CRATES` = "read it, it
2341    /// performs nothing". A crate in both would be asserting both at once, and the ledger consults them
2342    /// with an OR — so the contradiction would resolve silently to "covered" and nobody would look again.
2343    #[test]
2344    fn reviewed_pure_and_calibrated_are_disjoint() {
2345        for c in REVIEWED_PURE_CRATES {
2346            assert!(!CALIBRATED_CRATES.contains(&c),
2347                    "`{c}` is in BOTH lists — it cannot be rule-covered AND effect-free");
2348            assert!(!PATH_CALIBRATED_CRATES.contains(&c), "`{c}` is in BOTH lists (path-calibrated)");
2349            assert!(!CALIBRATED_PREFIXES.iter().any(|p| c.starts_with(p)),
2350                    "`{c}` is covered by a calibrated PREFIX as well as the pure list");
2351        }
2352    }
2353
2354    /// A reviewed-pure crate must actually classify as pure — the mirror of `calibrated_crates_are_live`.
2355    ///
2356    /// The list makes candor BELIEVE these crates rather than disclose them, so if someone later adds a
2357    /// rule for one, the claim "performs no effect of its own" is dead and the entry has to be re-read,
2358    /// not silently outvoted by the rule. Probed with the same tails the liveness test uses, which is a
2359    /// broad sweep of the effectful verb shapes candor knows.
2360    #[test]
2361    fn reviewed_pure_crates_classify_as_pure() {
2362        for c in REVIEWED_PURE_CRATES {
2363            for t in CALIBRATION_PROBE_TAILS {
2364                assert!(classify(c, &format!("{c}{t}")).is_none(),
2365                        "`{c}` is listed REVIEWED-PURE but classify() gives it an effect on `{c}{t}` — \
2366                         one of the two is wrong, and the list is the claim");
2367            }
2368        }
2369    }
2370
2371    #[test]
2372    fn calibrated_crates_are_live() {
2373        // Conversely, every crate advertised as calibrated must actually be matched by classify() for
2374        // some representative path — a dead entry would silently suppress a real coverage warning.
2375        for c in CALIBRATED_CRATES {
2376            assert!(
2377                CALIBRATION_PROBE_TAILS.iter().any(|t| classify(c, &format!("{c}{t}")).is_some()),
2378                "calibrated crate `{c}` is matched by no path in classify() — dead list entry"
2379            );
2380        }
2381    }
2382
2383    #[test]
2384    fn async_http_stack_classifies() {
2385        // The modern async-HTTP/TLS/QUIC/DNS stack (found by the independent-method differential on oha):
2386        // verb-keyed Net/Ipc/Fs/Env, crate-gated so generic verbs never fabricate across crates.
2387        assert_eq!(classify("hyper", "hyper::client::conn::http1::SendRequest::send_request"), Some("Net"));
2388        assert_eq!(classify("hyper", "hyper::client::conn::http1::handshake"), Some("Net"));
2389        assert_eq!(classify("hyper_util", "hyper_util::client::legacy::Client::request"), Some("Net"));
2390        assert_eq!(classify("hickory_resolver", "hickory_resolver::Resolver::lookup_ip"), Some("Net"));
2391        assert_eq!(classify("quinn", "quinn::Endpoint::connect"), Some("Net"));
2392        assert_eq!(classify("quinn", "quinn::RecvStream::read_to_end"), Some("Net")); // stream byte I/O, not just open
2393        assert_eq!(classify("quinn", "quinn::SendStream::write_all"), Some("Net"));
2394        assert_eq!(classify("tokio_rustls", "tokio_rustls::TlsConnector::connect"), Some("Net"));
2395        assert_eq!(classify("native_tls", "native_tls::TlsConnector::connect"), Some("Net"));
2396        assert_eq!(classify("tokio_vsock", "tokio_vsock::VsockStream::connect"), Some("Ipc"));
2397        assert_eq!(classify("rustls_native_certs", "rustls_native_certs::load_native_certs"), Some("Fs"));
2398        assert_eq!(classify("rlimit", "rlimit::setrlimit"), Some("Env"));
2399        // num_cpus is deliberately PURE (consistency with std::thread::available_parallelism; avoids Env spray)
2400        assert_eq!(classify("num_cpus", "num_cpus::get"), None);
2401        assert_eq!(classify("num_cpus", "num_cpus::get_physical"), None);
2402        // pure surface stays None (no fabrication): builder/type/config paths, and other crates' generic verbs
2403        assert_eq!(classify("hyper", "hyper::Request::builder"), None);
2404        assert_eq!(classify("hyper", "hyper::body::Bytes::new"), None);
2405        assert_eq!(classify("native_tls", "native_tls::TlsConnectorBuilder::min_protocol_version"), None);
2406        assert_eq!(classify("serde", "serde::Deserialize::request"), None); // generic verb, wrong crate
2407    }
2408
2409    #[test]
2410    fn coverage_differential_crates_classify() {
2411        // Crates the coverage differential found DISCLOSED-but-unmodeled. Each rule is verb-keyed +
2412        // crate-gated; the EFFECT verbs map to the right bucket and the PURE surface stays None (a
2413        // wrongly-flagged pure crate is a fabrication, so the negatives matter as much as the positives).
2414
2415        // rustls (sync TLS core) — record I/O is Net; config/cert + the buffered-decrypt step are pure.
2416        assert_eq!(classify("rustls", "rustls::ClientConnection::read_tls"), Some("Net"));
2417        assert_eq!(classify("rustls", "rustls::ConnectionCommon::write_tls"), Some("Net"));
2418        assert_eq!(classify("rustls", "rustls::Connection::complete_io"), Some("Net"));
2419        assert_eq!(classify("rustls", "rustls::ConnectionCommon::process_new_packets"), None); // buffered decrypt, no I/O
2420        assert_eq!(classify("rustls", "rustls::ClientConfig::builder"), None); // pure config
2421
2422        // native-tls variants — handshake is Net; builder is pure.
2423        assert_eq!(classify("native_tls_crate", "native_tls_crate::TlsConnector::connect"), Some("Net"));
2424        assert_eq!(classify("tokio_native_tls", "tokio_native_tls::TlsAcceptor::accept"), Some("Net"));
2425        assert_eq!(classify("native_tls_crate", "native_tls_crate::TlsConnectorBuilder::min_protocol_version"), None);
2426
2427        // etcetera — dir resolution reads env; the args data type is pure.
2428        assert_eq!(classify("etcetera", "etcetera::home_dir"), Some("Env"));
2429        assert_eq!(classify("etcetera", "etcetera::base_strategy::choose_base_strategy"), Some("Env"));
2430        assert_eq!(classify("etcetera", "etcetera::base_strategy::Xdg::config_dir"), Some("Env"));
2431        assert_eq!(classify("etcetera", "etcetera::app_strategy::AppStrategyArgs::new"), None); // pure data
2432
2433        // sqlx-core — connect is Net, execute/fetch round-trips are Db; options/builders pure.
2434        assert_eq!(classify("sqlx_core", "sqlx_core::connection::Connection::connect"), Some("Net"));
2435        assert_eq!(classify("sqlx_core", "sqlx_core::executor::Executor::fetch_one"), Some("Db"));
2436        assert_eq!(classify("sqlx_core", "sqlx_core::executor::Executor::execute"), Some("Db"));
2437        assert_eq!(classify("sqlx_core", "sqlx_core::pool::Pool::acquire"), Some("Db"));
2438        assert_eq!(classify("sqlx_core", "sqlx_core::pool::PoolOptions::max_connections"), None); // pure builder
2439
2440        // walkdir — the lazy read happens in next()/metadata(); builders + cached accessors pure.
2441        assert_eq!(classify("walkdir", "walkdir::IntoIter::next"), Some("Fs"));
2442        assert_eq!(classify("walkdir", "walkdir::DirEntry::metadata"), Some("Fs"));
2443        assert_eq!(classify("walkdir", "walkdir::WalkDir::new"), None); // builder
2444        assert_eq!(classify("walkdir", "walkdir::WalkDir::into_iter"), None); // no I/O until pulled
2445        assert_eq!(classify("walkdir", "walkdir::DirEntry::file_type"), None); // cached, no syscall
2446
2447        // filetime — set_* are utimes (Fs), now is Clock; from_* constructors pure.
2448        assert_eq!(classify("filetime", "filetime::set_file_mtime"), Some("Fs"));
2449        assert_eq!(classify("filetime", "filetime::set_file_handle_times"), Some("Fs"));
2450        assert_eq!(classify("filetime", "filetime::FileTime::now"), Some("Clock"));
2451        assert_eq!(classify("filetime", "filetime::FileTime::from_unix_time"), None);
2452        assert_eq!(classify("filetime", "filetime::FileTime::from_last_modification_time"), None); // reads &Metadata, not disk
2453
2454        // execute — the execute* verbs spawn (Exec); command/shell builders pure.
2455        assert_eq!(classify("execute", "execute::Execute::execute"), Some("Exec"));
2456        assert_eq!(classify("execute", "execute::Execute::execute_output"), Some("Exec"));
2457        assert_eq!(classify("execute", "execute::Execute::execute_multiple_output"), Some("Exec"));
2458        assert_eq!(classify("execute", "execute::command"), None); // only builds a Command
2459        assert_eq!(classify("execute", "execute::shell"), None);
2460
2461        // ctrlc — install signal handler (Ipc).
2462        assert_eq!(classify("ctrlc", "ctrlc::set_handler"), Some("Ipc"));
2463        assert_eq!(classify("ctrlc", "ctrlc::try_set_handler"), Some("Ipc"));
2464
2465        // clap — only the argv-reading terminals are Env; the whole builder + *_from variants pure.
2466        assert_eq!(classify("clap", "clap::Command::get_matches"), Some("Env"));
2467        assert_eq!(classify("clap", "clap::Command::try_get_matches"), Some("Env"));
2468        assert_eq!(classify("clap", "clap::Parser::parse"), Some("Env"));
2469        assert_eq!(classify("clap", "clap::Command::new"), None); // builder
2470        assert_eq!(classify("clap", "clap::Arg::about"), None); // builder
2471        assert_eq!(classify("clap", "clap::Command::get_matches_from"), None); // explicit args, no argv read
2472
2473        // jiff — now* is Clock; tz lookups read the tzdb (Fs); span/civil math pure.
2474        assert_eq!(classify("jiff", "jiff::Timestamp::now"), Some("Clock"));
2475        assert_eq!(classify("jiff", "jiff::Zoned::now_with"), Some("Clock"));
2476        assert_eq!(classify("jiff", "jiff::tz::TimeZone::system"), Some("Fs"));
2477        assert_eq!(classify("jiff", "jiff::tz::TimeZone::get"), Some("Fs"));
2478        assert_eq!(classify("jiff", "jiff::Span::checked_add"), None); // pure arithmetic
2479
2480        // env_logger — init installs the logger + reads RUST_LOG (Log); config setters pure.
2481        // TUI — the tty is a user dialogue channel (Ipc), the ruling dialoguer/console already carry.
2482        // Each verb below was read off the crate source (crossterm-0.28.1, ratatui-0.29.0), not guessed.
2483        assert_eq!(classify("crossterm", "crossterm::ExecutableCommand::execute"), Some("Ipc"));
2484        assert_eq!(classify("crossterm", "crossterm::QueueableCommand::queue"), Some("Ipc"));
2485        assert_eq!(classify("crossterm", "crossterm::event::read"), Some("Ipc"));
2486        assert_eq!(classify("crossterm", "crossterm::event::poll"), Some("Ipc"));
2487        assert_eq!(classify("crossterm", "crossterm::terminal::enable_raw_mode"), Some("Ipc"));
2488        // a tty IOCTL must not fall through: in a CALIBRATED crate an unmatched path is a purity CLAIM
2489        assert_eq!(classify("crossterm", "crossterm::terminal::size"), Some("Ipc"));
2490        // the Command VALUE types are pure — they describe an action, they do not perform one
2491        assert_eq!(classify("crossterm", "crossterm::style::Print"), None);
2492        assert_eq!(classify("crossterm", "crossterm::cursor::MoveTo"), None);
2493
2494        // ratatui: the backlog said "mark reviewed-pure"; the SOURCE says `Terminal::draw` ends in a
2495        // backend flush, so the write surface is Ipc and only the render surface is pure.
2496        assert_eq!(classify("ratatui", "ratatui::Terminal::draw"), Some("Ipc"));
2497        assert_eq!(classify("ratatui", "ratatui::Terminal::flush"), Some("Ipc"));
2498        assert_eq!(classify("ratatui", "ratatui::Terminal::clear"), Some("Ipc"));
2499        assert_eq!(classify("ratatui", "ratatui::Terminal::hide_cursor"), Some("Ipc"));
2500        // REGRESSION: `widgets::canvas` is an in-memory grid. `Context::draw` ends in `::draw` and was
2501        // FABRICATING Ipc — caught in review, measured on a fixture, and a hot path (per shape, per frame).
2502        assert_eq!(classify("ratatui", "ratatui::widgets::canvas::Context::draw"), None);
2503        assert_eq!(classify("ratatui", "ratatui::widgets::canvas::Context::layer"), None);
2504        // …while the real write surface still classifies, including a DIRECT backend call (which is why
2505        // the carve-out is a denylist on canvas rather than an allowlist on `Terminal::`).
2506        assert_eq!(classify("ratatui", "ratatui::backend::CrosstermBackend::flush"), Some("Ipc"));
2507        // the BULK of the 3,345 disclosed calls — widgets, layout, style — are genuinely pure
2508        assert_eq!(classify("ratatui", "ratatui::widgets::Paragraph::new"), None);
2509        assert_eq!(classify("ratatui", "ratatui::layout::Layout::split"), None);
2510        assert_eq!(classify("ratatui", "ratatui::style::Style::fg"), None);
2511        assert_eq!(classify("ratatui", "ratatui::buffer::Buffer::set_string"), None);
2512
2513        // tracing_subscriber — two effects, both read off 0.3.23. The filing said "Log/Fs"; the Fs half
2514        // is wrong (the crate ACCEPTS a File as a writer, it never opens one).
2515        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::init"), Some("Log"));
2516        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::try_init"), Some("Log"));
2517        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::SubscriberBuilder::init"), Some("Log"));
2518        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::EnvFilter::from_default_env"), Some("Env"));
2519        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::EnvFilter::from_env"), Some("Env"));
2520        // builders DESCRIBE a subscriber; they do not install one
2521        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::layer"), None);
2522        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::fmt::SubscriberBuilder::with_target"), None);
2523        assert_eq!(classify("tracing_subscriber", "tracing_subscriber::EnvFilter::new"), None);
2524
2525        assert_eq!(classify("env_logger", "env_logger::init"), Some("Log"));
2526        assert_eq!(classify("env_logger", "env_logger::try_init"), Some("Log"));
2527        assert_eq!(classify("env_logger", "env_logger::Builder::init"), Some("Log"));
2528        assert_eq!(classify("env_logger", "env_logger::Builder::format_timestamp"), None); // config
2529        assert_eq!(classify("env_logger", "env_logger::Builder::build"), None); // pure build
2530
2531        // dialoguer — interact* is tty I/O (Ipc); builders pure.
2532        assert_eq!(classify("dialoguer", "dialoguer::Input::interact_text"), Some("Ipc"));
2533        assert_eq!(classify("dialoguer", "dialoguer::Confirm::interact"), Some("Ipc"));
2534        assert_eq!(classify("dialoguer", "dialoguer::Select::interact_opt"), Some("Ipc"));
2535        assert_eq!(classify("dialoguer", "dialoguer::Input::with_prompt"), None); // builder
2536
2537        // console — Term I/O is Ipc, detection is Env, Style is pure.
2538        assert_eq!(classify("console", "console::Term::write_line"), Some("Ipc"));
2539        assert_eq!(classify("console", "console::Term::read_key"), Some("Ipc"));
2540        assert_eq!(classify("console", "console::colors_enabled"), Some("Env"));
2541        assert_eq!(classify("console", "console::Style::cyan"), None); // pure styling
2542        assert_eq!(classify("console", "console::strip_ansi_codes"), None); // pure text util
2543
2544        // terminal_colorsaurus — tty colour query (Ipc).
2545        assert_eq!(classify("terminal_colorsaurus", "terminal_colorsaurus::background_color"), Some("Ipc"));
2546        assert_eq!(classify("terminal_colorsaurus", "terminal_colorsaurus::color_palette"), Some("Ipc"));
2547
2548        // backoff — retry sleeps + reads the clock (Clock); config pure.
2549        assert_eq!(classify("backoff", "backoff::retry"), Some("Clock"));
2550        assert_eq!(classify("backoff", "backoff::retry_notify"), Some("Clock"));
2551        assert_eq!(classify("backoff", "backoff::ExponentialBackoff::default"), None);
2552
2553        // lscolors — ONLY from_env reads the environment; from_string/style_for_path pure.
2554        assert_eq!(classify("lscolors", "lscolors::LsColors::from_env"), Some("Env"));
2555        assert_eq!(classify("lscolors", "lscolors::LsColors::from_string"), None);
2556        assert_eq!(classify("lscolors", "lscolors::LsColors::style_for_path"), None);
2557
2558        // wild — argv readers (Env).
2559        assert_eq!(classify("wild", "wild::args"), Some("Env"));
2560        assert_eq!(classify("wild", "wild::args_os"), Some("Env"));
2561
2562        // grep_cli — only the firm Exec (CommandReader spawn); the isatty probes stay unmodeled.
2563        assert_eq!(classify("grep_cli", "grep_cli::CommandReaderBuilder::build"), Some("Exec"));
2564        assert_eq!(classify("grep_cli", "grep_cli::is_readable_stdin"), None); // isatty/fstat, not modeled
2565        assert_eq!(classify("grep_cli", "grep_cli::is_tty_stdout"), None);
2566
2567        // clircle — same-file detection issues fstat/lseek (Fs); equality is pure.
2568        assert_eq!(classify("clircle", "clircle::Identifier::try_from"), Some("Fs"));
2569        assert_eq!(classify("clircle", "clircle::Clircle::surely_conflicts_with"), Some("Fs"));
2570    }
2571
2572    #[test]
2573    fn log_tracing_emit_macros_classify_pre_expansion() {
2574        // candor-scan is pre-expansion: it sees the raw macro path (`log::info`, `tracing::warn`), not the
2575        // expanded dispatch the deep engine sees. Both the user-facing macro names AND the type surface:
2576        assert_eq!(classify("log", "log::info"), Some("Log"));
2577        assert_eq!(classify("log", "log::error"), Some("Log"));
2578        assert_eq!(classify("tracing", "tracing::warn"), Some("Log"));
2579        assert_eq!(classify("tracing", "tracing::info_span"), Some("Log"));
2580        // pure data-type surface stays None (no fabricated Log)
2581        assert_eq!(classify("log", "log::Level::as_str"), None);
2582        assert_eq!(classify("tracing", "tracing::Level::INFO"), None);
2583    }
2584
2585    #[test]
2586    fn classify_core_effects() {
2587        // A representative smoke test of the classifier's main families, so the published crate is not
2588        // shipped untested (these used to live only in the nightly-only src/lib.rs).
2589        assert_eq!(classify("std", "std::fs::read_to_string"), Some("Fs"));
2590        // std::path stat-family methods are Fs (each is a stat/readdir syscall); the pure
2591        // string-manipulation surface stays unclassified (the blackout screen's gix-dir find).
2592        assert_eq!(classify("std", "std::path::Path::symlink_metadata"), Some("Fs"));
2593        assert_eq!(classify("std", "std::path::PathBuf::read_dir"), Some("Fs"));
2594        assert_eq!(classify("std", "std::path::Path::exists"), Some("Fs"));
2595        assert_eq!(classify("std", "std::path::Path::join"), None); // pure string manipulation
2596        assert_eq!(classify("std", "std::path::PathBuf::file_name"), None);
2597        assert_eq!(classify("std", "std::path::Path::parent"), None);
2598        assert_eq!(classify("std", "std::process::Command::new"), Some("Exec"));
2599        assert_eq!(classify("std", "std::env::var"), Some("Env"));
2600        assert_eq!(classify("reqwest", "reqwest::Client::execute"), Some("Net"));
2601        // one-shot convenience fns send immediately → Net.
2602        assert_eq!(classify("reqwest", "reqwest::get"), Some("Net"));
2603        assert_eq!(classify("reqwest", "reqwest::blocking::get"), Some("Net"));
2604        // the URL-BEARING builder methods classify Net too — the DOMINANT idiom is the builder chain
2605        // `Client::new().post(url).send()`, whose URL literal rides the `.post(url)` step (NOT `.send()`),
2606        // so the endpoint (and the Llm host refinement) only get captured if the URL-naming step is Net.
2607        assert_eq!(classify("reqwest", "reqwest::Client::get"), Some("Net"));
2608        assert_eq!(classify("reqwest", "reqwest::Client::post"), Some("Net"));
2609        assert_eq!(classify("reqwest", "reqwest::Client::put"), Some("Net"));
2610        assert_eq!(classify("reqwest", "reqwest::Client::delete"), Some("Net"));
2611        assert_eq!(classify("reqwest", "reqwest::Client::request"), Some("Net"));
2612        // the PURE builder surface stays None (no URL, no dispatch).
2613        assert_eq!(classify("reqwest", "reqwest::RequestBuilder::header"), None);
2614        assert_eq!(classify("reqwest", "reqwest::RequestBuilder::json"), None);
2615        assert_eq!(classify("reqwest", "reqwest::ClientBuilder::build"), None);
2616        // RAW POSIX SOCKETS — the lowest network tier, pinned as a regression guard (four-way close:
2617        // swift got a raw-socket regression this week from a bare-identifier collision; rust never had
2618        // the gap because it classifies path-QUALIFIED via the syscall-leaf table, but pin it so the
2619        // `socket`/`connect` Net rows can't silently drop). `libc::connect`/`libc::socket` are the direct
2620        // FFI syscalls; `nix::sys::socket::connect` is the safe wrapper; both bottom out in the NET table.
2621        assert_eq!(classify("libc", "libc::connect"), Some("Net"));
2622        assert_eq!(classify("libc", "libc::socket"), Some("Net"));
2623        assert_eq!(classify("libc", "libc::bind"), Some("Net"));
2624        assert_eq!(classify("libc", "libc::accept"), Some("Net"));
2625        // nix routes through the libc syscall table (same leaves): I/O classified, generic fd ops skipped.
2626        assert_eq!(classify("nix", "nix::fcntl::open"), Some("Fs"));
2627        assert_eq!(classify("nix", "nix::sys::socket::connect"), Some("Net"));
2628        assert_eq!(classify("nix", "nix::sys::socket::socket"), Some("Net"));
2629        assert_eq!(classify("nix", "nix::unistd::execvp"), Some("Exec"));
2630        assert_eq!(classify("nix", "nix::unistd::write"), None); // generic fd op — deliberately unclassified
2631        assert_eq!(classify("nix", "nix::unistd::getpid"), None); // not I/O
2632        // rustix does raw syscalls (no libc underneath) → classified directly by leaf, same table.
2633        assert_eq!(classify("rustix", "rustix::time::clock_settime"), Some("Clock"));
2634        assert_eq!(classify("rustix", "rustix::fs::symlink"), Some("Fs"));
2635        assert_eq!(classify("rustix", "rustix::net::connect"), Some("Net"));
2636        assert_eq!(classify("rustix", "rustix::io::read"), None); // generic fd op
2637        // pnet raw packet capture: channel openers are Net, packet construction stays pure.
2638        assert_eq!(classify("pnet", "pnet::datalink::channel"), Some("Net"));
2639        assert_eq!(classify("pnet", "pnet::transport::transport_channel"), Some("Net"));
2640        assert_eq!(classify("pnet_datalink", "pnet_datalink::channel"), Some("Net"));
2641        assert_eq!(classify("pnet", "pnet::packet::ethernet::EthernetPacket::new"), None);
2642        assert_eq!(classify("pnet_base", "pnet_base::MacAddr::new"), None);
2643        // ignore (gitignore-aware walker): walk executors are Fs, config builders stay pure.
2644        assert_eq!(classify("ignore", "ignore::WalkBuilder::build_parallel"), Some("Fs"));
2645        assert_eq!(classify("ignore", "ignore::WalkBuilder::build"), Some("Fs"));
2646        assert_eq!(classify("ignore", "ignore::WalkParallel::run"), Some("Fs"));
2647        assert_eq!(classify("ignore", "ignore::WalkBuilder::add_ignore"), Some("Fs")); // reads the ignore file
2648        assert_eq!(classify("ignore", "ignore::overrides::OverrideBuilder::build"), None); // pure config
2649        assert_eq!(classify("ignore", "ignore::gitignore::GitignoreBuilder::build"), None); // pure config
2650        assert_eq!(classify("ignore", "ignore::DirEntry::path"), None); // pure accessor
2651        // notify fs-watching: watcher constructors + watch/unwatch are Fs, data types stay pure.
2652        assert_eq!(classify("notify", "notify::RecommendedWatcher::new"), Some("Fs"));
2653        assert_eq!(classify("notify", "notify::PollWatcher::new"), Some("Fs"));
2654        assert_eq!(classify("notify", "notify::recommended_watcher"), Some("Fs"));
2655        assert_eq!(classify("notify", "notify::INotifyWatcher::watch"), Some("Fs"));
2656        assert_eq!(classify("notify", "notify::Config::default"), None); // pure config
2657        assert_eq!(classify("notify", "notify::Event::new"), None); // pure data type
2658        assert_eq!(classify("rusqlite", "rusqlite::Connection::execute"), Some("Db"));
2659        // the rusqlite verb DIALECT (a verb probe found the canonical consumer API classifying pure):
2660        assert_eq!(classify("rusqlite", "rusqlite::Connection::query_row"), Some("Db"));
2661        assert_eq!(classify("rusqlite", "rusqlite::Statement::query_map"), Some("Db"));
2662        assert_eq!(classify("rusqlite", "rusqlite::Connection::execute_batch"), Some("Db"));
2663        assert_eq!(classify("rusqlite", "rusqlite::Connection::prepare_cached"), Some("Db"));
2664        assert_eq!(classify("rusqlite", "rusqlite::Connection::open"), Some("Db"));
2665        assert_eq!(classify("rusqlite", "rusqlite::Connection::open_in_memory"), Some("Db"));
2666        // …but `open` stays rusqlite-only (postgres has no open; nothing else may borrow it):
2667        assert_eq!(classify("postgres", "postgres::Client::open"), None);
2668        assert_eq!(classify("tokio_postgres", "tokio_postgres::Client::query_typed"), Some("Db"));
2669        // diesel's LIMIT-1 + streaming executions; sqlx's multi-result stream:
2670        assert_eq!(classify("diesel", "diesel::RunQueryDsl::first"), Some("Db"));
2671        assert_eq!(classify("diesel", "diesel::RunQueryDsl::load_iter"), Some("Db"));
2672        assert_eq!(classify("sqlx", "sqlx::query::Query::fetch_many"), Some("Db"));
2673        // sqlx's bare `query()` builder must STAY pure (the original sqlx lesson):
2674        assert_eq!(classify("sqlx", "sqlx::query"), None);
2675        // tracing: the emit/span-lifecycle dispatch is Log; the pure DATA-type accessors are not
2676        // (whole-crate Log fabricated Log on `Level::as_str` / `Span::is_disabled` — the data types are
2677        // pure, same principle as the `log` facade).
2678        assert_eq!(classify("tracing", "tracing::event"), Some("Log"));
2679        assert_eq!(classify("tracing", "tracing::Span::new_span"), Some("Log"));
2680        assert_eq!(classify("tracing", "tracing::Span::record"), Some("Log"));
2681        assert_eq!(classify("tracing", "tracing::Span::enter"), Some("Log"));
2682        assert_eq!(classify("tracing", "tracing::Level::as_str"), None); // pure accessor
2683        assert_eq!(classify("tracing", "tracing::Span::is_disabled"), None); // pure state read
2684        assert_eq!(classify("tracing", "tracing::Span::metadata"), None); // pure accessor
2685        assert_eq!(classify("tracing", "tracing::metadata::Level::TRACE"), None); // pure data type
2686        assert_eq!(classify("tracing", "tracing::field::Field::name"), None); // pure data type
2687        // memmap2: only the syscall-issuing map/flush/protect verbs are Fs; reads over an already-mapped
2688        // region (len/as_ptr/is_empty) and the request builder are PURE (whole-crate Fs fabricated Fs).
2689        assert_eq!(classify("memmap2", "memmap2::MmapOptions::map"), Some("Fs"));
2690        assert_eq!(classify("memmap2", "memmap2::MmapOptions::map_mut"), Some("Fs"));
2691        assert_eq!(classify("memmap2", "memmap2::Mmap::flush"), Some("Fs"));
2692        assert_eq!(classify("memmap2", "memmap2::MmapMut::make_read_only"), Some("Fs"));
2693        assert_eq!(classify("memmap2", "memmap2::Mmap::len"), None); // length read — pure
2694        assert_eq!(classify("memmap2", "memmap2::Mmap::is_empty"), None); // pure
2695        assert_eq!(classify("memmap2", "memmap2::Mmap::as_ptr"), None); // pointer — pure
2696        assert_eq!(classify("memmap2", "memmap2::MmapOptions::new"), None); // request builder — pure
2697        // arboard: the Clipboard handle's read/write verbs are Clipboard; `arboard::Error` formatting
2698        // and option data types are PURE (whole-crate Clipboard fabricated Clipboard on `Error::to_string`).
2699        assert_eq!(classify("arboard", "arboard::Clipboard::new"), Some("Clipboard"));
2700        assert_eq!(classify("arboard", "arboard::Clipboard::get_text"), Some("Clipboard"));
2701        assert_eq!(classify("arboard", "arboard::Clipboard::set_text"), Some("Clipboard"));
2702        assert_eq!(classify("arboard", "arboard::Clipboard::clear"), Some("Clipboard"));
2703        assert_eq!(classify("arboard", "arboard::Error::to_string"), None); // error formatting — pure
2704        assert_eq!(classify("arboard", "arboard::Error::fmt"), None); // Display impl — pure
2705        assert_eq!(classify("arboard", "arboard::ImageData::to_owned_img"), None); // pure data type
2706        // fastrand: value draws + entropy-seeded entry points are Rand; the DETERMINISTIC seeded ctor
2707        // `with_seed` and state split/copy (`fork`/`clone`) are PURE (whole-crate Rand fabricated Rand).
2708        assert_eq!(classify("fastrand", "fastrand::u32"), Some("Rand")); // top-level draw
2709        assert_eq!(classify("fastrand", "fastrand::Rng::usize"), Some("Rand"));
2710        assert_eq!(classify("fastrand", "fastrand::Rng::shuffle"), Some("Rand"));
2711        assert_eq!(classify("fastrand", "fastrand::Rng::new"), Some("Rand")); // entropy-seeded
2712        assert_eq!(classify("fastrand", "fastrand::Rng::with_seed"), None); // deterministic ctor — pure
2713        assert_eq!(classify("fastrand", "fastrand::Rng::fork"), None); // state split — pure
2714        assert_eq!(classify("fastrand", "fastrand::Rng::clone"), None); // state copy — pure
2715        // portable_pty / async_process: spawn/wait keep Exec; config GETTERS and pure data ctors/setters
2716        // do NOT (base Exec fabricated on `CommandBuilder::get_cwd` / `PtySize::default` / `Stdio::piped`).
2717        assert_eq!(classify("portable_pty", "portable_pty::PtySystem::openpty"), Some("Exec"));
2718        assert_eq!(classify("portable_pty", "portable_pty::SlavePty::spawn_command"), Some("Exec"));
2719        assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::get_argv"), None); // getter
2720        assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::get_cwd"), None); // getter
2721        assert_eq!(classify("portable_pty", "portable_pty::PtySize::default"), None); // pure data type
2722        assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::new"), None); // builder ctor
2723        assert_eq!(classify("async_process", "async_process::Command::spawn"), Some("Exec"));
2724        assert_eq!(classify("async_process", "async_process::Command::output"), Some("Exec"));
2725        assert_eq!(classify("async_process", "async_process::Stdio::piped"), None); // pure data type
2726        assert_eq!(classify("async_process", "async_process::Stdio::null"), None); // pure data type
2727        // FFI tiers (matched by distinctive leaf, alias-independent)
2728        assert_eq!(classify("libc", "libc::open"), Some("Fs"));
2729        assert_eq!(classify("libc", "libc::connect"), Some("Net"));
2730        assert_eq!(classify("libc", "libc::read"), None); // generic fd op — deliberately unclassified
2731        assert_eq!(classify("ffi", "ffi::sqlite3_step"), Some("Db"));
2732        assert_eq!(classify("raw", "raw::git_remote_fetch"), Some("Net"));
2733        // libgit2 clone + submodule clone/update fetch over the network (an A/B on git2 0.20 caught
2734        // `Submodule::update`/`clone` and `Repository::clone` reporting no Net — the latter because the
2735        // `src/build.rs` module was being dropped as if it were the Cargo build script).
2736        assert_eq!(classify("raw", "raw::git_clone"), Some("Net"));
2737        assert_eq!(classify("raw", "raw::git_submodule_clone"), Some("Net"));
2738        assert_eq!(classify("raw", "raw::git_submodule_update"), Some("Net"));
2739        assert_eq!(classify("raw", "raw::git_submodule_open"), None); // local subrepo open — not Net
2740        // libcurl: the transfer/raw-socket entry points are Net (an A/B on curl 0.4 caught the whole
2741        // crate reporting ZERO Net); the big setopt/init/getinfo surface — and the readiness-wait
2742        // multi_wait/poll — stay unclassified (the loop's perform is the boundary).
2743        assert_eq!(classify("curl_sys", "curl_sys::curl_easy_perform"), Some("Net"));
2744        assert_eq!(classify("curl_sys", "curl_sys::curl_easy_send"), Some("Net"));
2745        assert_eq!(classify("curl_sys", "curl_sys::curl_multi_perform"), Some("Net"));
2746        assert_eq!(classify("curl_sys", "curl_sys::curl_multi_socket_action"), Some("Net"));
2747        assert_eq!(classify("curl_sys", "curl_sys::curl_easy_setopt"), None); // in-memory option write
2748        assert_eq!(classify("curl_sys", "curl_sys::curl_easy_init"), None); // handle alloc
2749        assert_eq!(classify("curl_sys", "curl_sys::curl_multi_wait"), None); // readiness wait, no payload
2750        // consumer-side `curl` crate rule: the dispatch verbs are Net, the setopt builders pure.
2751        assert_eq!(classify("curl", "curl::easy::Easy::perform"), Some("Net"));
2752        assert_eq!(classify("curl", "curl::multi::Multi::perform"), Some("Net"));
2753        assert_eq!(classify("curl", "curl::easy::Easy::send"), Some("Net"));
2754        assert_eq!(classify("curl", "curl::easy::Easy::url"), None); // CURLOPT setter — pure
2755        assert_eq!(classify("curl", "curl::easy::Easy::timeout"), None); // pure setter; Multi::timeout under-reported by design
2756        assert_eq!(classify("ffi", "ffi::SSL_connect"), Some("Net"));
2757        // pure crates stay pure
2758        assert_eq!(classify("serde", "serde::Serialize::serialize"), None);
2759        assert_eq!(classify("std", "std::vec::Vec::push"), None);
2760
2761        // ── sweep 2026-06-17: fabrication carve-outs + DNS coverage (each fails pre-fix) ──
2762        // [24] std::net socket accessors are pure; the I/O verbs stay Net.
2763        assert_eq!(classify("std", "std::net::TcpStream::connect"), Some("Net"));
2764        assert_eq!(classify("std", "std::net::TcpStream::local_addr"), None);
2765        assert_eq!(classify("std", "std::net::TcpStream::nodelay"), None);
2766        assert_eq!(classify("std", "std::net::TcpStream::ttl"), None);
2767        assert_eq!(classify("std", "std::net::UdpSocket::peer_addr"), None);
2768        // [37] std DNS resolution is Net (was floored).
2769        assert_eq!(classify("std", "std::net::lookup_host"), Some("Net"));
2770        assert_eq!(classify("std", "core::net::ToSocketAddrs::to_socket_addrs"), Some("Net"));
2771        // [23] std::process getters are pure; spawn/new stay Exec.
2772        assert_eq!(classify("std", "std::process::Command::get_program"), None);
2773        assert_eq!(classify("std", "std::process::Command::get_args"), None);
2774        assert_eq!(classify("std", "std::process::Child::id"), None);
2775        assert_eq!(classify("std", "std::process::Command::spawn"), Some("Exec"));
2776        // [27] redis ConnectionManager::clone is an Arc bump (pure); a query round-trips.
2777        assert_eq!(classify("redis", "redis::aio::ConnectionManager::clone"), None);
2778        assert_eq!(classify("redis", "redis::aio::ConnectionManager::send_packed_command"), Some("Db"));
2779        // [5] sea_orm re-exported sea_query builder algebra is pure; execution verbs stay Db.
2780        assert_eq!(classify("sea_orm", "sea_orm::sea_query::Func::count"), None);
2781        assert_eq!(classify("sea_orm", "sea_orm::sea_query::Condition::all"), None);
2782        assert_eq!(classify("sea_orm", "sea_orm::Select::all"), Some("Db"));
2783    }
2784
2785    #[test]
2786    fn rand_osrng_handle_ops_are_pure_but_draws_are_rand() {
2787        // Adversarial-review fabrication: the blanket `contains("OsRng")` tagged `OsRng::clone` Rand,
2788        // but OsRng is a unit struct — clone/fork/default draw no entropy. The real draws still fire.
2789        assert_eq!(classify("rand", "rand::rngs::OsRng::clone"), None);
2790        assert_eq!(classify("rand", "rand::rngs::OsRng::default"), None);
2791        assert_eq!(classify("rand", "rand::rngs::OsRng::fill_bytes"), Some("Rand")); // a real draw
2792        assert_eq!(classify("rand", "rand::rngs::OsRng::next_u32"), Some("Rand"));
2793        assert_eq!(classify("rand", "rand::Rng::gen"), Some("Rand")); // verb path unaffected
2794        assert_eq!(classify("rand", "rand::distributions::Uniform::new"), None); // pure ctor still pure
2795    }
2796
2797    #[test]
2798    fn redis_connection_manager_config_builder_is_pure() {
2799        // Adversarial-review fabrication: `contains("ConnectionManager")` hit the pure *Config* builder.
2800        assert_eq!(classify("redis", "redis::aio::ConnectionManagerConfig::new"), None);
2801        assert_eq!(classify("redis", "redis::aio::ConnectionManagerConfig::set_max_delay"), None);
2802        // the LIVE manager still round-trips (Db).
2803        assert_eq!(classify("redis", "redis::aio::ConnectionManager::new"), Some("Db"));
2804        assert_eq!(classify("redis", "redis::Commands::get"), Some("Db"));
2805    }
2806
2807    #[test]
2808    fn pure_fd_transfer_is_not_an_effect() {
2809        // ADOPTING / EXTRACTING / BORROWING an already-open descriptor (or unwrapping an async type back
2810        // to its std type) issues NO syscall — it must be PURE even though it hangs off a std I/O type
2811        // whose prefix rule would otherwise fire Net/Fs/Ipc. (Real tokio sweep: `into_std`, `from_raw_fd`,
2812        // `as_raw_fd` all fabricated effects.)
2813        assert_eq!(classify("std", "std::net::TcpStream::from_raw_fd"), None);
2814        assert_eq!(classify("std", "std::net::TcpStream::into_raw_fd"), None);
2815        assert_eq!(classify("std", "std::net::TcpStream::as_raw_fd"), None);
2816        assert_eq!(classify("std", "std::net::TcpListener::from_raw_fd"), None);
2817        assert_eq!(classify("std", "std::net::UdpSocket::from_raw_socket"), None);
2818        assert_eq!(classify("std", "std::fs::File::from_raw_fd"), None);
2819        assert_eq!(classify("std", "std::fs::File::into_raw_fd"), None);
2820        assert_eq!(classify("std", "std::fs::File::as_raw_handle"), None);
2821        assert_eq!(classify("std", "std::os::unix::net::UnixStream::from_raw_fd"), None);
2822        // `SocketAddr::from_pathname` builds an address struct, opens no socket — pure. (socket2 sweep.)
2823        assert_eq!(classify("std", "std::os::unix::net::SocketAddr::from_pathname"), None);
2824        assert_eq!(classify("tokio", "tokio::net::TcpStream::from_raw_fd"), None);
2825        assert_eq!(classify("tokio", "tokio::net::TcpStream::into_std"), None); // unwrap → std type, pure
2826        assert_eq!(classify("tokio", "tokio::fs::File::into_std"), None);
2827        // …but a REAL open/connect on the SAME types still fires the effect — the carve-out is leaf-precise.
2828        assert_eq!(classify("std", "std::net::TcpStream::connect"), Some("Net"));
2829        assert_eq!(classify("std", "std::fs::File::open"), Some("Fs"));
2830        assert_eq!(classify("std", "std::fs::read"), Some("Fs"));
2831        assert_eq!(classify("std", "std::os::unix::net::UnixStream::connect"), Some("Ipc"));
2832        assert_eq!(classify("tokio", "tokio::net::TcpStream::connect"), Some("Net"));
2833    }
2834
2835    #[test]
2836    fn command_head_refines_the_exec_cliff() {
2837        use super::classify_command_head as h;
2838        // unambiguous external tools classify by basename (spec §4 ⟨0.5⟩)
2839        assert_eq!(h("curl"), &["Net"]);
2840        assert_eq!(h("telnet"), &["Net"]);
2841        assert_eq!(h("sftp"), &["Net"]);
2842        assert_eq!(h("/usr/local/bin/psql"), &["Db"]); // basename match strips the path
2843        assert_eq!(h("mongo"), &["Db"]);
2844        assert_eq!(h("cqlsh"), &["Db"]);
2845        // a candor engine is Fs/Env — spec-SUPPLIED by §7 item 12, not curation
2846        assert_eq!(h("candor-scan"), &["Env", "Fs"]);
2847        assert_eq!(h("candor-run.sh"), &["Env", "Fs"]);
2848        // an unrecognised head adds nothing — the bare Exec cliff stands (never guess). `make`/`npm`
2849        // run the project's own code; `git`/`rsync` are multi-modal (local vs remote) — all keep the
2850        // cliff rather than fabricate an effect for the common case.
2851        assert_eq!(h("some-unknown-tool"), &[] as &[&str]);
2852        assert_eq!(h("make"), &[] as &[&str]);
2853        assert_eq!(h("npm"), &[] as &[&str]);
2854        assert_eq!(h("git"), &[] as &[&str]);
2855        assert_eq!(h("rsync"), &[] as &[&str]);
2856        // a builder MODIFIER (`.arg`/`.env`) names no program — its literal must NOT refine (a
2857        // whole-crate-Exec crate classifies every method; `.env("psql",..)` must not fabricate Db).
2858        assert!(is_cmd_builder_method("env") && is_cmd_builder_method("arg") && is_cmd_builder_method("current_dir"));
2859        assert!(!is_cmd_builder_method("new")); // Command::new NAMES the program
2860        assert!(!is_cmd_builder_method("cmd")); // duct::cmd NAMES the program
2861        // The gate that ADMITS a literal to classify_command_head is an ALLOWLIST of program-NAMING
2862        // methods, not the builder denylist. Inversion matters: a whole-crate-Exec crate (portable_pty)
2863        // classifies EVERY method as Exec, so a getter like `cmd.get_env("psql")` — absent from the
2864        // builder denylist — would have leaked "psql" to the head and FABRICATED Db. Only `new`/`cmd`
2865        // name a program, so only they may refine.
2866        assert!(is_cmd_naming_method("new") && is_cmd_naming_method("cmd"));
2867        assert!(!is_cmd_naming_method("get_env")); // a GETTER, not a namer — the leak this closes
2868        assert!(!is_cmd_naming_method("arg") && !is_cmd_naming_method("env") && !is_cmd_naming_method("current_dir"));
2869    }
2870
2871    #[test]
2872    fn net_establishing_allowlist() {
2873        // sweep [3]/[7]: the masking guard's establishing-verb allowlist — host-bearing connect/request
2874        // verbs establish (a runtime host there is invisible); USE-verbs on a connected socket do NOT.
2875        assert!(is_net_establishing("connect") && is_net_establishing("connect_timeout"));
2876        assert!(is_net_establishing("get") && is_net_establishing("post") && is_net_establishing("request"));
2877        assert!(is_net_establishing("send_to") && is_net_establishing("to_socket_addrs"));
2878        // use-verbs (host fixed at connect) must NOT be establishing — else `connect("h").write()` flags.
2879        assert!(!is_net_establishing("write") && !is_net_establishing("read") && !is_net_establishing("send"));
2880        assert!(!is_net_establishing("flush") && !is_net_establishing("recv") && !is_net_establishing("peek"));
2881    }
2882
2883    #[test]
2884    fn fs_path_arg_allowlist() {
2885        // The Fs masking guard's path-naming-fn allowlist — free fns / constructors take the path as a
2886        // string arg (a runtime path there is invisible to the gate). Stat methods (path on the receiver)
2887        // and handle ops carry no path arg and must NOT flag — but they're caught by the caller's
2888        // `!is_method` gate; the allowlist itself just enumerates the path-NAMING leaves.
2889        assert!(is_fs_path_arg("write") && is_fs_path_arg("read") && is_fs_path_arg("read_to_string"));
2890        assert!(is_fs_path_arg("open") && is_fs_path_arg("create") && is_fs_path_arg("create_new"));
2891        assert!(is_fs_path_arg("remove_file") && is_fs_path_arg("rename") && is_fs_path_arg("copy"));
2892        assert!(is_fs_path_arg("create_dir_all") && is_fs_path_arg("canonicalize") && is_fs_path_arg("metadata"));
2893        // handle ops / pure builders take NO path arg — never path-naming.
2894        assert!(!is_fs_path_arg("write_all") && !is_fs_path_arg("flush") && !is_fs_path_arg("read_exact"));
2895        assert!(!is_fs_path_arg("new") && !is_fs_path_arg("sync_all") && !is_fs_path_arg("set_len"));
2896    }
2897
2898    #[test]
2899    fn db_query_arg_allowlist() {
2900        // The Db masking guard's query-bearing-verb allowlist — these take the raw SQL as a string arg
2901        // (a runtime query there is invisible to the gate). Build-then-execute terminals and non-query
2902        // ops carry no SQL string and must NOT flag.
2903        assert!(is_db_query_arg("execute") && is_db_query_arg("query") && is_db_query_arg("query_one"));
2904        assert!(is_db_query_arg("prepare") && is_db_query_arg("batch_execute") && is_db_query_arg("execute_batch"));
2905        assert!(is_db_query_arg("query_row") && is_db_query_arg("query_map") && is_db_query_arg("exec"));
2906        // build-then-execute terminals (query built structurally, no SQL string) must NOT flag.
2907        assert!(!is_db_query_arg("fetch_all") && !is_db_query_arg("load") && !is_db_query_arg("first"));
2908        assert!(!is_db_query_arg("all") && !is_db_query_arg("one") && !is_db_query_arg("stream"));
2909        // connection / lifecycle ops take no SQL — must NOT flag.
2910        assert!(!is_db_query_arg("connect") && !is_db_query_arg("open") && !is_db_query_arg("begin"));
2911        assert!(!is_db_query_arg("commit") && !is_db_query_arg("ping") && !is_db_query_arg("get_conn"));
2912    }
2913}
2914
2915#[cfg(test)]
2916mod fs_kind_tests {
2917    use super::fs_kind;
2918
2919    /// SPEC §2 `fs`. Most of what these assert is what the classifier REFUSES to say: §2 requires the
2920    /// field be "omitted rather than guessed", because an empty or partial `fs` reads as a positive claim
2921    /// ("reads but never writes") — the §4 trust contract's forbidden direction.
2922    #[test]
2923    fn write_verbs() {
2924        for p in ["std::fs::write", "std::fs::create_dir_all", "std::fs::remove_file",
2925                  "File::create", "std::fs::set_permissions", "f::write_all"] {
2926            assert_eq!(fs_kind(p), &["write"], "{p} mutates the disk");
2927        }
2928    }
2929
2930    #[test]
2931    fn read_verbs() {
2932        for p in ["std::fs::read_to_string", "std::fs::read_dir", "std::fs::metadata",
2933                  "File::open", "std::fs::canonicalize", "f::read_to_end"] {
2934            assert_eq!(fs_kind(p), &["read"], "{p} observes without mutating");
2935        }
2936    }
2937
2938    /// A copy/rename reads the source AND writes the destination — one call, both kinds.
2939    #[test]
2940    fn two_locator_verbs_are_both() {
2941        for p in ["std::fs::copy", "std::fs::rename", "std::fs::hard_link"] {
2942            assert_eq!(fs_kind(p), &["read", "write"], "{p}");
2943        }
2944    }
2945
2946    /// THE LOAD-BEARING CASE. A verb that does not reveal direction must contribute NOTHING — not a
2947    /// default, not a guess. Anything here returning a kind would let a function claim "reads but never
2948    /// writes" on the strength of a verb that said neither.
2949    #[test]
2950    fn unrevealing_verbs_make_no_claim() {
2951        for p in ["std::fs::OpenOptions", "some_crate::do_thing", "std::fs::File", "f::seek"] {
2952            assert!(fs_kind(p).is_empty(), "{p} must not claim a direction it did not reveal");
2953        }
2954    }
2955
2956    /// `OpenOptions::open` is `File::open`'s leaf and NOT its verb: the direction lives in the builder
2957    /// chain (`.read(true)`/`.write(true)`), which `fs_kind` cannot see. A `write(true)` builder claiming
2958    /// `["read"]` is the forbidden direction, so the type wins over the leaf here. `File::open` — where
2959    /// `open` DOES mean read — is unaffected, which is the whole reason this is keyed on the type.
2960    #[test]
2961    fn openoptions_open_claims_no_direction_but_file_open_still_reads() {
2962        for p in ["std::fs::OpenOptions::open", "fs_err::OpenOptions::open",
2963                  "tokio::fs::OpenOptions::open"] {
2964            assert!(fs_kind(p).is_empty(),
2965                    "{p}'s direction was set by the builder chain, which this function cannot see");
2966        }
2967        assert_eq!(fs_kind("std::fs::File::open"), &["read"], "`File::open` is unambiguously a read");
2968    }
2969}