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