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
10/// The SURPRISE heuristic (the cold-repo hook) — SHARED so candor-scan's scan-time note and
11/// candor-query's `tour` verb can't drift. Generic over the effect element type.
12pub mod surface;
13
14/// Project-supplied rules, consulted only when the built-in `classify` returns None.
15pub fn classify_extra(
16 crate_name: &str,
17 path: &str,
18 extra: &[(&'static str, bool, String)],
19) -> Option<&'static str> {
20 for (eff, is_crate, prefix) in extra {
21 let hit = if *is_crate { crate_name.starts_with(prefix.as_str()) } else { path.starts_with(prefix.as_str()) };
22 if hit {
23 return Some(eff);
24 }
25 }
26 None
27}
28
29/// The exact third-party crates `classify` has effect rules for, and the crate-name
30/// PREFIXES it recognizes. This is the single source of truth for "what candor knows":
31/// it is emitted beside the JSON report (`<prefix>.calibrated.json`) so the Claude Code
32/// receipt's coverage check reads candor's real coverage instead of a hand-copied list.
33/// Keep in lockstep with `classify` below — the `db_crates_are_calibrated` and
34/// `calibrated_crates_are_live` tests (in this crate's `tests` module) enforce both directions.
35pub const CALIBRATED_CRATES: [&str; 79] = [
36 // network (aws_config resolves credentials over the network on `.load()`;
37 // git2 remote ops — fetch/push/connect — contact the network; async_net is smol's net layer;
38 // pnet is raw L2/L3 packet capture)
39 "reqwest", "isahc", "ureq", "curl", "aws_config", "git2", "tokio_tcp", "tokio_udp", "async_net",
40 "async_nats", "lapin", "lettre", "tungstenite", "elasticsearch", "tonic", "rdkafka", "pnet",
41 // directory traversal (ignore = gitignore-aware walker, powers ripgrep/fd; its walk executors are Fs)
42 // + filesystem watching (notify = inotify/FSEvents/kqueue wrapper; powers watchexec/cargo-watch)
43 "ignore", "notify",
44 // database (see DB_CRATES in classify)
45 "sqlx", "rusqlite", "postgres", "tokio_postgres", "diesel", "redis", "mongodb",
46 "mysql", "mysql_async", "sea_orm", "deadpool_postgres",
47 // filesystem (async_fs = smol; fs_err = std::fs wrapper; tempfile; glob) / entropy /
48 // subprocess (async_process = smol; duct) / env (dotenvy/dotenv) / clock (time) / log / clipboard
49 "memmap2", "fs_err", "async_fs", "tempfile", "glob",
50 "rand", "getrandom", "fastrand",
51 // entropy: the password-hashing tier (salt mints + bcrypt's internal salt) + the OsRng source
52 "argon2", "bcrypt", "scrypt", "pbkdf2", "password_hash", "rand_core",
53 "portable_pty", "async_process", "duct",
54 "dotenvy", "dotenv",
55 "chrono", "time", "tracing", "log", "arboard",
56 // compiler diagnostic emission (a dylint lint's output) — see the Log rules in classify
57 "rustc_lint", "rustc_errors",
58 // raw syscalls via FFI — the syscall-name table that lights up the FFI-thin tier (nix is routed
59 // through the same table by leaf name, so a consumer of nix is covered without nix's own source)
60 "libc", "nix", "rustix",
61 // coverage-differential additions (verb-keyed; see the per-crate rules near the end of classify):
62 // sync TLS core + native-tls variants (Net); env/dir resolution + argv + LS_COLORS (Env);
63 // sqlx-core execution terminals (Net/Db); directory walk + timestamp mutation + same-file (Fs);
64 // process-spawn helpers (Exec); signal handler + interactive-tty prompts (Ipc); env_logger (Log);
65 // jiff/backoff clock reads (Clock).
66 "rustls", "native_tls_crate", "tokio_native_tls",
67 "etcetera", "wild", "lscolors",
68 "sqlx_core", "walkdir", "filetime", "clircle",
69 "execute", "ctrlc", "clap", "jiff", "env_logger",
70 "dialoguer", "console", "terminal_colorsaurus", "backoff", "grep_cli",
71];
72
73pub const CALIBRATED_PREFIXES: [&str; 3] = ["aws_sdk_", "aws_smithy", "cap_"];
74
75/// Crates `classify` matches by PATH prefix rather than crate-name equality (their effectful modules
76/// are recognised, e.g. `tokio::net::`/`async_std::fs::`/`mio::net::`), so they're absent from
77/// `CALIBRATED_CRATES` (which the liveness test probes by crate name). The coverage check must still
78/// treat them as *covered* — otherwise it would mislabel the most common async crates as blind spots.
79pub const PATH_CALIBRATED_CRATES: [&str; 3] = ["tokio", "async_std", "mio"];
80
81/// Representative path tails (each appended to a crate name) that the `calibrated_crates_are_live`
82/// liveness test probes: at least one must match for every `CALIBRATED_CRATES` entry, else the entry is
83/// dead. Exported as ONE source of truth because the nightly lint crate (`src/lib.rs`) runs the SAME
84/// liveness test — when the two probe lists were duplicated they drifted, and a rule keyed on a
85/// distinctive tail (pnet `::datalink::channel`, ignore `::WalkBuilder::build_parallel`, notify
86/// `::RecommendedWatcher::new`) added to only one list silently broke the other crate's `cargo test`.
87pub const CALIBRATION_PROBE_TAILS: &[&str] = &[
88 "::X::send", "::X::execute", "::X::call", "::X::query", "::X::fetch_one", "::Remote::fetch",
89 "::datalink::channel", "::WalkBuilder::build_parallel", "::RecommendedWatcher::new",
90 "::X::connect", "::Utc::now", "::X::load", "::__private_api::log", "::tempfile", "::glob",
91 "::X::run", "::dotenv", "::random", "::emit", "::X::emit_span_lint", "::X::anything",
92 "::SaltString::generate", "::hash", "::OsRng::fill_bytes",
93 // verb-precise crates whose whole-crate rules were narrowed to the effectful surface (the pure
94 // accessors/ctors/data-types now return None), so the liveness probe must name an EFFECTFUL path:
95 "::Mmap::map", "::event", "::u32", "::Clipboard::get_text", "::spawn_command",
96 // coverage-differential crates (each needs ≥1 effectful tail; existing tails already cover
97 // native_tls_crate/tokio_native_tls/sqlx_core via ::X::connect, execute via ::X::execute, jiff via ::now):
98 "::read_tls", "::home_dir", "::args", "::from_env", "::IntoIter::next", "::set_file_mtime",
99 "::surely_conflicts_with", "::set_handler", "::get_matches", "::init", "::interact",
100 "::write_line", "::background_color", "::retry", "::build",
101];
102
103/// Database client crates whose execution verbs are I/O (see the DB branch in `classify`).
104/// Module-level so `db_crates_are_calibrated` can enforce `DB_CRATES ⊆ CALIBRATED_CRATES`.
105pub const DB_CRATES: [&str; 11] = [
106 "sqlx", "rusqlite", "postgres", "tokio_postgres", "diesel", "redis", "mongodb",
107 "mysql", "mysql_async", "sea_orm", "deadpool_postgres",
108];
109
110/// Pure file-descriptor *ownership-transfer* leaves. These ADOPT an already-open descriptor
111/// (`from_raw_fd`/`from_raw_socket`/`from_raw_handle`), EXTRACT/BORROW one
112/// (`into_raw_fd`/`into_raw_socket`/`into_raw_handle`, `as_raw_fd`/`as_raw_socket`/`as_raw_handle`),
113/// or UNWRAP an async wrapper back to its std type (`into_std`) — none of them issue a syscall or
114/// 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
115/// coarse std-type PREFIX rules (`std::net::TcpStream`/`std::fs::File`/`std::os::unix::net` → Net/Fs/Ipc)
116/// even though the descriptor was opened ELSEWHERE. The portable_pty/async_process Exec rule already
117/// exempts `from_raw_fd`; this generalises the same carve-out across the net/fs/ipc prefix rules.
118/// (Found by a real-world sweep of tokio: `TcpStream::into_std`, `*::from_raw_fd`, `*::as_raw_fd` all
119/// fabricated Net/Fs/Ipc.)
120const PURE_FD_TRANSFER: &[&str] = &[
121 "from_raw_fd", "from_raw_socket", "from_raw_handle",
122 "into_raw_fd", "into_raw_socket", "into_raw_handle",
123 "as_raw_fd", "as_raw_socket", "as_raw_handle",
124 "into_std",
125 // `SocketAddr::from_pathname` (std/async-std unix net) builds an address STRUCT from a path —
126 // it opens no socket. The `std::os::unix::net` prefix rule below would otherwise fabricate Ipc
127 // on it. (Found sweeping socket2: `SockAddr::as_unix` → `from_pathname` reported Ipc.)
128 "from_pathname",
129];
130
131/// Classify a resolved callee by the crate it belongs to and its full path.
132pub fn classify(crate_name: &str, path: &str) -> Option<&'static str> {
133 // Pure fd ownership-transfer/extraction leaves are never an effect, regardless of which std I/O
134 // type they hang off — exempt them BEFORE the coarse prefix rules can fabricate Net/Fs/Ipc.
135 if PURE_FD_TRANSFER.contains(&path.rsplit("::").next().unwrap_or(path)) {
136 return None;
137 }
138 if crate_name.starts_with("aws_sdk_") || crate_name.starts_with("aws_smithy") {
139 // Only request dispatch is network I/O; builder setters/accessors are pure.
140 if path.ends_with("::send") || path.ends_with("::send_with") {
141 return Some("Net");
142 }
143 return None;
144 }
145 // aws-config resolves credentials/region on `.load()` — it reaches the IMDS metadata
146 // endpoint / STS over the network (and reads ~/.aws + env). Builders (`defaults()`,
147 // `SdkConfig::builder()`, `BehaviorVersion::latest()`) are pure; the `load` is the I/O.
148 // (Found hardening on a real app, ebman: `builder.load().await` was classified pure.)
149 if crate_name == "aws_config" {
150 if path.ends_with("::load") || path.ends_with("::load_defaults") {
151 return Some("Net");
152 }
153 return None;
154 }
155 // git2 (libgit2 FFI): remote operations contact the network; everything else is local
156 // to the .git directory. Match the remote verbs precisely — NOT bare `::clone`, which is
157 // the `Clone`-trait dup of a `Remote` handle (pure), not `Repository::clone`. (Found
158 // hardening on gitui: `remote.fetch`/`remote.push` were classified network-free — a git
159 // client reporting it makes no network calls.)
160 if crate_name == "git2" {
161 if path.ends_with("::fetch")
162 || path.ends_with("::push")
163 || path.ends_with("::download")
164 || path.ends_with("::connect")
165 || path.ends_with("::connect_auth")
166 || path.ends_with("::ls")
167 || path.ends_with("::upload")
168 {
169 return Some("Net");
170 }
171 return None;
172 }
173 // libc — raw syscalls via FFI. The FFI-thin tier (nix, and the syscall layer beneath rusqlite/git2)
174 // is invisible to a name classifier unless we model libc directly: a 35-crate calibration
175 // (eval/calibration) showed nix reporting ZERO library effects because every wrapper bottoms out in
176 // an unrecognised `libc::*` call. Classify by syscall name, but ONLY the UNAMBIGUOUS ones — the
177 // socket family is Net, path/dir syscalls are Fs, spawn/exec/wait is Exec, SysV/pipe IPC is Ipc,
178 // env/clock/entropy each their own. We deliberately SKIP the generic file-descriptor ops
179 // (read/write/close/lseek/dup/fcntl/ioctl/poll/select/epoll*/mmap): they operate on ANY fd — file,
180 // socket, or pipe — so a fixed label would mis-categorise as often as it helps. An honest
181 // no-classify (under-report) beats emitting the WRONG effect. Pure conversions (htons/inet_pton/
182 // gmtime) are also skipped.
183 //
184 // `nix` (the idiomatic SAFE libc wrapper, in ~every Rust systems/CLI crate) is routed through the
185 // SAME table: its functions keep the syscall leaf name (`nix::fcntl::open`, `nix::sys::socket::connect`,
186 // `nix::unistd::execvp`). Without this, a CONSUMER of nix analysed without nix's own source (the
187 // stable scanner, single-crate) sees `nix::*` cross-crate and under-reports — serialport-rs opens its
188 // device via `nix::fcntl::open` and reported ZERO Fs. The nightly lint reaches `libc::*` THROUGH nix's
189 // body; this gives the scanner the same coverage directly. (Found sweeping serialport-rs.)
190 // `rustix` is the same shape as nix but does RAW syscalls (no libc underneath), so its functions MUST
191 // be classified directly. Its leaf names are the syscall names too (`rustix::time::clock_settime`,
192 // `rustix::fs::mkfifoat`/`symlink`/`stat`, `rustix::net::connect`) — route it through the same table.
193 // The rustix-specific `*at`/variant leaves it doesn't share with libc just under-report (the safe
194 // direction). VALIDATED, not speculative: coreutils' `date` reads/sets the clock via
195 // `rustix::time::clock_getres`/`clock_settime` and reported Clock=0; the file I/O that goes through
196 // std::fs was already correct, which is why only the rustix-only effects (Clock/Ipc) were missing.
197 if crate_name == "libc" || crate_name == "nix" || crate_name == "rustix" {
198 let f = path.rsplit("::").next().unwrap_or(path);
199 // path / directory / metadata syscalls (incl. *64 and *at variants)
200 const FS: &[&str] = &[
201 "open", "open64", "openat", "openat2", "creat", "creat64", "stat", "stat64", "lstat",
202 "lstat64", "fstatat", "fstatat64", "newfstatat", "statx", "access", "faccessat",
203 "faccessat2", "mkdir", "mkdirat", "rmdir", "unlink", "unlinkat", "rename", "renameat",
204 "renameat2", "link", "linkat", "symlink", "symlinkat", "readlink", "readlinkat", "chmod",
205 "fchmodat", "chown", "lchown", "fchownat", "truncate", "truncate64", "ftruncate",
206 "ftruncate64", "opendir", "fdopendir", "readdir", "readdir64", "readdir_r", "closedir",
207 "rewinddir", "seekdir", "telldir", "scandir", "mkstemp", "mkstemps", "mkostemp", "mkdtemp",
208 "mknod", "mknodat", "chdir", "fchdir", "getcwd", "get_current_dir_name", "chroot",
209 "pivot_root", "statfs", "statfs64", "fstatfs", "fstatfs64", "statvfs", "fstatvfs", "mount",
210 "umount", "umount2", "fsync", "fdatasync", "sync", "syncfs", "sync_file_range", "fallocate",
211 "posix_fallocate", "posix_fadvise", "sendfile", "sendfile64", "copy_file_range", "flock",
212 "getdents", "getdents64", "utime", "utimes", "lutimes", "futimens", "utimensat", "futimesat",
213 "realpath",
214 ];
215 // socket family — these operate only on sockets, so Net is unambiguous (AF_UNIX domain isn't
216 // visible at the call, so a Unix socket reads as Net rather than Ipc; acceptable over-general).
217 const NET: &[&str] = &[
218 "socket", "setsockopt", "getsockopt", "bind", "listen", "accept", "accept4", "connect",
219 "shutdown", "send", "sendto", "sendmsg", "sendmmsg", "recv", "recvfrom", "recvmsg",
220 "recvmmsg", "getpeername", "getsockname", "getaddrinfo", "freeaddrinfo", "getnameinfo",
221 ];
222 // process creation / replacement / reaping
223 const EXEC: &[&str] = &[
224 "fork", "vfork", "clone", "clone3", "execl", "execlp", "execle", "execv", "execvp",
225 "execvpe", "execve", "execveat", "fexecve", "posix_spawn", "posix_spawnp", "system",
226 "popen", "pclose", "wait", "waitpid", "wait3", "wait4", "waitid",
227 ];
228 // pipes / FIFOs / SysV + POSIX message queues, semaphores, shared memory; socketpair (AF_UNIX)
229 const IPC: &[&str] = &[
230 "pipe", "pipe2", "mkfifo", "mkfifoat", "socketpair", "msgget", "msgsnd", "msgrcv", "msgctl",
231 "semget", "semop", "semtimedop", "semctl", "shmget", "shmat", "shmdt", "shmctl", "mq_open",
232 "mq_send", "mq_receive", "mq_timedsend", "mq_timedreceive", "mq_close", "mq_unlink",
233 ];
234 const ENV: &[&str] = &["getenv", "secure_getenv", "setenv", "putenv", "unsetenv", "clearenv"];
235 const CLOCK: &[&str] = &[
236 "time", "gettimeofday", "clock_gettime", "clock_getres", "nanosleep", "clock_nanosleep",
237 // SETTING the system clock is a clock effect too (was unclassified — found on coreutils `date`,
238 // which sets it via `clock_settime`).
239 "clock_settime", "settimeofday", "stime", "adjtime", "adjtimex", "clock_adjtime",
240 ];
241 const RAND: &[&str] = &["getrandom", "getentropy", "arc4random", "arc4random_buf", "arc4random_uniform"];
242 if FS.contains(&f) {
243 return Some("Fs");
244 }
245 if NET.contains(&f) {
246 return Some("Net");
247 }
248 if EXEC.contains(&f) {
249 return Some("Exec");
250 }
251 if IPC.contains(&f) {
252 return Some("Ipc");
253 }
254 if ENV.contains(&f) {
255 return Some("Env");
256 }
257 if CLOCK.contains(&f) {
258 return Some("Clock");
259 }
260 if RAND.contains(&f) {
261 return Some("Rand");
262 }
263 return None;
264 }
265 // C-library FFI bindings: libsqlite3 (under rusqlite) and libgit2 (under git2). Like the libc tier,
266 // these crates are thin Rust over a C library, so their real I/O is invisible until the C entry
267 // points are named. Match by the DISTINCTIVE C function name (`sqlite3_*` / `git_*`) via the call's
268 // LEAF — independent of the binding crate's alias: rusqlite calls `ffi::sqlite3_step`, git2 calls
269 // `raw::git_remote_fetch`, and the nightly lint resolves the same to `libsqlite3_sys`/`libgit2_sys`;
270 // all spellings share the leaf. Only the I/O-performing entry points are listed — the in-memory
271 // accessors (`sqlite3_bind_*`/`sqlite3_column_*`, `git_*_oid`/strarray/options builders) stay pure,
272 // so a non-listed `sqlite3_`/`git_` leaf returns None (under-report, never a wrong effect). Calibrated
273 // + validated against rusqlite 0.39 / git2 0.20 source (eval/calibration).
274 {
275 let leaf = path.rsplit("::").next().unwrap_or(path);
276 if let Some(rest) = leaf.strip_prefix("sqlite3_") {
277 let _ = rest;
278 // SQLite C API operations that touch the database (open/exec/step/prepare/backup/blob/wal).
279 const DB: &[&str] = &[
280 "sqlite3_open", "sqlite3_open_v2", "sqlite3_open16", "sqlite3_close", "sqlite3_close_v2",
281 "sqlite3_exec", "sqlite3_step", "sqlite3_prepare", "sqlite3_prepare_v2",
282 "sqlite3_prepare_v3", "sqlite3_prepare16", "sqlite3_prepare16_v2", "sqlite3_prepare16_v3",
283 "sqlite3_get_table", "sqlite3_backup_init", "sqlite3_backup_step", "sqlite3_backup_finish",
284 "sqlite3_blob_open", "sqlite3_blob_read", "sqlite3_blob_write", "sqlite3_blob_reopen",
285 "sqlite3_load_extension", "sqlite3_wal_checkpoint", "sqlite3_wal_checkpoint_v2",
286 ];
287 return DB.contains(&leaf).then_some("Db");
288 }
289 if leaf.starts_with("git_") {
290 // libgit2: remote/transport operations contact the network … (incl. submodule clone/update,
291 // which `git_clone`/fetch the subrepo over its remote — `allow_fetch` defaults on; an A/B on
292 // git2 0.20 caught `Submodule::update`/`clone` reporting no `Net`).
293 const NET: &[&str] = &[
294 "git_clone", "git_remote_connect", "git_remote_connect_ext", "git_remote_fetch",
295 "git_remote_download", "git_remote_upload", "git_remote_push", "git_remote_ls",
296 "git_submodule_clone", "git_submodule_update",
297 ];
298 // … and repository/index/odb/checkout/ref/config operations touch the on-disk .git store.
299 const FS: &[&str] = &[
300 "git_repository_open", "git_repository_open_ext", "git_repository_open_bare",
301 "git_repository_init", "git_repository_init_ext", "git_repository_discover",
302 "git_checkout_tree", "git_checkout_head", "git_checkout_index", "git_index_read",
303 "git_index_write", "git_index_write_tree", "git_index_write_tree_to",
304 "git_index_add_bypath", "git_index_add_all", "git_odb_open", "git_odb_read",
305 "git_odb_write", "git_odb_open_wstream", "git_odb_open_rstream",
306 "git_blob_create_fromdisk", "git_blob_create_fromworkdir", "git_blob_create_from_disk",
307 "git_blob_create_from_workdir", "git_blob_create_from_stream", "git_commit_create",
308 "git_commit_create_v", "git_reference_create", "git_reference_set_target",
309 "git_reference_delete", "git_config_open_default", "git_config_open_ondisk",
310 "git_config_add_file_ondisk", "git_tag_create", "git_treebuilder_write",
311 "git_packbuilder_write",
312 ];
313 if NET.contains(&leaf) {
314 return Some("Net");
315 }
316 if FS.contains(&leaf) {
317 return Some("Fs");
318 }
319 return None;
320 }
321 if leaf.starts_with("curl_") {
322 // libcurl (under the `curl` crate, called `curl_sys::curl_*`). Only the entry points that
323 // PERFORM network I/O: the blocking transfer (`curl_easy_perform`), raw socket send/recv,
324 // the HTTP/2 keepalive PING (`upkeep`), and the multi-interface transfer pumps. The large
325 // pure surface (setopt/init/cleanup/reset/getinfo/escape/multi_add_handle/fdset/info_read)
326 // stays unclassified, as do `curl_multi_wait`/`poll` (readiness WAIT on sockets, no payload —
327 // the loop's `perform` is the tagged boundary, per the I/O-boundary principle). An A/B on
328 // curl 0.4 caught the whole crate reporting ZERO Net (`Easy::perform` read as pure).
329 const NET: &[&str] = &[
330 "curl_easy_perform", "curl_easy_send", "curl_easy_recv", "curl_easy_upkeep",
331 "curl_multi_perform", "curl_multi_socket_action",
332 ];
333 return NET.contains(&leaf).then_some("Net");
334 }
335 if let Some(op) = leaf.strip_prefix("SSL_") {
336 // OpenSSL (libssl, under the `openssl`/`native-tls` crates, called `ffi::SSL_*`). The TLS
337 // handshake and record I/O run over the peer socket -> Net. Unlike libc read/write, an SSL_*
338 // op is ~always over a network BIO (the rare memory-BIO/sans-IO case is the honest exception
339 // we accept). The crypto surface (EVP_*/SHA*/AES*) and pure setup (SSL_CTX_new/SSL_set_fd) are
340 // NOT here; `BIO_*` is skipped (a BIO may be memory or socket). Validated vs openssl 0.9 source.
341 const SSL_NET: &[&str] = &[
342 "connect", "accept", "do_handshake", "read", "read_ex", "write", "write_ex", "peek",
343 "peek_ex", "shutdown",
344 ];
345 return SSL_NET.contains(&op).then_some("Net");
346 }
347 }
348 // HTTP clients use the same builder pattern as the AWS SDK: only the dispatch is
349 // I/O. (Found by the eval: ebman's reqwest calls to the Anthropic API + webhooks
350 // were silently classified network-free because reqwest wasn't recognized.)
351 if crate_name == "reqwest" || crate_name == "isahc" {
352 // The builder chain is pure; the dispatch (`::send`/`::execute`) is the I/O. PLUS the one-shot
353 // CONVENIENCE functions `reqwest::get` / `reqwest::blocking::get` / `isahc::get`, which send
354 // immediately — they're not the `Client::get` builder (a different path, `reqwest::Client::get`),
355 // so an exact match avoids false-positiving the builder. (Found running on `xh`: a one-shot
356 // `reqwest::get(url)` was classified network-free.)
357 if path.ends_with("::send")
358 || path.ends_with("::execute")
359 || path == "reqwest::get"
360 || path == "reqwest::blocking::get"
361 || path == "isahc::get"
362 {
363 return Some("Net");
364 }
365 return None;
366 }
367 if crate_name == "ureq" && path.ends_with("::call") {
368 return Some("Net");
369 }
370 // The `curl` crate (libcurl's safe binding — cargo's own HTTP client): the dispatch verbs are
371 // `perform` (Easy/Easy2/Transfer/Multi), raw-socket `send`/`recv`, the keepalive `upkeep`, and the
372 // multi-interface `action` (socket_action). The big setopt-style builder surface stays pure.
373 // `Multi::timeout` is deliberately NOT matched: `Easy::timeout` is a pure CURLOPT_TIMEOUT setter
374 // sharing the leaf — an under-report on the rare event-loop kick beats mis-tagging every consumer
375 // that sets a timeout. (Consumer-side companion to the curl_* FFI tier, same A/B finding.)
376 if crate_name == "curl"
377 && (path.ends_with("::perform")
378 || path.ends_with("::send")
379 || path.ends_with("::recv")
380 || path.ends_with("::upkeep")
381 || path.ends_with("::action"))
382 {
383 return Some("Net");
384 }
385 // The modern async-HTTP / TLS / QUIC / DNS stack — the LAYER reqwest/ureq/isahc build on, and that
386 // crates use DIRECTLY. Found by the independent-method differential on `oha` (2026-06-17): candor
387 // honestly DISCLOSED these as blind but never CLASSIFIED them, leaving real Net reaches uncovered.
388 // Verb-keyed (the pure type/builder/codec surface stays None) and CRATE-GATED, so generic verbs
389 // (request/connect/get/read/write/accept) never fabricate across unrelated crates. Same precision
390 // discipline as the reqwest/curl rules above; complements the scan_builder_entry_effect entries.
391 match crate_name {
392 // hyper 1.x client connection I/O (the builder/Body/Request types stay pure).
393 "hyper" if path.ends_with("::send_request") || path.ends_with("::handshake") => return Some("Net"),
394 // hyper-util's pooled legacy Client + its TCP connectors.
395 "hyper_util" if path.ends_with("::request") || path.ends_with("::connect") => return Some("Net"),
396 // hickory (trust-dns) resolver — issues DNS queries over the network.
397 "hickory_resolver"
398 if path.ends_with("::lookup_ip") || path.ends_with("::lookup") || path.ends_with("_lookup")
399 || path.ends_with("::resolve") => return Some("Net"),
400 // HTTP/3 over QUIC.
401 "h3" if path.ends_with("::send_request") || path.ends_with("::recv_data")
402 || path.ends_with("::recv_response") || path.ends_with("::send_data") => return Some("Net"),
403 // QUIC transport (UDP socket send/recv): connection setup, datagrams, AND the stream byte I/O
404 // (`RecvStream::read*` / `SendStream::write*` / `finish`). Opening a stream is caught above, but a
405 // fn that only HOLDS a stream and reads/writes it would otherwise read silent-pure (review: a Net
406 // under-report). Crate-gated to quinn, where these verbs are unambiguously the socket I/O.
407 "quinn" if path.ends_with("::connect") || path.ends_with("::accept") || path.ends_with("::open_bi")
408 || path.ends_with("::open_uni") || path.ends_with("::accept_bi") || path.ends_with("::accept_uni")
409 || path.ends_with("::send_datagram") || path.ends_with("::read_datagram")
410 || path.ends_with("::read") || path.ends_with("::read_chunk") || path.ends_with("::read_chunks")
411 || path.ends_with("::read_to_end") || path.ends_with("::write") || path.ends_with("::write_all")
412 || path.ends_with("::write_chunk") || path.ends_with("::write_chunks")
413 || path.ends_with("::finish") => return Some("Net"),
414 // TLS-over-TCP stream adapters — the actual socket handshake/I/O (the config/cert types stay pure).
415 "tokio_rustls" | "native_tls"
416 if path.ends_with("::connect") || path.ends_with("::accept") || path.ends_with("::handshake") =>
417 return Some("Net"),
418 // AF_VSOCK host<->guest sockets — inter-process / VM comms.
419 "tokio_vsock" if path.ends_with("::connect") || path.ends_with("::bind") || path.ends_with("::accept") =>
420 return Some("Ipc"),
421 // Loads the OS trust store from disk (cert files / keychain).
422 "rustls_native_certs" if path.ends_with("::load_native_certs") => return Some("Fs"),
423 // `rlimit` reads/mutates the process's kernel resource limits — the closest bucket is Env (host/
424 // process config); no dedicated process-state bucket exists, so getrlimit (read) and setrlimit
425 // (mutate) share it. NOTE: `num_cpus::get`/`get_physical` are deliberately NOT modeled — asking the
426 // OS for the CPU count is a near-pure topology query, and std's equivalent `thread::
427 // available_parallelism` classifies pure; modeling it as Env would spray Env over every thread-pool
428 // constructor (review: a high-noise over-report) for no capability a reviewer cares about.
429 "rlimit" if path.ends_with("::getrlimit") || path.ends_with("::setrlimit")
430 || path.ends_with("::increase_nofile_limit") => return Some("Env"),
431 // rustls — the SYNC TLS core (tokio_rustls/native_tls above are the async/system adapters). The
432 // record-layer I/O is `read_tls`/`write_tls` (pull/push raw bytes through a held `io::Read`/`Write`)
433 // and `complete_io` (loops them until the handshake/buffers drain). The config/cert/builder types
434 // (`ClientConfig`/`ServerConfig`/`ConfigBuilder`) are PURE. `process_new_packets` is deliberately
435 // EXCLUDED — it only decrypts ALREADY-buffered bytes (no socket touch; docs say call it AFTER
436 // read_tls), so flagging it would over-report Net on the pure decrypt step.
437 "rustls" if path.ends_with("::read_tls") || path.ends_with("::write_tls")
438 || path.ends_with("::complete_io") => return Some("Net"),
439 // native-tls under its alternate crate name + the tokio async wrapper (the `native_tls` arm above
440 // is the common name). The TLS handshake over a TcpStream is Net; the builder/cert types are pure.
441 "native_tls_crate" | "tokio_native_tls"
442 if path.ends_with("::connect") || path.ends_with("::accept")
443 || path.ends_with("::handshake") => return Some("Net"),
444 _ => {}
445 }
446 // Message-queue clients fully encapsulate the socket (the underlying tokio::net lives
447 // inside the crate, unseen), so a user's connect/publish/consume calls ARE the I/O
448 // boundary — to a remote broker, hence Net. Match the broker round-trip verbs (snake_case
449 // methods); the CamelCase option/property builders stay pure. (Found hardening on consumer
450 // apps: lapin `basic_publish`/`queue_declare` and async-nats `publish`/`subscribe` were
451 // classified pure — a message-queue client reporting no I/O.)
452 if crate_name == "async_nats" {
453 if path.ends_with("::connect")
454 || path.contains("::publish")
455 || path.ends_with("::subscribe")
456 || path.ends_with("::queue_subscribe")
457 || path.contains("::request")
458 || path.ends_with("::flush")
459 {
460 return Some("Net");
461 }
462 return None;
463 }
464 if crate_name == "lapin" {
465 if path.ends_with("::connect")
466 || path.ends_with("::create_channel")
467 || path.contains("::basic_")
468 || path.contains("::queue_")
469 || path.contains("::exchange_")
470 || path.contains("::tx_")
471 || path.ends_with("::confirm_select")
472 || path.ends_with("::close")
473 {
474 return Some("Net");
475 }
476 return None;
477 }
478 // SMTP email — lettre's `Transport::send` is the network dispatch; Message building is
479 // pure. (Found hardening on a lettre consumer: `mailer.send(&email)` classified pure.)
480 if crate_name == "lettre" {
481 if path.ends_with("::send") || path.ends_with("::send_raw") {
482 return Some("Net");
483 }
484 return None;
485 }
486 // WebSockets — tungstenite (the modern successor to the old `websocket` crate). connect
487 // and the socket read/write/send are network; Message constructors are pure. (Found on a
488 // tungstenite consumer: connect + send + read classified pure.)
489 if crate_name == "tungstenite" {
490 if path.ends_with("::connect")
491 || path.ends_with("::read")
492 || path.ends_with("::write")
493 || path.ends_with("::send")
494 || path.ends_with("::close")
495 || path.ends_with("::flush")
496 || path.ends_with("::read_message")
497 || path.ends_with("::write_message")
498 {
499 return Some("Net");
500 }
501 return None;
502 }
503 // elasticsearch: request builders are pure; only the `.send()` dispatch is HTTP I/O
504 // (same shape as reqwest / the AWS SDK). (Found on an elasticsearch consumer.)
505 if crate_name == "elasticsearch" && path.ends_with("::send") {
506 return Some("Net");
507 }
508 // gRPC — tonic. The transport connect and the Grpc client RPC dispatch are network;
509 // codecs and request/response wrappers are pure. (connect repro-confirmed on a consumer;
510 // the unary/streaming RPC verbs are from the tonic::client::Grpc API.)
511 if crate_name == "tonic" {
512 if path.ends_with("::connect")
513 || path.ends_with("::unary")
514 || path.ends_with("::server_streaming")
515 || path.ends_with("::client_streaming")
516 || path.ends_with("::streaming")
517 {
518 return Some("Net");
519 }
520 return None;
521 }
522 // Kafka — rdkafka (FFI to librdkafka). Producer send + consumer poll/recv/subscribe/
523 // commit are network round-trips to the brokers. (API-calibrated + unit-tested; a real
524 // repro needs librdkafka/cmake, deferred.)
525 if crate_name == "rdkafka" {
526 if path.ends_with("::send")
527 || path.ends_with("::send_result")
528 || path.ends_with("::recv")
529 || path.ends_with("::poll")
530 || path.ends_with("::subscribe")
531 || path.ends_with("::commit")
532 || path.ends_with("::commit_message")
533 || path.ends_with("::commit_consumer_state")
534 || path.ends_with("::store_offset")
535 || path.ends_with("::seek")
536 || path.ends_with("::fetch_metadata")
537 || path.ends_with("::fetch_watermarks")
538 || path.ends_with("::flush")
539 {
540 return Some("Net");
541 }
542 return None;
543 }
544 // cap-std: capability-oriented std. I/O goes *through* a held capability handle
545 // (Dir/Pool/Clock/...), so these calls ARE the effect. Recognising them means a
546 // cap-std project's real I/O is detected and matches the capability it declared
547 // (via `declared_caps`/`capstd_cap`) — conformance against unforgeable capabilities.
548 if crate_name.starts_with("cap_") {
549 if path.contains("::net::Unix") || path.contains("::os::") {
550 return Some("Ipc");
551 }
552 if path.contains("::net") {
553 return Some("Net");
554 }
555 if path.contains("::time") {
556 return Some("Clock");
557 }
558 if path.contains("::fs") || crate_name == "cap_tempfile" || crate_name == "cap_directories" {
559 return Some("Fs");
560 }
561 return None;
562 }
563 // Local IPC (Unix-domain sockets) is I/O but not *network* — keep it distinct so
564 // CANDOR_NO_AMBIENT and audits don't conflate it with internet access. async-std puts its
565 // Unix sockets under `os::unix::net` (mirroring std); async-net (smol's net layer) under
566 // `unix`.
567 if path.starts_with("tokio::net::Unix")
568 || path.starts_with("std::os::unix::net")
569 || path.starts_with("async_std::os::unix::net")
570 || path.starts_with("async_net::unix")
571 {
572 return Some("Ipc");
573 }
574 // Raw packet capture / raw sockets — libpnet (the dominant low-level networking crate; powers
575 // bandwhich, sniffers, custom-protocol tools). `datalink::channel` opens an L2 socket and
576 // `transport::transport_channel` an L3/L4 raw socket — both ARE network I/O. Packet construction
577 // (pnet_packet / pnet_base, MacAddr, Ethernet frames…) is pure and stays unclassified. The actual
578 // frame read/write happens via methods on the returned Sender/Receiver (trait-object dispatch the
579 // syntactic backend can't resolve), so the channel-open call is the precise Net boundary. (Found
580 // scanning bandwhich — a packet sniffer — which reported Net 0.)
581 if crate_name == "pnet" || crate_name == "pnet_datalink" || crate_name == "pnet_transport" {
582 if path.ends_with("::channel") || path.ends_with("::transport_channel") {
583 return Some("Net");
584 }
585 return None;
586 }
587 // Directory traversal — `ignore` (BurntSushi's gitignore-aware walker; powers ripgrep, fd). The walk
588 // EXECUTORS read the directory tree from disk = Fs. Type-precise on purpose: the configuration builders
589 // (`OverrideBuilder::build`, `GitignoreBuilder::build`, the `WalkBuilder` setters) and `DirEntry`
590 // accessors are PURE — only `WalkBuilder::build`/`build_parallel` (which kick off the walk) and
591 // `WalkParallel::run` (which drives it) touch the filesystem. A bare `build` would wrongly flag the
592 // config builders. (Found scanning fd — a file finder — which reported Fs 2: its own `fs::read_dir`
593 // was caught, but the `ignore`-based traversal that IS fd was invisible cross-crate.)
594 if crate_name == "ignore" {
595 if path == "ignore::WalkBuilder::build"
596 || path == "ignore::WalkBuilder::build_parallel"
597 || path.ends_with("::WalkParallel::run")
598 // `add_ignore(path)` LOOKS like a config setter but reads that ignore file from disk at call
599 // time (it returns the read error) — unlike the pure `add_custom_ignore_filename(name)` which
600 // only stores a filename string. The lone Fs-touching builder method in the otherwise-pure setter
601 // surface, so it was silently pure under the covered-crate floor.
602 || path == "ignore::WalkBuilder::add_ignore"
603 {
604 return Some("Fs");
605 }
606 return None;
607 }
608 // Filesystem watching — `notify` (the de-facto fs-watch crate: watchexec, cargo-watch, mdbook). A
609 // watcher opens an OS notification handle (inotify / FSEvents / kqueue / ReadDirectoryChanges) and
610 // registers paths — observing filesystem state changes = Fs. The lifecycle boundary: any
611 // `*Watcher::new` constructor (RecommendedWatcher/PollWatcher/INotifyWatcher/FsEventWatcher/…), the
612 // `recommended_watcher` convenience fn, and the `watch`/`unwatch` registration verbs. `Config`/`Event`/
613 // `EventKind` data types stay pure. (Found scanning watchexec: its watcher-`create` read Fs 0.)
614 if crate_name == "notify" {
615 if path.ends_with("Watcher::new")
616 || path.ends_with("::recommended_watcher")
617 || path.ends_with("::watch")
618 || path.ends_with("::unwatch")
619 {
620 return Some("Fs");
621 }
622 return None;
623 }
624 // std DNS resolution — `("host", 80).to_socket_addrs()` / `std::net::lookup_host("host")` perform a
625 // real getaddrinfo query (Net), but the classify table covered only the socket I/O *types*, so they
626 // floored silently (sweep [37]; the syntactic engine modelled DNS only at the libc layer).
627 if path.ends_with("::to_socket_addrs")
628 || path == "std::net::lookup_host"
629 || path.ends_with("ToSocketAddrs::to_socket_addrs")
630 {
631 return Some("Net");
632 }
633 // Raw sockets. Match the I/O *types* only — `std::net` also holds pure data types
634 // (SocketAddr, IpAddr, …) whose construction must NOT be flagged.
635 if path.starts_with("std::net::TcpStream")
636 || path.starts_with("std::net::TcpListener")
637 || path.starts_with("std::net::UdpSocket")
638 || path.starts_with("tokio::net::")
639 {
640 // …but the PURE accessors read back local/option state — no network I/O — so the whole-type Net
641 // rule fabricated Net on them (sweep [24], the precision failure; mirrors the arboard/memmap2 accessor
642 // carve-outs). local_addr/peer_addr return bound/connected addresses; nodelay/ttl/take_error read
643 // socket options/state. Every genuine verb (connect/read/write/send/recv/accept) stays Net.
644 if path.ends_with("::local_addr")
645 || path.ends_with("::peer_addr")
646 || path.ends_with("::nodelay")
647 || path.ends_with("::ttl")
648 || path.ends_with("::take_error")
649 {
650 return None;
651 }
652 return Some("Net");
653 }
654 // Legacy tokio 0.1 socket crates — `tokio_tcp`/`tokio_udp` are *entirely* networking
655 // (no pure types to over-flag), so the whole crate is Net. (Found hardening on websocat,
656 // which is still on tokio 0.1: its `tokio_tcp::TcpStream::connect` was classified
657 // network-free — a network tool confidently reporting 0 Net.)
658 if matches!(crate_name, "tokio_tcp" | "tokio_udp") {
659 return Some("Net");
660 }
661 // The other async runtimes mirror tokio's module layout, and their `net` modules hold only
662 // socket I/O types (the pure `SocketAddr`/`IpAddr` are re-exports that resolve to `std::net`,
663 // so they're excluded by def-path). `mio` is the low-level non-blocking-socket layer under
664 // tokio/others; `async_net` is smol's net crate. Closes the async-std/smol/mio gap the
665 // tokio_tcp note flagged. (Calibrated by module structure — these crates ARE networking — not
666 // a live repro; the TCP/UDP types are defined in-crate so the def-path prefix is exact.)
667 if path.starts_with("async_std::net::")
668 || path.starts_with("mio::net::")
669 || crate_name == "async_net"
670 {
671 return Some("Net");
672 }
673 // Database clients. Like the AWS/HTTP builders, only the execution verbs are I/O;
674 // query *construction* is pure. Best-effort across crates (tune via CANDOR_CONFIG).
675 // Note: bare `::query` is deliberately omitted — it executes in postgres/rusqlite but
676 // only *builds* in sqlx, so including it would false-positive sqlx's `query()` builder.
677 if DB_CRATES.contains(&crate_name) {
678 // Postgres / SQLite-family clients: `query`/`batch_execute`/`prepare`/etc. ARE the
679 // execution (round-trips to the server). sqlx is the outlier where bare `query()`
680 // only BUILDS — it keeps the narrow set below. (Found by running on a real
681 // tokio-postgres app, pgman: candor had reported only 4 of ~20 DB call sites.)
682 if matches!(crate_name, "postgres" | "tokio_postgres" | "deadpool_postgres" | "rusqlite") {
683 const PG: [&str; 19] = [
684 "::query", "::query_one", "::query_opt", "::query_raw", "::execute",
685 "::batch_execute", "::simple_query", "::prepare", "::prepare_typed",
686 "::copy_in", "::copy_out", "::transaction", "::connect",
687 // rusqlite's dialect of the same verbs (a verb-probe found the CANONICAL rusqlite
688 // consumer API classifying pure): `query_row` is the one-row read, `query_map`/
689 // `query_and_then` the many-row reads, `execute_batch` is rusqlite's name for
690 // batch_execute, `prepare_cached` round-trips like prepare. `query_typed` is
691 // tokio_postgres 0.7.10+.
692 "::query_row", "::query_map", "::query_and_then", "::execute_batch",
693 "::prepare_cached", "::query_typed",
694 ];
695 if PG.iter().any(|v| path.ends_with(v)) {
696 return Some("Db");
697 }
698 // rusqlite only: opening the database IS the connection establishment (`Connection::
699 // open`/`open_in_memory`/`open_with_flags` — the embedded analog of `::connect`).
700 if crate_name == "rusqlite"
701 && (path.ends_with("::open")
702 || path.ends_with("::open_in_memory")
703 || path.ends_with("::open_with_flags"))
704 {
705 return Some("Db");
706 }
707 return None;
708 }
709 // redis: the way redis is ACTUALLY used is the high-level `Commands`/`AsyncCommands`
710 // traits (`con.get`/`set`/`hset`/`lpush`/…) — every method is a round-trip — plus
711 // connection establishment. The shared VERBS below only catch the low-level
712 // `cmd("GET").query(con)`, so without this a normal redis user's calls classify as
713 // PURE. (Found hardening on redis-rs: a fn doing `con.get`/`set` reported no effects.)
714 if crate_name == "redis"
715 && (path.contains("Commands::")
716 || path.contains("::get_connection")
717 || path.contains("::get_async_connection")
718 || path.contains("::get_multiplexed_async_connection")
719 // a live `ConnectionManager` round-trips (Db), but `ConnectionManagerConfig` is a pure
720 // in-memory builder (set_number_of_retries/set_max_delay) — exclude it (adversarial review).
721 // `ConnectionManager::clone` is an Arc refcount bump — no Db round-trip (sweep [27]).
722 || (path.contains("ConnectionManager") && !path.contains("ConnectionManagerConfig")
723 && !path.ends_with("::clone"))
724 || path.ends_with("::query")
725 || path.ends_with("::query_async")
726 || path.ends_with("::req_command")
727 || path.ends_with("::req_packed_command")
728 || path.ends_with("::req_packed_commands"))
729 {
730 return Some("Db");
731 }
732 // mongodb: a document-store API with none of the SQL verbs — the user calls
733 // `coll.find_one`/`insert_one`/`aggregate`/… and `Client::with_uri_str`. Without
734 // these a mongodb user's calls classify PURE. (Found hardening: a fn doing
735 // `find_one`+`insert_one` reported no effects.) Handle accessors (name/namespace)
736 // and option/doc builders don't match these verbs, so they stay pure.
737 if crate_name == "mongodb" {
738 const MONGO: [&str; 27] = [
739 "::with_uri_str", "::connect", "::find", "::find_one", "::insert_one",
740 "::insert_many", "::update_one", "::update_many", "::delete_one",
741 "::delete_many", "::replace_one", "::aggregate", "::count_documents",
742 "::estimated_document_count", "::count", "::distinct", "::run_command",
743 "::find_one_and_update", "::find_one_and_delete", "::find_one_and_replace",
744 "::list_collections", "::list_collection_names", "::list_databases",
745 "::list_database_names", "::create_collection", "::create_index", "::watch",
746 ];
747 if MONGO.iter().any(|v| path.ends_with(v)) {
748 return Some("Db");
749 }
750 return None;
751 }
752 // mysql / mysql_async: the `query`/`exec` families + `get_conn`/`ping` execute
753 // immediately — no build-then-execute split like sqlx, so matching `::query` is safe
754 // here. Same DB-verb-dialect gap class as redis/mongodb; calibrated from the Queryable
755 // API (unit-tested; a real-app repro is the remaining confirmation).
756 if matches!(crate_name, "mysql" | "mysql_async") {
757 const MY: [&str; 16] = [
758 "::query", "::query_first", "::query_iter", "::query_map", "::query_fold",
759 "::query_drop", "::exec", "::exec_first", "::exec_iter", "::exec_map",
760 "::exec_fold", "::exec_drop", "::exec_batch", "::prep", "::ping", "::get_conn",
761 ];
762 if MY.iter().any(|v| path.ends_with(v)) {
763 return Some("Db");
764 }
765 return None;
766 }
767 // sea_orm: an ORM whose execution is split from building (like sqlx). The query
768 // BUILDERS (`Entity::find`, `Entity::insert`) are pure; execution happens at `.all`/
769 // `.one`/`.count`/`.stream` and `Insert/Update/Delete::exec`. The write path via an
770 // ActiveModel (`model.insert(db)`) executes too — distinguished from the `EntityTrait`
771 // builder by the trait in the path (`ActiveModelTrait::`). (Found hardening on a
772 // sea_orm consumer app: `.all(db)` reads and `ActiveModel::insert` writes were pure.)
773 if crate_name == "sea_orm" {
774 // sea_orm RE-EXPORTS sea_query (`sea_orm::sea_query::…`), whose builder algebra collides with
775 // the execution verbs: `Func::count(col)` builds a COUNT() expr, `Condition::all()` AND-groups
776 // filters, `Expr::count(…)` — all PURE, none touch a db. The `::all`/`::count`/`::one` execution
777 // rule fabricated Db on them (sweep [5]). sea_query is pure query construction end-to-end, so
778 // exclude the whole re-exported namespace first.
779 if path.contains("sea_query") {
780 return None;
781 }
782 if path.ends_with("::all")
783 || path.ends_with("::one")
784 || path.ends_with("::count")
785 || path.ends_with("::stream")
786 || path.ends_with("::exec")
787 || path.ends_with("::exec_with_returning")
788 || path.ends_with("::exec_without_returning")
789 || path.ends_with("::connect")
790 || path.ends_with("::execute")
791 || path.ends_with("::execute_unprepared")
792 || path.ends_with("::query_one")
793 || path.ends_with("::query_all")
794 || path.ends_with("::fetch_page")
795 || path.ends_with("::num_items")
796 || path.contains("ActiveModelTrait::")
797 {
798 return Some("Db");
799 }
800 return None;
801 }
802 // (Reached by sqlx + diesel — the build-vs-execute-split crates.) `first` is diesel's
803 // LIMIT-1 round trip and `load_iter` its 2.x streaming execution; `fetch_many` is sqlx's
804 // multi-result stream. All crate-gated, so a std `Vec::first` never resolves here.
805 const VERBS: [&str; 19] = [
806 "::execute", "::query_row", "::query_map", "::query_one", "::fetch_one",
807 "::fetch_all", "::fetch_optional", "::fetch", "::fetch_many", "::connect",
808 "::acquire", "::begin", "::commit", "::rollback", "::load", "::load_iter",
809 "::first", "::get_result", "::get_results",
810 ];
811 if VERBS.iter().any(|v| path.ends_with(v)) {
812 return Some("Db");
813 }
814 return None;
815 }
816 // std::path::Path / PathBuf STAT-family methods hit the filesystem (each is a stat/readlink/
817 // readdir syscall) — unlike the rest of the std::path surface, which is pure string manipulation
818 // (join/file_name/extension/parent/…). Verb-precise so the scanner's receiver inference can safely
819 // route a `path.symlink_metadata()` method call here. (A blackout screen caught gix-dir — an entire
820 // directory WALKER — reporting ZERO Fs because all its I/O is Path-method calls; same class as
821 // fd's residual `Path::symlink_metadata` under-report.)
822 if let Some(m) = path
823 .strip_prefix("std::path::Path::")
824 .or_else(|| path.strip_prefix("std::path::PathBuf::"))
825 {
826 const STAT: &[&str] = &[
827 "metadata", "symlink_metadata", "canonicalize", "read_link", "read_dir", "exists",
828 "try_exists", "is_file", "is_dir", "is_symlink",
829 ];
830 return STAT.contains(&m).then_some("Fs");
831 }
832 // Filesystem. `tokio::fs`/`async_std::fs` are the async mirrors of `std::fs`; `async_fs` is
833 // smol's fs crate; `fs_err` is a drop-in `std::fs` wrapper (its whole surface is fs I/O).
834 if path.starts_with("std::fs::")
835 || path.starts_with("tokio::fs::")
836 || path.starts_with("async_std::fs::")
837 || crate_name == "async_fs"
838 || crate_name == "fs_err"
839 {
840 return Some("Fs");
841 }
842 // memmap2: only `MmapOptions::map*` (and the in-place `Mmap::flush`/`make_*` protection
843 // changes / `remap`) actually issue the mmap/msync/mprotect/mremap syscall = Fs. The rest of the
844 // crate is PURE: `MmapOptions::new`/setters BUILD the request, and once a region is mapped, reads
845 // over it (`Mmap::len`/`is_empty`/`as_ptr`/`as_mut_ptr`/`deref` into the byte slice) are plain
846 // memory access with no syscall. Whole-crate Fs fabricated Fs on those reads (a `m.len()` the
847 // scanner's receiver inference routes to `memmap2::Mmap::len`). Match the syscall-issuing verbs;
848 // everything else returns None (pure). `map*` covers `map`/`map_mut`/`map_exec`/`map_copy`/
849 // `map_copy_read_only`/`map_raw`/`map_raw_read_only`/`map_anon`.
850 if crate_name == "memmap2" {
851 let m = path.rsplit("::").next().unwrap_or(path);
852 if m.starts_with("map")
853 || m == "flush"
854 || m == "flush_async"
855 || m == "flush_range"
856 || m == "flush_async_range"
857 || m == "remap"
858 || m.starts_with("make_")
859 || m == "advise"
860 || m == "advise_range"
861 || m == "lock"
862 || m == "unlock"
863 {
864 return Some("Fs");
865 }
866 return None;
867 }
868 // tempfile: creating a temp file/dir touches the disk. Match the create/persist verbs (the
869 // `Builder` setters — prefix/suffix/rand_bytes — stay pure). `persist`/`keep` rename/retain
870 // the file on disk; `close` removes it.
871 if crate_name == "tempfile"
872 && (path.ends_with("::tempfile")
873 || path.ends_with("::tempfile_in")
874 || path.ends_with("::tempdir")
875 || path.ends_with("::tempdir_in")
876 || path.ends_with("NamedTempFile::new")
877 || path.ends_with("NamedTempFile::new_in")
878 || path.ends_with("TempDir::new")
879 || path.ends_with("TempDir::new_in")
880 || path.ends_with("::persist")
881 || path.ends_with("::persist_noclobber")
882 || path.ends_with("::keep"))
883 {
884 return Some("Fs");
885 }
886 // glob: walks the filesystem to expand a pattern (the returned iterator reads directories).
887 // `Pattern::matches` is pure string matching — match only the directory-walking entry points.
888 if crate_name == "glob" && (path.ends_with("::glob") || path.ends_with("::glob_with")) {
889 return Some("Fs");
890 }
891 // Password-hashing / KDF crates — the entropy tier (the TS engine's CTA lesson: an invisible
892 // argon2 landed on exactly the call a security review cares about). In this engine's
893 // verb-precise style the ENTROPY is the salt mint: `SaltString::generate(OsRng)` in the
894 // password-hash API family, and bcrypt's `hash`/`hash_with_result` (salt minted internally).
895 // Verification and explicit-salt hashing are deterministic recomputation — pure. `rand_core`
896 // carries the OsRng source itself (otherwise the most common salt mint is invisible).
897 if matches!(crate_name, "argon2" | "scrypt" | "pbkdf2" | "password_hash") {
898 if path.contains("SaltString::generate") {
899 return Some("Rand");
900 }
901 return None;
902 }
903 if crate_name == "bcrypt" {
904 if path.ends_with("::hash") || path.ends_with("::hash_with_result") {
905 return Some("Rand");
906 }
907 return None;
908 }
909 if crate_name == "rand_core" {
910 if path.contains("OsRng")
911 || path.ends_with("::next_u32")
912 || path.ends_with("::next_u64")
913 || path.ends_with("::fill_bytes")
914 {
915 return Some("Rand");
916 }
917 return None;
918 }
919 // Randomness / entropy. `getrandom`/`fastrand` are effectful end-to-end. `rand` is NOT — it
920 // mixes entropy/generation (effectful) with *pure* distribution constructors (`Uniform::new`,
921 // `Normal::new`) and deterministic-seed constructors (`seed_from_u64`). Flagging the whole crate
922 // over-reported those as `Rand`; match only the calls that actually consume randomness — the
923 // entropy sources (`OsRng`, `thread_rng`/`rng`, `from_entropy`/`from_os_rng`) and the generation
924 // verbs (`gen*`/`random*`/`fill*`/`sample*`/`next_u*`). A `Uniform::new` is now correctly pure.
925 if crate_name == "getrandom" {
926 return Some("Rand");
927 }
928 // fastrand: like `rand`, it mixes entropy-consuming generation (effectful) with PURE deterministic
929 // pieces. `Rng::with_seed(42)` is a DETERMINISTIC seeded constructor (consumes no entropy — the same
930 // seed gives the same stream), and `Rng::fork`/`Rng::clone` just split/copy existing state. Those are
931 // PURE; whole-crate Rand fabricated Rand on them. The effect is the value-drawing methods (`u32`/
932 // `usize`/`bool`/`f64`/`char`/`alphanumeric`/`choice`/`choose_multiple`/`shuffle`/`fill`/the range
933 // forms) AND the entropy-seeded entry points: bare `Rng::new()` (seeds from the global entropy-backed
934 // generator), `fastrand::seed`, and the top-level `fastrand::u32(..)` free functions (which draw from
935 // the thread-local generator). `with_seed` is exempted explicitly; any other method on an `Rng`
936 // (i.e. a value draw) is Rand.
937 if crate_name == "fastrand" {
938 let m = path.rsplit("::").next().unwrap_or(path);
939 // Provably pure: deterministic seeded ctor + state split/copy.
940 if m == "with_seed" || m == "fork" || m == "clone" {
941 return None;
942 }
943 // Everything else fastrand exposes either draws a value or seeds from entropy → Rand. (The crate
944 // has no pure data types beyond the `Rng` handle itself, so a non-draw stray would have to be a
945 // method we don't recognise — keep the effect, the safe direction.)
946 return Some("Rand");
947 }
948 if crate_name == "rand" {
949 let rng_verb = path.ends_with("::gen")
950 || path.ends_with("::gen_range")
951 || path.ends_with("::gen_bool")
952 || path.ends_with("::gen_ratio")
953 || path.ends_with("::random")
954 || path.ends_with("::random_range")
955 || path.ends_with("::random_bool")
956 || path.ends_with("::random_ratio")
957 || path.ends_with("::random_iter") // rand 0.9 iterator generator
958 || path.ends_with("::gen_iter")
959 || path.ends_with("::fill")
960 || path.ends_with("::fill_bytes")
961 || path.ends_with("::try_fill")
962 || path.ends_with("::try_fill_bytes")
963 || path.ends_with("::sample")
964 || path.ends_with("::sample_iter")
965 || path.ends_with("::next_u32")
966 || path.ends_with("::next_u64")
967 || path.ends_with("::thread_rng")
968 || path.ends_with("::rng")
969 || path.ends_with("::from_entropy")
970 || path.ends_with("::from_os_rng");
971 // `OsRng` is the OS entropy SOURCE, but `clone`/`fork`/`default` just copy or construct the
972 // (zero-sized) handle and draw no entropy — pure, exactly like the `fastrand` arm's clone/fork
973 // exemption above. The actual draws (`fill_bytes`/`next_u*`/…) are caught by `rng_verb`. Without
974 // this exemption the blanket `contains("OsRng")` fabricated `Rand` on `OsRng::clone` (adversarial
975 // review: OsRng is a unit struct, cloning consumes nothing).
976 let m = path.rsplit("::").next().unwrap_or(path);
977 let os_rng = path.contains("OsRng") && !matches!(m, "clone" | "fork" | "default");
978 if rng_verb || os_rng {
979 return Some("Rand");
980 }
981 return None;
982 }
983 // Subprocess spawning. `tokio::process` is the async mirror of `std::process` — it exists
984 // only to spawn/control subprocesses (`Command`/`Child`, no pure data types like std's
985 // `Stdio`/`ExitStatus`/`exit`), so spawning through it is Exec just the same. Without this an
986 // async app's `tokio::process::Command::new(..).spawn()` classified pure — a silent under-report
987 // of subprocess execution, the dangerous direction (mirrors the tokio::fs/tokio::net coverage).
988 if path.starts_with("std::process::Command")
989 || path.starts_with("std::process::Child")
990 || path.starts_with("tokio::process::Command")
991 || path.starts_with("tokio::process::Child")
992 || path.starts_with("async_std::process::Command")
993 || path.starts_with("async_std::process::Child")
994 {
995 // PURE read-backs of the builder's stored fields / the cached pid — no spawn, no syscall — so the
996 // whole-type Exec rule fabricated Exec on them (sweep [23]; mirrors the portable_pty getter carve-
997 // out just below). get_program/get_args/get_envs/get_current_dir read the Command; Child::id reads
998 // the cached pid. Every genuine verb (new/spawn/output/status/wait/kill) stays Exec.
999 if path.ends_with("::get_program")
1000 || path.ends_with("::get_args")
1001 || path.ends_with("::get_envs")
1002 || path.ends_with("::get_current_dir")
1003 || path.ends_with("Child::id")
1004 {
1005 return None;
1006 }
1007 return Some("Exec");
1008 }
1009 // portable_pty / async_process are whole-crate Exec EXCEPT for the proven-pure surface they expose:
1010 // the `CommandBuilder` GETTERS (`get_argv`/`get_cwd`/`get_env`/`as_unix_command_line`…) read back
1011 // configuration, and the PURE DATA types (`PtySize::default`, `ExitStatus`/`Stdio`/`CommandBuilder`
1012 // construction/setters). The earlier `is_cmd_naming_method` fix stopped the head-refinement LEAK, but
1013 // the BASE Exec still fabricated on these accessors (a `cmd.get_cwd()` the scanner routes to
1014 // `portable_pty::CommandBuilder::get_cwd`). Subtract the read-back getters and the obvious pure
1015 // ctors/setters; the spawn/wait/exec surface (`spawn_command`/`openpty`/`wait`/`kill`/`exec`…) keeps
1016 // Exec. SUBTRACT only what is provably pure — when unrecognised, KEEP Exec (the safe direction).
1017 if crate_name == "async_process" || crate_name == "portable_pty" {
1018 let m = path.rsplit("::").next().unwrap_or(path);
1019 // configuration read-back getters — pure (no spawn).
1020 if m.starts_with("get_") || m == "as_unix_command_line" {
1021 return None;
1022 }
1023 // pure data-type ctors/setters/derives that NAME no program and spawn nothing.
1024 if matches!(
1025 m,
1026 "default" | "new" | "piped" | "null" | "inherit" | "from_raw_fd"
1027 | "arg" | "args" | "arg0" | "env" | "envs" | "env_clear" | "env_remove"
1028 | "cwd" | "current_dir" | "rows" | "cols"
1029 | "clone" | "fmt" | "eq" | "ne" | "hash"
1030 ) {
1031 return None;
1032 }
1033 return Some("Exec");
1034 }
1035 // duct: a subprocess-orchestration crate. `cmd()`/`cmd!` only *build* an Expression; the
1036 // spawn/wait happens at `run`/`read`/`start`. Match the execution verbs, not the builder.
1037 if crate_name == "duct"
1038 && (path.ends_with("::run")
1039 || path.ends_with("::read")
1040 || path.ends_with("::start")
1041 || path.ends_with("::read_chars"))
1042 {
1043 return Some("Exec");
1044 }
1045 if path.starts_with("std::env::") {
1046 return Some("Env");
1047 }
1048 // dotenvy / dotenv: load environment variables (reading a `.env` file and mutating the process
1049 // environment). Match the load/read entry points; `Error`/builder types stay pure.
1050 if matches!(crate_name, "dotenvy" | "dotenv")
1051 && (path.ends_with("::dotenv")
1052 || path.ends_with("::dotenv_override")
1053 || path.ends_with("::from_path")
1054 || path.ends_with("::from_path_override")
1055 || path.ends_with("::from_filename")
1056 || path.ends_with("::from_filename_override")
1057 || path.ends_with("::from_read")
1058 || path.ends_with("::from_read_override")
1059 || path.ends_with("::load")
1060 || path.ends_with("::var")
1061 || path.ends_with("::vars"))
1062 {
1063 return Some("Env");
1064 }
1065 // Wall-clock reads. Match the `now` accessor precisely (ends_with), not any path
1066 // containing the substring "now". The `time` crate (distinct from `std::time`/`chrono`)
1067 // reads the clock via `now_utc`/`now_local` (and the deprecated `Instant::now`).
1068 if (crate_name == "chrono" || path.starts_with("std::time::")) && path.ends_with("::now") {
1069 return Some("Clock");
1070 }
1071 if crate_name == "time"
1072 && (path.ends_with("::now_utc") || path.ends_with("::now_local") || path.ends_with("::now"))
1073 {
1074 return Some("Clock");
1075 }
1076 // `tracing`: same principle as the `log` facade below — the crate's TYPES are pure data, so match
1077 // the emit, not the whole crate. The actual program output is the macro-expanded
1078 // `Subscriber::event`/`event!`/`Span::*enter*` dispatch and the `Span::new*`/`Span::record`
1079 // recording path that drives the subscriber. The data-type accessors — `Level::as_str`,
1080 // `Span::is_disabled`/`metadata`/`id`, and constructing/reading `Level`/`LevelFilter`/`Span`/
1081 // `Event`/`Metadata`/`Field`/`FieldSet`/`Id` — are PURE (no output is produced), so whole-crate Log
1082 // fabricated Log on them. Match the emit verbs; everything else returns None.
1083 if crate_name == "tracing" {
1084 let m = path.rsplit("::").next().unwrap_or(path);
1085 // The user-facing emit MACROS (`tracing::info!`/`warn!`/…) — candor-scan is pre-expansion, so it
1086 // sees the raw macro path `tracing::info`, not the expanded `__tracing`/`Subscriber::event` the
1087 // deep (post-expansion) engine sees. Only the macro names; the pure DATA types (Level/Span/Event)
1088 // have other tails and stay None.
1089 if m == "trace" || m == "debug" || m == "info" || m == "warn" || m == "error"
1090 || m == "trace_span" || m == "debug_span" || m == "info_span" || m == "warn_span"
1091 || m == "error_span" || m == "span"
1092 || m == "event"
1093 || m == "new_span"
1094 || m == "record"
1095 || m == "record_follows_from"
1096 || m == "enter"
1097 || m == "exit"
1098 || m == "in_scope"
1099 || m == "entered"
1100 || path.contains("::__macro_support")
1101 || path.contains("::__tracing")
1102 || path.contains("Subscriber::event")
1103 || path.contains("Subscriber::new_span")
1104 || path.contains("Subscriber::enter")
1105 || path.contains("Subscriber::exit")
1106 {
1107 return Some("Log");
1108 }
1109 return None;
1110 }
1111 // The `log` facade: its macros route through `log::__private_api`; the crate's types
1112 // (`Level`, `LevelFilter`) are pure, so match the logging entry, not the whole crate.
1113 if crate_name == "log" {
1114 // Expanded macro form (deep engine) OR the raw user-facing macro names (candor-scan, pre-expansion).
1115 // `log::Level`/`LevelFilter`/`Record`/`Metadata` have other tails, so the type surface stays pure.
1116 let m = path.rsplit("::").next().unwrap_or(path);
1117 if path.contains("::__private_api")
1118 || m == "error" || m == "warn" || m == "info" || m == "debug" || m == "trace" || m == "log"
1119 {
1120 return Some("Log");
1121 }
1122 }
1123 // Compiler diagnostic emission — the ONE genuinely effectful operation in the otherwise-pure
1124 // rustc_* surface (a dylint lint's actual OUTPUT: it writes warnings/errors to the compiler's
1125 // diagnostic sink). Classified `Log` (same family as `tracing`/`log` — program output). Match the
1126 // emission verbs precisely; rustc_lint/rustc_errors are mostly pure types (Lint, LintId, the Diag
1127 // BUILDERS), and only the terminal `emit`/`emit_span_lint` actually produces output.
1128 if crate_name == "rustc_lint"
1129 && (path.ends_with("::emit_span_lint")
1130 || path.ends_with("::span_lint")
1131 || path.ends_with("::span_lint_hir"))
1132 {
1133 return Some("Log");
1134 }
1135 if crate_name == "rustc_errors"
1136 && (path.ends_with("::emit")
1137 || path.ends_with("::emit_diagnostic")
1138 || path.ends_with("::emit_now"))
1139 {
1140 return Some("Log");
1141 }
1142 // arboard: the effectful surface is the `Clipboard` handle's read/write verbs (each talks to the
1143 // OS clipboard / X11/Wayland/Win32/NSPasteboard server). The data types — chiefly `arboard::Error`
1144 // (whose `Display`/`to_string` formatting is pure) and the `ImageData`/`GetExtLinux`/`SetExtLinux`
1145 // option types — are PURE, so whole-crate Clipboard fabricated Clipboard on e.g. an error
1146 // `to_string()`. Match the handle verbs; everything else returns None. `Clipboard::new` opens the
1147 // connection to the clipboard server, so it's an effect too; `get`/`set` return the
1148 // builder-then-read `Get`/`Set` cursors whose `text`/`image`/`html` terminals do the I/O.
1149 if crate_name == "arboard" {
1150 let m = path.rsplit("::").next().unwrap_or(path);
1151 if m == "new"
1152 || m == "get"
1153 || m == "set"
1154 || m == "clear"
1155 || m == "get_text"
1156 || m == "set_text"
1157 || m == "set_html"
1158 || m == "get_image"
1159 || m == "set_image"
1160 || m == "text"
1161 || m == "image"
1162 || m == "html"
1163 {
1164 return Some("Clipboard");
1165 }
1166 return None;
1167 }
1168 // ── Coverage-differential additions (calibrated against each crate's real API; see the per-crate
1169 // notes). All verb-keyed + crate-gated, with the pure builder/config/data surface returning None.
1170
1171 // `etcetera` — XDG/known-folder base+app directory resolution. Each dir ACCESSOR reads the
1172 // environment at call time (`$HOME`/`$XDG_*` on Unix, `%APPDATA%`/`%LOCALAPPDATA%` on Windows), and
1173 // the `choose_*`/`home_dir` entry points read `$HOME`. The `AppStrategyArgs` data struct and the
1174 // strategy types themselves are PURE. (Found DISCLOSED-but-unmodeled in 3/4 differential projects.)
1175 if crate_name == "etcetera" {
1176 let m = path.rsplit("::").next().unwrap_or(path);
1177 if m == "home_dir"
1178 || m == "choose_base_strategy" || m == "choose_native_strategy" || m == "choose_app_strategy"
1179 || m == "config_dir" || m == "data_dir" || m == "cache_dir"
1180 || m == "state_dir" || m == "runtime_dir" || m == "data_local_dir"
1181 {
1182 return Some("Env");
1183 }
1184 return None;
1185 }
1186 // `sqlx-core` (crate `sqlx_core`) — the execution terminals under the sqlx core (the `sqlx` builder
1187 // table maps `sqlx::query*`; here it's the core `Executor`/`Connection`/`Pool` round-trips). Opening
1188 // the connection is the network boundary (Net); the query/transaction round-trips are Db. The
1189 // `*Options`/query-builder/row data types are PURE. Crate-gated so the generic verbs never spread.
1190 if crate_name == "sqlx_core" {
1191 if path.ends_with("::connect") || path.ends_with("::connect_with") {
1192 return Some("Net");
1193 }
1194 if path.ends_with("::fetch") || path.ends_with("::fetch_all") || path.ends_with("::fetch_one")
1195 || path.ends_with("::fetch_optional") || path.ends_with("::fetch_many")
1196 || path.ends_with("::execute") || path.ends_with("::execute_many")
1197 || path.ends_with("::prepare") || path.ends_with("::prepare_with")
1198 || path.ends_with("::acquire") || path.ends_with("::begin") || path.ends_with("::ping")
1199 {
1200 return Some("Db");
1201 }
1202 return None;
1203 }
1204 // `walkdir` — recursive directory traversal. The disk read (`read_dir` + `stat`) happens lazily in
1205 // `IntoIter::next` (driving the iterator), and `DirEntry::metadata` issues a `stat`. The
1206 // `WalkDir::new`/`max_depth`/`follow_links`/`sort_by` BUILDERS, `WalkDir::into_iter` (constructs the
1207 // iterator, no I/O until pulled), and the cached `DirEntry::path`/`file_name`/`file_type`/`depth`
1208 // accessors (`file_type` makes NO syscall) are PURE. (Companion to the already-modeled `ignore`.)
1209 if crate_name == "walkdir" {
1210 if path.ends_with("::IntoIter::next") || path.ends_with("::DirEntry::metadata") {
1211 return Some("Fs");
1212 }
1213 return None;
1214 }
1215 // `filetime` — file-timestamp mutation. The `set_*` free fns issue utimes/utimensat/futimens (Fs).
1216 // `FileTime::now` reads the system clock (Clock). The `FileTime::from_*`/`zero` value constructors
1217 // (incl. `from_last_modification_time(&Metadata)` etc., which read an ALREADY-loaded `&Metadata`, not
1218 // the disk) and the `seconds`/`nanoseconds` accessors are PURE.
1219 if crate_name == "filetime" {
1220 if path.ends_with("::set_file_mtime") || path.ends_with("::set_file_atime")
1221 || path.ends_with("::set_file_times") || path.ends_with("::set_symlink_file_times")
1222 || path.ends_with("::set_file_handle_times")
1223 {
1224 return Some("Fs");
1225 }
1226 if path.ends_with("::FileTime::now") {
1227 return Some("Clock");
1228 }
1229 return None;
1230 }
1231 // `execute` — the `Execute` trait that extends `std::process::Command` with run helpers. The
1232 // `execute*` verbs SPAWN a child process (Exec). The `execute::command`/`shell` free fns and the
1233 // `command!`/`command_args!` macros only BUILD a Command (no spawn) and stay PURE.
1234 if crate_name == "execute" {
1235 if path.contains("::execute") {
1236 return Some("Exec");
1237 }
1238 return None;
1239 }
1240 // `ctrlc` — installs an OS signal handler (Unix SIGINT/SIGTERM/SIGHUP, Windows CTRL_C_EVENT) and
1241 // spawns its handler thread. Signals are an inter-process control channel, so the closest bucket is
1242 // Ipc (candor has no dedicated Signal effect; same judgment as routing SysV/pipe IPC to Ipc).
1243 if crate_name == "ctrlc" {
1244 if path.ends_with("::set_handler") || path.ends_with("::try_set_handler") {
1245 return Some("Ipc");
1246 }
1247 return None;
1248 }
1249 // `clap` — argument parsing. ONLY the terminals that read `std::env::args_os` at call time are an
1250 // effect (Env): `get_matches`/`get_matches_mut`/`try_get_matches` and the derive `parse`/`try_parse`.
1251 // clap is MOSTLY PURE: the ENTIRE builder surface (`Command::new`/`arg`/`about`/`Arg::new`) stays
1252 // None, and crucially the `*_from`/`*_parse_from` variants take an EXPLICIT iterator (they do NOT
1253 // read argv) so they stay pure too. (`Arg::env` reads an env var at builder time but bare `::env` is
1254 // too generic to gate safely, so it's left unmodeled — under-report over fabrication.)
1255 if crate_name == "clap" {
1256 if path.ends_with("::get_matches") || path.ends_with("::get_matches_mut")
1257 || path.ends_with("::try_get_matches")
1258 || path.ends_with("::parse") || path.ends_with("::try_parse")
1259 {
1260 return Some("Env");
1261 }
1262 return None;
1263 }
1264 // `jiff` — date/time. `Timestamp::now`/`Zoned::now`/`Zoned::now_with` read the wall clock (Clock).
1265 // `tz::TimeZone::system`/`get` and `tz::db().get` read the system tzdb files from disk
1266 // (`/etc/localtime`, `/usr/share/zoneinfo`; `system` is also `$TZ`-overridable — Fs is the dominant
1267 // op, modeled as Fs). The `Span`/`civil` date math and `Timestamp`/`Zoned` arithmetic are PURE.
1268 if crate_name == "jiff" {
1269 if path.ends_with("::now") || path.ends_with("::now_with") {
1270 return Some("Clock");
1271 }
1272 if path.ends_with("::TimeZone::system") || path.ends_with("::TimeZone::get")
1273 || path.ends_with("::TimeZoneDatabase::get")
1274 {
1275 return Some("Fs");
1276 }
1277 return None;
1278 }
1279 // `env_logger` — installs the global logger and emits to stderr; reads `RUST_LOG`/`RUST_LOG_STYLE`.
1280 // The init terminals are the effect (Log — program output, same family as `log`/`tracing`). The
1281 // `Builder::new`/`build` and the format/filter/target config setters are PURE.
1282 if crate_name == "env_logger" {
1283 if path.ends_with("::init") || path.ends_with("::try_init")
1284 || path.ends_with("::init_from_env") || path.ends_with("::try_init_from_env")
1285 {
1286 return Some("Log");
1287 }
1288 return None;
1289 }
1290 // `dialoguer` — interactive terminal prompts. The `interact*` verbs read stdin + write the tty (a
1291 // console dialogue with the user — Ipc, like the other local-channel effects). The
1292 // `with_prompt`/`default`/`items`/`validate_with` BUILDERS are PURE.
1293 if crate_name == "dialoguer" {
1294 if path.ends_with("::interact") || path.ends_with("::interact_on")
1295 || path.ends_with("::interact_text") || path.ends_with("::interact_text_on")
1296 || path.ends_with("::interact_opt") || path.ends_with("::interact_on_opt")
1297 {
1298 return Some("Ipc");
1299 }
1300 return None;
1301 }
1302 // `console` — terminal handle + styling. The `Term` read/write verbs do tty I/O (Ipc, the user
1303 // dialogue channel; note there is NO `write_str` — `Term` impls `io::Write`). The free-fn terminal
1304 // detection (`colors_enabled`/`user_attended`) reads `CLICOLOR`/`CLICOLOR_FORCE` (Env). The `Style`
1305 // color/format methods and the text utils (`strip_ansi_codes`/`pad_str`/`measure_text_width`) are PURE.
1306 if crate_name == "console" {
1307 if path.ends_with("::write_line") || path.ends_with("::read_line")
1308 || path.ends_with("::read_line_initial_text") || path.ends_with("::read_char")
1309 || path.ends_with("::read_key") || path.ends_with("::read_key_raw")
1310 || path.ends_with("::read_secure_line")
1311 {
1312 return Some("Ipc");
1313 }
1314 if path.ends_with("::colors_enabled") || path.ends_with("::colors_enabled_stderr")
1315 || path.ends_with("::user_attended") || path.ends_with("::user_attended_stderr")
1316 {
1317 return Some("Env");
1318 }
1319 return None;
1320 }
1321 // `terminal_colorsaurus` — queries the terminal's colours by writing OSC 10/11 escapes and reading the
1322 // reply (bidirectional tty dialogue — Ipc, consistent with dialoguer/console). Nothing else is I/O.
1323 if crate_name == "terminal_colorsaurus" {
1324 if path.ends_with("::background_color") || path.ends_with("::foreground_color")
1325 || path.ends_with("::color_palette") || path.ends_with("::theme_mode")
1326 {
1327 return Some("Ipc");
1328 }
1329 return None;
1330 }
1331 // `backoff` — retry-with-backoff. `retry`/`retry_notify` consult the clock and `thread::sleep`
1332 // between attempts (Clock). The `ExponentialBackoff`/builder config is PURE. (The user closure's own
1333 // effects are out of scope here — we model only backoff's own Clock effect.)
1334 if crate_name == "backoff" {
1335 if path.ends_with("::retry") || path.ends_with("::retry_notify") {
1336 return Some("Clock");
1337 }
1338 return None;
1339 }
1340 // `lscolors` — LS_COLORS parsing. ONLY `from_env` reads the environment (Env). `from_string`/
1341 // `style_for_path`/`style_for*` and the `Style` type take explicit input and are PURE.
1342 if crate_name == "lscolors" {
1343 if path.ends_with("::from_env") {
1344 return Some("Env");
1345 }
1346 return None;
1347 }
1348 // `wild` — argv with glob expansion. `args`/`args_os` read `std::env::args(_os)` (Env). Nothing else.
1349 if crate_name == "wild" {
1350 if path.ends_with("::args") || path.ends_with("::args_os") {
1351 return Some("Env");
1352 }
1353 return None;
1354 }
1355 // `grep_cli` — only the firm effect is modeled: `CommandReaderBuilder::build` spawns a child process
1356 // (Exec). The `is_readable_stdin`/`is_tty_*` fd probes (isatty/fstat on the std descriptors) are
1357 // deliberately NOT modeled — candor doesn't classify `IsTerminal`/isatty as an effect anywhere, and
1358 // they read no data; flagging them would be an inconsistent over-report.
1359 if crate_name == "grep_cli" {
1360 if path.ends_with("::build") {
1361 return Some("Exec");
1362 }
1363 return None;
1364 }
1365 // `clircle` — detects whether two handles are the same file (cycle protection). `Identifier::try_from`
1366 // (File/Stdio) issues an `fstat`, and `surely_conflicts_with` does an `lseek` (`stream_position`) — both
1367 // Fs. The `PartialEq`/`Hash` comparisons read stored dev/ino and are PURE. (The named methods
1368 // `are_identical`/`same_file` do NOT exist in the crate — not modeled.)
1369 if crate_name == "clircle" {
1370 if path.ends_with("::try_from") || path.ends_with("::surely_conflicts_with") {
1371 return Some("Fs");
1372 }
1373 return None;
1374 }
1375 None
1376}
1377
1378pub fn cap_from_name(name: &str) -> Option<&'static str> {
1379 EFFECTS.iter().copied().find(|e| *e == name)
1380}
1381
1382/// Refine the `Exec` cliff (spec §4 ⟨0.5⟩): the effects a *literal, statically-known* subprocess
1383/// head implies, matched by basename (`/usr/bin/curl` → `curl`). The head's effects are ADDED to a
1384/// caller that already carries `Exec` (a subprocess is still spawned — `Exec` is never dropped); an
1385/// unrecognised or dynamically-built head returns `&[]` and keeps the bare cliff (never guess). A
1386/// **candor engine** reads `Fs`/`Env` only — spec §7 item 12 (the analyzer self-boundary) guarantees
1387/// that, so that case is spec-supplied, not curation. The rest is a small curated table under the
1388/// same under-report rule as the crate classifier. INVARIANT: every head here is an external tool
1389/// that does NOT run the analysed project's own code (so `make`/`npm`/`cargo` are deliberately
1390/// absent — they stay the cliff). The reference engines share this table so the `Exec` boundary —
1391/// the one boundary every engine hits — refines identically (the §4-consistency argument).
1392pub fn classify_command_head(cmd: &str) -> &'static [&'static str] {
1393 // Only UNAMBIGUOUS single-effect tools belong here. A multi-modal head (`git status` is local,
1394 // `git push` is Net; `rsync` local-vs-remote) would FABRICATE the effect for its common case —
1395 // the under-report rule forbids it, so such heads keep the bare cliff.
1396 match cmd.rsplit(['/', '\\']).next().unwrap_or(cmd) {
1397 "curl" | "wget" | "http" | "ssh" | "scp" | "sftp" | "ftp" | "telnet" => &["Net"],
1398 "psql" | "mysql" | "sqlite3" | "mongosh" | "mongo" | "redis-cli" | "cqlsh" | "influx" => &["Db"],
1399 // candor engines — Fs/Env only, guaranteed by spec §7 item 12 (the analyzer self-boundary)
1400 "candor" | "candor-run.sh" | "candor-scan" | "candor-query" | "candor-java"
1401 | "candor-classify" | "candor-report" | "cargo-candor" => &["Env", "Fs"],
1402 _ => &[],
1403 }
1404}
1405
1406/// Whether a subprocess-builder method only MODIFIES the command (`.arg`, `.env`, `.current_dir`)
1407/// rather than NAMING the program (`Command::new`, `duct::cmd`). A WHOLE-CRATE-Exec crate
1408/// (`portable_pty`, `duct`, `async_process`) classifies *every* method as `Exec`, so the
1409/// head-refinement must skip these: an arg or env-var-name literal that happened to match a head
1410/// (`.env("psql", …)`, `.arg("curl")`) would FABRICATE that effect — the §1 under-report rule. The
1411/// method is the call path's last segment.
1412pub fn is_cmd_builder_method(method: &str) -> bool {
1413 matches!(
1414 method,
1415 "arg" | "args" | "arg0" | "env" | "envs" | "env_clear" | "env_remove" | "current_dir"
1416 | "cwd" | "stdin" | "stdout" | "stderr" | "pre_exec" | "creation_flags" | "uid" | "gid"
1417 | "groups" | "process_group"
1418 )
1419}
1420
1421/// Whether a subprocess method NAMES the program (so its first string literal IS the command head to
1422/// refine): `Command::new("curl")`, `duct::cmd("curl", …)`. The head-refinement must fire ONLY here —
1423/// an ALLOWLIST, not "any method except known modifiers". A whole-crate-Exec crate classifies EVERY
1424/// method as `Exec`, so a denylist leaked NON-naming methods that aren't modifiers — a getter like
1425/// `CommandBuilder::get_env("psql")` (reading back an env-var KEY, not a program) fed `"psql"` to the
1426/// head classifier and FABRICATED `Db` (review find). Only `new`/`cmd` name a program; everything else
1427/// (modifiers, getters `get_*`, custom builder methods) keeps the bare `Exec` cliff — under-refine
1428/// (safe) rather than fabricate. `std::process::Command` is verb-precise so getters never fire `Exec`
1429/// there anyway; the allowlist makes the whole-crate-Exec crates safe too.
1430pub fn is_cmd_naming_method(method: &str) -> bool {
1431 matches!(method, "new" | "cmd")
1432}
1433
1434/// The masking guard (AS-EFF-008): a Net call whose method takes the HOST/URL as an argument is
1435/// "establishing" — a classified Net call here with no captured host literal leaves the endpoint
1436/// structurally INVISIBLE (a runtime-built host), so the surface is incomplete and the gate must fail
1437/// closed (else a benign sibling literal masks the runtime endpoint). An ALLOWLIST of connection-
1438/// establishing verbs — the SAFE direction: a USE-verb on an already-connected socket
1439/// (`stream.write`/`read`/`flush`, `socket.send`/`recv`) is NOT here, so a missing literal there (the
1440/// host was fixed at `connect`) never false-positives. Under-catching an unusual establishing verb is a
1441/// missed mask (sound-with-disclosure), never a broken gate. The arg is the method (path's last segment).
1442pub fn is_net_establishing(method: &str) -> bool {
1443 matches!(
1444 method,
1445 "connect"
1446 | "connect_timeout"
1447 | "get"
1448 | "post"
1449 | "put"
1450 | "patch"
1451 | "delete"
1452 | "head"
1453 | "request"
1454 | "send_to"
1455 | "lookup_host"
1456 | "to_socket_addrs"
1457 )
1458}
1459
1460/// The masking guard (AS-EFF-008), the `Fs` analog of `is_net_establishing`: whether an `Fs`-classified
1461/// call takes the filesystem PATH as a string argument (so a missing literal leaves the path
1462/// structurally INVISIBLE — a runtime-built path — and the surface is incomplete, fail-closed). An
1463/// ALLOWLIST of the path-NAMING free functions / constructors (`fs::write`/`read`/`File::open`/…), the
1464/// SAFE direction: a path-stat METHOD whose path is the RECEIVER (`p.metadata()`, `p.exists()`) is
1465/// invoked method-form and the caller gates on `!is_method`, so this never sees it; an op on an
1466/// already-opened handle (`file.write_all`, `mmap.flush`, `tempfile()` — a random name, no path arg)
1467/// is not here, so a missing literal there never false-positives. Under-catching an unusual
1468/// path-naming fn is a missed mask (sound-with-disclosure), never a broken gate. The arg is the
1469/// method/fn leaf (the path's last segment).
1470pub fn is_fs_path_arg(leaf: &str) -> bool {
1471 matches!(
1472 leaf,
1473 // std::fs / tokio::fs / async_std::fs / fs_err free functions taking a path argument
1474 "write"
1475 | "read"
1476 | "read_to_string"
1477 | "read_dir"
1478 | "read_link"
1479 | "copy"
1480 | "rename"
1481 | "remove_file"
1482 | "remove_dir"
1483 | "remove_dir_all"
1484 | "create_dir"
1485 | "create_dir_all"
1486 | "hard_link"
1487 | "soft_link"
1488 | "symlink"
1489 | "symlink_file"
1490 | "symlink_dir"
1491 | "symlink_metadata"
1492 | "canonicalize"
1493 | "metadata"
1494 | "set_permissions"
1495 | "exists"
1496 | "try_exists"
1497 // File / OpenOptions constructors taking a path argument
1498 | "open"
1499 | "create"
1500 | "create_new"
1501 )
1502}
1503
1504/// The masking guard (AS-EFF-008), the `Db` analog of `is_net_establishing`: whether a `Db`-classified
1505/// call takes the raw SQL QUERY as a string argument (so a missing literal leaves the table
1506/// structurally INVISIBLE — a runtime-built query — and the surface is incomplete, fail-closed). An
1507/// ALLOWLIST of the SQL-string-bearing execution/prepare verbs, the SAFE direction: a
1508/// build-then-execute terminal that takes NO SQL string (sqlx/diesel/sea_orm `fetch*`/`load*`/`first`/
1509/// `all`/`one`/`stream`, the document-store `find*`/`insert*`/…), and a non-query op (`connect`/
1510/// `open`/`acquire`/`begin`/`commit`/`ping`/`get_conn`), are NOT here — their query is built
1511/// structurally (never a maskable string literal) so a missing literal must not false-positive.
1512/// Under-catching an unusual query verb is a missed mask (sound-with-disclosure), never a broken gate.
1513/// The arg is the method leaf (the path's last segment).
1514pub fn is_db_query_arg(leaf: &str) -> bool {
1515 matches!(
1516 leaf,
1517 "execute"
1518 | "execute_batch"
1519 | "execute_unprepared"
1520 | "batch_execute"
1521 | "simple_query"
1522 | "query"
1523 | "query_one"
1524 | "query_opt"
1525 | "query_raw"
1526 | "query_row"
1527 | "query_map"
1528 | "query_and_then"
1529 | "query_typed"
1530 | "query_all"
1531 | "prepare"
1532 | "prepare_typed"
1533 | "prepare_cached"
1534 | "exec"
1535 | "exec_first"
1536 | "exec_iter"
1537 | "exec_map"
1538 | "exec_fold"
1539 | "exec_drop"
1540 | "exec_batch"
1541 | "prep"
1542 | "run_command"
1543 )
1544}
1545
1546/// Map a cap-std capability *type* to the effect it authorises. Holding one of these
1547/// (e.g. `&Dir`) is the real, unforgeable right to perform that effect — so candor
1548/// treats it as a declared capability, exactly like its own `&Fs` token.
1549pub fn capstd_cap(crate_name: &str, type_name: &str) -> Option<&'static str> {
1550 if !crate_name.starts_with("cap_") {
1551 return None;
1552 }
1553 Some(match type_name {
1554 "Dir" => "Fs",
1555 "TcpListener" | "TcpStream" | "UdpSocket" | "Pool" => "Net",
1556 "UnixListener" | "UnixStream" | "UnixDatagram" => "Ipc",
1557 "SystemClock" | "MonotonicClock" => "Clock",
1558 _ => return None,
1559 })
1560}
1561
1562/// Table names a SQL string literal STATICALLY reaches — the `Db` analog of the `Net` host /
1563/// `Exec` command / `Fs` path literal surface (feeds `allow Db in <scope> <table>…`, AS-EFF-008).
1564/// Conservative by construction, because a wrong capture here would FABRICATE: the string must
1565/// open with a SQL statement keyword, and only identifiers in table position are taken —
1566/// `FROM`/`JOIN` anywhere, `INTO` anywhere, statement-leading `UPDATE`/`TRUNCATE`, and
1567/// `TABLE` (create/drop/alter), skipping `ONLY`/`IF NOT EXISTS`. `UPDATE` mid-statement is
1568/// deliberately ignored (`FOR UPDATE SKIP LOCKED` must not yield a table "skip"). A
1569/// dynamically-built query yields nothing — the gate's opaque case — never a guess.
1570/// Output is lower-cased, quote/backtick-stripped, `schema.table` kept qualified, deduped.
1571/// SPEC §2 pins this algorithm token-for-token across engines; the cross-impl vector battery
1572/// (candor-spec conformance/tables/vectors.json, run.sh Part 4b) enforces the JVM/TS mirrors.
1573pub fn tables_in_sql(sql: &str) -> Vec<String> {
1574 const STMT: &[&str] =
1575 &["select", "insert", "update", "delete", "create", "drop", "alter", "truncate", "merge", "replace", "with"];
1576 // Tokens that can FOLLOW a table-introducing keyword without being a table.
1577 const SKIP: &[&str] = &["only", "if", "not", "exists", "table"];
1578 // Identifier-position tokens that are grammar, not a table (subqueries, locking clauses…).
1579 const STOP: &[&str] = &[
1580 "select", "set", "where", "values", "on", "using", "group", "order", "by", "limit",
1581 "returning", "as", "inner", "outer", "left", "right", "cross", "lateral", "natural",
1582 "union", "all", "distinct", "case", "when", "null", "default", "skip", "nowait", "of",
1583 "from", "join", "into", "update", "delete", "insert",
1584 ];
1585 // `,` survives as its OWN token (not a space): it's what lets `FROM t1, t2` continue the table
1586 // list without fabricating from other comma-ridden positions (column lists, ON clauses).
1587 let cleaned: String = sql
1588 .to_lowercase()
1589 .chars()
1590 .flat_map(|c| match c {
1591 '(' | ')' | ';' => vec![' '],
1592 ',' => vec![' ', ',', ' '],
1593 _ => vec![c],
1594 })
1595 .collect();
1596 let toks: Vec<&str> = cleaned.split_whitespace().collect();
1597 let Some(first) = toks.first() else { return Vec::new() };
1598 if !STMT.contains(first) {
1599 return Vec::new(); // not SQL — nothing to certify, nothing fabricated
1600 }
1601 let ident = |t: &str| -> Option<String> {
1602 let t = t.trim_matches(|c| matches!(c, '"' | '`' | '\''));
1603 let mut chars = t.chars();
1604 let ok_first = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_');
1605 let ok_rest = t.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '$' | '"' | '`'));
1606 (ok_first && ok_rest && !STOP.contains(&t)).then(|| t.replace(['"', '`'], ""))
1607 };
1608 let mut out: Vec<String> = Vec::new();
1609 let mut push = |t: Option<String>| {
1610 if let Some(t) = t {
1611 if !out.contains(&t) {
1612 out.push(t);
1613 }
1614 }
1615 };
1616 for (i, tok) in toks.iter().enumerate() {
1617 let table_pos = match *tok {
1618 "from" | "join" | "into" | "table" => true,
1619 // statement-leading only (see doc comment): `update t set …`, `truncate [table] t`.
1620 "update" | "truncate" => i == 0,
1621 _ => false,
1622 };
1623 if !table_pos {
1624 continue;
1625 }
1626 let mut j = i + 1;
1627 while j < toks.len() && SKIP.contains(&toks[j]) {
1628 j += 1;
1629 }
1630 let Some(next) = toks.get(j) else { continue };
1631 let Some(first) = ident(next) else { continue };
1632 push(Some(first));
1633 // Comma-ADJACENT continuation only: `FROM t1, t2, t3` takes all three, while an alias breaks
1634 // the chain (`FROM t1 a, t2` keeps just t1 — an under-report, never a guess: skipping an
1635 // alias to chase the comma would fabricate tables out of `INSERT INTO t (a, b)`'s column
1636 // list, whose parens are spaces by the time we tokenize).
1637 while j + 2 < toks.len() && toks[j + 1] == "," {
1638 let Some(more) = ident(toks[j + 2]) else { break };
1639 push(Some(more));
1640 j += 2;
1641 }
1642 }
1643 out
1644}
1645
1646#[cfg(test)]
1647mod tests {
1648 #[test]
1649 fn sql_table_extraction_is_conservative() {
1650 use super::tables_in_sql as t;
1651 assert_eq!(t("SELECT id FROM users WHERE x = 1"), vec!["users"]);
1652 assert_eq!(t("select * from ledger.entries e join customers c on c.id = e.cid"),
1653 vec!["ledger.entries", "customers"]);
1654 assert_eq!(t("INSERT INTO audit_log (a) VALUES (?1)"), vec!["audit_log"]);
1655 assert_eq!(t("UPDATE accounts SET v = ?"), vec!["accounts"]);
1656 assert_eq!(t("DELETE FROM sessions WHERE id = ?"), vec!["sessions"]);
1657 assert_eq!(t("CREATE TABLE IF NOT EXISTS cache (k TEXT)"), vec!["cache"]);
1658 assert_eq!(t("TRUNCATE TABLE staging"), vec!["staging"]);
1659 // FOR UPDATE locking clause must not yield a phantom table (mid-statement update ignored)
1660 assert_eq!(t("SELECT * FROM jobs FOR UPDATE SKIP LOCKED"), vec!["jobs"]);
1661 // a subquery in FROM position yields nothing for that position
1662 assert_eq!(t("SELECT * FROM (SELECT 1) q"), Vec::<String>::new());
1663 // not SQL -> nothing (never fabricate)
1664 assert_eq!(t("/tmp/some/path"), Vec::<String>::new());
1665 assert_eq!(t("hello world from nowhere"), Vec::<String>::new());
1666 // comma-ADJACENT continuation: a FROM list takes every table in the chain…
1667 assert_eq!(t("SELECT a FROM t1, t2, s.t3 WHERE x = 1"), vec!["t1", "t2", "s.t3"]);
1668 // …but an alias breaks it (under-report, never a guess)…
1669 assert_eq!(t("SELECT a FROM t1 a1, t2 WHERE x = 1"), vec!["t1"]);
1670 // …which is exactly what keeps a column list from fabricating (parens are spaces by now).
1671 assert_eq!(t("INSERT INTO t (a, b) VALUES (1, 2)"), vec!["t"]);
1672 // a subquery after the comma stops the chain too
1673 assert_eq!(t("SELECT a FROM t1, (SELECT 1) q"), vec!["t1"]);
1674 }
1675
1676 use super::*;
1677
1678 #[test]
1679 fn db_crates_are_calibrated() {
1680 // The calibrated set must cover every DB client the classifier knows, or the receipt's coverage
1681 // check would flag a recognized crate as a blind spot. (Was nightly-lint-only; now runs on stable.)
1682 for c in DB_CRATES {
1683 assert!(
1684 CALIBRATED_CRATES.contains(&c),
1685 "DB crate `{c}` is matched by classify() but missing from CALIBRATED_CRATES"
1686 );
1687 }
1688 }
1689
1690 #[test]
1691 fn calibrated_crates_are_live() {
1692 // Conversely, every crate advertised as calibrated must actually be matched by classify() for
1693 // some representative path — a dead entry would silently suppress a real coverage warning.
1694 for c in CALIBRATED_CRATES {
1695 assert!(
1696 CALIBRATION_PROBE_TAILS.iter().any(|t| classify(c, &format!("{c}{t}")).is_some()),
1697 "calibrated crate `{c}` is matched by no path in classify() — dead list entry"
1698 );
1699 }
1700 }
1701
1702 #[test]
1703 fn async_http_stack_classifies() {
1704 // The modern async-HTTP/TLS/QUIC/DNS stack (found by the independent-method differential on oha):
1705 // verb-keyed Net/Ipc/Fs/Env, crate-gated so generic verbs never fabricate across crates.
1706 assert_eq!(classify("hyper", "hyper::client::conn::http1::SendRequest::send_request"), Some("Net"));
1707 assert_eq!(classify("hyper", "hyper::client::conn::http1::handshake"), Some("Net"));
1708 assert_eq!(classify("hyper_util", "hyper_util::client::legacy::Client::request"), Some("Net"));
1709 assert_eq!(classify("hickory_resolver", "hickory_resolver::Resolver::lookup_ip"), Some("Net"));
1710 assert_eq!(classify("quinn", "quinn::Endpoint::connect"), Some("Net"));
1711 assert_eq!(classify("quinn", "quinn::RecvStream::read_to_end"), Some("Net")); // stream byte I/O, not just open
1712 assert_eq!(classify("quinn", "quinn::SendStream::write_all"), Some("Net"));
1713 assert_eq!(classify("tokio_rustls", "tokio_rustls::TlsConnector::connect"), Some("Net"));
1714 assert_eq!(classify("native_tls", "native_tls::TlsConnector::connect"), Some("Net"));
1715 assert_eq!(classify("tokio_vsock", "tokio_vsock::VsockStream::connect"), Some("Ipc"));
1716 assert_eq!(classify("rustls_native_certs", "rustls_native_certs::load_native_certs"), Some("Fs"));
1717 assert_eq!(classify("rlimit", "rlimit::setrlimit"), Some("Env"));
1718 // num_cpus is deliberately PURE (consistency with std::thread::available_parallelism; avoids Env spray)
1719 assert_eq!(classify("num_cpus", "num_cpus::get"), None);
1720 assert_eq!(classify("num_cpus", "num_cpus::get_physical"), None);
1721 // pure surface stays None (no fabrication): builder/type/config paths, and other crates' generic verbs
1722 assert_eq!(classify("hyper", "hyper::Request::builder"), None);
1723 assert_eq!(classify("hyper", "hyper::body::Bytes::new"), None);
1724 assert_eq!(classify("native_tls", "native_tls::TlsConnectorBuilder::min_protocol_version"), None);
1725 assert_eq!(classify("serde", "serde::Deserialize::request"), None); // generic verb, wrong crate
1726 }
1727
1728 #[test]
1729 fn coverage_differential_crates_classify() {
1730 // Crates the coverage differential found DISCLOSED-but-unmodeled. Each rule is verb-keyed +
1731 // crate-gated; the EFFECT verbs map to the right bucket and the PURE surface stays None (a
1732 // wrongly-flagged pure crate is a fabrication, so the negatives matter as much as the positives).
1733
1734 // rustls (sync TLS core) — record I/O is Net; config/cert + the buffered-decrypt step are pure.
1735 assert_eq!(classify("rustls", "rustls::ClientConnection::read_tls"), Some("Net"));
1736 assert_eq!(classify("rustls", "rustls::ConnectionCommon::write_tls"), Some("Net"));
1737 assert_eq!(classify("rustls", "rustls::Connection::complete_io"), Some("Net"));
1738 assert_eq!(classify("rustls", "rustls::ConnectionCommon::process_new_packets"), None); // buffered decrypt, no I/O
1739 assert_eq!(classify("rustls", "rustls::ClientConfig::builder"), None); // pure config
1740
1741 // native-tls variants — handshake is Net; builder is pure.
1742 assert_eq!(classify("native_tls_crate", "native_tls_crate::TlsConnector::connect"), Some("Net"));
1743 assert_eq!(classify("tokio_native_tls", "tokio_native_tls::TlsAcceptor::accept"), Some("Net"));
1744 assert_eq!(classify("native_tls_crate", "native_tls_crate::TlsConnectorBuilder::min_protocol_version"), None);
1745
1746 // etcetera — dir resolution reads env; the args data type is pure.
1747 assert_eq!(classify("etcetera", "etcetera::home_dir"), Some("Env"));
1748 assert_eq!(classify("etcetera", "etcetera::base_strategy::choose_base_strategy"), Some("Env"));
1749 assert_eq!(classify("etcetera", "etcetera::base_strategy::Xdg::config_dir"), Some("Env"));
1750 assert_eq!(classify("etcetera", "etcetera::app_strategy::AppStrategyArgs::new"), None); // pure data
1751
1752 // sqlx-core — connect is Net, execute/fetch round-trips are Db; options/builders pure.
1753 assert_eq!(classify("sqlx_core", "sqlx_core::connection::Connection::connect"), Some("Net"));
1754 assert_eq!(classify("sqlx_core", "sqlx_core::executor::Executor::fetch_one"), Some("Db"));
1755 assert_eq!(classify("sqlx_core", "sqlx_core::executor::Executor::execute"), Some("Db"));
1756 assert_eq!(classify("sqlx_core", "sqlx_core::pool::Pool::acquire"), Some("Db"));
1757 assert_eq!(classify("sqlx_core", "sqlx_core::pool::PoolOptions::max_connections"), None); // pure builder
1758
1759 // walkdir — the lazy read happens in next()/metadata(); builders + cached accessors pure.
1760 assert_eq!(classify("walkdir", "walkdir::IntoIter::next"), Some("Fs"));
1761 assert_eq!(classify("walkdir", "walkdir::DirEntry::metadata"), Some("Fs"));
1762 assert_eq!(classify("walkdir", "walkdir::WalkDir::new"), None); // builder
1763 assert_eq!(classify("walkdir", "walkdir::WalkDir::into_iter"), None); // no I/O until pulled
1764 assert_eq!(classify("walkdir", "walkdir::DirEntry::file_type"), None); // cached, no syscall
1765
1766 // filetime — set_* are utimes (Fs), now is Clock; from_* constructors pure.
1767 assert_eq!(classify("filetime", "filetime::set_file_mtime"), Some("Fs"));
1768 assert_eq!(classify("filetime", "filetime::set_file_handle_times"), Some("Fs"));
1769 assert_eq!(classify("filetime", "filetime::FileTime::now"), Some("Clock"));
1770 assert_eq!(classify("filetime", "filetime::FileTime::from_unix_time"), None);
1771 assert_eq!(classify("filetime", "filetime::FileTime::from_last_modification_time"), None); // reads &Metadata, not disk
1772
1773 // execute — the execute* verbs spawn (Exec); command/shell builders pure.
1774 assert_eq!(classify("execute", "execute::Execute::execute"), Some("Exec"));
1775 assert_eq!(classify("execute", "execute::Execute::execute_output"), Some("Exec"));
1776 assert_eq!(classify("execute", "execute::Execute::execute_multiple_output"), Some("Exec"));
1777 assert_eq!(classify("execute", "execute::command"), None); // only builds a Command
1778 assert_eq!(classify("execute", "execute::shell"), None);
1779
1780 // ctrlc — install signal handler (Ipc).
1781 assert_eq!(classify("ctrlc", "ctrlc::set_handler"), Some("Ipc"));
1782 assert_eq!(classify("ctrlc", "ctrlc::try_set_handler"), Some("Ipc"));
1783
1784 // clap — only the argv-reading terminals are Env; the whole builder + *_from variants pure.
1785 assert_eq!(classify("clap", "clap::Command::get_matches"), Some("Env"));
1786 assert_eq!(classify("clap", "clap::Command::try_get_matches"), Some("Env"));
1787 assert_eq!(classify("clap", "clap::Parser::parse"), Some("Env"));
1788 assert_eq!(classify("clap", "clap::Command::new"), None); // builder
1789 assert_eq!(classify("clap", "clap::Arg::about"), None); // builder
1790 assert_eq!(classify("clap", "clap::Command::get_matches_from"), None); // explicit args, no argv read
1791
1792 // jiff — now* is Clock; tz lookups read the tzdb (Fs); span/civil math pure.
1793 assert_eq!(classify("jiff", "jiff::Timestamp::now"), Some("Clock"));
1794 assert_eq!(classify("jiff", "jiff::Zoned::now_with"), Some("Clock"));
1795 assert_eq!(classify("jiff", "jiff::tz::TimeZone::system"), Some("Fs"));
1796 assert_eq!(classify("jiff", "jiff::tz::TimeZone::get"), Some("Fs"));
1797 assert_eq!(classify("jiff", "jiff::Span::checked_add"), None); // pure arithmetic
1798
1799 // env_logger — init installs the logger + reads RUST_LOG (Log); config setters pure.
1800 assert_eq!(classify("env_logger", "env_logger::init"), Some("Log"));
1801 assert_eq!(classify("env_logger", "env_logger::try_init"), Some("Log"));
1802 assert_eq!(classify("env_logger", "env_logger::Builder::init"), Some("Log"));
1803 assert_eq!(classify("env_logger", "env_logger::Builder::format_timestamp"), None); // config
1804 assert_eq!(classify("env_logger", "env_logger::Builder::build"), None); // pure build
1805
1806 // dialoguer — interact* is tty I/O (Ipc); builders pure.
1807 assert_eq!(classify("dialoguer", "dialoguer::Input::interact_text"), Some("Ipc"));
1808 assert_eq!(classify("dialoguer", "dialoguer::Confirm::interact"), Some("Ipc"));
1809 assert_eq!(classify("dialoguer", "dialoguer::Select::interact_opt"), Some("Ipc"));
1810 assert_eq!(classify("dialoguer", "dialoguer::Input::with_prompt"), None); // builder
1811
1812 // console — Term I/O is Ipc, detection is Env, Style is pure.
1813 assert_eq!(classify("console", "console::Term::write_line"), Some("Ipc"));
1814 assert_eq!(classify("console", "console::Term::read_key"), Some("Ipc"));
1815 assert_eq!(classify("console", "console::colors_enabled"), Some("Env"));
1816 assert_eq!(classify("console", "console::Style::cyan"), None); // pure styling
1817 assert_eq!(classify("console", "console::strip_ansi_codes"), None); // pure text util
1818
1819 // terminal_colorsaurus — tty colour query (Ipc).
1820 assert_eq!(classify("terminal_colorsaurus", "terminal_colorsaurus::background_color"), Some("Ipc"));
1821 assert_eq!(classify("terminal_colorsaurus", "terminal_colorsaurus::color_palette"), Some("Ipc"));
1822
1823 // backoff — retry sleeps + reads the clock (Clock); config pure.
1824 assert_eq!(classify("backoff", "backoff::retry"), Some("Clock"));
1825 assert_eq!(classify("backoff", "backoff::retry_notify"), Some("Clock"));
1826 assert_eq!(classify("backoff", "backoff::ExponentialBackoff::default"), None);
1827
1828 // lscolors — ONLY from_env reads the environment; from_string/style_for_path pure.
1829 assert_eq!(classify("lscolors", "lscolors::LsColors::from_env"), Some("Env"));
1830 assert_eq!(classify("lscolors", "lscolors::LsColors::from_string"), None);
1831 assert_eq!(classify("lscolors", "lscolors::LsColors::style_for_path"), None);
1832
1833 // wild — argv readers (Env).
1834 assert_eq!(classify("wild", "wild::args"), Some("Env"));
1835 assert_eq!(classify("wild", "wild::args_os"), Some("Env"));
1836
1837 // grep_cli — only the firm Exec (CommandReader spawn); the isatty probes stay unmodeled.
1838 assert_eq!(classify("grep_cli", "grep_cli::CommandReaderBuilder::build"), Some("Exec"));
1839 assert_eq!(classify("grep_cli", "grep_cli::is_readable_stdin"), None); // isatty/fstat, not modeled
1840 assert_eq!(classify("grep_cli", "grep_cli::is_tty_stdout"), None);
1841
1842 // clircle — same-file detection issues fstat/lseek (Fs); equality is pure.
1843 assert_eq!(classify("clircle", "clircle::Identifier::try_from"), Some("Fs"));
1844 assert_eq!(classify("clircle", "clircle::Clircle::surely_conflicts_with"), Some("Fs"));
1845 }
1846
1847 #[test]
1848 fn log_tracing_emit_macros_classify_pre_expansion() {
1849 // candor-scan is pre-expansion: it sees the raw macro path (`log::info`, `tracing::warn`), not the
1850 // expanded dispatch the deep engine sees. Both the user-facing macro names AND the type surface:
1851 assert_eq!(classify("log", "log::info"), Some("Log"));
1852 assert_eq!(classify("log", "log::error"), Some("Log"));
1853 assert_eq!(classify("tracing", "tracing::warn"), Some("Log"));
1854 assert_eq!(classify("tracing", "tracing::info_span"), Some("Log"));
1855 // pure data-type surface stays None (no fabricated Log)
1856 assert_eq!(classify("log", "log::Level::as_str"), None);
1857 assert_eq!(classify("tracing", "tracing::Level::INFO"), None);
1858 }
1859
1860 #[test]
1861 fn classify_core_effects() {
1862 // A representative smoke test of the classifier's main families, so the published crate is not
1863 // shipped untested (these used to live only in the nightly-only src/lib.rs).
1864 assert_eq!(classify("std", "std::fs::read_to_string"), Some("Fs"));
1865 // std::path stat-family methods are Fs (each is a stat/readdir syscall); the pure
1866 // string-manipulation surface stays unclassified (the blackout screen's gix-dir find).
1867 assert_eq!(classify("std", "std::path::Path::symlink_metadata"), Some("Fs"));
1868 assert_eq!(classify("std", "std::path::PathBuf::read_dir"), Some("Fs"));
1869 assert_eq!(classify("std", "std::path::Path::exists"), Some("Fs"));
1870 assert_eq!(classify("std", "std::path::Path::join"), None); // pure string manipulation
1871 assert_eq!(classify("std", "std::path::PathBuf::file_name"), None);
1872 assert_eq!(classify("std", "std::path::Path::parent"), None);
1873 assert_eq!(classify("std", "std::process::Command::new"), Some("Exec"));
1874 assert_eq!(classify("std", "std::env::var"), Some("Env"));
1875 assert_eq!(classify("reqwest", "reqwest::Client::execute"), Some("Net"));
1876 // one-shot convenience fns send immediately → Net; the `Client::get` builder stays pure.
1877 assert_eq!(classify("reqwest", "reqwest::get"), Some("Net"));
1878 assert_eq!(classify("reqwest", "reqwest::blocking::get"), Some("Net"));
1879 assert_eq!(classify("reqwest", "reqwest::Client::get"), None);
1880 assert_eq!(classify("reqwest", "reqwest::RequestBuilder::header"), None);
1881 // nix routes through the libc syscall table (same leaves): I/O classified, generic fd ops skipped.
1882 assert_eq!(classify("nix", "nix::fcntl::open"), Some("Fs"));
1883 assert_eq!(classify("nix", "nix::sys::socket::connect"), Some("Net"));
1884 assert_eq!(classify("nix", "nix::unistd::execvp"), Some("Exec"));
1885 assert_eq!(classify("nix", "nix::unistd::write"), None); // generic fd op — deliberately unclassified
1886 assert_eq!(classify("nix", "nix::unistd::getpid"), None); // not I/O
1887 // rustix does raw syscalls (no libc underneath) → classified directly by leaf, same table.
1888 assert_eq!(classify("rustix", "rustix::time::clock_settime"), Some("Clock"));
1889 assert_eq!(classify("rustix", "rustix::fs::symlink"), Some("Fs"));
1890 assert_eq!(classify("rustix", "rustix::net::connect"), Some("Net"));
1891 assert_eq!(classify("rustix", "rustix::io::read"), None); // generic fd op
1892 // pnet raw packet capture: channel openers are Net, packet construction stays pure.
1893 assert_eq!(classify("pnet", "pnet::datalink::channel"), Some("Net"));
1894 assert_eq!(classify("pnet", "pnet::transport::transport_channel"), Some("Net"));
1895 assert_eq!(classify("pnet_datalink", "pnet_datalink::channel"), Some("Net"));
1896 assert_eq!(classify("pnet", "pnet::packet::ethernet::EthernetPacket::new"), None);
1897 assert_eq!(classify("pnet_base", "pnet_base::MacAddr::new"), None);
1898 // ignore (gitignore-aware walker): walk executors are Fs, config builders stay pure.
1899 assert_eq!(classify("ignore", "ignore::WalkBuilder::build_parallel"), Some("Fs"));
1900 assert_eq!(classify("ignore", "ignore::WalkBuilder::build"), Some("Fs"));
1901 assert_eq!(classify("ignore", "ignore::WalkParallel::run"), Some("Fs"));
1902 assert_eq!(classify("ignore", "ignore::WalkBuilder::add_ignore"), Some("Fs")); // reads the ignore file
1903 assert_eq!(classify("ignore", "ignore::overrides::OverrideBuilder::build"), None); // pure config
1904 assert_eq!(classify("ignore", "ignore::gitignore::GitignoreBuilder::build"), None); // pure config
1905 assert_eq!(classify("ignore", "ignore::DirEntry::path"), None); // pure accessor
1906 // notify fs-watching: watcher constructors + watch/unwatch are Fs, data types stay pure.
1907 assert_eq!(classify("notify", "notify::RecommendedWatcher::new"), Some("Fs"));
1908 assert_eq!(classify("notify", "notify::PollWatcher::new"), Some("Fs"));
1909 assert_eq!(classify("notify", "notify::recommended_watcher"), Some("Fs"));
1910 assert_eq!(classify("notify", "notify::INotifyWatcher::watch"), Some("Fs"));
1911 assert_eq!(classify("notify", "notify::Config::default"), None); // pure config
1912 assert_eq!(classify("notify", "notify::Event::new"), None); // pure data type
1913 assert_eq!(classify("rusqlite", "rusqlite::Connection::execute"), Some("Db"));
1914 // the rusqlite verb DIALECT (a verb probe found the canonical consumer API classifying pure):
1915 assert_eq!(classify("rusqlite", "rusqlite::Connection::query_row"), Some("Db"));
1916 assert_eq!(classify("rusqlite", "rusqlite::Statement::query_map"), Some("Db"));
1917 assert_eq!(classify("rusqlite", "rusqlite::Connection::execute_batch"), Some("Db"));
1918 assert_eq!(classify("rusqlite", "rusqlite::Connection::prepare_cached"), Some("Db"));
1919 assert_eq!(classify("rusqlite", "rusqlite::Connection::open"), Some("Db"));
1920 assert_eq!(classify("rusqlite", "rusqlite::Connection::open_in_memory"), Some("Db"));
1921 // …but `open` stays rusqlite-only (postgres has no open; nothing else may borrow it):
1922 assert_eq!(classify("postgres", "postgres::Client::open"), None);
1923 assert_eq!(classify("tokio_postgres", "tokio_postgres::Client::query_typed"), Some("Db"));
1924 // diesel's LIMIT-1 + streaming executions; sqlx's multi-result stream:
1925 assert_eq!(classify("diesel", "diesel::RunQueryDsl::first"), Some("Db"));
1926 assert_eq!(classify("diesel", "diesel::RunQueryDsl::load_iter"), Some("Db"));
1927 assert_eq!(classify("sqlx", "sqlx::query::Query::fetch_many"), Some("Db"));
1928 // sqlx's bare `query()` builder must STAY pure (the original sqlx lesson):
1929 assert_eq!(classify("sqlx", "sqlx::query"), None);
1930 // tracing: the emit/span-lifecycle dispatch is Log; the pure DATA-type accessors are not
1931 // (whole-crate Log fabricated Log on `Level::as_str` / `Span::is_disabled` — the data types are
1932 // pure, same principle as the `log` facade).
1933 assert_eq!(classify("tracing", "tracing::event"), Some("Log"));
1934 assert_eq!(classify("tracing", "tracing::Span::new_span"), Some("Log"));
1935 assert_eq!(classify("tracing", "tracing::Span::record"), Some("Log"));
1936 assert_eq!(classify("tracing", "tracing::Span::enter"), Some("Log"));
1937 assert_eq!(classify("tracing", "tracing::Level::as_str"), None); // pure accessor
1938 assert_eq!(classify("tracing", "tracing::Span::is_disabled"), None); // pure state read
1939 assert_eq!(classify("tracing", "tracing::Span::metadata"), None); // pure accessor
1940 assert_eq!(classify("tracing", "tracing::metadata::Level::TRACE"), None); // pure data type
1941 assert_eq!(classify("tracing", "tracing::field::Field::name"), None); // pure data type
1942 // memmap2: only the syscall-issuing map/flush/protect verbs are Fs; reads over an already-mapped
1943 // region (len/as_ptr/is_empty) and the request builder are PURE (whole-crate Fs fabricated Fs).
1944 assert_eq!(classify("memmap2", "memmap2::MmapOptions::map"), Some("Fs"));
1945 assert_eq!(classify("memmap2", "memmap2::MmapOptions::map_mut"), Some("Fs"));
1946 assert_eq!(classify("memmap2", "memmap2::Mmap::flush"), Some("Fs"));
1947 assert_eq!(classify("memmap2", "memmap2::MmapMut::make_read_only"), Some("Fs"));
1948 assert_eq!(classify("memmap2", "memmap2::Mmap::len"), None); // length read — pure
1949 assert_eq!(classify("memmap2", "memmap2::Mmap::is_empty"), None); // pure
1950 assert_eq!(classify("memmap2", "memmap2::Mmap::as_ptr"), None); // pointer — pure
1951 assert_eq!(classify("memmap2", "memmap2::MmapOptions::new"), None); // request builder — pure
1952 // arboard: the Clipboard handle's read/write verbs are Clipboard; `arboard::Error` formatting
1953 // and option data types are PURE (whole-crate Clipboard fabricated Clipboard on `Error::to_string`).
1954 assert_eq!(classify("arboard", "arboard::Clipboard::new"), Some("Clipboard"));
1955 assert_eq!(classify("arboard", "arboard::Clipboard::get_text"), Some("Clipboard"));
1956 assert_eq!(classify("arboard", "arboard::Clipboard::set_text"), Some("Clipboard"));
1957 assert_eq!(classify("arboard", "arboard::Clipboard::clear"), Some("Clipboard"));
1958 assert_eq!(classify("arboard", "arboard::Error::to_string"), None); // error formatting — pure
1959 assert_eq!(classify("arboard", "arboard::Error::fmt"), None); // Display impl — pure
1960 assert_eq!(classify("arboard", "arboard::ImageData::to_owned_img"), None); // pure data type
1961 // fastrand: value draws + entropy-seeded entry points are Rand; the DETERMINISTIC seeded ctor
1962 // `with_seed` and state split/copy (`fork`/`clone`) are PURE (whole-crate Rand fabricated Rand).
1963 assert_eq!(classify("fastrand", "fastrand::u32"), Some("Rand")); // top-level draw
1964 assert_eq!(classify("fastrand", "fastrand::Rng::usize"), Some("Rand"));
1965 assert_eq!(classify("fastrand", "fastrand::Rng::shuffle"), Some("Rand"));
1966 assert_eq!(classify("fastrand", "fastrand::Rng::new"), Some("Rand")); // entropy-seeded
1967 assert_eq!(classify("fastrand", "fastrand::Rng::with_seed"), None); // deterministic ctor — pure
1968 assert_eq!(classify("fastrand", "fastrand::Rng::fork"), None); // state split — pure
1969 assert_eq!(classify("fastrand", "fastrand::Rng::clone"), None); // state copy — pure
1970 // portable_pty / async_process: spawn/wait keep Exec; config GETTERS and pure data ctors/setters
1971 // do NOT (base Exec fabricated on `CommandBuilder::get_cwd` / `PtySize::default` / `Stdio::piped`).
1972 assert_eq!(classify("portable_pty", "portable_pty::PtySystem::openpty"), Some("Exec"));
1973 assert_eq!(classify("portable_pty", "portable_pty::SlavePty::spawn_command"), Some("Exec"));
1974 assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::get_argv"), None); // getter
1975 assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::get_cwd"), None); // getter
1976 assert_eq!(classify("portable_pty", "portable_pty::PtySize::default"), None); // pure data type
1977 assert_eq!(classify("portable_pty", "portable_pty::CommandBuilder::new"), None); // builder ctor
1978 assert_eq!(classify("async_process", "async_process::Command::spawn"), Some("Exec"));
1979 assert_eq!(classify("async_process", "async_process::Command::output"), Some("Exec"));
1980 assert_eq!(classify("async_process", "async_process::Stdio::piped"), None); // pure data type
1981 assert_eq!(classify("async_process", "async_process::Stdio::null"), None); // pure data type
1982 // FFI tiers (matched by distinctive leaf, alias-independent)
1983 assert_eq!(classify("libc", "libc::open"), Some("Fs"));
1984 assert_eq!(classify("libc", "libc::connect"), Some("Net"));
1985 assert_eq!(classify("libc", "libc::read"), None); // generic fd op — deliberately unclassified
1986 assert_eq!(classify("ffi", "ffi::sqlite3_step"), Some("Db"));
1987 assert_eq!(classify("raw", "raw::git_remote_fetch"), Some("Net"));
1988 // libgit2 clone + submodule clone/update fetch over the network (an A/B on git2 0.20 caught
1989 // `Submodule::update`/`clone` and `Repository::clone` reporting no Net — the latter because the
1990 // `src/build.rs` module was being dropped as if it were the Cargo build script).
1991 assert_eq!(classify("raw", "raw::git_clone"), Some("Net"));
1992 assert_eq!(classify("raw", "raw::git_submodule_clone"), Some("Net"));
1993 assert_eq!(classify("raw", "raw::git_submodule_update"), Some("Net"));
1994 assert_eq!(classify("raw", "raw::git_submodule_open"), None); // local subrepo open — not Net
1995 // libcurl: the transfer/raw-socket entry points are Net (an A/B on curl 0.4 caught the whole
1996 // crate reporting ZERO Net); the big setopt/init/getinfo surface — and the readiness-wait
1997 // multi_wait/poll — stay unclassified (the loop's perform is the boundary).
1998 assert_eq!(classify("curl_sys", "curl_sys::curl_easy_perform"), Some("Net"));
1999 assert_eq!(classify("curl_sys", "curl_sys::curl_easy_send"), Some("Net"));
2000 assert_eq!(classify("curl_sys", "curl_sys::curl_multi_perform"), Some("Net"));
2001 assert_eq!(classify("curl_sys", "curl_sys::curl_multi_socket_action"), Some("Net"));
2002 assert_eq!(classify("curl_sys", "curl_sys::curl_easy_setopt"), None); // in-memory option write
2003 assert_eq!(classify("curl_sys", "curl_sys::curl_easy_init"), None); // handle alloc
2004 assert_eq!(classify("curl_sys", "curl_sys::curl_multi_wait"), None); // readiness wait, no payload
2005 // consumer-side `curl` crate rule: the dispatch verbs are Net, the setopt builders pure.
2006 assert_eq!(classify("curl", "curl::easy::Easy::perform"), Some("Net"));
2007 assert_eq!(classify("curl", "curl::multi::Multi::perform"), Some("Net"));
2008 assert_eq!(classify("curl", "curl::easy::Easy::send"), Some("Net"));
2009 assert_eq!(classify("curl", "curl::easy::Easy::url"), None); // CURLOPT setter — pure
2010 assert_eq!(classify("curl", "curl::easy::Easy::timeout"), None); // pure setter; Multi::timeout under-reported by design
2011 assert_eq!(classify("ffi", "ffi::SSL_connect"), Some("Net"));
2012 // pure crates stay pure
2013 assert_eq!(classify("serde", "serde::Serialize::serialize"), None);
2014 assert_eq!(classify("std", "std::vec::Vec::push"), None);
2015
2016 // ── sweep 2026-06-17: fabrication carve-outs + DNS coverage (each fails pre-fix) ──
2017 // [24] std::net socket accessors are pure; the I/O verbs stay Net.
2018 assert_eq!(classify("std", "std::net::TcpStream::connect"), Some("Net"));
2019 assert_eq!(classify("std", "std::net::TcpStream::local_addr"), None);
2020 assert_eq!(classify("std", "std::net::TcpStream::nodelay"), None);
2021 assert_eq!(classify("std", "std::net::TcpStream::ttl"), None);
2022 assert_eq!(classify("std", "std::net::UdpSocket::peer_addr"), None);
2023 // [37] std DNS resolution is Net (was floored).
2024 assert_eq!(classify("std", "std::net::lookup_host"), Some("Net"));
2025 assert_eq!(classify("std", "core::net::ToSocketAddrs::to_socket_addrs"), Some("Net"));
2026 // [23] std::process getters are pure; spawn/new stay Exec.
2027 assert_eq!(classify("std", "std::process::Command::get_program"), None);
2028 assert_eq!(classify("std", "std::process::Command::get_args"), None);
2029 assert_eq!(classify("std", "std::process::Child::id"), None);
2030 assert_eq!(classify("std", "std::process::Command::spawn"), Some("Exec"));
2031 // [27] redis ConnectionManager::clone is an Arc bump (pure); a query round-trips.
2032 assert_eq!(classify("redis", "redis::aio::ConnectionManager::clone"), None);
2033 assert_eq!(classify("redis", "redis::aio::ConnectionManager::send_packed_command"), Some("Db"));
2034 // [5] sea_orm re-exported sea_query builder algebra is pure; execution verbs stay Db.
2035 assert_eq!(classify("sea_orm", "sea_orm::sea_query::Func::count"), None);
2036 assert_eq!(classify("sea_orm", "sea_orm::sea_query::Condition::all"), None);
2037 assert_eq!(classify("sea_orm", "sea_orm::Select::all"), Some("Db"));
2038 }
2039
2040 #[test]
2041 fn rand_osrng_handle_ops_are_pure_but_draws_are_rand() {
2042 // Adversarial-review fabrication: the blanket `contains("OsRng")` tagged `OsRng::clone` Rand,
2043 // but OsRng is a unit struct — clone/fork/default draw no entropy. The real draws still fire.
2044 assert_eq!(classify("rand", "rand::rngs::OsRng::clone"), None);
2045 assert_eq!(classify("rand", "rand::rngs::OsRng::default"), None);
2046 assert_eq!(classify("rand", "rand::rngs::OsRng::fill_bytes"), Some("Rand")); // a real draw
2047 assert_eq!(classify("rand", "rand::rngs::OsRng::next_u32"), Some("Rand"));
2048 assert_eq!(classify("rand", "rand::Rng::gen"), Some("Rand")); // verb path unaffected
2049 assert_eq!(classify("rand", "rand::distributions::Uniform::new"), None); // pure ctor still pure
2050 }
2051
2052 #[test]
2053 fn redis_connection_manager_config_builder_is_pure() {
2054 // Adversarial-review fabrication: `contains("ConnectionManager")` hit the pure *Config* builder.
2055 assert_eq!(classify("redis", "redis::aio::ConnectionManagerConfig::new"), None);
2056 assert_eq!(classify("redis", "redis::aio::ConnectionManagerConfig::set_max_delay"), None);
2057 // the LIVE manager still round-trips (Db).
2058 assert_eq!(classify("redis", "redis::aio::ConnectionManager::new"), Some("Db"));
2059 assert_eq!(classify("redis", "redis::Commands::get"), Some("Db"));
2060 }
2061
2062 #[test]
2063 fn pure_fd_transfer_is_not_an_effect() {
2064 // ADOPTING / EXTRACTING / BORROWING an already-open descriptor (or unwrapping an async type back
2065 // to its std type) issues NO syscall — it must be PURE even though it hangs off a std I/O type
2066 // whose prefix rule would otherwise fire Net/Fs/Ipc. (Real tokio sweep: `into_std`, `from_raw_fd`,
2067 // `as_raw_fd` all fabricated effects.)
2068 assert_eq!(classify("std", "std::net::TcpStream::from_raw_fd"), None);
2069 assert_eq!(classify("std", "std::net::TcpStream::into_raw_fd"), None);
2070 assert_eq!(classify("std", "std::net::TcpStream::as_raw_fd"), None);
2071 assert_eq!(classify("std", "std::net::TcpListener::from_raw_fd"), None);
2072 assert_eq!(classify("std", "std::net::UdpSocket::from_raw_socket"), None);
2073 assert_eq!(classify("std", "std::fs::File::from_raw_fd"), None);
2074 assert_eq!(classify("std", "std::fs::File::into_raw_fd"), None);
2075 assert_eq!(classify("std", "std::fs::File::as_raw_handle"), None);
2076 assert_eq!(classify("std", "std::os::unix::net::UnixStream::from_raw_fd"), None);
2077 // `SocketAddr::from_pathname` builds an address struct, opens no socket — pure. (socket2 sweep.)
2078 assert_eq!(classify("std", "std::os::unix::net::SocketAddr::from_pathname"), None);
2079 assert_eq!(classify("tokio", "tokio::net::TcpStream::from_raw_fd"), None);
2080 assert_eq!(classify("tokio", "tokio::net::TcpStream::into_std"), None); // unwrap → std type, pure
2081 assert_eq!(classify("tokio", "tokio::fs::File::into_std"), None);
2082 // …but a REAL open/connect on the SAME types still fires the effect — the carve-out is leaf-precise.
2083 assert_eq!(classify("std", "std::net::TcpStream::connect"), Some("Net"));
2084 assert_eq!(classify("std", "std::fs::File::open"), Some("Fs"));
2085 assert_eq!(classify("std", "std::fs::read"), Some("Fs"));
2086 assert_eq!(classify("std", "std::os::unix::net::UnixStream::connect"), Some("Ipc"));
2087 assert_eq!(classify("tokio", "tokio::net::TcpStream::connect"), Some("Net"));
2088 }
2089
2090 #[test]
2091 fn command_head_refines_the_exec_cliff() {
2092 use super::classify_command_head as h;
2093 // unambiguous external tools classify by basename (spec §4 ⟨0.5⟩)
2094 assert_eq!(h("curl"), &["Net"]);
2095 assert_eq!(h("telnet"), &["Net"]);
2096 assert_eq!(h("sftp"), &["Net"]);
2097 assert_eq!(h("/usr/local/bin/psql"), &["Db"]); // basename match strips the path
2098 assert_eq!(h("mongo"), &["Db"]);
2099 assert_eq!(h("cqlsh"), &["Db"]);
2100 // a candor engine is Fs/Env — spec-SUPPLIED by §7 item 12, not curation
2101 assert_eq!(h("candor-scan"), &["Env", "Fs"]);
2102 assert_eq!(h("candor-run.sh"), &["Env", "Fs"]);
2103 // an unrecognised head adds nothing — the bare Exec cliff stands (never guess). `make`/`npm`
2104 // run the project's own code; `git`/`rsync` are multi-modal (local vs remote) — all keep the
2105 // cliff rather than fabricate an effect for the common case.
2106 assert_eq!(h("some-unknown-tool"), &[] as &[&str]);
2107 assert_eq!(h("make"), &[] as &[&str]);
2108 assert_eq!(h("npm"), &[] as &[&str]);
2109 assert_eq!(h("git"), &[] as &[&str]);
2110 assert_eq!(h("rsync"), &[] as &[&str]);
2111 // a builder MODIFIER (`.arg`/`.env`) names no program — its literal must NOT refine (a
2112 // whole-crate-Exec crate classifies every method; `.env("psql",..)` must not fabricate Db).
2113 assert!(is_cmd_builder_method("env") && is_cmd_builder_method("arg") && is_cmd_builder_method("current_dir"));
2114 assert!(!is_cmd_builder_method("new")); // Command::new NAMES the program
2115 assert!(!is_cmd_builder_method("cmd")); // duct::cmd NAMES the program
2116 // The gate that ADMITS a literal to classify_command_head is an ALLOWLIST of program-NAMING
2117 // methods, not the builder denylist. Inversion matters: a whole-crate-Exec crate (portable_pty)
2118 // classifies EVERY method as Exec, so a getter like `cmd.get_env("psql")` — absent from the
2119 // builder denylist — would have leaked "psql" to the head and FABRICATED Db. Only `new`/`cmd`
2120 // name a program, so only they may refine.
2121 assert!(is_cmd_naming_method("new") && is_cmd_naming_method("cmd"));
2122 assert!(!is_cmd_naming_method("get_env")); // a GETTER, not a namer — the leak this closes
2123 assert!(!is_cmd_naming_method("arg") && !is_cmd_naming_method("env") && !is_cmd_naming_method("current_dir"));
2124 }
2125
2126 #[test]
2127 fn net_establishing_allowlist() {
2128 // sweep [3]/[7]: the masking guard's establishing-verb allowlist — host-bearing connect/request
2129 // verbs establish (a runtime host there is invisible); USE-verbs on a connected socket do NOT.
2130 assert!(is_net_establishing("connect") && is_net_establishing("connect_timeout"));
2131 assert!(is_net_establishing("get") && is_net_establishing("post") && is_net_establishing("request"));
2132 assert!(is_net_establishing("send_to") && is_net_establishing("to_socket_addrs"));
2133 // use-verbs (host fixed at connect) must NOT be establishing — else `connect("h").write()` flags.
2134 assert!(!is_net_establishing("write") && !is_net_establishing("read") && !is_net_establishing("send"));
2135 assert!(!is_net_establishing("flush") && !is_net_establishing("recv") && !is_net_establishing("peek"));
2136 }
2137
2138 #[test]
2139 fn fs_path_arg_allowlist() {
2140 // The Fs masking guard's path-naming-fn allowlist — free fns / constructors take the path as a
2141 // string arg (a runtime path there is invisible to the gate). Stat methods (path on the receiver)
2142 // and handle ops carry no path arg and must NOT flag — but they're caught by the caller's
2143 // `!is_method` gate; the allowlist itself just enumerates the path-NAMING leaves.
2144 assert!(is_fs_path_arg("write") && is_fs_path_arg("read") && is_fs_path_arg("read_to_string"));
2145 assert!(is_fs_path_arg("open") && is_fs_path_arg("create") && is_fs_path_arg("create_new"));
2146 assert!(is_fs_path_arg("remove_file") && is_fs_path_arg("rename") && is_fs_path_arg("copy"));
2147 assert!(is_fs_path_arg("create_dir_all") && is_fs_path_arg("canonicalize") && is_fs_path_arg("metadata"));
2148 // handle ops / pure builders take NO path arg — never path-naming.
2149 assert!(!is_fs_path_arg("write_all") && !is_fs_path_arg("flush") && !is_fs_path_arg("read_exact"));
2150 assert!(!is_fs_path_arg("new") && !is_fs_path_arg("sync_all") && !is_fs_path_arg("set_len"));
2151 }
2152
2153 #[test]
2154 fn db_query_arg_allowlist() {
2155 // The Db masking guard's query-bearing-verb allowlist — these take the raw SQL as a string arg
2156 // (a runtime query there is invisible to the gate). Build-then-execute terminals and non-query
2157 // ops carry no SQL string and must NOT flag.
2158 assert!(is_db_query_arg("execute") && is_db_query_arg("query") && is_db_query_arg("query_one"));
2159 assert!(is_db_query_arg("prepare") && is_db_query_arg("batch_execute") && is_db_query_arg("execute_batch"));
2160 assert!(is_db_query_arg("query_row") && is_db_query_arg("query_map") && is_db_query_arg("exec"));
2161 // build-then-execute terminals (query built structurally, no SQL string) must NOT flag.
2162 assert!(!is_db_query_arg("fetch_all") && !is_db_query_arg("load") && !is_db_query_arg("first"));
2163 assert!(!is_db_query_arg("all") && !is_db_query_arg("one") && !is_db_query_arg("stream"));
2164 // connection / lifecycle ops take no SQL — must NOT flag.
2165 assert!(!is_db_query_arg("connect") && !is_db_query_arg("open") && !is_db_query_arg("begin"));
2166 assert!(!is_db_query_arg("commit") && !is_db_query_arg("ping") && !is_db_query_arg("get_conn"));
2167 }
2168}