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