rust-audit 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
rust_audit.py ADDED
@@ -0,0 +1,2996 @@
1
+ #!/usr/bin/env python3
2
+ """Rust Audit -- static auditor and auto-fixer for AI-generated Rust code.
3
+
4
+ Detects stubs, fakes, demo code, fake tests, panic/unsafe misuse, swallowed
5
+ errors, async pitfalls and hallucinated / typosquatted / fake dependencies.
6
+
7
+ Python 3.11+ standard library only. All output is in English.
8
+
9
+ Subcommands:
10
+ scan static analysis (offline by default, --online for registry checks)
11
+ fix apply safe fixes transactionally (validated, idempotent) or emit
12
+ assisted patch proposals as unified diffs
13
+ cargo evidence pipeline over real Rust tooling (clippy JSON, mutants,
14
+ coverage, OSV, guarddog, ...); missing tools are "skipped"
15
+ full scan + cargo merged report
16
+ selftest built-in rule smoke test
17
+
18
+ Every subcommand accepts --no-exec (static-only mode): no process is ever
19
+ spawned (zero-subprocess guarantee enforced at the single _spawn() entry
20
+ point), and every check that would have executed is recorded as skipped in
21
+ the not-executed report.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import bisect
27
+ import dataclasses
28
+ import difflib
29
+ import fnmatch
30
+ import hashlib
31
+ import json
32
+ import os
33
+ import re
34
+ import shutil
35
+ import subprocess
36
+ import sys
37
+ import tempfile
38
+ import time
39
+ try:
40
+ import tomllib
41
+ except ModuleNotFoundError: # Python < 3.11
42
+ sys.stderr.write(
43
+ "ERROR Rust Audit needs Python 3.11+ (tomllib); this interpreter is "
44
+ f"{sys.version.split()[0]}. Re-run with python3.13, python3.12 or python3.11.\n")
45
+ raise SystemExit(2)
46
+ import urllib.error
47
+ import urllib.request
48
+ from dataclasses import dataclass, field
49
+ from pathlib import Path
50
+
51
+ VERSION = "0.2.3"
52
+ TOOL_NAME = "Rust Audit"
53
+
54
+ SEVERITY_RANK = {"critical": 4, "high": 3, "medium": 2, "low": 1, "info": 0}
55
+ SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"]
56
+
57
+ # --------------------------------------------------------------------------
58
+ # Process-execution choke point. EVERY external command in this tool goes
59
+ # through _spawn() — the single subprocess.run call site of the whole file —
60
+ # so `--no-exec` (static-only mode) is a zero-subprocess guarantee enforced
61
+ # in code, not by convention: with the flag set, no path can create a process.
62
+ # `shutil.which` is pure PATH lookup (no execution) and stays allowed.
63
+ # --------------------------------------------------------------------------
64
+ NO_EXEC = False # set from --no-exec in main()
65
+ EXEC_BLOCKED_NOTE = "--no-exec: static-only mode (zero-subprocess guarantee)"
66
+
67
+
68
+ class ExecBlocked(RuntimeError):
69
+ """A process spawn was requested while --no-exec (static-only) is active."""
70
+
71
+
72
+ def _spawn(argv: list[str], *, cwd: Path, timeout: int):
73
+ """The one and only subprocess.run call site of Rust Audit."""
74
+ if NO_EXEC:
75
+ raise ExecBlocked(" ".join(argv))
76
+ return subprocess.run(argv, cwd=cwd, capture_output=True, text=True,
77
+ encoding="utf-8", errors="replace", timeout=timeout)
78
+
79
+ # --------------------------------------------------------------------------
80
+ # Rule catalog. fix: "safe" (auto-applicable), "assisted" (diff proposal
81
+ # only), "none" (never auto-fixed: behaviour, concurrency, security).
82
+ # --------------------------------------------------------------------------
83
+ RULES: dict[str, dict] = {
84
+ "R01": {"tier": 1, "title": "Unimplemented stub", "severity": "critical", "fix": "none"},
85
+ "R02": {"tier": 1, "title": "Fake / meaningless test", "severity": "high", "fix": "none"},
86
+ "R03": {"tier": 1, "title": "Stubbed logic", "severity": "high", "fix": "none"},
87
+ "R04": {"tier": 1, "title": "Placeholder data used as real", "severity": "high", "fix": "none"},
88
+ "R05": {"tier": 1, "title": "Demo code", "severity": "medium", "fix": "none"},
89
+ "R06": {"tier": 1, "title": "Fake/test double outside tests", "severity": "medium", "fix": "none"},
90
+ "R07": {"tier": 1, "title": "Over-mocking / mock-only tests", "severity": "low", "fix": "none"},
91
+ "R08": {"tier": 1, "title": "Flaky / order-dependent test", "severity": "medium", "fix": "none"},
92
+ "R09": {"tier": 2, "title": "unwrap/expect/panic in production code", "severity": "high", "fix": "assisted"},
93
+ "R10": {"tier": 2, "title": "Swallowed error", "severity": "medium", "fix": "assisted"},
94
+ "R11": {"tier": 2, "title": "unsafe without justification", "severity": "high", "fix": "none"},
95
+ "R12": {"tier": 2, "title": "Hardcoded secret", "severity": "critical", "fix": "safe"},
96
+ "R13": {"tier": 2, "title": "Missing input validation", "severity": "medium", "fix": "none"},
97
+ "R14": {"tier": 3, "title": "clone() abuse", "severity": "medium", "fix": "none"},
98
+ "R15": {"tier": 3, "title": "Invented lifetimes", "severity": "low", "fix": "none"},
99
+ "R16": {"tier": 3, "title": "Async misuse (Tokio)", "severity": "high", "fix": "assisted"},
100
+ "R17": {"tier": 3, "title": "Arc<Mutex<>> overuse", "severity": "low", "fix": "none"},
101
+ "R18a": {"tier": 4, "title": "Unresolved external crate reference", "severity": "high", "fix": "none"},
102
+ "R18b": {"tier": 4, "title": "Standard module used as external crate", "severity": "high", "fix": "safe"},
103
+ "R18c": {"tier": 4, "title": "Near-miss crate name", "severity": "high", "fix": "none"},
104
+ "R18d": {"tier": 4, "title": "Suspicious / nonexistent / impossible crate or version", "severity": "critical", "fix": "none"},
105
+ "R18e": {"tier": 4, "title": "Declared dependency never used", "severity": "low", "fix": "none"},
106
+ "R19": {"tier": 4, "title": "Slopsquatting window (recently registered crate)", "severity": "high", "fix": "none"},
107
+ "R20": {"tier": 4, "title": "Potential typosquat of a popular crate", "severity": "high", "fix": "none"},
108
+ "R21": {"tier": 4, "title": "Risky build.rs / proc-macro", "severity": "high", "fix": "none"},
109
+ }
110
+
111
+ # Modules commonly hallucinated as standalone crates (arxiv 2606.08444: 45% of
112
+ # crate hallucinations are std/internal modules referenced as external crates).
113
+ STD_MODULES = {
114
+ "alloc", "any", "arch", "ascii", "borrow", "boxed", "cell", "char", "clone",
115
+ "cmp", "collections", "convert", "default", "env", "error", "f32", "f64",
116
+ "ffi", "fmt", "fs", "future", "hash", "i8", "i16", "i32", "i64", "i128",
117
+ "io", "isize", "iter", "marker", "mem", "net", "num", "ops", "option",
118
+ "os", "panic", "path", "pin", "prelude", "process", "ptr", "rc", "result",
119
+ "slice", "str", "string", "sync", "task", "thread", "time", "u8", "u16",
120
+ "u32", "u64", "u128", "unit", "usize", "vec",
121
+ }
122
+ STD_CRATES = {"std", "core", "alloc", "proc_macro", "test"}
123
+
124
+ # Known major versions of popular crates: offline "impossible version" check.
125
+ KNOWN_MAJORS = {
126
+ "tokio": 1, "serde": 1, "serde_json": 1, "axum": 0, "hyper": 1,
127
+ "reqwest": 0, "clap": 4, "anyhow": 1, "thiserror": 1, "rand": 0,
128
+ "regex": 1, "log": 0, "env_logger": 0, "chrono": 0, "uuid": 1,
129
+ "once_cell": 1, "tracing": 0, "tower": 0, "tonic": 0, "prost": 0,
130
+ "sqlx": 0, "diesel": 2, "rocket": 0, "actix-web": 4, "warp": 0,
131
+ "futures": 0, "bytes": 1, "libc": 0, "parking_lot": 0, "lazy_static": 1,
132
+ "itertools": 0, "tempfile": 3, "url": 2, "semver": 1, "toml": 0,
133
+ "indexmap": 2, "hashbrown": 0, "smallvec": 1, "bitflags": 2, "syn": 2,
134
+ "quote": 1, "proc-macro2": 1, "async-trait": 0, "pin-project": 1,
135
+ "dashmap": 5, "criterion": 0, "cc": 1, "serde_derive": 1,
136
+ }
137
+
138
+ SUSPICIOUS_NAME_RE = re.compile(
139
+ r"(?i)^(rust|async|simple|easy|smart|fast)[-_].*$|"
140
+ r".*[-_](utils?|helpers?|extensions?|extra|lite|lib|core)$"
141
+ )
142
+
143
+ GENERIC_TEST_NAME_RE = re.compile(
144
+ r"(?i)^(test|it)[_-]?\d+$|^test_(ok|works|fine|good|basic|simple|pass|passes|it|kind)$"
145
+ r"|^works$|^test$"
146
+ )
147
+
148
+ TEST_ATTR_RE = re.compile(
149
+ r"#\[\s*(?:tokio::|async_std::|core::prelude::v1::)?"
150
+ r"(?:test|bench|rstest|quickcheck|proptest)\b"
151
+ )
152
+
153
+
154
+ @dataclass
155
+ class Finding:
156
+ rule_id: str
157
+ severity: str
158
+ message: str
159
+ file: str
160
+ line: int
161
+ function: str = ""
162
+ evidence: str = ""
163
+ source: str = "static"
164
+ confidence: str = "high"
165
+ fix: str = "none"
166
+ fix_hint: str = ""
167
+
168
+ @property
169
+ def tier(self) -> int:
170
+ return RULES.get(self.rule_id, {}).get("tier", 0)
171
+
172
+ def to_dict(self) -> dict:
173
+ d = dataclasses.asdict(self)
174
+ d["tier"] = self.tier
175
+ return d
176
+
177
+
178
+ @dataclass
179
+ class EvidenceRun:
180
+ tool: str
181
+ command: str
182
+ status: str # pass | fail | findings | skipped | error
183
+ exit_code: int | None = None
184
+ duration_s: float = 0.0
185
+ excerpt: str = ""
186
+ notes: str = ""
187
+
188
+ def to_dict(self) -> dict:
189
+ return dataclasses.asdict(self)
190
+
191
+
192
+ # --------------------------------------------------------------------------
193
+ # Masking lexer: blanks comments and/or string/char literals while keeping
194
+ # offsets and line numbers intact, so pattern rules never match inside
195
+ # comments or string contents.
196
+ # --------------------------------------------------------------------------
197
+ def _blank(seg: str) -> str:
198
+ return "".join("\n" if c == "\n" else " " for c in seg)
199
+
200
+
201
+ def mask_source(src: str, mask_strings: bool = True, mask_comments: bool = True) -> str:
202
+ out: list[str] = []
203
+ i, n = 0, len(src)
204
+ while i < n:
205
+ ch = src[i]
206
+ nxt = src[i + 1] if i + 1 < n else ""
207
+ if ch == "/" and nxt == "*":
208
+ j = src.find("*/", i + 2)
209
+ j = n if j < 0 else j + 2
210
+ out.append(_blank(src[i:j]) if mask_comments else src[i:j])
211
+ i = j
212
+ elif ch == "/" and nxt == "/":
213
+ j = src.find("\n", i)
214
+ j = n if j < 0 else j
215
+ out.append(_blank(src[i:j]) if mask_comments else src[i:j])
216
+ i = j
217
+ elif ch == "b" and nxt in ("r", '"', "'"):
218
+ out.append(ch)
219
+ i += 1
220
+ elif ch == "r" and (nxt == '"' or nxt == "#"):
221
+ k, hashes = i + 1, 0
222
+ while k < n and src[k] == "#":
223
+ hashes += 1
224
+ k += 1
225
+ if k < n and src[k] == '"':
226
+ end = '"' + "#" * hashes
227
+ j = src.find(end, k + 1)
228
+ j = n if j < 0 else j + len(end)
229
+ out.append(_blank(src[i:j]) if mask_strings else src[i:j])
230
+ i = j
231
+ else:
232
+ out.append(ch)
233
+ i += 1
234
+ elif ch == '"':
235
+ j = i + 1
236
+ while j < n:
237
+ if src[j] == "\\":
238
+ j += 2
239
+ continue
240
+ if src[j] == '"':
241
+ j += 1
242
+ break
243
+ j += 1
244
+ out.append(_blank(src[i:j]) if mask_strings else src[i:j])
245
+ i = j
246
+ elif ch == "'":
247
+ if i + 1 < n and src[i + 1] == "\\":
248
+ j = src.find("'", i + 2)
249
+ j = n if j < 0 else j + 1
250
+ out.append(_blank(src[i:j]) if mask_strings else src[i:j])
251
+ i = j
252
+ elif i + 2 < n and src[i + 2] == "'":
253
+ out.append(_blank(src[i:i + 3]) if mask_strings else src[i:i + 3])
254
+ i += 3
255
+ else:
256
+ out.append(ch) # lifetime
257
+ i += 1
258
+ else:
259
+ out.append(ch)
260
+ i += 1
261
+ return "".join(out)
262
+
263
+
264
+ class Source:
265
+ """One Rust source file with masked views and offset->line mapping."""
266
+
267
+ def __init__(self, path: str, text: str):
268
+ self.path = path
269
+ self.text = text
270
+ self.masked_all = mask_source(text, True, True) # code only
271
+ self.masked_comments = mask_source(text, False, True) # strings visible
272
+ self.line_starts = [0]
273
+ for idx, c in enumerate(text):
274
+ if c == "\n":
275
+ self.line_starts.append(idx + 1)
276
+
277
+ def line_of(self, offset: int) -> int:
278
+ return bisect.bisect_right(self.line_starts, offset)
279
+
280
+ def line_text(self, lineno: int) -> str:
281
+ if 1 <= lineno <= len(self.line_starts):
282
+ start = self.line_starts[lineno - 1]
283
+ end = self.text.find("\n", start)
284
+ end = len(self.text) if end < 0 else end
285
+ return self.text[start:end].strip()
286
+ return ""
287
+
288
+
289
+ def match_pair(text: str, open_idx: int) -> int:
290
+ """Index of the bracket matching text[open_idx] (parens/brackets/braces)."""
291
+ pairs = {"(": ")", "[": "]", "{": "}"}
292
+ opener = text[open_idx]
293
+ closer = pairs.get(opener)
294
+ if closer is None:
295
+ return -1
296
+ depth = 0
297
+ i, n = open_idx, len(text)
298
+ while i < n:
299
+ c = text[i]
300
+ if c == opener:
301
+ depth += 1
302
+ elif c == closer:
303
+ depth -= 1
304
+ if depth == 0:
305
+ return i
306
+ i += 1
307
+ return -1
308
+
309
+
310
+ @dataclass
311
+ class FnInfo:
312
+ name: str
313
+ start: int
314
+ end: int
315
+ sig: str
316
+ body: str
317
+ body_start_line: int
318
+ attrs: list[str]
319
+ is_test: bool
320
+ is_async: bool
321
+ line: int
322
+
323
+
324
+ @dataclass
325
+ class FileUnit:
326
+ rel: str
327
+ path: Path
328
+ src: Source
329
+ kind: str # lib | build | example | test | bench | other
330
+ fns: list[FnInfo]
331
+ test_regions: list[tuple[int, int]]
332
+ contract_regions: list[tuple[int, int]] # impl Trait for X / trait blocks
333
+
334
+
335
+ FN_RE = re.compile(r"\bfn\s+([A-Za-z_]\w*)\s*")
336
+ MOD_RE = re.compile(r"\bmod\s+([A-Za-z_]\w*)\s*\{")
337
+
338
+
339
+ def _attrs_above(src: Source, line: int) -> list[str]:
340
+ attrs: list[str] = []
341
+ i = line - 1
342
+ while i >= 1:
343
+ text = src.line_text(i)
344
+ stripped = text.strip()
345
+ if stripped.startswith("#[") or stripped.startswith("#!["):
346
+ attrs.append(stripped)
347
+ i -= 1
348
+ elif stripped.startswith("//"):
349
+ i -= 1
350
+ elif stripped == "":
351
+ break
352
+ else:
353
+ break
354
+ return attrs
355
+
356
+
357
+ def _file_kind(rel: str) -> str:
358
+ p = rel.replace("\\", "/")
359
+ if p.endswith("build.rs") or re.search(r"(^|/)build-[^/]*\.rs$", p):
360
+ return "build"
361
+ if "/examples/" in f"/{p}" or p.startswith("examples/"):
362
+ return "example"
363
+ if "/benches/" in f"/{p}" or p.startswith("benches/"):
364
+ return "bench"
365
+ if "/tests/" in f"/{p}" or p.startswith("tests/"):
366
+ return "test"
367
+ return "lib"
368
+
369
+
370
+ def parse_file(rel: str, path: Path, text: str) -> FileUnit:
371
+ src = Source(rel, text)
372
+ masked = src.masked_all
373
+ kind = _file_kind(rel)
374
+
375
+ test_regions: list[tuple[int, int]] = []
376
+ for m in MOD_RE.finditer(masked):
377
+ name = m.group(1)
378
+ lookback = masked[max(0, m.start() - 200): m.start()]
379
+ cfg_test = "#[cfg(test)]" in lookback
380
+ if cfg_test or name in ("tests", "test"):
381
+ open_idx = masked.index("{", m.start())
382
+ close_idx = match_pair(masked, open_idx)
383
+ if close_idx > 0:
384
+ test_regions.append((m.start(), close_idx))
385
+
386
+ # Regions where a constant return or an ignored parameter fulfils a
387
+ # documented contract: `impl Trait for Type` blocks and `trait` blocks
388
+ # (default methods).
389
+ contract_regions: list[tuple[int, int]] = []
390
+ for m in re.finditer(r"\b(?:impl\b[^{};]*\bfor\b[^{};]*|trait\b[^{};]*)\{", masked):
391
+ close_idx = match_pair(masked, m.end() - 1)
392
+ if close_idx > 0:
393
+ contract_regions.append((m.start(), close_idx))
394
+
395
+ fns: list[FnInfo] = []
396
+ for m in FN_RE.finditer(masked):
397
+ name = m.group(1)
398
+ # find parameter list
399
+ i = m.end()
400
+ n = len(masked)
401
+ while i < n and masked[i] != "(":
402
+ if masked[i] in ";{":
403
+ i = -1
404
+ break
405
+ i += 1
406
+ if i < 0 or i >= n:
407
+ continue
408
+ close_paren = match_pair(masked, i)
409
+ if close_paren < 0:
410
+ continue
411
+ # find body open brace (or ';' for declarations without body)
412
+ j = close_paren + 1
413
+ depth = 0
414
+ open_brace = -1
415
+ is_decl = False
416
+ while j < n:
417
+ c = masked[j]
418
+ if depth == 0 and c == "{":
419
+ open_brace = j
420
+ break
421
+ if depth == 0 and c == ";":
422
+ is_decl = True
423
+ break
424
+ if c in "([{":
425
+ depth += 1
426
+ elif c in ")]}":
427
+ depth -= 1
428
+ if depth < 0:
429
+ break
430
+ j += 1
431
+ if is_decl or open_brace < 0:
432
+ continue
433
+ close_brace = match_pair(masked, open_brace)
434
+ if close_brace < 0:
435
+ continue
436
+ sig = masked[m.start():open_brace]
437
+ body = masked[open_brace + 1: close_brace]
438
+ line = src.line_of(m.start())
439
+ attrs = _attrs_above(src, line)
440
+ attr_blob = " ".join(attrs)
441
+ in_test_region = any(a <= m.start() <= b for a, b in test_regions)
442
+ is_test = bool(
443
+ TEST_ATTR_RE.search(attr_blob)
444
+ or "#[cfg(test)]" in attr_blob
445
+ or in_test_region
446
+ or kind == "test"
447
+ )
448
+ pre = masked[max(0, m.start() - 40): m.start()]
449
+ is_async = bool(re.search(r"\basync\s*$", pre))
450
+ fns.append(FnInfo(
451
+ name=name, start=m.start(), end=close_brace, sig=sig, body=body,
452
+ body_start_line=src.line_of(open_brace), attrs=attrs,
453
+ is_test=is_test, is_async=is_async, line=line,
454
+ ))
455
+ # Inner functions are local helpers: inherit the enclosing function's
456
+ # context (e.g. compile-time type assertions inside a #[test] fn).
457
+ for i, fn in enumerate(fns):
458
+ for j, outer in enumerate(fns):
459
+ if i != j and outer.start < fn.start and fn.end <= outer.end \
460
+ and outer.is_test:
461
+ fn.is_test = True
462
+ break
463
+ return FileUnit(rel=rel, path=path, src=src, kind=kind, fns=fns,
464
+ test_regions=test_regions,
465
+ contract_regions=contract_regions)
466
+
467
+
468
+ def locate_fn(fns: list[FnInfo], offset: int) -> FnInfo | None:
469
+ best = None
470
+ for fn in fns:
471
+ if fn.start <= offset <= fn.end:
472
+ if best is None or (fn.end - fn.start) < (best.end - best.start):
473
+ best = fn
474
+ return best
475
+
476
+
477
+ def macro_calls(masked: str, *names: str) -> list[tuple[str, int, int, str]]:
478
+ """(macro_name, start, end, arg_text) for each macro invocation."""
479
+ res: list[tuple[str, int, int, str]] = []
480
+ for name in names:
481
+ for m in re.finditer(r"\b" + re.escape(name) + r"!\s*\(", masked):
482
+ open_idx = masked.index("(", m.start())
483
+ close_idx = match_pair(masked, open_idx)
484
+ if close_idx > 0:
485
+ res.append((name, m.start(), close_idx, masked[open_idx + 1: close_idx]))
486
+ return res
487
+
488
+
489
+ # --------------------------------------------------------------------------
490
+ # Cargo.toml / Cargo.lock parsing
491
+ # --------------------------------------------------------------------------
492
+ def _norm(name: str) -> str:
493
+ return name.replace("-", "_")
494
+
495
+
496
+ @dataclass
497
+ class Dep:
498
+ key: str # key in Cargo.toml
499
+ package: str # registry name (respects `package = "..."`)
500
+ alias: str # name used in Rust code
501
+ kind: str # normal | dev | build | workspace
502
+ version_req: str
503
+ features: list[str]
504
+ optional: bool
505
+
506
+
507
+ def parse_manifest(manifest_path: Path) -> tuple[list[Dep], dict]:
508
+ try:
509
+ data = tomllib.loads(manifest_path.read_text(encoding="utf-8"))
510
+ except Exception:
511
+ return [], {}
512
+ deps: list[Dep] = []
513
+ sections = [
514
+ ("dependencies", "normal"),
515
+ ("dev-dependencies", "dev"),
516
+ ("build-dependencies", "build"),
517
+ ]
518
+ for sec, kind in sections:
519
+ for key, val in (data.get(sec) or {}).items():
520
+ deps.append(_mk_dep(key, val, kind))
521
+ for key, val in ((data.get("workspace") or {}).get("dependencies") or {}).items():
522
+ deps.append(_mk_dep(key, val, "workspace"))
523
+ for target in (data.get("target") or {}).values():
524
+ for sec, kind in sections:
525
+ for key, val in (target.get(sec) or {}).items():
526
+ deps.append(_mk_dep(key, val, kind))
527
+ return deps, data
528
+
529
+
530
+ def _mk_dep(key: str, val, kind: str) -> Dep:
531
+ if isinstance(val, str):
532
+ return Dep(key=key, package=key, alias=_norm(key), kind=kind,
533
+ version_req=val, features=[], optional=False)
534
+ if isinstance(val, dict):
535
+ package = val.get("package", key)
536
+ return Dep(
537
+ key=key, package=package, alias=_norm(key), kind=kind,
538
+ version_req=str(val.get("version", "")),
539
+ features=list(val.get("features") or []),
540
+ optional=bool(val.get("optional", False)),
541
+ )
542
+ return Dep(key=key, package=key, alias=_norm(key), kind=kind,
543
+ version_req="", features=[], optional=False)
544
+
545
+
546
+ def parse_lock(lock_path: Path) -> dict[str, str]:
547
+ """crate name -> resolved version (first entry wins)."""
548
+ out: dict[str, str] = {}
549
+ if not lock_path.exists():
550
+ return out
551
+ try:
552
+ data = tomllib.loads(lock_path.read_text(encoding="utf-8"))
553
+ except Exception:
554
+ return out
555
+ for pkg in data.get("package") or []:
556
+ name = pkg.get("name")
557
+ if name and name not in out:
558
+ out[name] = str(pkg.get("version", ""))
559
+ return out
560
+
561
+
562
+ def levenshtein(a: str, b: str) -> int:
563
+ if a == b:
564
+ return 0
565
+ if not a or not b:
566
+ return max(len(a), len(b))
567
+ prev = list(range(len(b) + 1))
568
+ for i, ca in enumerate(a, 1):
569
+ cur = [i]
570
+ for j, cb in enumerate(b, 1):
571
+ cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
572
+ prev = cur
573
+ return prev[-1]
574
+
575
+
576
+ # --------------------------------------------------------------------------
577
+ # Bundled resources (also loadable from the skill's resources/ directory)
578
+ # --------------------------------------------------------------------------
579
+ def _data_dir(name: str) -> Path:
580
+ """Where the bundled data lives, in every install shape.
581
+
582
+ Checkout: `scripts/rust_audit.py` -> `../resources` (repo root).
583
+ Wheel: the module sits in site-packages, so the data installed by
584
+ `data-files` is under `<sys.prefix>/share/rustaudit/`.
585
+ """
586
+ here = Path(__file__).resolve().parent
587
+ candidates = (here / name,
588
+ here.parent / name,
589
+ Path(sys.prefix) / "share" / "rustaudit" / name)
590
+ for candidate in candidates:
591
+ if candidate.is_dir():
592
+ return candidate
593
+ return candidates[0]
594
+
595
+
596
+ def _resource_dir() -> Path:
597
+ return _data_dir("resources")
598
+
599
+
600
+ def _template_dir() -> Path:
601
+ return _data_dir("templates")
602
+
603
+
604
+ def load_name_list(filename: str, fallback: set[str]) -> set[str]:
605
+ p = _resource_dir() / filename
606
+ if p.exists():
607
+ return {
608
+ line.strip()
609
+ for line in p.read_text(encoding="utf-8").splitlines()
610
+ if line.strip() and not line.startswith("#")
611
+ }
612
+ return set(fallback)
613
+
614
+
615
+ FALLBACK_POPULAR = {
616
+ "serde", "serde_json", "tokio", "rand", "clap", "anyhow", "thiserror",
617
+ "regex", "log", "chrono", "uuid", "tracing", "axum", "hyper", "reqwest",
618
+ "futures", "bytes", "libc", "parking_lot", "itertools", "once_cell",
619
+ "async-trait", "syn", "quote", "proc-macro2", "tempfile", "url", "toml",
620
+ "env_logger", "indexmap", "hashbrown", "smallvec", "bitflags", "cc",
621
+ "tower", "tonic", "prost", "sqlx", "diesel", "rocket", "actix-web",
622
+ "warp", "mime", "semver", "criterion", "lazy_static", "pin-project",
623
+ "dashmap", "tracing-subscriber", "serde_derive", "dotenv", "base64",
624
+ "hex", "sha2", "md5", "num-traits", "rayon", "crossbeam", "mio",
625
+ "socket2", "trust-dns-resolver", "rustls", "openssl", "native-tls",
626
+ }
627
+ FALLBACK_SUSPICIOUS = {
628
+ "rust-http-client", "rust-database", "rust-utils", "async-utils",
629
+ "async-helpers", "file-utils", "rust-orm", "rust-helpers", "easy-utils",
630
+ "tokio-utils-extra", "serde-helpers", "axum-extensions", "simple-crud",
631
+ "async-database-pool", "rust-file-utils", "smart-cache", "fast-json",
632
+ }
633
+
634
+
635
+ class Registry:
636
+ def __init__(self, deps: list[Dep], corpus_extra: set[str] | None = None):
637
+ self.deps = deps
638
+ self.by_alias = {_norm(d.alias): d for d in deps}
639
+ self.by_package = {_norm(d.package): d for d in deps}
640
+ self.popular = load_name_list("popular_crates.txt", FALLBACK_POPULAR)
641
+ self.suspicious = load_name_list("suspicious_names.txt", FALLBACK_SUSPICIOUS)
642
+ if corpus_extra:
643
+ self.suspicious |= corpus_extra
644
+
645
+ def known_roots(self) -> set[str]:
646
+ roots = set(self.by_alias) | set(self.by_package)
647
+ roots |= {_norm(k) for k in self.by_alias}
648
+ return roots
649
+
650
+
651
+ # --------------------------------------------------------------------------
652
+ # Rule checks (per file)
653
+ # --------------------------------------------------------------------------
654
+ def _ctx_label(unit: FileUnit, fn: FnInfo | None) -> tuple[str, str]:
655
+ if fn is not None:
656
+ return fn.name, ("test" if fn.is_test else unit.kind)
657
+ return "", unit.kind
658
+
659
+
660
+ def _prod_severity(unit: FileUnit, fn: FnInfo | None, prod: str, test: str = "info",
661
+ example: str = "info") -> str | None:
662
+ """Severity by context; None means 'do not report'."""
663
+ if fn is not None and fn.is_test:
664
+ return None if test == "skip" else test
665
+ if unit.kind in ("example", "bench"):
666
+ return example
667
+ return prod
668
+
669
+
670
+ def check_r01(unit: FileUnit, out: list[Finding]) -> None:
671
+ masked = unit.src.masked_all
672
+ for m in re.finditer(r"\b(todo!|unimplemented!)\s*\(", masked):
673
+ fn = locate_fn(unit.fns, m.start())
674
+ sev = _prod_severity(unit, fn, "critical", "info", "info")
675
+ if sev is None:
676
+ continue
677
+ name, _ = _ctx_label(unit, fn)
678
+ out.append(Finding(
679
+ "R01", sev,
680
+ f"`{m.group(1)}!()` marks this code as unimplemented (stub).",
681
+ unit.rel, unit.src.line_of(m.start()), name,
682
+ evidence=masked[m.start(): m.start() + 40].strip(),
683
+ fix="none",
684
+ fix_hint="Implement the behaviour or return an explicit error; never ship todo!()/unimplemented!().",
685
+ ))
686
+ strings = unit.src.masked_comments
687
+ for m in re.finditer(r"panic!\s*\(\s*\"(?:not implemented|unimplemented|TODO|FIXME)\"", strings):
688
+ fn = locate_fn(unit.fns, m.start())
689
+ sev = _prod_severity(unit, fn, "critical", "info", "info")
690
+ if sev is None:
691
+ continue
692
+ name, _ = _ctx_label(unit, fn)
693
+ out.append(Finding(
694
+ "R01", sev, 'Placeholder panic!("not implemented").',
695
+ unit.rel, unit.src.line_of(m.start()), name,
696
+ evidence=m.group(0), fix="none",
697
+ fix_hint="Implement the behaviour or return a typed error.",
698
+ ))
699
+
700
+
701
+ def _pure_args(args: str) -> bool:
702
+ """True when the assert arguments can only read state.
703
+
704
+ Conservative: calls, field/method access (auto-deref may run code),
705
+ indexing, deref and `?` make the arguments effectful; float literals do
706
+ not (their `.` is a value, not a path). PartialEq impls are out of scope,
707
+ as in the Test Smells Catalog itself."""
708
+ scan = mask_source(args, True, True)
709
+ scan = re.sub(r"\d+\.\d+", "", scan)
710
+ return not re.search(r"[(\[.*?]", scan)
711
+
712
+
713
+ def _only_asserts_between(text: str, spans: list[tuple[int, int]],
714
+ e1: int, s2: int) -> bool:
715
+ """True when only other assert calls (and ; { }) separate the two spots."""
716
+ pos = e1
717
+ for a_start, a_end in spans:
718
+ if e1 <= a_start and a_end <= s2:
719
+ if not re.fullmatch(r"[\s;{}]*", text[pos:a_start]):
720
+ return False
721
+ pos = a_end
722
+ return bool(re.fullmatch(r"[\s;{}]*", text[pos:s2]))
723
+
724
+
725
+ def check_r02(unit: FileUnit, out: list[Finding]) -> None:
726
+ for fn in unit.fns:
727
+ # Only functions carrying a real test attribute are tests: helpers
728
+ # inside test regions cannot be "tests that cannot fail".
729
+ if not fn.is_test or not TEST_ATTR_RE.search(" ".join(fn.attrs)):
730
+ continue
731
+ body, masked = fn.body, unit.src.masked_all
732
+ open_idx = masked.find("{", fn.start)
733
+ raw_body = unit.src.text[open_idx + 1: fn.end] if open_idx >= 0 else body
734
+ # Assertions are compared with their string literals visible: masked
735
+ # literals make distinct asserts look identical (false duplicates) and
736
+ # hide failure messages from the roulette check.
737
+ body_vis = mask_source(raw_body, False, True)
738
+ asserts = macro_calls(body_vis, "assert", "assert_eq", "assert_ne")
739
+ has_panic = bool(re.search(r"\b(panic!|unreachable!)\s*\(", body))
740
+ returns_result = "Result" in fn.sig
741
+ has_try = bool(re.search(r"\?\s*;", body))
742
+ # #[should_panic]: the panic IS the oracle. unwrap()/expect() panic on
743
+ # bad behaviour, so they are oracles too ("cannot fail" would be wrong).
744
+ should_panic = any("should_panic" in a for a in fn.attrs)
745
+ has_unwrap = bool(re.search(r"\.(?:unwrap|expect)\s*\(", body))
746
+ if not asserts and not has_panic and not should_panic \
747
+ and not has_unwrap and not (returns_result and has_try):
748
+ if "(" not in body:
749
+ # No call and no oracle: the test literally cannot fail.
750
+ out.append(Finding(
751
+ "R02", "high",
752
+ f"Test `{fn.name}` cannot fail (empty body: no call, no assertion).",
753
+ unit.rel, fn.line, fn.name, evidence="no call, no oracle in body",
754
+ fix="none",
755
+ fix_hint="Assert an observable behaviour; a test that cannot fail proves nothing.",
756
+ ))
757
+ else:
758
+ # It calls helpers whose assertions may be the oracle; the tool
759
+ # cannot see through the call, so it reports only what is true.
760
+ out.append(Finding(
761
+ "R02", "low",
762
+ f"Test `{fn.name}` has no direct oracle in its body "
763
+ f"(assertions may live in helpers: verify it can fail).",
764
+ unit.rel, fn.line, fn.name, evidence="no assert*/panic! in body",
765
+ fix="none",
766
+ fix_hint="Assert an observable behaviour here, or rely on the "
767
+ "helper's assertions knowingly.",
768
+ ))
769
+ for _, start, _, args in asserts:
770
+ compact = re.sub(r"\s+", " ", args).strip()
771
+ # Bare `false` is NOT tautological: in #[should_panic] tests it is
772
+ # the "fail if we got here" guard. Only always-PASSING forms count.
773
+ if re.fullmatch(
774
+ r"true|true\s*==\s*true|false\s*==\s*false|1\s*==\s*1|0\s*==\s*0",
775
+ compact) or re.fullmatch(r"(.+?)\s*,\s*\1", compact):
776
+ out.append(Finding(
777
+ "R02", "high",
778
+ f"Tautological assertion in test `{fn.name}` (always passes regardless of behaviour).",
779
+ unit.rel, fn.line + body[:start].count("\n"), fn.name,
780
+ evidence=compact[:60], fix="none",
781
+ fix_hint="Assert the real expected value of the behaviour under test.",
782
+ ))
783
+ if GENERIC_TEST_NAME_RE.match(fn.name):
784
+ out.append(Finding(
785
+ "R02", "low",
786
+ f"Generic test name `{fn.name}` (does not state the behaviour being verified).",
787
+ unit.rel, fn.line, fn.name, evidence=fn.name, fix="none",
788
+ fix_hint="Rename to `test_<behaviour>_<condition>_<expected>`.",
789
+ ))
790
+ # Assertion roulette: 3+ bare `assert!` without messages. In Rust,
791
+ # assert_eq!/assert_ne! print left/right values and every panic
792
+ # carries file:line, so they cannot be roulette.
793
+ no_msg = 0
794
+ for name, _, _, args in asserts:
795
+ if name == "assert" and not re.search(r",\s*\"", args):
796
+ no_msg += 1
797
+ if no_msg >= 3:
798
+ out.append(Finding(
799
+ "R02", "low",
800
+ f"Assertion roulette in `{fn.name}`: {no_msg} assertions without failure messages.",
801
+ unit.rel, fn.line, fn.name, evidence=f"{no_msg} bare asserts", fix="none",
802
+ fix_hint="Give each assertion an explanatory message.",
803
+ ))
804
+ # Duplicate assertion: identical asserts repeated inside ONE scenario
805
+ # (nothing but other asserts between them). The catalog's fix is
806
+ # "vary the input" — a statement between the repeats means a new
807
+ # scenario, where repeating the expectation is intentional (ADR 0002).
808
+ # macro_calls groups by macro name, so sort by position; the key keeps
809
+ # the macro name (assert_ne! vs assert_eq! assert OPPOSITES). Its `end`
810
+ # is the INDEX of the closing paren, so spans grow by one to cover it:
811
+ # the gap between two calls must not start inside the first one.
812
+ asserts.sort(key=lambda t: t[1])
813
+ spans = [(s, e + 1) for _, s, e, _ in asserts]
814
+ occ: dict[str, list[tuple[int, int]]] = {}
815
+ pure: dict[str, bool] = {}
816
+ for name, start, end, args in asserts:
817
+ key = name + "!(" + re.sub(r"\s+", " ", args) + ")"
818
+ if key:
819
+ occ.setdefault(key, []).append((start, end + 1))
820
+ pure[key] = _pure_args(args)
821
+ for key, spots in occ.items():
822
+ if len(spots) < 2:
823
+ continue
824
+ run = best = 1
825
+ claim_pure = pure[key]
826
+ for (_, e1), (s2, _) in zip(spots, spots[1:]):
827
+ if _only_asserts_between(body_vis, spans, e1, s2):
828
+ # The interleaved asserts matter too: their expressions
829
+ # can advance state (recv_many, poll), turning the repeat
830
+ # into a deliberate re-check, not a redundant one.
831
+ inner = [a for _, s, e, a in asserts
832
+ if e1 <= s and e + 1 <= s2]
833
+ claim_pure = claim_pure and all(map(_pure_args, inner))
834
+ run += 1
835
+ best = max(best, run)
836
+ else:
837
+ run = 1
838
+ if best >= 2:
839
+ # Read-only repeated AND interleaved arguments make
840
+ # redundancy provable -> plain claim. Effectful ones can
841
+ # advance state (e.g. `stream.next().await`), so the claim
842
+ # carries only the provable repeat plus a hedge
843
+ # (ADR 0001: claim only what is provable).
844
+ hedge = "" if claim_pure else (
845
+ "; redundant only if the interleaved asserts do not "
846
+ "advance state")
847
+ out.append(Finding(
848
+ "R02", "low",
849
+ f"Duplicate assertion in `{fn.name}` ({best}x identical "
850
+ f"back-to-back{hedge}).",
851
+ unit.rel, fn.line, fn.name, evidence=key[:60], fix="none",
852
+ fix_hint="Remove the redundancy or vary the tested input.",
853
+ ))
854
+
855
+ # magic numbers in expected results
856
+ magic = [a for _, _, _, a in asserts if re.search(r"^\s*-?\d+\s*,|\,\s*-?\d+\s*$", re.sub(r"\s+", " ", a))]
857
+ if len(magic) >= 2:
858
+ out.append(Finding(
859
+ "R02", "low",
860
+ f"Magic number test in `{fn.name}`: {len(magic)} assertions compare against bare numeric literals.",
861
+ unit.rel, fn.line, fn.name, evidence=str(len(magic)), fix="none",
862
+ fix_hint="Derive expected values from named constants or the specification.",
863
+ ))
864
+ ignores = len(re.findall(r"#\[\s*ignore\s*\]", unit.src.masked_all))
865
+ if ignores >= 3:
866
+ out.append(Finding(
867
+ "R02", "low",
868
+ f"{ignores} tests marked #[ignore] in one file (suite is systematically disabled).",
869
+ unit.rel, 1, "", evidence=f"{ignores}x #[ignore]", fix="none",
870
+ fix_hint="Fix and re-enable ignored tests or delete them.",
871
+ ))
872
+
873
+
874
+ def check_r03(unit: FileUnit, out: list[Finding]) -> None:
875
+ for fn in unit.fns:
876
+ if fn.is_test or unit.kind in ("example", "bench"):
877
+ continue
878
+ masked = unit.src.masked_all
879
+ open_idx = masked.find("{", fn.start)
880
+ raw_body = unit.src.text[open_idx + 1: fn.end] if open_idx >= 0 else fn.body
881
+ # In `impl Trait for Type` blocks and `trait` default methods a
882
+ # constant return or a no-op body fulfils the documented contract
883
+ # (metadata getters, deliberate defaults), not stubbed logic.
884
+ in_contract = any(a <= fn.start <= b for a, b in unit.contract_regions)
885
+ # An empty generic fn is the compile-time type-assertion idiom
886
+ # (`fn check<T: Send>() {}`), not a stub.
887
+ is_type_assert = "<" in fn.sig.split("(", 1)[0]
888
+ # Only a truly empty `{}` is a stub: a body carrying a comment is a
889
+ # documented no-op and a literal body is a real return value.
890
+ if not raw_body.strip() and not in_contract and not is_type_assert:
891
+ out.append(Finding(
892
+ "R03", "high", f"Function `{fn.name}` has an empty body (stub).",
893
+ unit.rel, fn.line, fn.name, evidence="{}", fix="none",
894
+ fix_hint="Implement the function or make it return a typed 'unsupported' error.",
895
+ ))
896
+ continue
897
+ visible = mask_source(raw_body, False, True) # strings visible, comments hidden
898
+ if not in_contract and re.fullmatch(
899
+ r"(?:return\s+)?(?:true|false|-?\d+(?:\.\d+)?[a-z]*|\"[^\"\n]*\"|"
900
+ r"'(?:\\.|[^\\'])'|Self|[A-Z][A-Z0-9_]{2,})\s*;?",
901
+ visible.strip()):
902
+ out.append(Finding(
903
+ "R03", "high",
904
+ f"Function `{fn.name}` always returns a constant (stubbed logic).",
905
+ unit.rel, fn.line, fn.name,
906
+ evidence=re.sub(r"\s+", " ", visible)[:60], fix="none",
907
+ fix_hint="Implement the real computation or document why a constant is correct.",
908
+ ))
909
+ if re.search(r"\bif\s+(false|0\s*==\s*1|1\s*==\s*0)\b", fn.body):
910
+ out.append(Finding(
911
+ "R03", "high",
912
+ f"Function `{fn.name}` contains a placeholder `if false` branch (dead stub path).",
913
+ unit.rel, fn.line, fn.name, evidence="if false {", fix="none",
914
+ fix_hint="Remove the placeholder branch or wire the real feature flag.",
915
+ ))
916
+ # Scoped to the parameter list so return types and where-clauses
917
+ # (`F: FnOnce...`, `S::Error`) never invent phantom params;
918
+ # underscore-prefixed params are intentional ignores, not stubs.
919
+ params = re.findall(r"(?:^|,)\s*(?:mut\s+)?([A-Za-z_]\w*)\s*:(?!:)",
920
+ fn.sig.split("(", 1)[-1].split(")", 1)[0])
921
+ params = [p for p in params if p not in ("self",) and not p.startswith("_")]
922
+ # `#[allow(unused_variables)]` declares the ignored params intentional.
923
+ if params and len(re.findall(r"\w+", fn.body)) < 30 \
924
+ and not any("unused_variables" in a for a in fn.attrs):
925
+ missing = [p for p in params if not re.search(r"\b" + re.escape(p) + r"\b", fn.body)]
926
+ if len(missing) == len(params):
927
+ out.append(Finding(
928
+ "R03", "high",
929
+ f"Function `{fn.name}` ignores all of its parameters (stub signature).",
930
+ unit.rel, fn.line, fn.name,
931
+ evidence=f"unused params: {', '.join(params)}", fix="none",
932
+ fix_hint="Use the parameters or remove them from the signature.",
933
+ ))
934
+
935
+
936
+ def check_r04(unit: FileUnit, out: list[Finding]) -> None:
937
+ strings = unit.src.masked_comments
938
+ for m in re.finditer(r"\"([^\"\\\n]{3,120})\"", strings):
939
+ val = m.group(1)
940
+ if re.search(r"(?i)\b(lorem ipsum|lorem|dummy|placeholder|sample value|test data|foo bar|fixme|xxx)\b", val) \
941
+ or val in ("example.com", "example.org", "example.net"):
942
+ fn = locate_fn(unit.fns, m.start())
943
+ sev = _prod_severity(unit, fn, "high", "skip", "info")
944
+ if sev is None:
945
+ continue
946
+ name, _ = _ctx_label(unit, fn)
947
+ out.append(Finding(
948
+ "R04", sev,
949
+ f"Placeholder string used as real data: \"{val[:50]}\".",
950
+ unit.rel, unit.src.line_of(m.start()), name,
951
+ evidence=val[:60], fix="none",
952
+ fix_hint="Use real data or read the value from configuration/env.",
953
+ ))
954
+
955
+
956
+ def check_r05(unit: FileUnit, out: list[Finding]) -> None:
957
+ stem = Path(unit.rel).stem.lower()
958
+ if re.search(r"(?i)^(demo|sample|placeholder|dummy)", stem):
959
+ out.append(Finding(
960
+ "R05", "low", f"Demo-style file name `{Path(unit.rel).name}`.",
961
+ unit.rel, 1, "", evidence=stem, fix="none",
962
+ fix_hint="Keep demo code out of production trees.",
963
+ ))
964
+ for fn in unit.fns:
965
+ if fn.is_test:
966
+ continue
967
+ if re.search(r"(?i)^(demo|sample|placeholder|dummy|foo|bar)(_|$)|_(demo|sample|placeholder|dummy)$", fn.name):
968
+ sev = "info" if unit.kind in ("example", "bench") else "medium"
969
+ out.append(Finding(
970
+ "R05", sev, f"Demo-style function name `{fn.name}`.",
971
+ unit.rel, fn.line, fn.name, evidence=fn.name, fix="none",
972
+ fix_hint="Rename to describe the real behaviour or remove it.",
973
+ ))
974
+ body = fn.body.strip()
975
+ if fn.name == "main" and body and re.fullmatch(
976
+ r"((println!|print!|dbg!|eprintln!)\s*\([^;]*\)\s*;\s*)+", re.sub(r"\s+", " ", body)):
977
+ out.append(Finding(
978
+ "R05", "info", "Trivial `fn main` that only prints (demo code).",
979
+ unit.rel, fn.line, fn.name, evidence=body[:40], fix="none",
980
+ fix_hint="Replace with the real entry point.",
981
+ ))
982
+
983
+
984
+ def check_r06(unit: FileUnit, out: list[Finding]) -> None:
985
+ for m in re.finditer(
986
+ r"\b(?:struct|enum|type|trait)\s+((?:Fake|Mock|InMemory|Dummy|Stub)[A-Za-z0-9_]*|"
987
+ r"(?:fake|mock|in_memory|dummy|stub)_[A-Za-z0-9_]*)\b", unit.src.masked_all):
988
+ fn = locate_fn(unit.fns, m.start())
989
+ if fn is not None and fn.is_test:
990
+ continue
991
+ if unit.kind in ("test",) or any(a <= m.start() <= b for a, b in unit.test_regions):
992
+ continue
993
+ out.append(Finding(
994
+ "R06", "medium",
995
+ f"Test double `{m.group(1)}` declared outside test code (fake leaking into production).",
996
+ unit.rel, unit.src.line_of(m.start()), "", evidence=m.group(1), fix="none",
997
+ fix_hint="Move fakes/mocks under #[cfg(test)] or a tests/ module.",
998
+ ))
999
+
1000
+
1001
+ def check_r07(unit: FileUnit, out: list[Finding]) -> None:
1002
+ for fn in unit.fns:
1003
+ if not fn.is_test:
1004
+ continue
1005
+ hits = re.findall(
1006
+ r"\bMock[A-Za-z0-9_]*\b|\bmock!\s*\(|\bassert_called\w*\b|\.verify\s*\(|\bwithf\s*\(",
1007
+ fn.body)
1008
+ if len(hits) > THRESHOLDS.get("mocks_per_test", 5):
1009
+ out.append(Finding(
1010
+ "R07", "low",
1011
+ f"Over-mocking in test `{fn.name}`: {len(hits)} mock interactions/verifications.",
1012
+ unit.rel, fn.line, fn.name, evidence=f"{len(hits)} mock hits", fix="none",
1013
+ fix_hint="Mock external boundaries only; assert observable behaviour, not call internals.",
1014
+ ))
1015
+ if re.search(r"\bassert\w*!\s*\(\s*(?:\w+\.)?is_ok\(\)\s*\)", fn.body) and "Mock" in fn.body:
1016
+ out.append(Finding(
1017
+ "R07", "low",
1018
+ f"Test `{fn.name}` only asserts mock setup results (mock-only test).",
1019
+ unit.rel, fn.line, fn.name, evidence="assert!(mock.is_ok())", fix="none",
1020
+ fix_hint="Assert on real interactions with the system under test.",
1021
+ ))
1022
+
1023
+
1024
+ def check_r08(unit: FileUnit, out: list[Finding]) -> None:
1025
+ for fn in unit.fns:
1026
+ if not fn.is_test:
1027
+ continue
1028
+ checks = [
1029
+ (r"\b(?:std::)?thread::sleep\s*\(|\bSleep\b", "medium",
1030
+ "Sleep in test (timing dependence -> flaky)."),
1031
+ (r"\bInstant::now\s*\(|\bSystemTime::now\s*\(", "medium",
1032
+ "Wall-clock/monotonic time read in test (flaky on slow machines)."),
1033
+ (r"\b(?:rand::|random\s*\()", "low",
1034
+ "Randomness in test without fixed seed (flaky)."),
1035
+ (r"\bstd::env::(?:set_var|remove_var)\b", "medium",
1036
+ "Test mutates process environment (order-dependent, unsafe in parallel tests)."),
1037
+ (r"\b(?:HashMap|HashSet)[^;]*\.iter\s*\(\)", "low",
1038
+ "Iteration over unordered HashMap/HashSet in test (order dependence)."),
1039
+ ]
1040
+ for pattern, sev, msg in checks:
1041
+ if re.search(pattern, fn.body):
1042
+ out.append(Finding(
1043
+ "R08", sev, f"{msg} (`{fn.name}`)",
1044
+ unit.rel, fn.line, fn.name,
1045
+ evidence=re.search(pattern, fn.body).group(0)[:50], fix="none",
1046
+ fix_hint="Use deterministic time/random injection and sorted collections.",
1047
+ ))
1048
+
1049
+
1050
+ def check_r09(unit: FileUnit, out: list[Finding]) -> None:
1051
+ for m in re.finditer(r"\.\s*(unwrap|expect)\s*\(|\bpanic!\s*\(", unit.src.masked_all):
1052
+ fn = locate_fn(unit.fns, m.start())
1053
+ sev = _prod_severity(unit, fn, "high", "skip", "info")
1054
+ if sev is None:
1055
+ continue
1056
+ name, _ = _ctx_label(unit, fn)
1057
+ what = m.group(0).strip()
1058
+ out.append(Finding(
1059
+ "R09", sev,
1060
+ f"`{what}` turns an Option/Result into a panic in production code.",
1061
+ unit.rel, unit.src.line_of(m.start()), name, evidence=what, fix="assisted",
1062
+ fix_hint="Propagate with `?`, match on the error, or provide a default explicitly.",
1063
+ ))
1064
+
1065
+
1066
+ def check_r10(unit: FileUnit, out: list[Finding]) -> None:
1067
+ masked = unit.src.masked_all
1068
+ patterns = [
1069
+ (r"\blet\s+_\s*=", "Discarded value (`let _ =`) hides errors/ignored Results."),
1070
+ (r"\bif\s+let\s+Ok\s*\(\s*_\w*\s*\)", "Error branch silently ignored (`if let Ok(_)`)"),
1071
+ (r"\.ok\s*\(\)\s*;", "Result flattened with `.ok()` and dropped."),
1072
+ (r"unwrap_or_default\s*\(\)", "Failure converted to a silent default."),
1073
+ ]
1074
+ for pattern, msg in patterns:
1075
+ for m in re.finditer(pattern, masked):
1076
+ fn = locate_fn(unit.fns, m.start())
1077
+ sev = _prod_severity(unit, fn, "medium", "skip", "info")
1078
+ if sev is None:
1079
+ continue
1080
+ name, _ = _ctx_label(unit, fn)
1081
+ out.append(Finding(
1082
+ "R10", sev, msg, unit.rel, unit.src.line_of(m.start()), name,
1083
+ evidence=m.group(0), fix="assisted",
1084
+ fix_hint="Handle or propagate the error explicitly; only discard with a stated reason.",
1085
+ ))
1086
+
1087
+
1088
+ UNSAFE_RE = re.compile(
1089
+ r"\bunsafe\b|\btransmute(?:_copy)?\s*\(|\bfrom_raw_parts(?:_mut)?\s*\(|"
1090
+ r"\*(?:const|mut)\b|extern\s+\"[^\"]*\"|\bstd::ptr\b|\bcore::ptr\b"
1091
+ )
1092
+
1093
+
1094
+ def check_r11(unit: FileUnit, out: list[Finding]) -> None:
1095
+ for m in UNSAFE_RE.finditer(unit.src.masked_all):
1096
+ line = unit.src.line_of(m.start())
1097
+ above = "\n".join(unit.src.line_text(i) for i in range(max(1, line - 3), line))
1098
+ if re.search(r"(?i)\bSAFETY\b", above) or above.strip().startswith("///"):
1099
+ continue
1100
+ fn = locate_fn(unit.fns, m.start())
1101
+ sev = _prod_severity(unit, fn, "high", "skip", "info")
1102
+ if sev is None:
1103
+ continue
1104
+ name, _ = _ctx_label(unit, fn)
1105
+ out.append(Finding(
1106
+ "R11", sev,
1107
+ f"`{m.group(0).strip()}` used without a SAFETY justification comment.",
1108
+ unit.rel, line, name, evidence=m.group(0).strip(), fix="none",
1109
+ fix_hint="Avoid unsafe; if unavoidable, add `// SAFETY: ...` and test with Miri.",
1110
+ ))
1111
+
1112
+
1113
+ SECRET_PATTERNS = [
1114
+ (re.compile(r"\bAKIA[0-9A-Z]{16}\b"), "AWS access key id"),
1115
+ (re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b"), "GitHub token"),
1116
+ (re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), "Slack token"),
1117
+ (re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----"), "private key"),
1118
+ (re.compile(r"\bsk-[A-Za-z0-9]{20,}\b"), "API secret key"),
1119
+ ]
1120
+ SECRET_ASSIGN_RE = re.compile(
1121
+ r"(?i)\b(api[_-]?key|apikey|secret[_-]?key|access[_-]?key|password|passwd|auth[_-]?token|token)\s*[:=]\s*\"([^\"]{8,})\""
1122
+ )
1123
+ SECRET_LITERAL_RE = re.compile(r"\"[A-Za-z0-9+/=_%\-]{40,}\"|\"[0-9a-fA-F]{32,}\"")
1124
+
1125
+
1126
+ def check_r12(unit: FileUnit, out: list[Finding]) -> None:
1127
+ raw = unit.src.text
1128
+ for pattern, label in SECRET_PATTERNS:
1129
+ for m in pattern.finditer(raw):
1130
+ out.append(Finding(
1131
+ "R12", "critical", f"Hardcoded {label} in source.",
1132
+ unit.rel, unit.src.line_of(m.start()), "",
1133
+ evidence=m.group(0)[:12] + "…", fix="none",
1134
+ fix_hint="Move to an environment variable or secret manager and rotate the leaked value.",
1135
+ ))
1136
+ for m in SECRET_ASSIGN_RE.finditer(raw):
1137
+ if "env::var" in raw[max(0, m.start() - 40): m.start()]:
1138
+ continue
1139
+ if unit.kind in ("test",):
1140
+ continue
1141
+ out.append(Finding(
1142
+ "R12", "critical",
1143
+ f"Hardcoded credential in assignment `{m.group(1)}`.",
1144
+ unit.rel, unit.src.line_of(m.start()), "",
1145
+ evidence=f"{m.group(1)} = \"…\"", fix="safe",
1146
+ fix_hint="Read from std::env::var (fix --safe rewrites the assignment and fills .env.example).",
1147
+ ))
1148
+ for m in SECRET_LITERAL_RE.finditer(raw):
1149
+ fn = locate_fn(unit.fns, m.start())
1150
+ if fn is not None and fn.is_test:
1151
+ continue
1152
+ if unit.kind in ("test", "bench"):
1153
+ continue
1154
+ out.append(Finding(
1155
+ "R12", "critical", "Long high-entropy literal that looks like a hardcoded secret.",
1156
+ unit.rel, unit.src.line_of(m.start()), "",
1157
+ evidence=m.group(0)[:12] + "…", fix="none",
1158
+ fix_hint="Confirm it is not a credential; if it is, move it to env/secret store.",
1159
+ confidence="medium",
1160
+ ))
1161
+
1162
+
1163
+ def check_r13(unit: FileUnit, out: list[Finding]) -> None:
1164
+ for fn in unit.fns:
1165
+ if fn.is_test or unit.kind in ("example", "bench"):
1166
+ continue
1167
+ body = fn.body
1168
+ if re.search(r"\benv::args\s*\(", body) and re.search(r"\.unwrap\s*\(|\.expect\s*\(|\[[^\]]+\]", body):
1169
+ out.append(Finding(
1170
+ "R13", "medium",
1171
+ f"`{fn.name}` reads CLI args without validation (panic on empty/invalid input).",
1172
+ unit.rel, fn.line, fn.name, evidence="env::args + unwrap/index", fix="none",
1173
+ fix_hint="Validate presence, type and bounds of every argument; return a typed error.",
1174
+ ))
1175
+ for m in re.finditer(r"(from_str\s*\([^;]*?\)|\.parse\s*::<[^>]*>\s*\([^;]*?\))\s*\.\s*(unwrap|expect)\s*\(", body):
1176
+ out.append(Finding(
1177
+ "R13", "medium",
1178
+ f"`{fn.name}` parses untrusted input with unchecked `{m.group(2)}`.",
1179
+ unit.rel, fn.line + body[: m.start()].count("\n"), fn.name,
1180
+ evidence=re.sub(r"\s+", " ", m.group(0))[:60], fix="none",
1181
+ fix_hint="Handle the parse error explicitly and validate ranges/formats.",
1182
+ ))
1183
+
1184
+
1185
+ def check_r14(unit: FileUnit, out: list[Finding]) -> None:
1186
+ for fn in unit.fns:
1187
+ if fn.is_test or unit.kind in ("example", "bench"):
1188
+ continue
1189
+ clones = re.findall(r"\.clone\s*\(\)", fn.body)
1190
+ if len(clones) > THRESHOLDS.get("clones_per_fn", 3):
1191
+ out.append(Finding(
1192
+ "R14", "medium",
1193
+ f"`{fn.name}` calls `.clone()` {len(clones)} times (borrow-checker fight, hidden cost).",
1194
+ unit.rel, fn.line, fn.name, evidence=f"{len(clones)}x .clone()", fix="none",
1195
+ fix_hint="Prefer borrowing (&/&mut) or restructure ownership; clone only with a real need.",
1196
+ ))
1197
+
1198
+
1199
+ def check_r15(unit: FileUnit, out: list[Finding]) -> None:
1200
+ for fn in unit.fns:
1201
+ lifetimes = re.findall(r"'[a-z][a-z0-9_]*\b", fn.sig.split(")")[0] if ")" in fn.sig else fn.sig)
1202
+ uniq = set(lifetimes)
1203
+ if len(uniq) >= THRESHOLDS.get("lifetimes_per_fn", 3):
1204
+ out.append(Finding(
1205
+ "R15", "low",
1206
+ f"`{fn.name}` declares {len(uniq)} lifetimes (invented lifetimes to appease the borrow checker).",
1207
+ unit.rel, fn.line, fn.name, evidence=", ".join(sorted(uniq)), fix="none",
1208
+ fix_hint="Restructure the data instead of adding lifetimes.",
1209
+ ))
1210
+
1211
+
1212
+ TOKIO_FEATURE_NEEDS = {
1213
+ "net": "net", "time": "time", "fs": "fs", "sync": "sync", "process": "process",
1214
+ "io": "io-util", "signal": "signal", "stream": "stream",
1215
+ }
1216
+
1217
+
1218
+ def check_r16(unit: FileUnit, out: list[Finding], deps: list[Dep]) -> None:
1219
+ for fn in unit.fns:
1220
+ if fn.is_async and not fn.is_test:
1221
+ if re.search(r"\b(?:std::)?thread::sleep\s*\(", fn.body):
1222
+ out.append(Finding(
1223
+ "R16", "high",
1224
+ f"`{fn.name}` blocks the Tokio runtime with `std::thread::sleep`.",
1225
+ unit.rel, fn.line, fn.name, evidence="thread::sleep in async fn", fix="assisted",
1226
+ fix_hint="Use `tokio::time::sleep(d).await` or `spawn_blocking` for blocking work.",
1227
+ ))
1228
+ if re.search(r"\bstd::fs::|\bfs::read_to_string|\bfs::write\b", fn.body):
1229
+ out.append(Finding(
1230
+ "R16", "medium",
1231
+ f"`{fn.name}` performs blocking filesystem I/O inside async code.",
1232
+ unit.rel, fn.line, fn.name, evidence="std::fs in async fn", fix="none",
1233
+ fix_hint="Use tokio::fs or move the work to `tokio::task::spawn_blocking`.",
1234
+ ))
1235
+ if re.search(r"\bblock_on\b", fn.body):
1236
+ out.append(Finding(
1237
+ "R16", "medium",
1238
+ f"`{fn.name}` calls `block_on` from inside async code (deadlock risk).",
1239
+ unit.rel, fn.line, fn.name, evidence="block_on", fix="none",
1240
+ fix_hint="Await the future instead of blocking the runtime thread.",
1241
+ ))
1242
+ lock_m = re.search(r"\.(?:lock|read|write)\s*\(\s*\)", fn.body)
1243
+ if lock_m and ".await" in fn.body[lock_m.end():]:
1244
+ out.append(Finding(
1245
+ "R16", "high",
1246
+ f"`{fn.name}` holds a synchronous Mutex/RwLock guard across `.await` (deadlock/blocking).",
1247
+ unit.rel, fn.line, fn.name, evidence="lock() … .await", fix="none",
1248
+ fix_hint="Drop the guard before the await or use tokio::sync primitives.",
1249
+ ))
1250
+ if not fn.is_test and re.search(r"\bfor\b[^{]*\{", fn.body):
1251
+ for_m = re.search(r"\bfor\b", fn.body)
1252
+ spawn_m = re.search(r"\btokio::spawn\s*\(", fn.body)
1253
+ if for_m and spawn_m and spawn_m.start() > for_m.start():
1254
+ out.append(Finding(
1255
+ "R16", "medium",
1256
+ f"`{fn.name}` spawns one task per loop iteration without bounds.",
1257
+ unit.rel, fn.line, fn.name, evidence="tokio::spawn in loop", fix="none",
1258
+ fix_hint="Bound concurrency with a Semaphore, worker pool or batches.",
1259
+ ))
1260
+ # tokio feature completeness (cross-cutting, one finding per missing feature)
1261
+ tokio_dep = next((d for d in deps if _norm(d.package) == "tokio"), None)
1262
+ if tokio_dep is not None:
1263
+ features = {f.replace("-", "_") for f in tokio_dep.features}
1264
+ if "full" not in features and features != {"full"}:
1265
+ used = set()
1266
+ for m in re.finditer(r"\btokio::([a-z_]+)", unit.src.masked_all):
1267
+ used.add(m.group(1))
1268
+ if re.search(r"#\[\s*tokio::(main|test)\s*\]", unit.src.masked_all):
1269
+ used.update({"macros"})
1270
+ if re.search(r"\btokio::spawn\s*\(", unit.src.masked_all):
1271
+ used.add("rt")
1272
+ needed = set()
1273
+ for u in used:
1274
+ if u in TOKIO_FEATURE_NEEDS:
1275
+ needed.add(TOKIO_FEATURE_NEEDS[u])
1276
+ elif u in ("main", "test", "macros"):
1277
+ needed.add("macros")
1278
+ elif u in ("spawn", "rt"):
1279
+ needed.add("rt-multi-thread")
1280
+ if "rt" in used and "rt_multi_thread" not in features and "rt" not in features:
1281
+ needed.add("rt-multi-thread")
1282
+ missing = sorted(n for n in needed if n.replace("-", "_") not in features)
1283
+ if missing:
1284
+ out.append(Finding(
1285
+ "R16", "medium",
1286
+ f"Tokio features used but not declared in Cargo.toml: {', '.join(missing)}.",
1287
+ unit.rel, 1, "", evidence=f"used: {sorted(used)}", fix="none",
1288
+ fix_hint=f'Add features = [{", ".join(repr(f) for f in missing)}] to the tokio dependency.',
1289
+ ))
1290
+
1291
+
1292
+ def check_r17(unit: FileUnit, out: list[Finding]) -> None:
1293
+ hits = re.findall(
1294
+ r"Arc\s*<\s*Mutex\s*<|Arc\s*<\s*std::sync::Mutex|Arc::new\s*\(\s*(?:std::sync::)?Mutex::new",
1295
+ unit.src.masked_all)
1296
+ if len(hits) >= THRESHOLDS.get("arc_mutex_per_file", 4):
1297
+ out.append(Finding(
1298
+ "R17", "low",
1299
+ f"`Arc<Mutex<..>>` used {len(hits)} times in one file (shared mutable state everywhere).",
1300
+ unit.rel, 1, "", evidence=f"{len(hits)} occurrences", fix="none",
1301
+ fix_hint="Prefer message passing (channels), actors or immutable shared data.",
1302
+ ))
1303
+
1304
+
1305
+ def check_r21(unit: FileUnit, out: list[Finding]) -> None:
1306
+ if unit.kind != "build" and "proc-macro" not in unit.rel:
1307
+ # proc-macro crates are flagged at manifest level below
1308
+ pass
1309
+ if unit.kind == "build":
1310
+ for pattern, msg in [
1311
+ (r"Command::new\s*\(", "build.rs executes an external command at compile time"),
1312
+ (r"\bTcpStream\b|\bUdpSocket\b|\breqwest\b|\bhyper::|curl", "build.rs opens network connections"),
1313
+ (r"fs::write\b|File::create\s*\(|OpenOptions", "build.rs writes files during build"),
1314
+ ]:
1315
+ m = re.search(pattern, unit.src.masked_all)
1316
+ if m:
1317
+ out.append(Finding(
1318
+ "R21", "high", f"{msg} (supply-chain risk: code runs on every build).",
1319
+ unit.rel, unit.src.line_of(m.start()), "main",
1320
+ evidence=m.group(0), fix="none",
1321
+ fix_hint="Inspect build.rs line by line; a hallucinated dependency often hides here.",
1322
+ ))
1323
+
1324
+
1325
+ # --------------------------------------------------------------------------
1326
+ # Cross-file crate reference rules (R18a-e, R20)
1327
+ # --------------------------------------------------------------------------
1328
+ USE_ROOT_RE = re.compile(r"\buse\s+([A-Za-z_]\w*)\s*::")
1329
+ EXTERN_RE = re.compile(r"\bextern\s+crate\s+([A-Za-z_]\w*)")
1330
+
1331
+
1332
+ def collect_use_roots(units: list[FileUnit]) -> dict[str, list[tuple[str, int, str]]]:
1333
+ roots: dict[str, list[tuple[str, int, str]]] = {}
1334
+ for unit in units:
1335
+ for m in list(USE_ROOT_RE.finditer(unit.src.masked_all)) + \
1336
+ list(EXTERN_RE.finditer(unit.src.masked_all)):
1337
+ root = m.group(1)
1338
+ roots.setdefault(root, []).append(
1339
+ (unit.rel, unit.src.line_of(m.start()), m.group(0)))
1340
+ return roots
1341
+
1342
+
1343
+ def local_modules(units: list[FileUnit]) -> set[str]:
1344
+ mods: set[str] = set()
1345
+ for unit in units:
1346
+ for m in MOD_RE.finditer(unit.src.masked_all):
1347
+ mods.add(m.group(1))
1348
+ stem = Path(unit.rel).stem
1349
+ if stem != "main" and stem != "lib":
1350
+ mods.add(_norm(stem))
1351
+ return mods
1352
+
1353
+
1354
+ def check_crate_refs(units: list[FileUnit], reg: Registry, out: list[Finding]) -> None:
1355
+ roots = collect_use_roots(units)
1356
+ mods = local_modules(units)
1357
+ known = reg.known_roots()
1358
+ popular = reg.popular
1359
+
1360
+ for root, sites in sorted(roots.items()):
1361
+ if root in STD_CRATES or root in {"self", "super", "crate"} or root in mods:
1362
+ continue
1363
+ rel, line, evidence = sites[0]
1364
+ if root in STD_MODULES:
1365
+ out.append(Finding(
1366
+ "R18b", "high",
1367
+ f"`{root}` is a standard-library module, not an external crate "
1368
+ f"(namespace confusion; {len(sites)} reference(s)).",
1369
+ rel, line, "", evidence=evidence, fix="safe",
1370
+ fix_hint=f"Qualify the path: `use std::{root}::...`.",
1371
+ ))
1372
+ continue
1373
+ if _norm(root) in {_norm(p) for p in popular}:
1374
+ continue
1375
+ # near-miss against declared deps and popular crates; sorted() in each
1376
+ # group: `known`/`popular` are sets (hash-randomized order), so ties
1377
+ # at equal edit distance must resolve deterministically (declared
1378
+ # deps keep priority over popular crates).
1379
+ best, best_dist = None, 99
1380
+ for cand in sorted(known) + sorted(_norm(p) for p in popular):
1381
+ if cand == _norm(root):
1382
+ continue # never compare a name against itself
1383
+ d = levenshtein(_norm(root), cand)
1384
+ if d < best_dist:
1385
+ best, best_dist = cand, d
1386
+ if best is not None and 0 < best_dist <= 2:
1387
+ out.append(Finding(
1388
+ "R18c", "high",
1389
+ f"Crate `{root}` does not exist but is within {best_dist} edit(s) of `{best}` "
1390
+ f"(near-miss / hallucinated name).",
1391
+ rel, line, "", evidence=f"{root} ~ {best}", fix="none",
1392
+ fix_hint=f"Verify on https://crates.io/crates/{best} and https://docs.rs/{best}; "
1393
+ f"never rename automatically.",
1394
+ ))
1395
+ continue
1396
+ if SUSPICIOUS_NAME_RE.match(root) or root in reg.suspicious:
1397
+ out.append(Finding(
1398
+ "R18d", "critical",
1399
+ f"Crate `{root}` matches a hallucinated/generic naming pattern "
1400
+ f"(possible slopsquatting bait; declared or not, verify before use).",
1401
+ rel, line, "", evidence=root, fix="none",
1402
+ fix_hint="Do not add this dependency. Verify the real crate on crates.io/docs.rs first.",
1403
+ ))
1404
+ continue
1405
+ if root in known:
1406
+ continue
1407
+ out.append(Finding(
1408
+ "R18a", "high",
1409
+ f"`use {root}::...` references a crate that is not declared in Cargo.toml "
1410
+ f"(unresolved external identifier; compilation will fail).",
1411
+ rel, line, "", evidence=evidence, fix="none",
1412
+ fix_hint=f"Add a verified dependency (crates.io/docs.rs) or fix the path.",
1413
+ ))
1414
+
1415
+ # declared-but-unused and version checks
1416
+ all_text = "\n".join(u.src.masked_all for u in units)
1417
+ build_text = "\n".join(u.src.masked_all for u in units if u.kind == "build")
1418
+ for dep in reg.deps:
1419
+ if dep.kind == "dev":
1420
+ probe = all_text
1421
+ elif dep.kind == "build":
1422
+ probe = build_text
1423
+ else:
1424
+ probe = all_text
1425
+ used = re.search(r"\b" + re.escape(_norm(dep.package)) + r"\b", probe) or \
1426
+ re.search(r"\b" + re.escape(_norm(dep.key)) + r"\b", probe) or \
1427
+ re.search(r"\b" + re.escape(dep.package.replace("-", "_")) + r"\b", probe)
1428
+ if not used and dep.package not in ("serde",):
1429
+ out.append(Finding(
1430
+ "R18e", "low",
1431
+ f"Dependency `{dep.package}` is declared but never referenced "
1432
+ f"(possibly an invented or leftover dependency).",
1433
+ "Cargo.toml", 1, "", evidence=f"{dep.kind}-dependency {dep.package}", fix="none",
1434
+ fix_hint="Remove it or confirm with `cargo machete` / `cargo udeps`.",
1435
+ source="static", confidence="medium",
1436
+ ))
1437
+ # impossible version requirements
1438
+ m = re.match(r"\s*(?:\^|>=)?\s*(\d+)", dep.version_req or "")
1439
+ if m:
1440
+ major = int(m.group(1))
1441
+ known_major = KNOWN_MAJORS.get(_norm(dep.package))
1442
+ if major > 100 or (known_major is not None and major > known_major):
1443
+ out.append(Finding(
1444
+ "R18d", "critical",
1445
+ f"Impossible version requirement `{dep.version_req}` for `{dep.package}` "
1446
+ f"(published majors end at {known_major if known_major is not None else '2 digits'}).",
1447
+ "Cargo.toml", 1, "", evidence=f"{dep.package} = \"{dep.version_req}\"",
1448
+ fix="none",
1449
+ fix_hint="Check the real version on crates.io/docs.rs; the AI likely invented it.",
1450
+ ))
1451
+
1452
+ # typosquat similarity (R20)
1453
+ # sorted(): `popular` is a set, so its iteration order is hash-randomized
1454
+ # per process; without a stable order, equal-distance candidates (fst ~
1455
+ # csv vs fst ~ pest) flip between runs and the report is not reproducible.
1456
+ pop_sorted = sorted(popular)
1457
+ for dep in reg.deps:
1458
+ name = _norm(dep.package)
1459
+ if name in {_norm(p) for p in popular}:
1460
+ continue
1461
+ best, best_dist = None, 99
1462
+ for p in pop_sorted:
1463
+ d = levenshtein(name, _norm(p))
1464
+ if d < best_dist:
1465
+ best, best_dist = p, d
1466
+ if best is not None and best_dist <= 2:
1467
+ out.append(Finding(
1468
+ "R20", "high",
1469
+ f"Dependency `{dep.package}` is not popular but is {best_dist} edit(s) away from "
1470
+ f"`{best}` (potential typosquat).",
1471
+ "Cargo.toml", 1, "", evidence=f"{dep.package} ~ {best}", fix="none",
1472
+ fix_hint=f"Confirm you meant https://crates.io/crates/{best}; check downloads, "
1473
+ f"repository and age of `{dep.package}` before using it.",
1474
+ ))
1475
+
1476
+
1477
+ # --------------------------------------------------------------------------
1478
+ # Online checks (crates.io API, sparse index, docs.rs, OSV) -- --online only
1479
+ # --------------------------------------------------------------------------
1480
+ UA = {"User-Agent": f"{TOOL_NAME}/{VERSION} (dependency verification)"}
1481
+
1482
+
1483
+ def _http_json(url: str, payload: dict | None = None, timeout: float = 12.0):
1484
+ data = json.dumps(payload).encode() if payload is not None else None
1485
+ req = urllib.request.Request(url, data=data, headers={**UA, "Content-Type": "application/json"})
1486
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
1487
+ return json.loads(resp.read().decode("utf-8"))
1488
+
1489
+
1490
+ def _sparse_index(name: str) -> list[dict]:
1491
+ n = name.replace("-", "_").lower()
1492
+ if len(n) == 1:
1493
+ prefix = f"1"
1494
+ elif len(n) == 2:
1495
+ prefix = f"2"
1496
+ elif len(n) == 3:
1497
+ prefix = f"3/{n[0]}"
1498
+ else:
1499
+ prefix = f"{n[:2]}/{n[2:4]}"
1500
+ url = f"https://index.crates.io/{prefix}/{n}"
1501
+ req = urllib.request.Request(url, headers=UA)
1502
+ with urllib.request.urlopen(req, timeout=12.0) as resp:
1503
+ return [json.loads(line) for line in resp.read().decode("utf-8").splitlines() if line.strip()]
1504
+
1505
+
1506
+ def online_check_deps(deps: list[Dep], locked: dict[str, str], out: list[Finding]) -> list[dict]:
1507
+ """Registry verification table + R18d/R19/OSV findings."""
1508
+ table: list[dict] = []
1509
+ for dep in deps:
1510
+ name = dep.package
1511
+ row: dict = {"crate": name, "version_req": dep.version_req,
1512
+ "crates.io": "", "docs.rs": "", "features": "", "status": ""}
1513
+ try:
1514
+ data = _http_json(f"https://crates.io/api/v1/crates/{name}")
1515
+ except urllib.error.HTTPError as e:
1516
+ if e.code == 404:
1517
+ row["status"] = "NOT FOUND on crates.io"
1518
+ out.append(Finding(
1519
+ "R18d", "critical",
1520
+ f"Crate `{name}` does not exist on crates.io (hallucinated/slopsquat candidate).",
1521
+ "Cargo.toml", 1, "", evidence=f"HTTP 404 for {name}",
1522
+ source="crates.io", fix="none",
1523
+ fix_hint="Remove the dependency. If an attacker registered this name, it is malware bait.",
1524
+ ))
1525
+ table.append(row)
1526
+ continue
1527
+ row["status"] = f"crates.io error HTTP {e.code}"
1528
+ table.append(row)
1529
+ continue
1530
+ except Exception as e: # network failure must not fake a pass
1531
+ row["status"] = f"check failed: {e}"
1532
+ table.append(row)
1533
+ continue
1534
+
1535
+ crate = data.get("crate") or {}
1536
+ versions = data.get("versions") or []
1537
+ row["crates.io"] = f"https://crates.io/crates/{name}"
1538
+ row["downloads"] = crate.get("downloads")
1539
+ row["updated_at"] = crate.get("updated_at")
1540
+ row["repository"] = (crate.get("repository") or "")
1541
+
1542
+ resolved = locked.get(name) or locked.get(name.replace("_", "-")) or crate.get("max_version")
1543
+ row["version"] = resolved
1544
+ row["docs.rs"] = f"https://docs.rs/{name}/{resolved}" if resolved else f"https://docs.rs/{name}"
1545
+
1546
+ # yanked resolved version
1547
+ for v in versions:
1548
+ if str(v.get("num")) == str(resolved) and v.get("yanked"):
1549
+ out.append(Finding(
1550
+ "R18d", "high", f"Resolved version {name}@{resolved} is yanked.",
1551
+ "Cargo.lock", 1, "", evidence=f"{name} {resolved} yanked",
1552
+ source="crates.io", fix="none",
1553
+ fix_hint="Move to a non-yanked version and review why it was yanked.",
1554
+ ))
1555
+ row["status"] = "yanked"
1556
+
1557
+ # slopsquatting window: brand-new crate + tiny downloads + generic name
1558
+ created = [v.get("created_at") or "" for v in versions]
1559
+ first = min(created) if created else ""
1560
+ try:
1561
+ first_ts = time.mktime(time.strptime(first[:10], "%Y-%m-%d"))
1562
+ age_days = (time.time() - first_ts) / 86400
1563
+ except Exception:
1564
+ age_days = None
1565
+ downloads = crate.get("downloads") or 0
1566
+ if age_days is not None and age_days < 90 and downloads < 10000 and \
1567
+ (SUSPICIOUS_NAME_RE.match(name) or name in Registry(deps).suspicious):
1568
+ out.append(Finding(
1569
+ "R19", "high",
1570
+ f"Crate `{name}` was first published {int(age_days)} days ago with "
1571
+ f"{downloads} downloads and a generic name (classic slopsquatting window).",
1572
+ "Cargo.toml", 1, "", evidence=f"first publish: {first[:10]}",
1573
+ source="crates.io", fix="none",
1574
+ fix_hint="Require human review of the crate source, repository and publisher before merging.",
1575
+ ))
1576
+ row["status"] = (row["status"] + "; recent+suspicious").strip("; ")
1577
+
1578
+ # feature verification via sparse index
1579
+ try:
1580
+ entries = _sparse_index(name)
1581
+ entry = next((e for e in entries if e.get("vers") == resolved), entries[-1] if entries else None)
1582
+ if entry is not None:
1583
+ feats = set((entry.get("features") or {}).keys())
1584
+ feats |= {f.replace("-", "_") for f in feats}
1585
+ row["features"] = ", ".join(sorted(feats))[:120]
1586
+ missing = [f for f in dep.features
1587
+ if f not in feats and f.replace("-", "_") not in feats and f != "full"]
1588
+ if missing and dep.features:
1589
+ out.append(Finding(
1590
+ "R18d", "high",
1591
+ f"Features {missing} do not exist in {name}@{entry.get('vers')} "
1592
+ f"(invented feature names).",
1593
+ "Cargo.toml", 1, "", evidence=f"available: {sorted(set(feats))[:8]}",
1594
+ source="crates.io-index", fix="none",
1595
+ fix_hint="Check the exact feature list on docs.rs for that version.",
1596
+ ))
1597
+ except Exception:
1598
+ row["features"] = "unverified (index unreachable)"
1599
+
1600
+ # docs.rs probe
1601
+ try:
1602
+ req = urllib.request.Request(row["docs.rs"], method="HEAD", headers=UA)
1603
+ with urllib.request.urlopen(req, timeout=10.0) as resp:
1604
+ row["docs.rs status"] = resp.status
1605
+ except urllib.error.HTTPError as e:
1606
+ if e.code == 404:
1607
+ out.append(Finding(
1608
+ "R18d", "medium",
1609
+ f"docs.rs has no build for {name}@{resolved} (unverifiable API).",
1610
+ "Cargo.toml", 1, "", evidence=row["docs.rs"],
1611
+ source="docs.rs", fix="none", confidence="medium",
1612
+ fix_hint="Confirm the API you use exists in this exact version.",
1613
+ ))
1614
+ row["docs.rs status"] = 404
1615
+ except Exception:
1616
+ row["docs.rs status"] = "unreachable"
1617
+
1618
+ # OSV advisories for the resolved version
1619
+ if resolved:
1620
+ try:
1621
+ osv = _http_json("https://api.osv.dev/v1/query",
1622
+ {"package": {"name": name, "ecosystem": "crates.io"},
1623
+ "version": str(resolved)})
1624
+ vulns = osv.get("vulns") or []
1625
+ row["osv"] = [v.get("id") for v in vulns]
1626
+ for v in vulns:
1627
+ out.append(Finding(
1628
+ "R19", "high",
1629
+ f"OSV advisory {v.get('id')} affects {name}@{resolved}.",
1630
+ "Cargo.lock", 1, "", evidence=str(v.get("summary", ""))[:120],
1631
+ source="osv", fix="none",
1632
+ fix_hint="Upgrade to a fixed version or apply the advisory's guidance.",
1633
+ ))
1634
+ except Exception:
1635
+ row["osv"] = "unverified"
1636
+ table.append(row)
1637
+ return table
1638
+
1639
+
1640
+ # --------------------------------------------------------------------------
1641
+ # Configuration
1642
+ # --------------------------------------------------------------------------
1643
+ THRESHOLDS: dict = {
1644
+ "clones_per_fn": 3,
1645
+ "lifetimes_per_fn": 3,
1646
+ "arc_mutex_per_file": 4,
1647
+ "mocks_per_test": 5,
1648
+ }
1649
+
1650
+ DEFAULT_CONFIG = {
1651
+ "rules": {"disabled": []},
1652
+ "thresholds": dict(THRESHOLDS),
1653
+ "exclude": ["target/*", "*/target/*", ".git/*", "*/.git/*", "report/*", "*/node_modules/*"],
1654
+ "corpus": "",
1655
+ }
1656
+
1657
+
1658
+ def load_config(root: Path, explicit: str | None) -> dict:
1659
+ cfg = json.loads(json.dumps(DEFAULT_CONFIG))
1660
+ candidates = []
1661
+ if explicit:
1662
+ candidates.append(Path(explicit))
1663
+ candidates += [root / "rust-audit.toml", root / ".rust-audit.toml"]
1664
+ for candidate in candidates:
1665
+ if candidate.exists():
1666
+ try:
1667
+ data = tomllib.loads(candidate.read_text(encoding="utf-8"))
1668
+ except Exception as exc:
1669
+ print(f"warning: cannot parse {candidate}: {exc}", file=sys.stderr)
1670
+ continue
1671
+ for key in ("rules", "thresholds", "exclude", "corpus"):
1672
+ if key in data:
1673
+ if isinstance(data[key], dict) and isinstance(cfg.get(key), dict):
1674
+ cfg[key].update(data[key])
1675
+ else:
1676
+ cfg[key] = data[key]
1677
+ break
1678
+ THRESHOLDS.update(cfg.get("thresholds") or {})
1679
+ return cfg
1680
+
1681
+
1682
+ # --------------------------------------------------------------------------
1683
+ # Scan engine
1684
+ # --------------------------------------------------------------------------
1685
+ PER_FILE_CHECKS = [
1686
+ check_r01, check_r02, check_r03, check_r04, check_r05, check_r06,
1687
+ check_r07, check_r08, check_r09, check_r10, check_r11, check_r12,
1688
+ check_r13, check_r14, check_r15, check_r17, check_r21,
1689
+ ]
1690
+
1691
+
1692
+ def run_scan(root: Path, cfg: dict, online: bool = False):
1693
+ findings: list[Finding] = []
1694
+ units: list[FileUnit] = []
1695
+ excludes = list(cfg.get("exclude") or [])
1696
+
1697
+ def excluded(rel: str) -> bool:
1698
+ return any(fnmatch.fnmatch(rel, g) for g in excludes)
1699
+
1700
+ for path in sorted(root.rglob("*.rs")):
1701
+ rel = path.relative_to(root).as_posix()
1702
+ if excluded(rel):
1703
+ continue
1704
+ if path.with_suffix(".stderr").exists():
1705
+ continue # trybuild compile-fail fixture: it must NOT compile, so
1706
+ # no code-quality verdict on it means anything
1707
+ try:
1708
+ text = path.read_text(encoding="utf-8", errors="replace")
1709
+ except OSError:
1710
+ continue
1711
+ unit = parse_file(rel, path, text)
1712
+ units.append(unit)
1713
+ for check in PER_FILE_CHECKS:
1714
+ if check is check_r16:
1715
+ continue
1716
+ check(unit, findings)
1717
+
1718
+ deps: list[Dep] = []
1719
+ locked: dict[str, str] = {}
1720
+ for manifest in sorted(root.rglob("Cargo.toml")):
1721
+ rel = manifest.relative_to(root).as_posix()
1722
+ if excluded(rel):
1723
+ continue
1724
+ found, _ = parse_manifest(manifest)
1725
+ deps += found
1726
+ for lock in sorted(root.rglob("Cargo.lock")):
1727
+ if not excluded(lock.relative_to(root).as_posix()):
1728
+ locked.update(parse_lock(lock))
1729
+ # de-duplicate deps by (package, kind)
1730
+ seen: set[tuple[str, str]] = set()
1731
+ uniq_deps: list[Dep] = []
1732
+ for d in deps:
1733
+ key = (_norm(d.package), d.kind)
1734
+ if key not in seen:
1735
+ seen.add(key)
1736
+ uniq_deps.append(d)
1737
+ deps = uniq_deps
1738
+
1739
+ for unit in units:
1740
+ check_r16(unit, findings, deps)
1741
+
1742
+ corpus: set[str] = set()
1743
+ corpus_file = cfg.get("corpus") or ""
1744
+ if corpus_file and Path(corpus_file).exists():
1745
+ corpus = {ln.strip() for ln in Path(corpus_file).read_text(encoding="utf-8").splitlines()
1746
+ if ln.strip() and not ln.startswith("#")}
1747
+ reg = Registry(deps, corpus)
1748
+ check_crate_refs(units, reg, findings)
1749
+
1750
+ online_table: list[dict] = []
1751
+ if online:
1752
+ online_table = online_check_deps(deps, locked, findings)
1753
+
1754
+ disabled = set(cfg.get("rules", {}).get("disabled") or [])
1755
+ findings = [f for f in findings if f.rule_id not in disabled]
1756
+ findings.sort(key=lambda f: (-SEVERITY_RANK.get(f.severity, 0), f.file, f.line, f.rule_id))
1757
+ return findings, units, reg, online_table
1758
+
1759
+
1760
+ def summarize(findings: list[Finding]) -> dict:
1761
+ by_sev = {s: 0 for s in SEVERITY_ORDER}
1762
+ by_rule: dict[str, int] = {}
1763
+ by_tier = {1: 0, 2: 0, 3: 0, 4: 0}
1764
+ for f in findings:
1765
+ by_sev[f.severity] = by_sev.get(f.severity, 0) + 1
1766
+ by_rule[f.rule_id] = by_rule.get(f.rule_id, 0) + 1
1767
+ by_tier[f.tier] = by_tier.get(f.tier, 0) + 1
1768
+ return {"total": len(findings), "by_severity": by_sev, "by_rule": by_rule,
1769
+ "by_tier": by_tier}
1770
+
1771
+
1772
+ # --------------------------------------------------------------------------
1773
+ # Report writers: terminal, Markdown, JSON, SARIF 2.1.0
1774
+ # --------------------------------------------------------------------------
1775
+ def render_terminal(findings: list[Finding], online_table: list[dict],
1776
+ evidence: list[EvidenceRun], limit: int = 25) -> str:
1777
+ lines: list[str] = []
1778
+ summary = summarize(findings)
1779
+ lines.append(f"{TOOL_NAME} v{VERSION} — summary: {summary['total']} finding(s)")
1780
+ for sev in SEVERITY_ORDER:
1781
+ if summary["by_severity"].get(sev):
1782
+ lines.append(f" {sev.upper():8s} {summary['by_severity'][sev]}")
1783
+ lines.append("")
1784
+ for f in findings[:limit]:
1785
+ fn = f" ({f.function})" if f.function else ""
1786
+ lines.append(f"{f.severity.upper():8s} [{f.rule_id}] {f.file}:{f.line}{fn}")
1787
+ lines.append(f" {f.message}")
1788
+ if f.fix_hint:
1789
+ lines.append(f" fix: {f.fix_hint}")
1790
+ if len(findings) > limit:
1791
+ lines.append(f"... {len(findings) - limit} more finding(s) in the report files")
1792
+ if online_table:
1793
+ lines.append("")
1794
+ lines.append("dependency verification table:")
1795
+ for row in online_table:
1796
+ lines.append(f" {row.get('crate')}: {row.get('status') or 'ok'} "
1797
+ f"{row.get('crates.io', '')}")
1798
+ if evidence:
1799
+ lines.append("")
1800
+ lines.append("evidence runs:")
1801
+ for ev in evidence:
1802
+ lines.append(f" [{ev.status}] {ev.tool} ({ev.command}) exit={ev.exit_code}")
1803
+ return "\n".join(lines)
1804
+
1805
+
1806
+ def render_markdown(findings: list[Finding], online_table: list[dict],
1807
+ evidence: list[EvidenceRun], target: str) -> str:
1808
+ summary = summarize(findings)
1809
+ lines = [
1810
+ f"# {TOOL_NAME} report — `{target}`",
1811
+ "",
1812
+ f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')} — tool version {VERSION}",
1813
+ "",
1814
+ "## Summary",
1815
+ "",
1816
+ "| Severity | Count |",
1817
+ "|---|---|",
1818
+ ]
1819
+ for sev in SEVERITY_ORDER:
1820
+ lines.append(f"| {sev} | {summary['by_severity'].get(sev, 0)} |")
1821
+ lines += ["", "| Tier | Count |", "|---|---|"]
1822
+ for tier in (1, 2, 3, 4):
1823
+ lines.append(f"| Tier {tier} | {summary['by_tier'].get(tier, 0)} |")
1824
+
1825
+ for tier, title in [(1, "Stubs, fakes, demo code and fake tests"),
1826
+ (2, "Panics, unsafe, swallowed errors, security"),
1827
+ (3, "Idiom and quality"),
1828
+ (4, "Fake/hallucinated dependencies and supply chain")]:
1829
+ tier_findings = [f for f in findings if f.tier == tier]
1830
+ if not tier_findings:
1831
+ continue
1832
+ lines += ["", f"## Tier {tier}: {title}", ""]
1833
+ for f in tier_findings:
1834
+ fn = f" in `{f.function}`" if f.function else ""
1835
+ lines += [
1836
+ f"### [{f.rule_id} · {f.severity}] {f.file}:{f.line}{fn}",
1837
+ "",
1838
+ f"- **Problem:** {f.message}",
1839
+ f"- **Evidence:** `{f.evidence}`" if f.evidence else "- **Evidence:** (see source)",
1840
+ f"- **Source:** {f.source} (confidence: {f.confidence})",
1841
+ f"- **Fix ({f.fix}):** {f.fix_hint}" if f.fix_hint else "",
1842
+ "",
1843
+ ]
1844
+
1845
+ if online_table:
1846
+ lines += ["## Dependency verification table", "",
1847
+ "| Crate | Version | crates.io | docs.rs | Features | Status |",
1848
+ "|---|---|---|---|---|---|"]
1849
+ for row in online_table:
1850
+ crate = str(row.get("crate", ""))
1851
+ version = str(row.get("version", row.get("version_req", "")))
1852
+ crates_io = str(row.get("crates.io", ""))
1853
+ docs_rs = str(row.get("docs.rs", ""))
1854
+ features = str(row.get("features", ""))[:60]
1855
+ status = str(row.get("status") or "ok")
1856
+ lines.append(f"| {crate} | {version} | {crates_io} | {docs_rs} | {features} | {status} |")
1857
+ lines.append("")
1858
+
1859
+ if evidence:
1860
+ lines += ["## Evidence runs (tool pipeline)", "",
1861
+ "| Tool | Command | Status | Exit | Duration |",
1862
+ "|---|---|---|---|---|"]
1863
+ for ev in evidence:
1864
+ lines.append(f"| {ev.tool} | `{ev.command}` | {ev.status} | {ev.exit_code} | "
1865
+ f"{ev.duration_s:.1f}s |")
1866
+ lines.append("")
1867
+ for ev in evidence:
1868
+ if ev.excerpt:
1869
+ lines += [f"<details><summary>{ev.tool} output excerpt</summary>", "",
1870
+ "```", ev.excerpt[:3000], "```", "", "</details>", ""]
1871
+ return "\n".join(lines)
1872
+
1873
+
1874
+ def render_json(findings: list[Finding], online_table: list[dict],
1875
+ evidence: list[EvidenceRun], target: str) -> str:
1876
+ payload = {
1877
+ "tool": TOOL_NAME,
1878
+ "version": VERSION,
1879
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
1880
+ "target": target,
1881
+ "summary": summarize(findings),
1882
+ "findings": [f.to_dict() for f in findings],
1883
+ "online": online_table,
1884
+ "evidence_runs": [e.to_dict() for e in evidence],
1885
+ }
1886
+ return json.dumps(payload, indent=2, ensure_ascii=False)
1887
+
1888
+
1889
+ def render_sarif(findings: list[Finding]) -> str:
1890
+ level_map = {"critical": "error", "high": "error", "medium": "warning",
1891
+ "low": "note", "info": "note"}
1892
+ rules = []
1893
+ for rid, meta in RULES.items():
1894
+ rules.append({
1895
+ "id": rid,
1896
+ "shortDescription": {"text": meta["title"]},
1897
+ "defaultConfiguration": {"level": level_map.get(meta["severity"], "warning")},
1898
+ "properties": {"tags": [f"tier{meta['tier']}", "rust", "ai-generated-code"],
1899
+ "precision": "medium"},
1900
+ })
1901
+ results = []
1902
+ for f in findings:
1903
+ fp = hashlib.sha1(
1904
+ f"{f.rule_id}|{f.file}|{f.line}|{f.evidence}".encode("utf-8")).hexdigest()[:16]
1905
+ results.append({
1906
+ "ruleId": f.rule_id,
1907
+ "level": level_map.get(f.severity, "warning"),
1908
+ "message": {"text": f.message},
1909
+ "locations": [{"physicalLocation": {
1910
+ "artifactLocation": {"uri": f.file.replace("\\", "/")},
1911
+ "region": {"startLine": max(1, f.line)},
1912
+ }}],
1913
+ "partialFingerprints": {"rustAiAudit": fp},
1914
+ })
1915
+ return json.dumps({
1916
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
1917
+ "version": "2.1.0",
1918
+ "runs": [{"tool": {"driver": {
1919
+ "name": TOOL_NAME,
1920
+ "version": VERSION,
1921
+ "informationUri": "https://github.com/rust-secure-code/",
1922
+ "rules": rules,
1923
+ }}, "results": results}],
1924
+ }, indent=2)
1925
+
1926
+
1927
+ # --------------------------------------------------------------------------
1928
+ # Auto-fix: safe (transactional + validated + idempotent) and assisted diffs
1929
+ # --------------------------------------------------------------------------
1930
+ DENY_TEMPLATE = """[advisories]
1931
+ vulnerability = "deny"
1932
+ unmaintained = "warn"
1933
+ yanked = "deny"
1934
+
1935
+ [bans]
1936
+ multiple-versions = "warn"
1937
+ wildcards = "deny"
1938
+
1939
+ [sources]
1940
+ unknown-registry = "deny"
1941
+ unknown-git = "deny"
1942
+ allow-registry = ["https://github.com/rust-lang/crates.io-index"]
1943
+ """
1944
+
1945
+ SECRET_FIX_RE = re.compile(
1946
+ r"(?i)\blet\s+(api[_-]?key|apikey|secret[_-]?key|access[_-]?key|password|passwd|"
1947
+ r"auth[_-]?token|token)\s*=\s*\"[^\"]{8,}\"")
1948
+
1949
+
1950
+ def _unified(rel: str, old: str, new: str) -> str:
1951
+ return "".join(difflib.unified_diff(
1952
+ old.splitlines(keepends=True), new.splitlines(keepends=True),
1953
+ fromfile=f"a/{rel}", tofile=f"b/{rel}"))
1954
+
1955
+
1956
+ def plan_safe_fixes(units: list[FileUnit], findings: list[Finding]) -> tuple[dict[str, str], dict[str, str]]:
1957
+ """Returns ({rel: new_text}, {rel: unified_diff}) without touching disk."""
1958
+ edits: dict[str, str] = {}
1959
+ env_additions: set[str] = set()
1960
+ texts = {u.rel: u.src.text for u in units}
1961
+ by_rel = {u.rel: u for u in units}
1962
+
1963
+ for f in findings:
1964
+ if f.rule_id == "R18b" and f.fix == "safe":
1965
+ m = re.search(r"use\s+([A-Za-z_]\w*)\s*::", f.evidence or "")
1966
+ if not m:
1967
+ m = re.search(r"`([A-Za-z_]\w*)`", f.message)
1968
+ if not m:
1969
+ continue
1970
+ root = m.group(1)
1971
+ unit = by_rel.get(f.file)
1972
+ if unit is None:
1973
+ continue
1974
+ if re.search(r"\bmod\s+" + re.escape(root) + r"\b", unit.src.masked_all):
1975
+ continue # real local module: do not touch
1976
+ current = edits.get(f.file, texts[f.file])
1977
+ new = re.sub(r"(?m)^(\s*use\s+)" + re.escape(root) + r"(?=\s*::)",
1978
+ r"\1std::" + root, current)
1979
+ if new != current:
1980
+ edits[f.file] = new
1981
+ elif f.rule_id == "R12" and f.fix == "safe":
1982
+ unit = by_rel.get(f.file)
1983
+ if unit is None:
1984
+ continue
1985
+ current = edits.get(f.file, texts[f.file])
1986
+
1987
+ def _env_sub(m: re.Match) -> str:
1988
+ name = m.group(1)
1989
+ env_name = re.sub(r"[^A-Za-z0-9]", "_", name).upper()
1990
+ env_additions.add(f"{env_name}=")
1991
+ return f'let {name} = std::env::var("{env_name}").unwrap_or_default()'
1992
+
1993
+ new = SECRET_FIX_RE.sub(_env_sub, current)
1994
+ if new != current:
1995
+ edits[f.file] = new
1996
+
1997
+ diffs = {rel: _unified(rel, texts[rel], new) for rel, new in edits.items()}
1998
+ return edits, diffs, env_additions
1999
+
2000
+
2001
+ def _rewrite_sleeps(body: str) -> str:
2002
+ """thread::sleep(EXPR) -> tokio::time::sleep(EXPR).await (proposal text)."""
2003
+ out = body
2004
+ while True:
2005
+ m = re.search(r"\b(?:std::)?thread::sleep\s*\(", out)
2006
+ if not m:
2007
+ return out
2008
+ open_idx = out.find("(", m.start())
2009
+ close_idx = match_pair(out, open_idx)
2010
+ if close_idx < 0:
2011
+ return out
2012
+ arg = out[open_idx + 1: close_idx]
2013
+ out = out[:m.start()] + f"tokio::time::sleep({arg}).await" + out[close_idx + 1:]
2014
+
2015
+
2016
+ def plan_assisted_fixes(units: list[FileUnit], findings: list[Finding]) -> list[str]:
2017
+ """Behaviour-changing rewrites are proposals only: unified diffs for review.
2018
+
2019
+ All edits are computed against the original text and applied in reverse
2020
+ offset order, so sequential rewrites never corrupt each other's offsets.
2021
+ """
2022
+ diffs: list[str] = []
2023
+ wanted = {f.rule_id for f in findings}
2024
+ for unit in units:
2025
+ text = unit.src.text
2026
+ masked = unit.src.masked_all
2027
+ edits: list[tuple[int, int, str]] = [] # (start, end, replacement) on text
2028
+ for fn in unit.fns:
2029
+ if fn.is_test:
2030
+ continue
2031
+ open_idx = masked.find("{", fn.start)
2032
+ close_idx = match_pair(masked, open_idx) if open_idx >= 0 else -1
2033
+ if open_idx < 0 or close_idx < 0:
2034
+ continue
2035
+ body = text[open_idx + 1: close_idx]
2036
+ new_body = body
2037
+ if "R09" in wanted and re.search(r"\b(Result|Option)\b", fn.sig):
2038
+ new_body = re.sub(r"\.unwrap\(\)", "?", new_body)
2039
+ new_body = re.sub(r"\.expect\s*\([^)]*\)", "?", new_body)
2040
+ if "R10" in wanted and re.search(r"\bResult\b", fn.sig):
2041
+ new_body = re.sub(r"let\s+_\s*=\s*([^;]+);", r"\1?;", new_body, count=2)
2042
+ if "R16" in wanted and fn.is_async:
2043
+ new_body = _rewrite_sleeps(new_body)
2044
+ if new_body != body:
2045
+ edits.append((open_idx + 1, close_idx, new_body))
2046
+ if not edits:
2047
+ continue
2048
+ changed = text
2049
+ for start, end, repl in sorted(edits, key=lambda e: -e[0]):
2050
+ changed = changed[:start] + repl + changed[end:]
2051
+ if changed != text:
2052
+ diffs.append(_unified(unit.rel, text, changed))
2053
+ return diffs
2054
+
2055
+
2056
+ def run_validators(root: Path, budget: int = 600) -> list[EvidenceRun]:
2057
+ runs: list[EvidenceRun] = []
2058
+ if not (root / "Cargo.toml").exists() or not shutil.which("cargo"):
2059
+ runs.append(EvidenceRun("validation", "cargo fmt/check/test", "skipped",
2060
+ notes="no Cargo.toml or cargo toolchain; edits kept without compile validation"))
2061
+ return runs
2062
+ for name, argv in [("cargo fmt", ["cargo", "fmt", "--", "--check"]),
2063
+ ("cargo check", ["cargo", "check", "--all-targets"]),
2064
+ ("cargo test", ["cargo", "test"])]:
2065
+ t0 = time.time()
2066
+ try:
2067
+ proc = _spawn(argv, cwd=root, timeout=budget)
2068
+ excerpt = ((proc.stdout or "") + (proc.stderr or ""))[-3000:]
2069
+ runs.append(EvidenceRun(name, " ".join(argv),
2070
+ "pass" if proc.returncode == 0 else "fail",
2071
+ proc.returncode, time.time() - t0, excerpt))
2072
+ if proc.returncode != 0:
2073
+ break
2074
+ except ExecBlocked:
2075
+ runs.append(EvidenceRun(name, " ".join(argv), "skipped",
2076
+ notes=EXEC_BLOCKED_NOTE))
2077
+ except Exception as exc:
2078
+ runs.append(EvidenceRun(name, " ".join(argv), "error", None,
2079
+ time.time() - t0, str(exc)))
2080
+ break
2081
+ return runs
2082
+
2083
+
2084
+ def _rust_digest(root: Path) -> str:
2085
+ """Content fingerprint of all .rs sources (fixer change detection)."""
2086
+ h = hashlib.sha256()
2087
+ for p in sorted(root.rglob("*.rs")):
2088
+ rel = p.relative_to(root)
2089
+ if p.is_symlink() or rel.parts[0] in {".git", "target"}:
2090
+ continue
2091
+ h.update(rel.as_posix().encode("utf-8"))
2092
+ h.update(p.read_bytes())
2093
+ return h.hexdigest()
2094
+
2095
+
2096
+ def run_safe_fixes(root: Path, findings: list[Finding], units: list[FileUnit],
2097
+ dry_run: bool = False, use_cargo: bool = True) -> dict:
2098
+ edits, diffs, env_additions = plan_safe_fixes(units, findings)
2099
+ report: dict = {"applied": [], "diffs": list(diffs.values()), "reverted": [],
2100
+ "evidence": [], "created": []}
2101
+ if dry_run:
2102
+ report["evidence"].append(EvidenceRun("fix --safe", "dry-run", "pass",
2103
+ notes="no files written"))
2104
+ return report
2105
+
2106
+ backups: dict[Path, str] = {}
2107
+ backup_dir = root / "report" / f"backup-{time.strftime('%Y%m%d-%H%M%S')}"
2108
+ try:
2109
+ for rel, new in edits.items():
2110
+ path = root / rel
2111
+ backups[path] = path.read_text(encoding="utf-8")
2112
+ backup_dir.mkdir(parents=True, exist_ok=True)
2113
+ (backup_dir / rel.replace("/", "__")).write_text(backups[path], encoding="utf-8")
2114
+ path.write_text(new, encoding="utf-8")
2115
+ report["applied"].append(rel)
2116
+ if env_additions:
2117
+ env_file = root / ".env.example"
2118
+ existing = env_file.read_text(encoding="utf-8") if env_file.exists() else ""
2119
+ add = "\n".join(sorted(a for a in env_additions if a.split("=")[0] not in existing))
2120
+ if add:
2121
+ env_file.write_text(existing + ("\n" if existing else "") + add + "\n",
2122
+ encoding="utf-8")
2123
+ report["created"].append(".env.example")
2124
+ tier4 = any(f.tier == 4 for f in findings)
2125
+ deny = root / "deny.toml"
2126
+ if tier4 and not deny.exists():
2127
+ template = _template_dir() / "deny.toml"
2128
+ deny.write_text(template.read_text(encoding="utf-8") if template.exists() else DENY_TEMPLATE,
2129
+ encoding="utf-8")
2130
+ report["created"].append("deny.toml")
2131
+ if use_cargo and shutil.which("cargo") and (root / "Cargo.toml").exists():
2132
+ def _fixer(argv: list[str]) -> None:
2133
+ try:
2134
+ proc = _spawn(argv, cwd=root, timeout=900)
2135
+ report["evidence"].append(EvidenceRun(
2136
+ argv[1], " ".join(argv), "pass" if proc.returncode == 0 else "fail",
2137
+ proc.returncode, excerpt=((proc.stdout or "") + (proc.stderr or ""))[-1500:]))
2138
+ except ExecBlocked:
2139
+ report["evidence"].append(EvidenceRun(argv[1], " ".join(argv), "skipped",
2140
+ notes=EXEC_BLOCKED_NOTE))
2141
+ except Exception as exc:
2142
+ report["evidence"].append(EvidenceRun(argv[1], " ".join(argv), "error",
2143
+ notes=str(exc)))
2144
+
2145
+ # clippy --fix splices suggestions unformatted and can need extra
2146
+ # passes for overlapping fixes; loop it to a fixpoint (bounded) and
2147
+ # run fmt LAST, so one `fix` run converges and a second one applies
2148
+ # nothing (the documented idempotency).
2149
+ for _ in range(3):
2150
+ before = _rust_digest(root)
2151
+ _fixer(["cargo", "clippy", "--fix", "--allow-dirty",
2152
+ "--allow-staged", "--allow-no-vcs", "--all-targets"])
2153
+ if _rust_digest(root) == before:
2154
+ break
2155
+ _fixer(["cargo", "fmt"])
2156
+ validations = run_validators(root)
2157
+ report["evidence"].extend(validations)
2158
+ if any(v.status in ("fail", "error") for v in validations):
2159
+ for path, original in backups.items():
2160
+ path.write_text(original, encoding="utf-8")
2161
+ report["reverted"].append(path.relative_to(root).as_posix())
2162
+ report["applied"] = []
2163
+ except Exception as exc:
2164
+ for path, original in backups.items():
2165
+ path.write_text(original, encoding="utf-8")
2166
+ report["reverted"] = [str(p.relative_to(root)) for p in backups]
2167
+ report["applied"] = []
2168
+ report["evidence"].append(EvidenceRun("fix --safe", "apply", "error", notes=str(exc)))
2169
+ return report
2170
+
2171
+
2172
+ # --------------------------------------------------------------------------
2173
+ # Evidence pipeline (cargo subcommand)
2174
+ # --------------------------------------------------------------------------
2175
+ CLIPPY_LINT_MAP = {
2176
+ "clippy::unwrap_used": "R09", "clippy::expect_used": "R09", "clippy::panic": "R09",
2177
+ "clippy::todo": "R01", "clippy::unimplemented": "R01",
2178
+ "clippy::indexing_slicing": "R13", "clippy::arithmetic_side_effects": "R13",
2179
+ "clippy::await_holding_lock": "R16", "clippy::mem_forget": "R11",
2180
+ "clippy::transmute_undefined_repr": "R11", "clippy::rc_buffer": "R17",
2181
+ }
2182
+
2183
+ CLIPPY_FLAGS = ["--", "-W", "clippy::unwrap_used", "-W", "clippy::expect_used",
2184
+ "-W", "clippy::panic", "-W", "clippy::indexing_slicing",
2185
+ "-W", "clippy::todo", "-W", "clippy::unimplemented",
2186
+ "-W", "clippy::arithmetic_side_effects", "-W", "clippy::await_holding_lock"]
2187
+
2188
+
2189
+ def _run_cmd(argv: list[str], cwd: Path, timeout: int) -> tuple[int | None, str, float]:
2190
+ t0 = time.time()
2191
+ try:
2192
+ # utf-8 + replace: cargo emits UTF-8 progress output that breaks cp1252
2193
+ proc = _spawn(argv, cwd=cwd, timeout=timeout)
2194
+ return proc.returncode, ((proc.stdout or "") + (proc.stderr or "")), time.time() - t0
2195
+ except ExecBlocked:
2196
+ return None, f"skipped: {EXEC_BLOCKED_NOTE}", 0.0
2197
+ except FileNotFoundError:
2198
+ return None, "", 0.0
2199
+ except subprocess.TimeoutExpired:
2200
+ return -9, "TIMEOUT", time.time() - t0
2201
+ except Exception as exc:
2202
+ return None, str(exc), time.time() - t0
2203
+
2204
+
2205
+ def _parse_clippy_json(output: str, findings: list[Finding]) -> None:
2206
+ for line in output.splitlines():
2207
+ line = line.strip()
2208
+ if not line.startswith("{"):
2209
+ continue
2210
+ try:
2211
+ obj = json.loads(line)
2212
+ except Exception:
2213
+ continue
2214
+ if obj.get("reason") != "compiler-message":
2215
+ continue
2216
+ msg = obj.get("message") or {}
2217
+ code = ((msg.get("code") or {}).get("code") or "") if isinstance(msg.get("code"), dict) else ""
2218
+ rid = CLIPPY_LINT_MAP.get(code)
2219
+ if not rid:
2220
+ continue
2221
+ span = next((s for s in (msg.get("spans") or []) if s.get("is_primary")), None)
2222
+ rel = span.get("file_name", "") if span else ""
2223
+ line_no = span.get("line_start", 1) if span else 1
2224
+ findings.append(Finding(
2225
+ rid, RULES[rid]["severity"], f"clippy {code}: {msg.get('message', '')}",
2226
+ rel, line_no, evidence=code, source="clippy", fix="none",
2227
+ fix_hint="Address the clippy diagnostic; it corroborates the static scan."))
2228
+
2229
+
2230
+ def collect_evidence(root: Path, deep: bool = False, supply_chain: bool = False,
2231
+ release: bool = False, miri: bool = False, budget: int = 600,
2232
+ findings: list[Finding] | None = None) -> list[EvidenceRun]:
2233
+ findings = findings if findings is not None else []
2234
+ runs: list[EvidenceRun] = []
2235
+ if NO_EXEC:
2236
+ runs.append(EvidenceRun("cargo pipeline", "(all evidence tools)", "skipped",
2237
+ notes=EXEC_BLOCKED_NOTE))
2238
+ return runs
2239
+ if not (root / "Cargo.toml").exists():
2240
+ runs.append(EvidenceRun("cargo", "cargo", "skipped", notes="no Cargo.toml in target"))
2241
+ return runs
2242
+ if not shutil.which("cargo"):
2243
+ runs.append(EvidenceRun("cargo", "cargo", "skipped", notes="cargo toolchain not installed"))
2244
+ return runs
2245
+
2246
+ # Tier A
2247
+ code, out, dur = _run_cmd(["cargo", "fmt", "--", "--check"], root, min(budget, 300))
2248
+ runs.append(EvidenceRun("cargo fmt", "cargo fmt -- --check",
2249
+ "pass" if code == 0 else ("skipped" if code is None else "fail"),
2250
+ code, dur, out[-2000:]))
2251
+ code, out, dur = _run_cmd(["cargo", "clippy", "--all-targets", "--message-format=json"] + CLIPPY_FLAGS,
2252
+ root, budget)
2253
+ before = len(findings)
2254
+ if code is not None:
2255
+ _parse_clippy_json(out, findings)
2256
+ runs.append(EvidenceRun("cargo clippy", "cargo clippy --all-targets --message-format=json",
2257
+ "findings" if len(findings) > before else
2258
+ ("pass" if code == 0 else ("skipped" if code is None else "fail")),
2259
+ code, dur, out[-2000:], notes=f"{len(findings) - before} lint finding(s)"))
2260
+ code, out, dur = _run_cmd(["cargo", "test"], root, budget)
2261
+ runs.append(EvidenceRun("cargo test", "cargo test",
2262
+ "pass" if code == 0 else ("skipped" if code is None else "fail"),
2263
+ code, dur, out[-2000:]))
2264
+ if release:
2265
+ code, out, dur = _run_cmd(["cargo", "build", "--release"], root, budget)
2266
+ runs.append(EvidenceRun("cargo build --release", "cargo build --release",
2267
+ "pass" if code == 0 else ("skipped" if code is None else "fail"),
2268
+ code, dur, out[-1500:]))
2269
+
2270
+ # Tier B (deep)
2271
+ if deep:
2272
+ code, out, dur = _run_cmd(["cargo", "llvm-cov", "--json", "--output-path",
2273
+ str(root / "report" / "coverage.json")], root, budget)
2274
+ runs.append(EvidenceRun("cargo llvm-cov", "cargo llvm-cov --json",
2275
+ "pass" if code == 0 else ("skipped" if code is None else "fail"),
2276
+ code, dur, out[-1500:]))
2277
+ code, out, dur = _run_cmd(["cargo", "mutants", "--no-shuffle"], root, budget)
2278
+ status = "pass" if code == 0 else ("skipped" if code is None else "fail")
2279
+ outcomes = root / "mutants.out" / "outcomes.json"
2280
+ if outcomes.exists():
2281
+ try:
2282
+ data = json.loads(outcomes.read_text(encoding="utf-8"))
2283
+ missed = [o for o in data.get("outcomes", []) if o.get("outcome") == "missed"]
2284
+ if missed:
2285
+ status = "findings"
2286
+ findings.append(Finding(
2287
+ "R02", "high",
2288
+ f"{len(missed)} mutants survived the test suite (tests do not constrain behaviour).",
2289
+ "mutants.out/outcomes.json", 1, evidence=f"{len(missed)} missed mutants",
2290
+ source="mutants", fix="none",
2291
+ fix_hint="Add assertions that fail when the mutated behaviour changes."))
2292
+ except Exception:
2293
+ pass
2294
+ runs.append(EvidenceRun("cargo mutants", "cargo mutants --no-shuffle", status, code, dur,
2295
+ out[-1500:]))
2296
+ code, out, dur = _run_cmd(["cargo", "machete", "--json"], root, 300)
2297
+ if code is not None:
2298
+ try:
2299
+ data = json.loads(out)
2300
+ for manifest, unused in (data.get("unused_crates") or {}).items():
2301
+ for name in unused:
2302
+ findings.append(Finding(
2303
+ "R18e", "low", f"`{name}` declared in {manifest} but never used (cargo-machete).",
2304
+ manifest, 1, evidence=name, source="machete", fix="none",
2305
+ fix_hint="Remove the dependency or confirm with cargo udeps."))
2306
+ except Exception:
2307
+ pass
2308
+ runs.append(EvidenceRun("cargo machete", "cargo machete --json",
2309
+ "pass" if code == 0 else ("skipped" if code is None else "fail"),
2310
+ code, dur, out[-1200:]))
2311
+ if miri:
2312
+ code, out, dur = _run_cmd(["cargo", "+nightly", "miri", "test"], root, budget)
2313
+ runs.append(EvidenceRun("cargo miri", "cargo +nightly miri test",
2314
+ "pass" if code == 0 else ("skipped" if code is None else "fail"),
2315
+ code, dur, out[-1500:]))
2316
+
2317
+ # Tier C (supply chain)
2318
+ if supply_chain:
2319
+ chain = [
2320
+ ("cargo audit", ["cargo", "audit"]),
2321
+ ("cargo deny", ["cargo", "deny", "check"]),
2322
+ ("cargo vet", ["cargo", "vet", "check", "--output-format=json"]),
2323
+ ("cargo unmaintained", ["cargo", "unmaintained"]),
2324
+ ("cargo supply-chain", ["cargo", "supply-chain", "list-owners"]),
2325
+ ("guarddog", ["guarddog", "crates", "verify", "Cargo.lock"]),
2326
+ ("osv-scanner", ["osv-scanner", "scan", "source", "-r", ".", "--format", "json"]),
2327
+ ]
2328
+ for name, argv in chain:
2329
+ if not shutil.which(argv[0]):
2330
+ runs.append(EvidenceRun(name, " ".join(argv), "skipped",
2331
+ notes=f"{argv[0]} not installed"))
2332
+ continue
2333
+ code, out, dur = _run_cmd(argv, root, min(budget, 300))
2334
+ runs.append(EvidenceRun(name, " ".join(argv),
2335
+ "pass" if code == 0 else ("findings" if code == 1 else "fail"),
2336
+ code, dur, out[-1500:]))
2337
+ return runs
2338
+
2339
+
2340
+ # --------------------------------------------------------------------------
2341
+ # Selftest (embedded micro-corpus; one snippet per offline rule)
2342
+ # --------------------------------------------------------------------------
2343
+ SELFTEST_CARGO = """[package]
2344
+ name = "selftest"
2345
+ version = "0.1.0"
2346
+ edition = "2021"
2347
+
2348
+ [dependencies]
2349
+ serdee = "1"
2350
+ itertools = "0.10"
2351
+ tokio = { version = "25", features = ["http"] }
2352
+ """
2353
+
2354
+ SELFTEST_FILES: dict[str, str] = {
2355
+ "src/r01.rs": "pub fn a() -> u32 { todo!(\"x\") }",
2356
+ "src/r02.rs": """#[cfg(test)]
2357
+ mod tests {
2358
+ #[test]
2359
+ fn test_ok() { assert!(true); }
2360
+ }
2361
+ """,
2362
+ "src/r03.rs": "pub fn is_valid(input: &str, limit: usize) -> bool { true }",
2363
+ "src/r04.rs": "pub fn bio() -> String { \"lorem ipsum\".to_string() }",
2364
+ "src/r05.rs": "pub fn demo_response() -> &'static str { \"ok\" }",
2365
+ "src/r06.rs": "pub struct InMemoryStore { pub rows: Vec<u8> }",
2366
+ "src/r07.rs": """#[cfg(test)]
2367
+ mod tests {
2368
+ #[test]
2369
+ fn heavy() {
2370
+ let m1 = MockA; let m2 = MockB; let m3 = MockC;
2371
+ let m4 = MockD; let m5 = MockE; let m6 = MockF;
2372
+ assert!(m1.ready());
2373
+ }
2374
+ }
2375
+ """,
2376
+ "src/r08.rs": """#[cfg(test)]
2377
+ mod tests {
2378
+ #[test]
2379
+ fn flaky() {
2380
+ std::thread::sleep(std::time::Duration::from_millis(1));
2381
+ assert!(true == true);
2382
+ }
2383
+ }
2384
+ """,
2385
+ "src/r09.rs": "pub fn parse(raw: &str) -> u32 { raw.parse::<u32>().unwrap() }",
2386
+ "src/r10.rs": "pub fn save(p: &str) -> std::io::Result<()> { let _ = std::fs::write(p, \"x\"); Ok(()) }",
2387
+ "src/r11.rs": "pub fn deref(p: *const u8) -> u8 { unsafe { std::ptr::read(p) } }",
2388
+ "src/r12.rs": "pub fn connect() { let password = \"supersecretpassword123\"; drop(password); }",
2389
+ "src/r13.rs": "pub fn port() -> u16 { let a: Vec<String> = std::env::args().collect(); a[1].parse::<u16>().unwrap() }",
2390
+ "src/r14.rs": "pub fn cl(v: Vec<u8>) -> Vec<u8> { let a = v.clone(); let b = a.clone(); let c = b.clone(); let d = c.clone(); d }",
2391
+ "src/r15.rs": "pub fn pick<'a, 'b, 'c>(x: &'a str, _y: &'b str, _z: &'c str) -> &'a str { x }",
2392
+ "src/r16.rs": "pub async fn slow() { std::thread::sleep(std::time::Duration::from_secs(1)); }",
2393
+ "src/r17.rs": """use std::sync::{Arc, Mutex};
2394
+ pub fn shared() -> Vec<Arc<Mutex<u32>>> {
2395
+ vec![Arc::new(Mutex::new(1)), Arc::new(Mutex::new(2)),
2396
+ Arc::new(Mutex::new(3)), Arc::new(Mutex::new(4))]
2397
+ }
2398
+ """,
2399
+ "src/r18a.rs": """use quantum_cache::Cache;
2400
+ pub fn q(_c: Cache) {}
2401
+ """,
2402
+ "src/r18b.rs": """use fs::File;
2403
+ pub fn open() -> Option<File> { None }
2404
+ """,
2405
+ "src/r18c.rs": """use serdee::Serialize;
2406
+ pub fn s<T: Serialize>(_t: T) {}
2407
+ """,
2408
+ "src/r20.rs": "pub fn nothing() {}",
2409
+ "src/r21.rs": "fn main() { std::process::Command::new(\"curl\").status().unwrap(); }",
2410
+ }
2411
+
2412
+ SELFTEST_EXPECT = ["R01", "R02", "R03", "R04", "R05", "R06", "R07", "R08", "R09",
2413
+ "R10", "R11", "R12", "R13", "R14", "R15", "R16", "R17",
2414
+ "R18a", "R18b", "R18c", "R18d", "R18e", "R20", "R21"]
2415
+
2416
+
2417
+ def cmd_selftest(_args) -> int:
2418
+ with tempfile.TemporaryDirectory(prefix="rust-audit-") as tmp:
2419
+ root = Path(tmp)
2420
+ (root / "src").mkdir()
2421
+ (root / "Cargo.toml").write_text(SELFTEST_CARGO, encoding="utf-8")
2422
+ (root / "build.rs").write_text(
2423
+ 'fn main() { std::fs::write("out.bin", "x").unwrap(); }\n', encoding="utf-8")
2424
+ for rel, content in SELFTEST_FILES.items():
2425
+ (root / rel).parent.mkdir(parents=True, exist_ok=True)
2426
+ (root / rel).write_text(content, encoding="utf-8")
2427
+ cfg = load_config(root, None)
2428
+ findings, _, _, _ = run_scan(root, cfg, online=False)
2429
+ found = {f.rule_id for f in findings}
2430
+ missing = [r for r in SELFTEST_EXPECT if r not in found]
2431
+ print(f"selftest: {len(found)}/{len(SELFTEST_EXPECT)} rule ids triggered")
2432
+ for rid in sorted(found):
2433
+ print(f" [ok] {rid}")
2434
+ print(" [skipped] R19 (online-only: run `scan --online`)")
2435
+ if missing:
2436
+ print(f"FAIL: rules not triggered: {missing}")
2437
+ return 1
2438
+ print("selftest PASS")
2439
+ return 0
2440
+
2441
+
2442
+ # --------------------------------------------------------------------------
2443
+ # Tool inventory, best-effort installer (setup) and the dated
2444
+ # "not-executed checks" report required for honest audit reporting.
2445
+ # --------------------------------------------------------------------------
2446
+ # (tool id, what check it unlocks, rule ids, how to enable it)
2447
+ TOOL_INVENTORY: list[tuple[str, str, str, str]] = [
2448
+ ("cargo-mutants",
2449
+ "Mutation testing: proves whether tests can fail (detects tests that prove nothing)",
2450
+ "R02 evidence",
2451
+ "python rust_audit.py setup (or: cargo install cargo-mutants), then `cargo --deep`"),
2452
+ ("cargo-llvm-cov",
2453
+ "Coverage: prioritise stub functions that no test ever exercises",
2454
+ "R01/R03 evidence",
2455
+ "python rust_audit.py setup (or: cargo install cargo-llvm-cov), then `cargo --deep`"),
2456
+ ("cargo-udeps",
2457
+ "Unused-dependency confirmation (declared but never used)",
2458
+ "R18e evidence",
2459
+ "python rust_audit.py setup (or: cargo install cargo-udeps; run with `cargo +nightly udeps`)"),
2460
+ ("guarddog",
2461
+ "Malicious-crate scan of Cargo.lock (source + metadata heuristics)",
2462
+ "R19 evidence",
2463
+ "python rust_audit.py setup (or: pipx install guarddog), then `cargo --supply-chain`"),
2464
+ ("osv-scanner",
2465
+ "OSV advisory scan of the dependency tree",
2466
+ "R19 evidence",
2467
+ "python rust_audit.py setup (or install from https://github.com/google/osv-scanner/releases)"),
2468
+ ("cargo-miri",
2469
+ "Undefined-behaviour detection for unsafe code (use-after-free, aliasing, ...)",
2470
+ "R11 evidence",
2471
+ "rustup +nightly component add miri, then `cargo --miri`"),
2472
+ ("typomania",
2473
+ "Rust Foundation typosquatting corpus checks (R20 backend)",
2474
+ "R20 evidence",
2475
+ "library-only crate (no binary): the built-in similarity engine stays active as R20"),
2476
+ ]
2477
+
2478
+ # (tool id, install strategies tried in order, note)
2479
+ INSTALL_TARGETS: list[tuple[str, list[list[str]], str]] = [
2480
+ ("cargo-mutants",
2481
+ [["cargo", "install", "cargo-mutants", "--locked"], ["cargo", "install", "cargo-mutants"]],
2482
+ "large dependency tree; first install compiles for several minutes"),
2483
+ ("cargo-llvm-cov",
2484
+ [["cargo", "install", "cargo-llvm-cov", "--locked"], ["cargo", "install", "cargo-llvm-cov"]],
2485
+ "requires the llvm-tools component for full coverage"),
2486
+ ("cargo-udeps",
2487
+ [["cargo", "install", "cargo-udeps", "--locked"], ["cargo", "install", "cargo-udeps"],
2488
+ ["cargo", "+nightly", "install", "cargo-udeps"]],
2489
+ "must be RUN with nightly: `cargo +nightly udeps`"),
2490
+ ("guarddog",
2491
+ [[sys.executable, "-m", "pip", "install", "--user", "guarddog"],
2492
+ ["pipx", "install", "guarddog"],
2493
+ [sys.executable, "-m", "pip", "install", "guarddog"]],
2494
+ "Python package (DataDog); pipx gives the cleanest install"),
2495
+ ("osv-scanner",
2496
+ [["go", "install", "github.com/google/osv-scanner/v2/cmd/osv-scanner@latest"]],
2497
+ "Go binary: requires a Go toolchain, otherwise download the release binary"),
2498
+ ("cargo-miri",
2499
+ [["rustup", "component", "add", "miri", "--toolchain", "nightly"],
2500
+ ["rustup", "component", "add", "miri"]],
2501
+ "needs the nightly toolchain"),
2502
+ ("typomania",
2503
+ [["cargo", "install", "typomania"],
2504
+ ["cargo", "install", "--git", "https://github.com/rustfoundation/typomania"]],
2505
+ "library-only crate: install is expected to report 'no binaries'; R20 built-in engine covers the check"),
2506
+ ]
2507
+
2508
+
2509
+ def _excerpt(out: str, limit: int = 220) -> str:
2510
+ """Single-line head+tail excerpt of a tool output, surfacing the real error.
2511
+
2512
+ Single-line and pipe-free: the value goes straight into a Markdown table
2513
+ cell, where newlines and `|` would break the row.
2514
+ """
2515
+ lines = [ln for ln in out.strip().splitlines()
2516
+ if not ln.strip().startswith("[notice]")]
2517
+ text = "\n".join(lines).strip()
2518
+ body = text if len(text) <= limit * 2 else f"{text[:limit]} … {text[-limit:]}"
2519
+ specific = [ln.strip() for ln in lines if re.search(
2520
+ r"(?i)error\[E\d+\]|cannot find|externally-managed|permission denied|"
2521
+ r"failed to build|could not build|no space left|command not found", ln)]
2522
+ generic = [ln.strip() for ln in lines
2523
+ if re.search(r"(?i)\berror\b|cannot", ln)
2524
+ and not re.match(r"(?i)\s*(note:|this error originates)", ln)]
2525
+ pick = specific or generic
2526
+ if pick and pick[-1] not in body:
2527
+ body = f"{pick[-1][:limit]} ⏎ {body}"
2528
+ return body.replace("\n", " ⏎ ").replace("|", "/")
2529
+
2530
+
2531
+ def _attempts_file(out_dir: Path) -> Path:
2532
+ return out_dir / f"setup-attempts-{time.strftime('%Y-%m-%d')}.json"
2533
+
2534
+
2535
+ def _load_attempts(out_dir: Path) -> list[dict]:
2536
+ af = _attempts_file(out_dir)
2537
+ if af.exists():
2538
+ try:
2539
+ return json.loads(af.read_text(encoding="utf-8"))
2540
+ except Exception:
2541
+ return []
2542
+ return []
2543
+
2544
+
2545
+ def _run_probe(argv: list[str], timeout: int = 25) -> tuple[bool, str]:
2546
+ if NO_EXEC: # PATH lookup only: never spawn under --no-exec
2547
+ path = shutil.which(argv[0])
2548
+ return path is not None, (f"on PATH, not executed (--no-exec)" if path
2549
+ else "not on PATH")
2550
+ code, out, _dur = _run_cmd(argv, Path.cwd(), timeout)
2551
+ if code is None:
2552
+ return False, "runner not available"
2553
+ first = next((ln.strip() for ln in out.splitlines() if ln.strip()), "")
2554
+ return code == 0, first[:80]
2555
+
2556
+
2557
+ def collect_tool_rows() -> list[dict]:
2558
+ """Probe the optional tooling once (cheap) for the not-executed report."""
2559
+ if NO_EXEC: # presence via PATH lookup only — probing would spawn processes
2560
+ rows = []
2561
+ for tool_id, purpose, rules, hint in TOOL_INVENTORY:
2562
+ where = shutil.which(tool_id)
2563
+ rows.append({"tool": tool_id, "purpose": purpose, "rules": rules,
2564
+ "status": (f"not probed (--no-exec); on PATH: {where}" if where
2565
+ else "not probed (--no-exec); not on PATH"),
2566
+ "enable": hint})
2567
+ return rows
2568
+ installed_cargo_bins: dict[str, str] = {}
2569
+ if shutil.which("cargo"):
2570
+ code, out, _ = _run_cmd(["cargo", "install", "--list"], Path.cwd(), 30)
2571
+ if code == 0:
2572
+ for ln in out.splitlines():
2573
+ m = re.match(r"^\s*([A-Za-z0-9_-]+)\s+(v?[0-9][^:]*)\s*:", ln)
2574
+ if m:
2575
+ installed_cargo_bins[m.group(1)] = m.group(2).strip()
2576
+ rows: list[dict] = []
2577
+ for tool_id, purpose, rules, hint in TOOL_INVENTORY:
2578
+ status = "NOT INSTALLED"
2579
+ if tool_id in installed_cargo_bins:
2580
+ status = f"installed ({installed_cargo_bins[tool_id]})"
2581
+ elif tool_id == "cargo-miri":
2582
+ ok, ver = _run_probe(["cargo", "+nightly", "miri", "--version"])
2583
+ status = f"installed ({ver})" if ok else "NOT INSTALLED (nightly component missing)"
2584
+ elif tool_id == "typomania":
2585
+ status = "NOT INSTALLED (library-only crate; built-in R20 engine active)"
2586
+ elif shutil.which(tool_id):
2587
+ ok, ver = _run_probe([tool_id, "--version"])
2588
+ status = f"installed ({ver})" if ok else "installed (version unknown)"
2589
+ rows.append({"tool": tool_id, "purpose": purpose, "rules": rules,
2590
+ "status": status, "enable": hint})
2591
+ return rows
2592
+
2593
+
2594
+ def write_gap_report(out_dir: Path, cmd: str, target: str,
2595
+ evidence: list[EvidenceRun], flags: dict,
2596
+ attempts: list[dict] | None = None) -> Path:
2597
+ """Regenerate `not-executed-<date>.md`: every check that did NOT run, and why."""
2598
+ out_dir.mkdir(parents=True, exist_ok=True)
2599
+ date = time.strftime("%Y-%m-%d")
2600
+ path = out_dir / f"not-executed-{date}.md"
2601
+ if attempts is None:
2602
+ attempts = _load_attempts(out_dir) # accumulate across runs of the day
2603
+ rows = collect_tool_rows()
2604
+ lines = [
2605
+ f"# {TOOL_NAME} — NOT-EXECUTED checks report ({date})",
2606
+ "",
2607
+ f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')} — tool {VERSION} — command: `{cmd}` — target: `{target}`",
2608
+ "",
2609
+ "This file lists every check that could **not** be executed in this run",
2610
+ "(missing tool, network not used, or feature not requested) together with",
2611
+ "the reason and how to enable it. Regenerated on each run for the same date.",
2612
+ "",
2613
+ "## Optional tooling status",
2614
+ "",
2615
+ "| Tool | Unlocks | Rules | Status | How to enable |",
2616
+ "|---|---|---|---|---|",
2617
+ ]
2618
+ for r in rows:
2619
+ lines.append(f"| {r['tool']} | {r['purpose']} | {r['rules']} | {r['status']} | {r['enable']} |")
2620
+
2621
+ lines += ["", "## Checks not executed in this run", ""]
2622
+ not_run: list[tuple[str, str]] = []
2623
+ if NO_EXEC:
2624
+ not_run.append((
2625
+ "ALL process execution: cargo evidence pipeline, fix compile "
2626
+ "validation, setup installs",
2627
+ "remove `--no-exec` (this static-only mode enforces the "
2628
+ "zero-subprocess guarantee)"))
2629
+ if not flags.get("online"):
2630
+ not_run.append((
2631
+ "Live registry verification: crate existence, yanked versions, feature "
2632
+ "names, docs.rs builds, OSV advisories (R18d/R19 online)",
2633
+ "run `scan --online` (requires network)"))
2634
+ if not flags.get("deep"):
2635
+ not_run.append(("Mutation testing + coverage + unused-deps evidence (`--deep`)",
2636
+ "run `cargo --deep` with the optional tools installed"))
2637
+ if not flags.get("miri"):
2638
+ not_run.append(("Miri undefined-behaviour run for unsafe code (R11 evidence)",
2639
+ "run `cargo --miri` with the nightly component installed"))
2640
+ if not flags.get("supply_chain"):
2641
+ not_run.append(("Supply-chain evidence: cargo-audit/deny/vet, guarddog, osv-scanner",
2642
+ "run `cargo --supply-chain`"))
2643
+ if flags.get("fix_validation_skipped"):
2644
+ not_run.append(("Compile validation of auto-fixes (fmt/check/test)",
2645
+ "run `fix` inside a compilable crate with the cargo toolchain installed"))
2646
+ for check, how in not_run:
2647
+ lines.append(f"- **{check}** — how to enable: {how}")
2648
+
2649
+ skipped = [e for e in evidence if e.status == "skipped"]
2650
+ lines += ["", "## Skipped tool runs (evidence pipeline)", ""]
2651
+ if skipped:
2652
+ lines += ["| Tool | Command | Reason |", "|---|---|---|"]
2653
+ for e in skipped:
2654
+ lines.append(f"| {e.tool} | `{e.command}` | {e.notes or 'not available'} |")
2655
+ else:
2656
+ lines.append("(none — every requested tool in the pipeline ran)")
2657
+
2658
+ lines += ["", "## Install attempts (setup)", ""]
2659
+ if attempts:
2660
+ lines += ["| Tool | Attempted | Result |", "|---|---|---|"]
2661
+ for a in attempts:
2662
+ result = str(a["result"]).replace("\n", " ⏎ ").replace("|", "/")
2663
+ attempt = str(a.get("attempt", "—"))
2664
+ lines.append(f"| {a['tool']} | `{attempt}` | {result} |")
2665
+ else:
2666
+ lines.append("(no install attempts in this run — use `python rust_audit.py setup`)")
2667
+
2668
+ cargo_ok, cargo_ver = _run_probe(["cargo", "--version"])
2669
+ lines += [
2670
+ "", "## Environment", "",
2671
+ f"- python: {sys.version.split()[0]} ({sys.platform})",
2672
+ f"- cargo: {cargo_ver if cargo_ok else 'NOT INSTALLED'}",
2673
+ f"- {TOOL_NAME}: {VERSION}",
2674
+ ]
2675
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
2676
+ return path
2677
+
2678
+
2679
+ def cmd_setup(args) -> int:
2680
+ """Best-effort installer for the optional evidence tools + not-executed report."""
2681
+ try:
2682
+ sys.stdout.reconfigure(line_buffering=True) # live progress in CI logs
2683
+ except Exception:
2684
+ pass
2685
+ only = {t.strip() for t in (args.only or "").split(",") if t.strip()}
2686
+ skip = {t.strip() for t in (args.skip or "").split(",") if t.strip()}
2687
+ attempts: list[dict] = []
2688
+ existing = {r["tool"]: r["status"] for r in collect_tool_rows()}
2689
+ print(f"{TOOL_NAME} setup — installing optional evidence tools (best effort)")
2690
+ for tool_id, strategies, note in INSTALL_TARGETS:
2691
+ if only and tool_id not in only:
2692
+ continue
2693
+ if tool_id in skip:
2694
+ continue
2695
+ if existing.get(tool_id, "").startswith("installed") and not args.reinstall:
2696
+ attempts.append({"tool": tool_id, "attempt": "—", "result": "already installed"})
2697
+ print(f" [ok] {tool_id}: already installed")
2698
+ continue
2699
+ if args.dry_run or NO_EXEC:
2700
+ shown = " | ".join(" ".join(s) for s in strategies)
2701
+ result = ("dry-run: not attempted" if args.dry_run
2702
+ else "skipped: " + EXEC_BLOCKED_NOTE)
2703
+ attempts.append({"tool": tool_id, "attempt": shown, "result": result})
2704
+ print(f" [plan] {tool_id}: would try: {shown}")
2705
+ continue
2706
+ done = False
2707
+ for argv in strategies:
2708
+ if not shutil.which(argv[0]):
2709
+ attempts.append({"tool": tool_id, "attempt": " ".join(argv),
2710
+ "result": f"skipped: `{argv[0]}` runner not available"})
2711
+ continue
2712
+ print(f" [running] {tool_id}: {' '.join(argv)}")
2713
+ code, out, dur = _run_cmd(argv, Path.cwd(), args.timeout)
2714
+ if code == 0:
2715
+ attempts.append({"tool": tool_id, "attempt": " ".join(argv),
2716
+ "result": f"installed OK ({dur:.0f}s)"})
2717
+ print(f" [ok] {tool_id}: installed ({dur:.0f}s)")
2718
+ done = True
2719
+ break
2720
+ reason = ("timed out" if code == -9 else f"exit {code}")
2721
+ attempts.append({"tool": tool_id, "attempt": " ".join(argv),
2722
+ "result": f"FAILED ({reason}): {_excerpt(out)}"})
2723
+ if not done and not any(a["tool"] == tool_id and a["result"].startswith("installed")
2724
+ for a in attempts):
2725
+ if not any(a["tool"] == tool_id and a["result"].startswith("dry-run")
2726
+ for a in attempts):
2727
+ print(f" [failed] {tool_id}: could not install — see not-executed report")
2728
+ print(f" note: {note}")
2729
+ out_dir = Path(args.out or "report")
2730
+ out_dir.mkdir(parents=True, exist_ok=True)
2731
+ history = _load_attempts(out_dir) + attempts
2732
+ _attempts_file(out_dir).write_text(
2733
+ json.dumps(history, indent=2, ensure_ascii=False), encoding="utf-8")
2734
+ gap = write_gap_report(
2735
+ out_dir, "setup", str(Path(args.root).resolve()),
2736
+ [], {"online": False, "deep": False, "miri": False, "supply_chain": False},
2737
+ attempts=history)
2738
+ print(f"not-executed report: {gap}")
2739
+ return 0
2740
+
2741
+
2742
+ # --------------------------------------------------------------------------
2743
+ # CLI
2744
+ # --------------------------------------------------------------------------
2745
+ def _filter_rules(findings: list[Finding], rules_arg: str | None) -> list[Finding]:
2746
+ if not rules_arg:
2747
+ return findings
2748
+ wanted = {r.strip().upper() for r in rules_arg.split(",") if r.strip()}
2749
+ return [f for f in findings if f.rule_id in wanted or any(f.rule_id.startswith(w) for w in wanted)]
2750
+
2751
+
2752
+ def _emit_reports(findings, online_table, evidence, args) -> None:
2753
+ formats = [f.strip() for f in (args.format or "terminal").split(",") if f.strip()]
2754
+ out_dir = Path(args.out or "report")
2755
+ stamp = time.strftime("%Y%m%d-%H%M%S")
2756
+ target = str(args.root)
2757
+ if "terminal" in formats:
2758
+ print(render_terminal(findings, online_table, evidence))
2759
+ if "md" in formats or "markdown" in formats:
2760
+ out_dir.mkdir(parents=True, exist_ok=True)
2761
+ path = out_dir / f"rust-audit-{stamp}.md"
2762
+ path.write_text(render_markdown(findings, online_table, evidence, target), encoding="utf-8")
2763
+ print(f"wrote {path}")
2764
+ if "json" in formats:
2765
+ out_dir.mkdir(parents=True, exist_ok=True)
2766
+ path = out_dir / f"rust-audit-{stamp}.json"
2767
+ path.write_text(render_json(findings, online_table, evidence, target), encoding="utf-8")
2768
+ print(f"wrote {path}")
2769
+ if "sarif" in formats:
2770
+ out_dir.mkdir(parents=True, exist_ok=True)
2771
+ path = out_dir / f"rust-audit-{stamp}.sarif"
2772
+ path.write_text(render_sarif(findings), encoding="utf-8")
2773
+ print(f"wrote {path}")
2774
+
2775
+
2776
+ def _require_root(root: Path, need_sources: bool) -> int | None:
2777
+ """Exit-code 2 contract: refuse to silently audit the wrong directory."""
2778
+ if not root.is_dir():
2779
+ print(f"error: target directory not found: {root}", file=sys.stderr)
2780
+ return 2
2781
+ if need_sources and not any(root.rglob("*.rs")) and not any(root.rglob("Cargo.toml")):
2782
+ print(f"error: no Rust sources or Cargo.toml under {root} (wrong path?)",
2783
+ file=sys.stderr)
2784
+ return 2
2785
+ return None
2786
+
2787
+
2788
+ def cmd_scan(args) -> int:
2789
+ root = Path(args.root).resolve()
2790
+ bad = _require_root(root, need_sources=True)
2791
+ if bad is not None:
2792
+ return bad
2793
+ cfg = load_config(root, args.config)
2794
+ if args.exclude:
2795
+ cfg["exclude"] = list(cfg.get("exclude") or []) + [g.strip() for g in args.exclude.split(",")]
2796
+ findings, units, _, online_table = run_scan(root, cfg, online=args.online)
2797
+ findings = _filter_rules(findings, args.rules)
2798
+ _emit_reports(findings, online_table, [], args)
2799
+ if not args.no_gap_report:
2800
+ gap = write_gap_report(Path(args.out or "report"), "scan", str(root), [],
2801
+ {"online": args.online, "deep": False, "miri": False,
2802
+ "supply_chain": False})
2803
+ print(f"not-executed report: {gap}")
2804
+ fail_on = "medium" if args.strict else (args.fail_on or "high")
2805
+ if any(SEVERITY_RANK.get(f.severity, 0) >= SEVERITY_RANK[fail_on] for f in findings):
2806
+ return 1
2807
+ return 0
2808
+
2809
+
2810
+ def cmd_fix(args) -> int:
2811
+ root = Path(args.root).resolve()
2812
+ bad = _require_root(root, need_sources=True)
2813
+ if bad is not None:
2814
+ return bad
2815
+ cfg = load_config(root, args.config)
2816
+ findings, units, _, _ = run_scan(root, cfg, online=False)
2817
+ findings = _filter_rules(findings, args.rules)
2818
+ rc = 0
2819
+ fix_report: dict = {"evidence": []}
2820
+ if args.assisted:
2821
+ diffs = plan_assisted_fixes(units, findings)
2822
+ print("assisted patch proposals (NOT applied; review each diff):")
2823
+ for d in diffs:
2824
+ print(d)
2825
+ if not diffs:
2826
+ print("(no assisted fixes applicable)")
2827
+ else:
2828
+ report = run_safe_fixes(root, findings, units, dry_run=args.dry_run,
2829
+ use_cargo=not args.no_cargo)
2830
+ fix_report = report
2831
+ print(f"safe fixes applied: {len(report['applied'])} file(s)")
2832
+ for rel in report["applied"]:
2833
+ print(f" edited {rel}")
2834
+ for rel in report["created"]:
2835
+ print(f" created {rel}")
2836
+ for rel in report["reverted"]:
2837
+ print(f" REVERTED {rel} (validation failed)")
2838
+ rc = 1
2839
+ if args.dry_run or args.diff:
2840
+ for d in report["diffs"]:
2841
+ print(d)
2842
+ for ev in report["evidence"]:
2843
+ print(f" [{ev.status}] {ev.tool}: {ev.notes or ev.command}")
2844
+ # idempotency check: a second planning pass must produce no edits
2845
+ if not args.dry_run and report["applied"]:
2846
+ findings2, units2, _, _ = run_scan(root, cfg, online=False)
2847
+ edits2, _, _ = plan_safe_fixes(units2, findings2)
2848
+ if edits2:
2849
+ print(f"warning: {len(edits2)} file(s) still have applicable safe fixes (not idempotent)")
2850
+ else:
2851
+ print("idempotency check: no further safe fixes apply")
2852
+ if not args.no_gap_report:
2853
+ validation_skipped = args.assisted or args.no_cargo or args.dry_run or any(
2854
+ e.status == "skipped" for e in fix_report.get("evidence", []))
2855
+ gap = write_gap_report(Path(args.out or "report"), "fix", str(root),
2856
+ fix_report.get("evidence", []),
2857
+ {"online": False, "deep": False, "miri": False,
2858
+ "supply_chain": False,
2859
+ "fix_validation_skipped": validation_skipped})
2860
+ print(f"not-executed report: {gap}")
2861
+ return rc
2862
+
2863
+
2864
+ def cmd_cargo(args) -> int:
2865
+ root = Path(args.root).resolve()
2866
+ bad = _require_root(root, need_sources=False)
2867
+ if bad is not None:
2868
+ return bad
2869
+ findings: list[Finding] = []
2870
+ runs = collect_evidence(root, deep=args.deep, supply_chain=args.supply_chain,
2871
+ release=args.release, miri=args.miri, budget=args.budget,
2872
+ findings=findings)
2873
+ _emit_reports(findings, [], runs, args)
2874
+ if not args.no_gap_report:
2875
+ gap = write_gap_report(Path(args.out or "report"), "cargo", str(root), runs,
2876
+ {"online": False, "deep": args.deep, "miri": args.miri,
2877
+ "supply_chain": args.supply_chain})
2878
+ print(f"not-executed report: {gap}")
2879
+ return 1 if any(SEVERITY_RANK.get(f.severity, 0) >= SEVERITY_RANK[args.fail_on or "high"]
2880
+ for f in findings) else 0
2881
+
2882
+
2883
+ def cmd_full(args) -> int:
2884
+ root = Path(args.root).resolve()
2885
+ bad = _require_root(root, need_sources=True)
2886
+ if bad is not None:
2887
+ return bad
2888
+ cfg = load_config(root, args.config)
2889
+ findings, units, _, online_table = run_scan(root, cfg, online=args.online)
2890
+ runs = collect_evidence(root, deep=args.deep, supply_chain=True,
2891
+ release=args.release, miri=args.miri, budget=args.budget,
2892
+ findings=findings)
2893
+ findings = _filter_rules(findings, args.rules)
2894
+ findings.sort(key=lambda f: (-SEVERITY_RANK.get(f.severity, 0), f.file, f.line, f.rule_id))
2895
+ _emit_reports(findings, online_table, runs, args)
2896
+ if not args.no_gap_report:
2897
+ gap = write_gap_report(Path(args.out or "report"), "full", str(root), runs,
2898
+ {"online": args.online, "deep": args.deep, "miri": args.miri,
2899
+ "supply_chain": True})
2900
+ print(f"not-executed report: {gap}")
2901
+ fail_on = "medium" if args.strict else (args.fail_on or "high")
2902
+ return 1 if any(SEVERITY_RANK.get(f.severity, 0) >= SEVERITY_RANK[fail_on]
2903
+ for f in findings) else 0
2904
+
2905
+
2906
+ def _add_common(p) -> None:
2907
+ p.add_argument("root", nargs="?", default=".", help="target directory (default: .)")
2908
+ p.add_argument("--format", default="terminal", help="terminal,md,json,sarif (comma list)")
2909
+ p.add_argument("--out", default="report", help="report output directory")
2910
+ p.add_argument("--fail-on", default="high", choices=list(SEVERITY_RANK),
2911
+ help="exit 1 when findings reach this severity")
2912
+ p.add_argument("--rules", help="only these rule ids (comma list, prefixes ok)")
2913
+ p.add_argument("--config", help="path to rust-audit.toml")
2914
+ p.add_argument("--exclude", help="extra exclude globs (comma list)")
2915
+ p.add_argument("--strict", action="store_true", help="fail on medium and above")
2916
+ p.add_argument("--no-gap-report", action="store_true",
2917
+ help="do not write the dated not-executed checks report")
2918
+ p.add_argument("--no-exec", action="store_true",
2919
+ help="static-only mode: never spawn a process (zero-subprocess "
2920
+ "guarantee enforced in code; pipeline/validator runs are "
2921
+ "recorded as skipped)")
2922
+
2923
+
2924
+ def main(argv: list[str] | None = None) -> int:
2925
+ parser = argparse.ArgumentParser(prog=TOOL_NAME, description=__doc__)
2926
+ sub = parser.add_subparsers(dest="cmd", required=True)
2927
+
2928
+ p_scan = sub.add_parser("scan", help="static analysis of Rust sources and Cargo manifests")
2929
+ _add_common(p_scan)
2930
+ p_scan.add_argument("--online", action="store_true",
2931
+ help="verify crates against crates.io/docs.rs/OSV (network)")
2932
+ p_scan.set_defaults(func=cmd_scan)
2933
+
2934
+ p_fix = sub.add_parser("fix", help="apply safe fixes or propose assisted patches")
2935
+ _add_common(p_fix)
2936
+ p_fix.add_argument("--safe", action="store_true", help="apply machine-safe fixes (default)")
2937
+ p_fix.add_argument("--assisted", action="store_true",
2938
+ help="emit unified diffs for behaviour-changing fixes (never applied)")
2939
+ p_fix.add_argument("--dry-run", action="store_true", help="show diffs, write nothing")
2940
+ p_fix.add_argument("--diff", action="store_true", help="print the diffs of applied fixes")
2941
+ p_fix.add_argument("--no-cargo", action="store_true",
2942
+ help="skip cargo fmt/clippy --fix integration")
2943
+ p_fix.set_defaults(func=cmd_fix)
2944
+
2945
+ p_cargo = sub.add_parser("cargo", help="evidence pipeline over installed Rust tooling")
2946
+ _add_common(p_cargo)
2947
+ p_cargo.add_argument("--release", action="store_true", help="also cargo build --release")
2948
+ p_cargo.add_argument("--miri", action="store_true", help="also cargo +nightly miri test")
2949
+ p_cargo.add_argument("--deep", action="store_true",
2950
+ help="coverage, mutation testing and unused-dependency checks")
2951
+ p_cargo.add_argument("--supply-chain", action="store_true",
2952
+ help="audit, deny, vet, guarddog, osv-scanner")
2953
+ p_cargo.add_argument("--budget", type=int, default=600, help="per-tool timeout seconds")
2954
+ p_cargo.set_defaults(func=cmd_cargo)
2955
+
2956
+ p_full = sub.add_parser("full", help="scan + cargo merged report")
2957
+ _add_common(p_full)
2958
+ p_full.add_argument("--online", action="store_true")
2959
+ p_full.add_argument("--release", action="store_true")
2960
+ p_full.add_argument("--miri", action="store_true")
2961
+ p_full.add_argument("--deep", action="store_true")
2962
+ p_full.add_argument("--budget", type=int, default=600)
2963
+ p_full.set_defaults(func=cmd_full)
2964
+
2965
+ p_setup = sub.add_parser(
2966
+ "setup", help="best-effort install of the optional evidence tools "
2967
+ "(cargo-mutants, cargo-llvm-cov, cargo-udeps, guarddog, "
2968
+ "osv-scanner, miri, typomania) + not-executed report")
2969
+ p_setup.add_argument("root", nargs="?", default=".")
2970
+ p_setup.add_argument("--out", default="report", help="report output directory")
2971
+ p_setup.add_argument("--timeout", type=int, default=600,
2972
+ help="per-tool install timeout in seconds")
2973
+ p_setup.add_argument("--only", help="comma list of tool ids to install")
2974
+ p_setup.add_argument("--skip", help="comma list of tool ids to skip")
2975
+ p_setup.add_argument("--reinstall", action="store_true",
2976
+ help="attempt install even if already present")
2977
+ p_setup.add_argument("--dry-run", action="store_true",
2978
+ help="show what would be attempted, install nothing")
2979
+ p_setup.add_argument("--no-exec", action="store_true",
2980
+ help="static-only mode: never spawn a process")
2981
+ p_setup.set_defaults(func=cmd_setup)
2982
+
2983
+ p_self = sub.add_parser("selftest", help="built-in rule smoke test")
2984
+ p_self.add_argument("--no-exec", action="store_true",
2985
+ help="accepted for uniformity; selftest never spawns anyway")
2986
+ p_self.set_defaults(func=cmd_selftest)
2987
+
2988
+ args = parser.parse_args(argv)
2989
+ global NO_EXEC
2990
+ NO_EXEC = bool(getattr(args, "no_exec", False))
2991
+ return args.func(args)
2992
+
2993
+
2994
+ if __name__ == "__main__":
2995
+ sys.exit(main())
2996
+