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