rust-audit 0.2.4__tar.gz → 0.2.6__tar.gz

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.
Files changed (25) hide show
  1. {rust_audit-0.2.4 → rust_audit-0.2.6}/PKG-INFO +21 -2
  2. {rust_audit-0.2.4 → rust_audit-0.2.6}/README.md +20 -1
  3. {rust_audit-0.2.4 → rust_audit-0.2.6}/scripts/rust_audit.egg-info/PKG-INFO +21 -2
  4. {rust_audit-0.2.4 → rust_audit-0.2.6}/scripts/rust_audit.egg-info/SOURCES.txt +4 -0
  5. {rust_audit-0.2.4 → rust_audit-0.2.6}/scripts/rust_audit.py +31 -4
  6. {rust_audit-0.2.4 → rust_audit-0.2.6}/tests/test_check_skill_sync.py +87 -2
  7. rust_audit-0.2.6/tests/test_check_wheel_parity.py +109 -0
  8. rust_audit-0.2.6/tests/test_ci_workflows.py +127 -0
  9. {rust_audit-0.2.4 → rust_audit-0.2.6}/tests/test_packaging.py +44 -0
  10. rust_audit-0.2.6/tests/test_report_identity.py +102 -0
  11. {rust_audit-0.2.4 → rust_audit-0.2.6}/tests/test_skill_wrapper_cli.py +5 -0
  12. rust_audit-0.2.6/tests/test_sync_installed_copies.py +260 -0
  13. {rust_audit-0.2.4 → rust_audit-0.2.6}/LICENSE +0 -0
  14. {rust_audit-0.2.4 → rust_audit-0.2.6}/pyproject.toml +0 -0
  15. {rust_audit-0.2.4 → rust_audit-0.2.6}/resources/popular_crates.txt +0 -0
  16. {rust_audit-0.2.4 → rust_audit-0.2.6}/resources/suspicious_names.txt +0 -0
  17. {rust_audit-0.2.4 → rust_audit-0.2.6}/scripts/rust_audit.egg-info/dependency_links.txt +0 -0
  18. {rust_audit-0.2.4 → rust_audit-0.2.6}/scripts/rust_audit.egg-info/entry_points.txt +0 -0
  19. {rust_audit-0.2.4 → rust_audit-0.2.6}/scripts/rust_audit.egg-info/requires.txt +0 -0
  20. {rust_audit-0.2.4 → rust_audit-0.2.6}/scripts/rust_audit.egg-info/top_level.txt +0 -0
  21. {rust_audit-0.2.4 → rust_audit-0.2.6}/setup.cfg +0 -0
  22. {rust_audit-0.2.4 → rust_audit-0.2.6}/templates/deny.toml +0 -0
  23. {rust_audit-0.2.4 → rust_audit-0.2.6}/tests/test_interpreter_guard.py +0 -0
  24. {rust_audit-0.2.4 → rust_audit-0.2.6}/tests/test_production_e2e.py +0 -0
  25. {rust_audit-0.2.4 → rust_audit-0.2.6}/tests/test_verify_release.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rust-audit
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Static auditor and auto-fixer for AI-generated Rust traps — stubs, fake tests, swallowed errors, panic/unsafe misuse, hallucinated or typosquatted crates
5
5
  Author-email: Antonio Cantallops Alba <contact@exyonq.org>
6
6
  License-Expression: Apache-2.0
@@ -52,13 +52,20 @@ Nothing is fetched or executed at install time, and
52
52
  the wheel carries the same `rust_audit.py` as the signed release asset — so the
53
53
  verify-before-use chain below still applies to the script you download.
54
54
 
55
+ The wheel's digest is attested by the workflow that built it, so you can check
56
+ where it came from before running it:
57
+
58
+ ```bash
59
+ gh attestation verify rust_audit-*-py3-none-any.whl -R Doken-Tokuyama/Rust-Audit
60
+ ```
61
+
55
62
  ## Quick start
56
63
 
57
64
  ```bash
58
65
  # 0) verify before use — check the exact audited bytes before executing them
59
66
  # (key pin -> SSH signature -> checksum -> tag identity) and proceed only
60
67
  # on VERIFIED (exit 0); see SECURITY.md for the pinned key and the chain
61
- scripts/verify_release.sh v0.2.4 ./rust_audit.py
68
+ scripts/verify_release.sh v0.2.6 ./rust_audit.py
62
69
 
63
70
  python3 scripts/rust_audit.py selftest
64
71
  python3 scripts/rust_audit.py scan <rust-project> --format terminal,md,json,sarif --fail-on high
@@ -86,6 +93,18 @@ python3 -m venv .venv && .venv/bin/pip install pytest
86
93
  .venv/bin/python -m pytest tests/
87
94
  ```
88
95
 
96
+ CI runs exactly that on every push to `main`, every `v*` release tag and every
97
+ pull request (`.github/workflows/tests.yml`: Python 3.11 and 3.13, pytest as
98
+ the only install), so a commit is verified without anyone remembering to type
99
+ it. `tests/test_ci_workflows.py` keeps the job honest: it must exist, it must
100
+ run this suite, it may not swallow a failure, and every action is pinned to
101
+ one commit SHA across all workflows. A second job audits the **published
102
+ artifact**: `python -m build`, install with `--no-index` into a clean venv,
103
+ `rust-audit selftest`, then the golden scan from both the wheel and the
104
+ checkout, which `scripts/check_wheel_parity.py` requires to agree. What the
105
+ jobs prove — and what they do not, including why they cannot block a direct
106
+ push to `main` — is in [docs/ci.md](docs/ci.md).
107
+
89
108
  See [SKILL.md](SKILL.md) for the rule catalog (R01–R21), the auto-fix policy,
90
109
  the CI templates and the honest limitations.
91
110
 
@@ -22,13 +22,20 @@ Nothing is fetched or executed at install time, and
22
22
  the wheel carries the same `rust_audit.py` as the signed release asset — so the
23
23
  verify-before-use chain below still applies to the script you download.
24
24
 
25
+ The wheel's digest is attested by the workflow that built it, so you can check
26
+ where it came from before running it:
27
+
28
+ ```bash
29
+ gh attestation verify rust_audit-*-py3-none-any.whl -R Doken-Tokuyama/Rust-Audit
30
+ ```
31
+
25
32
  ## Quick start
26
33
 
27
34
  ```bash
28
35
  # 0) verify before use — check the exact audited bytes before executing them
29
36
  # (key pin -> SSH signature -> checksum -> tag identity) and proceed only
30
37
  # on VERIFIED (exit 0); see SECURITY.md for the pinned key and the chain
31
- scripts/verify_release.sh v0.2.4 ./rust_audit.py
38
+ scripts/verify_release.sh v0.2.6 ./rust_audit.py
32
39
 
33
40
  python3 scripts/rust_audit.py selftest
34
41
  python3 scripts/rust_audit.py scan <rust-project> --format terminal,md,json,sarif --fail-on high
@@ -56,6 +63,18 @@ python3 -m venv .venv && .venv/bin/pip install pytest
56
63
  .venv/bin/python -m pytest tests/
57
64
  ```
58
65
 
66
+ CI runs exactly that on every push to `main`, every `v*` release tag and every
67
+ pull request (`.github/workflows/tests.yml`: Python 3.11 and 3.13, pytest as
68
+ the only install), so a commit is verified without anyone remembering to type
69
+ it. `tests/test_ci_workflows.py` keeps the job honest: it must exist, it must
70
+ run this suite, it may not swallow a failure, and every action is pinned to
71
+ one commit SHA across all workflows. A second job audits the **published
72
+ artifact**: `python -m build`, install with `--no-index` into a clean venv,
73
+ `rust-audit selftest`, then the golden scan from both the wheel and the
74
+ checkout, which `scripts/check_wheel_parity.py` requires to agree. What the
75
+ jobs prove — and what they do not, including why they cannot block a direct
76
+ push to `main` — is in [docs/ci.md](docs/ci.md).
77
+
59
78
  See [SKILL.md](SKILL.md) for the rule catalog (R01–R21), the auto-fix policy,
60
79
  the CI templates and the honest limitations.
61
80
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rust-audit
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: Static auditor and auto-fixer for AI-generated Rust traps — stubs, fake tests, swallowed errors, panic/unsafe misuse, hallucinated or typosquatted crates
5
5
  Author-email: Antonio Cantallops Alba <contact@exyonq.org>
6
6
  License-Expression: Apache-2.0
@@ -52,13 +52,20 @@ Nothing is fetched or executed at install time, and
52
52
  the wheel carries the same `rust_audit.py` as the signed release asset — so the
53
53
  verify-before-use chain below still applies to the script you download.
54
54
 
55
+ The wheel's digest is attested by the workflow that built it, so you can check
56
+ where it came from before running it:
57
+
58
+ ```bash
59
+ gh attestation verify rust_audit-*-py3-none-any.whl -R Doken-Tokuyama/Rust-Audit
60
+ ```
61
+
55
62
  ## Quick start
56
63
 
57
64
  ```bash
58
65
  # 0) verify before use — check the exact audited bytes before executing them
59
66
  # (key pin -> SSH signature -> checksum -> tag identity) and proceed only
60
67
  # on VERIFIED (exit 0); see SECURITY.md for the pinned key and the chain
61
- scripts/verify_release.sh v0.2.4 ./rust_audit.py
68
+ scripts/verify_release.sh v0.2.6 ./rust_audit.py
62
69
 
63
70
  python3 scripts/rust_audit.py selftest
64
71
  python3 scripts/rust_audit.py scan <rust-project> --format terminal,md,json,sarif --fail-on high
@@ -86,6 +93,18 @@ python3 -m venv .venv && .venv/bin/pip install pytest
86
93
  .venv/bin/python -m pytest tests/
87
94
  ```
88
95
 
96
+ CI runs exactly that on every push to `main`, every `v*` release tag and every
97
+ pull request (`.github/workflows/tests.yml`: Python 3.11 and 3.13, pytest as
98
+ the only install), so a commit is verified without anyone remembering to type
99
+ it. `tests/test_ci_workflows.py` keeps the job honest: it must exist, it must
100
+ run this suite, it may not swallow a failure, and every action is pinned to
101
+ one commit SHA across all workflows. A second job audits the **published
102
+ artifact**: `python -m build`, install with `--no-index` into a clean venv,
103
+ `rust-audit selftest`, then the golden scan from both the wheel and the
104
+ checkout, which `scripts/check_wheel_parity.py` requires to agree. What the
105
+ jobs prove — and what they do not, including why they cannot block a direct
106
+ push to `main` — is in [docs/ci.md](docs/ci.md).
107
+
89
108
  See [SKILL.md](SKILL.md) for the rule catalog (R01–R21), the auto-fix policy,
90
109
  the CI templates and the honest limitations.
91
110
 
@@ -12,8 +12,12 @@ scripts/rust_audit.egg-info/requires.txt
12
12
  scripts/rust_audit.egg-info/top_level.txt
13
13
  templates/deny.toml
14
14
  tests/test_check_skill_sync.py
15
+ tests/test_check_wheel_parity.py
16
+ tests/test_ci_workflows.py
15
17
  tests/test_interpreter_guard.py
16
18
  tests/test_packaging.py
17
19
  tests/test_production_e2e.py
20
+ tests/test_report_identity.py
18
21
  tests/test_skill_wrapper_cli.py
22
+ tests/test_sync_installed_copies.py
19
23
  tests/test_verify_release.py
@@ -48,9 +48,29 @@ import urllib.request
48
48
  from dataclasses import dataclass, field
49
49
  from pathlib import Path
50
50
 
51
- VERSION = "0.2.4"
51
+ VERSION = "0.2.6"
52
52
  TOOL_NAME = "Rust Audit"
53
53
 
54
+ # Identity of the file that is actually running. Two copies of this auditor
55
+ # print the same shapes and the same version string, so a report that does not
56
+ # carry the digest cannot say which engine produced it — the 0.1.0 copy left in
57
+ # a skill tree and the released wheel would look identical.
58
+ _ENGINE_IDENTITY: dict[str, str] | None = None
59
+
60
+
61
+ def engine_identity() -> dict[str, str]:
62
+ """`{version, path, sha256}` of the running module, computed once."""
63
+ global _ENGINE_IDENTITY
64
+ if _ENGINE_IDENTITY is None:
65
+ path = Path(__file__).resolve()
66
+ try:
67
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
68
+ except OSError:
69
+ digest = "unavailable"
70
+ _ENGINE_IDENTITY = {"version": VERSION, "path": str(path),
71
+ "sha256": digest}
72
+ return _ENGINE_IDENTITY
73
+
54
74
  SEVERITY_RANK = {"critical": 4, "high": 3, "medium": 2, "low": 1, "info": 0}
55
75
  SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"]
56
76
 
@@ -1776,7 +1796,8 @@ def render_terminal(findings: list[Finding], online_table: list[dict],
1776
1796
  evidence: list[EvidenceRun], limit: int = 25) -> str:
1777
1797
  lines: list[str] = []
1778
1798
  summary = summarize(findings)
1779
- lines.append(f"{TOOL_NAME} v{VERSION} — summary: {summary['total']} finding(s)")
1799
+ lines.append(f"{TOOL_NAME} v{VERSION} (sha256 {engine_identity()['sha256'][:12]}…)"
1800
+ f" — summary: {summary['total']} finding(s)")
1780
1801
  for sev in SEVERITY_ORDER:
1781
1802
  if summary["by_severity"].get(sev):
1782
1803
  lines.append(f" {sev.upper():8s} {summary['by_severity'][sev]}")
@@ -1810,6 +1831,7 @@ def render_markdown(findings: list[Finding], online_table: list[dict],
1810
1831
  f"# {TOOL_NAME} report — `{target}`",
1811
1832
  "",
1812
1833
  f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')} — tool version {VERSION}",
1834
+ f"Engine: `{engine_identity()['path']}` — sha256 `{engine_identity()['sha256']}`",
1813
1835
  "",
1814
1836
  "## Summary",
1815
1837
  "",
@@ -1876,6 +1898,7 @@ def render_json(findings: list[Finding], online_table: list[dict],
1876
1898
  payload = {
1877
1899
  "tool": TOOL_NAME,
1878
1900
  "version": VERSION,
1901
+ "engine": engine_identity(),
1879
1902
  "generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
1880
1903
  "target": target,
1881
1904
  "summary": summarize(findings),
@@ -1919,6 +1942,8 @@ def render_sarif(findings: list[Finding]) -> str:
1919
1942
  "name": TOOL_NAME,
1920
1943
  "version": VERSION,
1921
1944
  "informationUri": "https://github.com/rust-secure-code/",
1945
+ "properties": {"rustAuditSha256": engine_identity()["sha256"],
1946
+ "rustAuditPath": engine_identity()["path"]},
1922
1947
  "rules": rules,
1923
1948
  }}, "results": results}],
1924
1949
  }, indent=2)
@@ -2604,7 +2629,8 @@ def write_gap_report(out_dir: Path, cmd: str, target: str,
2604
2629
  lines = [
2605
2630
  f"# {TOOL_NAME} — NOT-EXECUTED checks report ({date})",
2606
2631
  "",
2607
- f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')} — tool {VERSION} — command: `{cmd}` — target: `{target}`",
2632
+ f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')} — tool {VERSION} "
2633
+ f"(sha256 {engine_identity()['sha256']}) — command: `{cmd}` — target: `{target}`",
2608
2634
  "",
2609
2635
  "This file lists every check that could **not** be executed in this run",
2610
2636
  "(missing tool, network not used, or feature not requested) together with",
@@ -2670,7 +2696,8 @@ def write_gap_report(out_dir: Path, cmd: str, target: str,
2670
2696
  "", "## Environment", "",
2671
2697
  f"- python: {sys.version.split()[0]} ({sys.platform})",
2672
2698
  f"- cargo: {cargo_ver if cargo_ok else 'NOT INSTALLED'}",
2673
- f"- {TOOL_NAME}: {VERSION}",
2699
+ f"- {TOOL_NAME}: {VERSION} (sha256 {engine_identity()['sha256']})",
2700
+ f"- engine: {engine_identity()['path']}",
2674
2701
  ]
2675
2702
  path.write_text("\n".join(lines) + "\n", encoding="utf-8")
2676
2703
  return path
@@ -19,6 +19,7 @@ import pytest
19
19
 
20
20
  REPO = Path(__file__).resolve().parent.parent
21
21
  TOOL = REPO / "scripts" / "check_skill_sync.py"
22
+ ENGINE = REPO / "scripts" / "rust_audit.py"
22
23
  ACCOUNT = "a" * 64
23
24
  SKILL = b"---\nname: demo\n---\n\nBody of the skill.\n"
24
25
  FAMILY = b"---\nname: other\n---\n\nOther body.\n"
@@ -34,6 +35,12 @@ def run(root, *extra):
34
35
  timeout=120)
35
36
 
36
37
 
38
+ def terminal(root, *extra):
39
+ """The rendered model, for the output contract."""
40
+ return subprocess.run([sys.executable, str(TOOL), str(root), *extra],
41
+ capture_output=True, text=True, timeout=120)
42
+
43
+
37
44
  def model(root, *extra):
38
45
  result = run(root, *extra)
39
46
  return result, json.loads(result.stdout)
@@ -197,14 +204,14 @@ def test_repo_cross_check_flags_a_stale_canonical(tmp_path):
197
204
  def test_terminal_output_renders_the_model_without_json(tmp_path):
198
205
  root = tmp_path / "account-skills"
199
206
  write_registry(root / ACCOUNT, {"demo": SKILL})
200
- result = subprocess.run([sys.executable, str(TOOL), str(root)],
201
- capture_output=True, text=True, timeout=120)
207
+ result = terminal(root)
202
208
  assert result.returncode == 0, result.stderr
203
209
  text = result.stdout
204
210
  assert "revision 2" in text
205
211
  assert "live snapshot" in text and "name == sha256(current.json)" in text
206
212
  assert "rollback snapshot" in text
207
213
  assert "VERDICT consistent" in text
214
+ assert "engines — canonical" in text and "none found" in text
208
215
 
209
216
 
210
217
  def test_bad_json_and_missing_root_are_execution_errors(tmp_path):
@@ -238,4 +245,82 @@ def test_this_machine_is_consistent():
238
245
  "account-skills")
239
246
  assert result.returncode == 0, json.dumps(data["findings"], indent=2)
240
247
  assert data["accounts"], "no account inspected"
248
+
249
+
250
+ # --------------------------------------------------------------- engine copies
251
+ # The skills half and the engine half are one verdict: instructions that name
252
+ # a rule are useless next to an engine that predates it. These fixtures plant
253
+ # copies of the auditor inside the scanned tree, so the machine's own copies
254
+ # (Cursor's store, an installed wheel) can never decide the result.
255
+
256
+ def plant_engine(root, *parts, engine=None):
257
+ """A copy of the auditor at <root>/<parts>/scripts/rust_audit.py."""
258
+ scripts = root.joinpath(*parts, "scripts")
259
+ scripts.mkdir(parents=True, exist_ok=True)
260
+ (scripts / "rust_audit.py").write_bytes(
261
+ engine if engine is not None else ENGINE.read_bytes())
262
+ return scripts.parent
263
+
264
+
265
+ def test_engine_copy_under_the_root_is_reported_current(tmp_path):
266
+ root = tmp_path / "account-skills"
267
+ write_registry(root / ACCOUNT, {"demo": SKILL})
268
+ plant_engine(root, "bare") # no SKILL.md: engine bytes only
269
+ result, data = model(root)
270
+ assert result.returncode == 0, result.stdout + result.stderr
271
+ assert data["engines"]["canonical"]["path"] == str(ENGINE.resolve())
272
+ assert [c["status"] for c in data["engines"]["copies"]] == ["current"]
273
+ text = terminal(root)
274
+ assert text.returncode == 0, text.stderr
275
+ assert "engines — canonical" in text.stdout
276
+ assert "current" in text.stdout
277
+
278
+
279
+ def test_stale_engine_is_a_finding_and_names_the_version(tmp_path):
280
+ root = tmp_path / "account-skills"
281
+ write_registry(root / ACCOUNT, {"demo": SKILL})
282
+ plant_engine(root, "bare", engine=b'VERSION = "0.0.1"\n')
283
+ result, data = model(root)
284
+ assert result.returncode == 1, result.stdout + result.stderr
285
+ assert kinds(data) == ["engine-stale"]
286
+ assert "0.0.1" in data["findings"][0]["detail"]
287
+ assert data["engines"]["copies"][0]["version"] == "0.0.1"
288
+
289
+
290
+ def test_engine_without_current_instructions_is_a_finding(tmp_path):
291
+ """A current engine next to stale prose is still not up to date."""
292
+ root = tmp_path / "account-skills"
293
+ write_registry(root / ACCOUNT, {"demo": SKILL})
294
+ tree = plant_engine(root, "skill", "rust-audit")
295
+ (tree / "SKILL.md").write_bytes(SKILL)
296
+ result, data = model(root)
297
+ assert result.returncode == 1, result.stdout + result.stderr
298
+ assert data["engines"]["copies"][0]["status"] == "current"
299
+ assert "engine-docs" in kinds(data)
300
+ assert "README.md (missing)" in data["engines"]["copies"][0]["docs"]
301
+
302
+
303
+ def test_engines_can_be_left_out(tmp_path):
304
+ root = tmp_path / "account-skills"
305
+ write_registry(root / ACCOUNT, {"demo": SKILL})
306
+ plant_engine(root, "bare", engine=b'VERSION = "0.0.1"\n')
307
+ result, data = model(root, "--no-engines")
308
+ assert result.returncode == 0, result.stdout + result.stderr
309
+ assert "engines" not in data
310
+ assert data["findings"] == []
311
+
312
+
313
+ def test_extra_engine_root_is_scanned_and_must_exist(tmp_path):
314
+ root = tmp_path / "account-skills"
315
+ write_registry(root / ACCOUNT, {"demo": SKILL})
316
+ elsewhere = tmp_path / "elsewhere"
317
+ plant_engine(elsewhere, "bare")
318
+ result, data = model(root, "--engine-root", str(elsewhere))
319
+ assert result.returncode == 0, result.stdout + result.stderr
320
+ assert [c["status"] for c in data["engines"]["copies"]] == ["current"]
321
+ assert str(elsewhere) in data["engines"]["roots"]
322
+ missing = run(root, "--engine-root", str(tmp_path / "nope"))
323
+ assert missing.returncode == 2
324
+ assert "no such directory" in missing.stderr
325
+
241
326
  assert data["accounts"][0]["live"]["materialized"] is True
@@ -0,0 +1,109 @@
1
+ """The wheel/checkout parity check must fail for the right reasons.
2
+
3
+ `scripts/check_wheel_parity.py` is the only thing standing between "the
4
+ installed artifact audits like the code we tested" and a CI step that compares
5
+ two reports and always says OK. These tests feed it synthetic reports so each
6
+ refusal is exercised: a different findings summary, a different version, a
7
+ wheel that does not ship the audited bytes, a comparison that never installed
8
+ anything (same engine path), and an empty audit that would otherwise agree
9
+ with itself.
10
+ """
11
+ import json
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ REPO = Path(__file__).resolve().parent.parent
17
+ SCRIPT = REPO / "scripts" / "check_wheel_parity.py"
18
+
19
+ SUMMARY = {
20
+ "total": 3,
21
+ "by_severity": {"high": 2, "low": 1},
22
+ "by_rule": {"R01": 2, "R09": 1},
23
+ }
24
+
25
+
26
+ def report(directory: Path, *, summary=None, version="0.2.5",
27
+ sha="a" * 64, path="/site-packages/rust_audit.py") -> Path:
28
+ directory.mkdir(parents=True, exist_ok=True)
29
+ payload = {
30
+ "tool": "Rust Audit",
31
+ "version": version,
32
+ "engine": {"version": version, "path": path, "sha256": sha},
33
+ "summary": SUMMARY if summary is None else summary,
34
+ "findings": [],
35
+ }
36
+ (directory / "rust-audit-2026-10-20.json").write_text(json.dumps(payload))
37
+ (directory / "not-executed-2026-10-20.md").write_text("# gaps\n")
38
+ return directory
39
+
40
+
41
+ def run(*args) -> subprocess.CompletedProcess:
42
+ return subprocess.run([sys.executable, str(SCRIPT), *map(str, args)],
43
+ capture_output=True, text=True, timeout=60)
44
+
45
+
46
+ def pair(tmp_path, **installed):
47
+ return (report(tmp_path / "installed", path="/venv/lib/rust_audit.py",
48
+ **installed),
49
+ report(tmp_path / "checkout", path="/src/scripts/rust_audit.py"))
50
+
51
+
52
+ def test_identical_reports_agree(tmp_path):
53
+ proc = run(*pair(tmp_path))
54
+ assert proc.returncode == 0, proc.stdout + proc.stderr
55
+ assert "PARITY OK" in proc.stdout and "3 findings" in proc.stdout
56
+
57
+
58
+ def test_a_different_findings_summary_disagrees(tmp_path):
59
+ other = {"total": 4, "by_severity": {"high": 3, "low": 1},
60
+ "by_rule": {"R01": 3, "R09": 1}}
61
+ proc = run(*pair(tmp_path, summary=other))
62
+ assert proc.returncode == 1, proc.stdout
63
+ assert "DISAGREE" in proc.stderr
64
+ assert "installed total 4 vs checkout total 3" in proc.stderr
65
+
66
+
67
+ def test_a_different_version_disagrees(tmp_path):
68
+ proc = run(*pair(tmp_path, version="0.2.4"))
69
+ assert proc.returncode == 1
70
+ assert "'0.2.4' installed vs '0.2.5'" in proc.stderr
71
+
72
+
73
+ def test_a_wheel_that_transforms_the_module_disagrees(tmp_path):
74
+ proc = run(*pair(tmp_path, sha="b" * 64))
75
+ assert proc.returncode == 1
76
+ assert "does not ship the audited bytes" in proc.stderr
77
+
78
+
79
+ def test_two_runs_of_the_same_engine_prove_nothing(tmp_path):
80
+ """The failure mode of a step that never installed anything."""
81
+ same = tmp_path / "installed"
82
+ report(same, path="/src/scripts/rust_audit.py")
83
+ checkout = report(tmp_path / "checkout", path="/src/scripts/rust_audit.py")
84
+ proc = run(same, checkout)
85
+ assert proc.returncode == 1
86
+ assert "nothing was installed" in proc.stderr
87
+
88
+
89
+ def test_an_empty_audit_cannot_show_agreement(tmp_path):
90
+ empty = {"total": 0, "by_severity": {}, "by_rule": {}}
91
+ installed, checkout = pair(tmp_path)
92
+ report(installed, summary=empty)
93
+ report(checkout, summary=empty)
94
+ proc = run(installed, checkout)
95
+ assert proc.returncode == 1
96
+ assert "cannot show agreement" in proc.stderr
97
+
98
+
99
+ def test_usage_and_missing_reports_exit_2(tmp_path):
100
+ assert run().returncode == 2
101
+ assert run(tmp_path / "installed").returncode == 2
102
+ assert run(tmp_path / "nope", tmp_path / "elsewhere").returncode == 2
103
+
104
+
105
+ def test_the_job_in_ci_actually_calls_it():
106
+ """A helper nobody runs guards nothing."""
107
+ workflow = (REPO / ".github" / "workflows" / "tests.yml").read_text()
108
+ assert "scripts/check_wheel_parity.py" in workflow, (
109
+ "tests.yml never compares the installed wheel with the checkout")
@@ -0,0 +1,127 @@
1
+ """The CI that runs this suite must exist, keep running it, and stay pinned.
2
+
3
+ A workflow is the only thing that runs the suite without a human remembering
4
+ to, so three failure modes get a test of their own: the job disappearing or
5
+ being narrowed to a manual trigger, the job being allowed to pass on failure
6
+ (`|| true`, `continue-on-error`, a non-failing pytest invocation), and an
7
+ action reference left on a moving tag — which turns "green on this commit"
8
+ into "green on whatever upstream shipped this morning".
9
+
10
+ The last test ties the matrix to the floor `pyproject.toml` declares, so a
11
+ version bump cannot leave CI testing a Python the package no longer claims.
12
+ """
13
+ import re
14
+ from pathlib import Path
15
+
16
+ REPO = Path(__file__).resolve().parent.parent
17
+ WORKFLOWS = REPO / ".github" / "workflows"
18
+ SUITE = WORKFLOWS / "tests.yml"
19
+ PYPROJECT = REPO / "pyproject.toml"
20
+ USE = re.compile(r"^\s*(?:-\s+)?uses:\s*(\S+)(?:\s+#\s*(\S.*))?$")
21
+ SHA = re.compile(r"[0-9a-f]{40}")
22
+
23
+
24
+ def workflows():
25
+ return sorted(WORKFLOWS.glob("*.yml"))
26
+
27
+
28
+ def behaviour(path):
29
+ """The workflow's lines, minus whole-line comments: a comment runs nothing."""
30
+ return "\n".join(line for line in path.read_text(encoding="utf-8").splitlines()
31
+ if not line.lstrip().startswith("#"))
32
+
33
+
34
+ def test_the_suite_runs_on_push_and_on_pull_requests():
35
+ text = SUITE.read_text(encoding="utf-8")
36
+ assert "python -m pytest" in text, "the CI job does not run the suite"
37
+ assert re.search(r"^on:$", text, re.M), "tests.yml has no trigger block"
38
+ assert "branches: [main]" in text, "pushes to main must run the suite"
39
+ assert "pull_request:" in text, "pull requests must run the suite"
40
+ assert "workflow_dispatch:" in text, "a maintainer must be able to run it"
41
+
42
+
43
+ def test_no_workflow_can_pass_a_failing_suite():
44
+ for path in workflows():
45
+ text = behaviour(path)
46
+ assert "continue-on-error" not in text, f"{path.name} ignores failures"
47
+ assert "|| true" not in text, f"{path.name} swallows an exit status"
48
+ assert "--continue-on-collection-errors" not in text, (
49
+ f"{path.name} would hide a collection error")
50
+
51
+
52
+ def test_every_action_is_pinned_to_a_commit():
53
+ """A tag or branch can move; a 40-hex commit cannot."""
54
+ for path in workflows():
55
+ for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
56
+ match = USE.match(line)
57
+ if match is None:
58
+ continue
59
+ ref, comment = match.group(1), match.group(2)
60
+ where = f"{path.name}:{number}"
61
+ assert "@" in ref, f"{where} has no version: {ref}"
62
+ sha = ref.rsplit("@", 1)[1]
63
+ assert SHA.fullmatch(sha), f"{where} {ref} is not pinned to a commit"
64
+ assert comment and re.match(r"(v\d|release/)", comment), (
65
+ f"{where} {ref} carries no readable version comment")
66
+
67
+
68
+ def test_the_suite_also_runs_on_release_tags():
69
+ """A tag is what gets signed and published; the suite must cover it."""
70
+ text = SUITE.read_text(encoding="utf-8")
71
+ assert re.search(r'^\s+tags:\s*\["v\*"\]', text, re.M), (
72
+ "release tags do not run the suite: the signed bytes would be untested")
73
+
74
+
75
+ def test_each_action_is_pinned_to_one_commit_everywhere():
76
+ """Two majors of the same action is drift nobody decided; one is policy."""
77
+ found = {}
78
+ for path in workflows():
79
+ for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
80
+ match = USE.match(line)
81
+ if match is None:
82
+ continue
83
+ action, _, sha = match.group(1).partition("@")
84
+ found.setdefault(action, []).append(
85
+ (sha, (match.group(2) or "").strip(), f"{path.name}:{number}"))
86
+ for action, uses in found.items():
87
+ commits = {sha for sha, _, _ in uses}
88
+ assert len(commits) == 1, (
89
+ f"{action} is pinned to {len(commits)} commits: {uses}")
90
+ versions = {version for _, version, _ in uses}
91
+ assert len(versions) == 1, (
92
+ f"{action} is described with {len(versions)} versions: {uses}")
93
+
94
+
95
+ def test_the_published_artifact_is_built_installed_and_audited():
96
+ """AGENTS.md §6b: the wheel is a path of its own, so CI must walk it.
97
+
98
+ The job is not a second copy of the goldens: it runs the same engine and
99
+ the installed one over the same pinned workspace and requires the two to
100
+ agree, so a wheel that lost a data file, shipped other bytes or was never
101
+ installed (both scans naming the checkout) fails here.
102
+ """
103
+ _, sep, wheel = SUITE.read_text(encoding="utf-8").partition("\n wheel:")
104
+ assert sep, "tests.yml has no job that checks the published wheel"
105
+ required = {
106
+ "python -m build": "the distribution is never built",
107
+ "--no-index --find-links dist":
108
+ "the install is allowed to fetch something else",
109
+ "share/rustaudit/":
110
+ "the job never checks where the wheel put its data files",
111
+ "selftest": "the installed command's selftest never runs",
112
+ "report/installed": "the installed engine never scans",
113
+ "report/checkout": "there is nothing to compare against",
114
+ "check_wheel_parity.py": "the two engines are never compared",
115
+ "needs: pytest":
116
+ "the wheel job inherits the goldens from the pytest job; without "
117
+ "`needs` a red suite no longer stops it",
118
+ }
119
+ missing = [why for needle, why in required.items() if needle not in wheel]
120
+ assert not missing, " / ".join(missing)
121
+
122
+
123
+ def test_the_matrix_covers_the_declared_python_floor():
124
+ floor = re.search(r'^requires-python\s*=\s*">=([0-9.]+)"',
125
+ PYPROJECT.read_text(encoding="utf-8"), re.M).group(1)
126
+ assert f'"{floor}"' in SUITE.read_text(encoding="utf-8"), (
127
+ f"pyproject.toml requires >= {floor} and CI never runs it")
@@ -17,6 +17,9 @@ REPO = Path(__file__).resolve().parent.parent
17
17
  TOOL = REPO / "scripts" / "rust_audit.py"
18
18
  PYPROJECT = REPO / "pyproject.toml"
19
19
  PUBLISH_WORKFLOW = REPO / ".github" / "workflows" / "publish.yml"
20
+ ATTEST_WORKFLOW = REPO / ".github" / "workflows" / "attest-release.yml"
21
+ # actions/attest-build-provenance v4.2.2, pinned by commit: a tag can move.
22
+ ATTEST_SHA = "4d101475d8b20a2381f78447822ac1eab6504dd8"
20
23
 
21
24
 
22
25
  def load_module(path=TOOL, name="rust_audit_probe"):
@@ -72,6 +75,47 @@ def test_only_one_distribution_is_published():
72
75
  "an alias distribution reappeared under packaging/")
73
76
 
74
77
 
78
+ def test_provenance_attestations_are_wired_in():
79
+ """Both channels attest a digest, from the workflow that can sign it.
80
+
81
+ `gh attestation verify` only answers for a digest some workflow run
82
+ signed with the repository's OIDC identity, so deleting either step
83
+ would silently downgrade the channel to "trust the uploader".
84
+ """
85
+ publish = PUBLISH_WORKFLOW.read_text()
86
+ attest = ATTEST_WORKFLOW.read_text()
87
+ for name, workflow in (("publish.yml", publish),
88
+ ("attest-release.yml", attest)):
89
+ assert "id-token: write" in workflow, f"{name}: nothing to sign with"
90
+ assert "attestations: write" in workflow, (
91
+ f"{name}: the attestation would have nowhere to go")
92
+ assert ATTEST_SHA in workflow, (
93
+ f"{name}: actions/attest-build-provenance is not pinned to "
94
+ f"{ATTEST_SHA} — a moving tag can be retargeted")
95
+ assert "subject-path: dist/main/*" in publish, (
96
+ "the wheel and sdist are not attested where they are built")
97
+ assert "subject-path: assets/*" in attest, (
98
+ "the uploaded release assets are not attested")
99
+ assert "release:" in attest and "published" in attest, (
100
+ "release assets only exist after `release: published`")
101
+ assert "gh release download" in attest, (
102
+ "the job must attest the bytes that were actually uploaded")
103
+
104
+
105
+ def test_security_says_what_each_channel_proves():
106
+ security = (REPO / "SECURITY.md").read_text()
107
+ assert "gh attestation verify" in security, (
108
+ "the attestations exist but SECURITY.md never tells anyone how to "
109
+ "check them")
110
+ assert "not wired in yet" not in security, (
111
+ "SECURITY.md still says attestations are missing")
112
+ rows = [line for line in security.splitlines()
113
+ if line.startswith("| GitHub release asset |")
114
+ or line.startswith("| PyPI wheel")]
115
+ assert len(rows) == 2, (
116
+ "the channel table must cover the release asset and the wheel")
117
+
118
+
75
119
  def test_declared_data_files_exist():
76
120
  project = tomllib.loads(PYPROJECT.read_text())
77
121
  declared = project["tool"]["setuptools"]["data-files"]
@@ -0,0 +1,102 @@
1
+ """Every audit report must say which engine produced it.
2
+
3
+ A 0.1.0 copy sitting in a skill tree and the released wheel print the same
4
+ shapes and the same version string; without a digest a report cannot be tied
5
+ to the bytes that produced it, so "this audit was clean" cannot be checked
6
+ against the engine someone actually ran. Each report format must carry the
7
+ version, the path and the sha256 of the file that executed — and the digest
8
+ must come from the running file, not from a constant.
9
+ """
10
+ import hashlib
11
+ import json
12
+ import re
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ REPO = Path(__file__).resolve().parent.parent
18
+ TOOL = REPO / "scripts" / "rust_audit.py"
19
+
20
+ LIB_RS = "pub fn demo() -> u32 {\n todo!()\n}\n"
21
+
22
+
23
+ def tool_version() -> str:
24
+ return re.search(r'^VERSION = "([^"]+)"', TOOL.read_text(), re.M).group(1)
25
+
26
+
27
+ def sha256(path: Path) -> str:
28
+ return hashlib.sha256(path.read_bytes()).hexdigest()
29
+
30
+
31
+ def workspace(root: Path) -> Path:
32
+ crate = root / "ws"
33
+ (crate / "src").mkdir(parents=True)
34
+ (crate / "Cargo.toml").write_text(
35
+ '[package]\nname = "demo"\nversion = "0.1.0"\nedition = "2021"\n')
36
+ (crate / "src" / "lib.rs").write_text(LIB_RS)
37
+ return crate
38
+
39
+
40
+ def scan(engine: Path, crate: Path, out: Path):
41
+ return subprocess.run(
42
+ [sys.executable, str(engine), "scan", str(crate),
43
+ "--format", "terminal,md,json,sarif", "--out", str(out)],
44
+ capture_output=True, text=True, timeout=300)
45
+
46
+
47
+ def reports(out: Path) -> dict[str, Path]:
48
+ """The `rust-audit-*` artefacts by suffix (not the not-executed report)."""
49
+ found = {}
50
+ for path in sorted(out.iterdir()):
51
+ if path.name.startswith("rust-audit-"):
52
+ found[path.suffix] = path
53
+ return found
54
+
55
+
56
+ def test_every_report_format_carries_the_engine_identity(tmp_path):
57
+ result = scan(TOOL, workspace(tmp_path), tmp_path / "report")
58
+ assert result.returncode in (0, 1), result.stdout + result.stderr
59
+ paths = reports(tmp_path / "report")
60
+ identity = {"version": tool_version(), "path": str(TOOL.resolve()),
61
+ "sha256": sha256(TOOL)}
62
+
63
+ payload = json.loads(paths[".json"].read_text())
64
+ assert payload["engine"] == identity
65
+ assert payload["version"] == identity["version"]
66
+
67
+ markdown = paths[".md"].read_text()
68
+ assert identity["sha256"] in markdown
69
+ assert f"tool version {identity['version']}" in markdown
70
+ assert str(TOOL.resolve()) in markdown
71
+
72
+ sarif = json.loads(paths[".sarif"].read_text())
73
+ driver = sarif["runs"][0]["tool"]["driver"]
74
+ assert driver["version"] == identity["version"]
75
+ assert driver["properties"]["rustAuditSha256"] == identity["sha256"]
76
+
77
+ gap = next(p for p in (tmp_path / "report").iterdir()
78
+ if p.name.startswith("not-executed-"))
79
+ gap_text = gap.read_text()
80
+ assert identity["sha256"] in gap_text, (
81
+ "the not-executed report is part of the verdict and must identify "
82
+ "the engine that produced it")
83
+
84
+
85
+ def test_the_digest_is_the_running_file_not_a_constant(tmp_path):
86
+ """A modified copy must report its own digest, not the repository's."""
87
+ copy = tmp_path / "engine" / "rust_audit.py"
88
+ copy.parent.mkdir()
89
+ copy.write_bytes(TOOL.read_bytes() + b"\n# local patch\n")
90
+ result = scan(copy, workspace(tmp_path), tmp_path / "report")
91
+ assert result.returncode in (0, 1), result.stderr
92
+ payload = json.loads(reports(tmp_path / "report")[".json"].read_text())
93
+ assert payload["engine"]["sha256"] == sha256(copy)
94
+ assert payload["engine"]["sha256"] != sha256(TOOL)
95
+ assert payload["engine"]["path"] == str(copy.resolve())
96
+
97
+
98
+ def test_the_terminal_summary_shows_the_digest(tmp_path):
99
+ result = scan(TOOL, workspace(tmp_path), tmp_path / "report")
100
+ first = result.stdout.splitlines()[0]
101
+ assert first.startswith("Rust Audit v" + tool_version())
102
+ assert sha256(TOOL)[:12] in first, first
@@ -113,6 +113,11 @@ def test_live_copies_serve_the_repo_instructions():
113
113
  + "\n".join(f" {f['kind']} {f.get('skill', '')}: {f['detail']}"
114
114
  for f in model["findings"]))
115
115
  assert code == 0, model["verdict"]
116
+ # The engine half ran too: a model without it would call a machine with a
117
+ # stale copy of the auditor "up to date". Canonical must be this checkout.
118
+ engines = model["engines"]
119
+ assert Path(engines["canonical"]["path"]) == CHECK_SYNC.parent / "rust_audit.py", \
120
+ engines["canonical"]
116
121
  # Non-vacuous: the wrappers were actually compared with the live surfaces.
117
122
  rows = [row for acc in model["accounts"] for row in acc["skills"]]
118
123
  for name in SKILLS:
@@ -0,0 +1,260 @@
1
+ """Contract of scripts/sync_installed_copies.py.
2
+
3
+ An agent that ships the auditor (Freebuff account skill, Cursor's agent
4
+ store, a vendored copy) runs *its own* `scripts/rust_audit.py`. The command
5
+ exists to make a stale engine visible and to refresh it, so its exit codes
6
+ must mean something: 0 nothing stale · 1 stale/drift found · 2 execution
7
+ error. These tests drive the CLI — report, `--fix`, `--force`, the error
8
+ paths — and assert the bytes that end up on disk, because "it printed fixed"
9
+ without the file changing would be the bug worth catching.
10
+ """
11
+ import json
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ REPO = Path(__file__).resolve().parent.parent
18
+ TOOL = REPO / "scripts" / "sync_installed_copies.py"
19
+
20
+ ENGINE = b'VERSION = "2.0.0"\nprint("current engine")\n'
21
+ OLD_ENGINE = b'VERSION = "1.0.0"\nprint("old engine")\n'
22
+ CRATES = b"serde\ntokio\n"
23
+ OLD_CRATES = b"serde\n"
24
+ DENY = b"[advisories]\nyanked = \"deny\"\n"
25
+
26
+
27
+ def make_tree(root, version_engine=ENGINE):
28
+ """A canonical checkout plus copies, laid out like a real install."""
29
+ canonical = root / "repo" / "scripts" / "rust_audit.py"
30
+ canonical.parent.mkdir(parents=True)
31
+ canonical.write_bytes(version_engine)
32
+ for rel, data in (("resources/popular_crates.txt", CRATES),
33
+ ("resources/suspicious_names.txt", CRATES),
34
+ ("templates/deny.toml", DENY)):
35
+ path = root / "repo" / rel
36
+ path.parent.mkdir(parents=True, exist_ok=True)
37
+ path.write_bytes(data)
38
+
39
+ def copy(name, engine, crates=CRATES, deny=DENY):
40
+ base = root / "agents" / name
41
+ (base / "scripts").mkdir(parents=True)
42
+ (base / "scripts" / "rust_audit.py").write_bytes(engine)
43
+ for rel, data in (("resources/popular_crates.txt", crates),
44
+ ("resources/suspicious_names.txt", crates),
45
+ ("templates/deny.toml", deny)):
46
+ path = base / rel
47
+ path.parent.mkdir(parents=True, exist_ok=True)
48
+ path.write_bytes(data)
49
+ return base
50
+
51
+ copy("ok", ENGINE)
52
+ copy("old", OLD_ENGINE, OLD_CRATES, b"[advisories]\n")
53
+ copy("drift", ENGINE.replace(b"current engine", b"patched engine"))
54
+ copy("ahead", b'VERSION = "3.0.0"\nprint("newer engine")\n')
55
+ return canonical
56
+
57
+
58
+ def run(*args):
59
+ return subprocess.run([sys.executable, str(TOOL), *args],
60
+ capture_output=True, text=True, timeout=120)
61
+
62
+
63
+ def report(root, *extra):
64
+ result = run("--canonical", str(root / "repo" / "scripts" / "rust_audit.py"),
65
+ "--root", str(root / "agents"), "--json", *extra)
66
+ return result, json.loads(result.stdout) if result.stdout else None
67
+
68
+
69
+ def statuses(model):
70
+ return {Path(row["path"]).parent.parent.name: row["status"]
71
+ for row in model["copies"]}
72
+
73
+
74
+ def test_report_names_every_copy_and_its_state(tmp_path):
75
+ make_tree(tmp_path)
76
+ result, model = report(tmp_path)
77
+ assert result.returncode == 1, result.stderr
78
+ assert model["canonical"]["version"] == "2.0.0"
79
+ assert statuses(model) == {"ok": "current", "old": "stale",
80
+ "drift": "drift", "ahead": "newer"}
81
+ human = run("--canonical", str(tmp_path / "repo" / "scripts" / "rust_audit.py"),
82
+ "--root", str(tmp_path / "agents"))
83
+ assert human.returncode == 1
84
+ assert "rerun with --fix" in human.stdout
85
+
86
+
87
+ def test_fix_refreshes_the_engine_and_the_runtime_data(tmp_path):
88
+ root = tmp_path
89
+ make_tree(root)
90
+ # Only the stale copy is present, so a clean run can exit 0.
91
+ for name in ("drift", "ahead"):
92
+ shutil.rmtree(root / "agents" / name)
93
+
94
+ result, model = report(root, "--fix")
95
+ assert result.returncode == 0, result.stderr + result.stdout
96
+ assert statuses(model) == {"ok": "current", "old": "fixed"}
97
+ old = root / "agents" / "old"
98
+ assert (old / "scripts" / "rust_audit.py").read_bytes() == ENGINE
99
+ assert (old / "resources" / "popular_crates.txt").read_bytes() == CRATES
100
+ assert (old / "templates" / "deny.toml").read_bytes() == DENY
101
+
102
+
103
+ def test_drift_is_reported_and_never_overwritten_without_force(tmp_path):
104
+ make_tree(tmp_path)
105
+ patched = (tmp_path / "agents" / "drift" / "scripts" / "rust_audit.py").read_bytes()
106
+ result, model = report(tmp_path, "--fix")
107
+ assert result.returncode == 1
108
+ assert statuses(model)["drift"] == "drift"
109
+ assert (tmp_path / "agents" / "drift" / "scripts" / "rust_audit.py").read_bytes() == patched
110
+
111
+ forced, model = report(tmp_path, "--fix", "--force")
112
+ assert forced.returncode == 0, forced.stderr
113
+ assert statuses(model)["drift"] == "fixed"
114
+ assert (tmp_path / "agents" / "drift" / "scripts" / "rust_audit.py").read_bytes() == ENGINE
115
+
116
+
117
+ def test_the_canonical_engine_is_never_touched(tmp_path):
118
+ root = tmp_path
119
+ canonical = make_tree(root)
120
+ before = canonical.read_bytes()
121
+ result, model = report(root, "--fix")
122
+ # exit 1 because the drift copy is left alone on purpose.
123
+ assert result.returncode == 1, result.stderr
124
+ assert canonical.read_bytes() == before
125
+ assert all(Path(row["path"]) != canonical for row in model["copies"])
126
+
127
+
128
+ FRONTMATTER = ("---\nname: rust-audit\ndescription: demo\n"
129
+ "metadata:\n source: Rust-Audit-main.zip\n---\n")
130
+ SKILL_BODY = "# Rust Audit\n\nCurrent instructions.\n"
131
+ OLD_SKILL_BODY = "# Rust Audit\n\nOld instructions. 0.1.0 era.\n"
132
+
133
+
134
+ def add_instructions(root, name, body=OLD_SKILL_BODY, readme="old readme\n"):
135
+ base = root / "agents" / name
136
+ (base / "SKILL.md").write_text(FRONTMATTER + "\n" + body)
137
+ (base / "README.md").write_text(readme)
138
+ (base / "docs" / "adr").mkdir(parents=True, exist_ok=True)
139
+ (base / "docs" / "adr" / "0001-a.md").write_text("old adr\n")
140
+ return base
141
+
142
+
143
+ def add_repo_docs(root, body=SKILL_BODY):
144
+ repo = root / "repo"
145
+ (repo / "SKILL.md").write_text(body)
146
+ (repo / "README.md").write_text("current readme\n")
147
+ (repo / "CONTEXT.md").write_text("current context\n")
148
+ (repo / "SECURITY.md").write_text("current security\n")
149
+ (repo / "docs" / "adr").mkdir(parents=True, exist_ok=True)
150
+ (repo / "docs" / "adr" / "0001-a.md").write_text("current adr\n")
151
+
152
+
153
+ def docs_tree(root):
154
+ """A copy at the current engine whose *instructions* are from 0.1.0."""
155
+ make_tree(root)
156
+ add_repo_docs(root)
157
+ shutil.rmtree(root / "agents" / "drift") # isolate the docs behaviour
158
+ shutil.rmtree(root / "agents" / "ahead")
159
+ return add_instructions(root, "ok")
160
+
161
+
162
+ def test_instruction_drift_is_a_finding_even_with_a_current_engine(tmp_path):
163
+ root = tmp_path
164
+ docs_tree(root)
165
+ result, model = report(root)
166
+ row = {Path(r["path"]).parent.parent.name: r for r in model["copies"]}["ok"]
167
+ assert result.returncode == 1, result.stdout
168
+ assert row["status"] == "current"
169
+ assert row["docs"] == ["SKILL.md", "README.md", "CONTEXT.md (missing)",
170
+ "SECURITY.md (missing)", "docs/adr/0001-a.md"]
171
+
172
+
173
+ def test_fix_aligns_the_instructions_and_keeps_the_frontmatter(tmp_path):
174
+ root = tmp_path
175
+ base = docs_tree(root)
176
+ result, model = report(root, "--fix")
177
+ assert result.returncode == 0, result.stderr + result.stdout
178
+ text = (base / "SKILL.md").read_text()
179
+ assert text.startswith(FRONTMATTER), "the target's identity was overwritten"
180
+ assert text.endswith(SKILL_BODY), text[-120:]
181
+ assert (base / "README.md").read_text() == "current readme\n"
182
+ assert (base / "SECURITY.md").read_text() == "current security\n"
183
+ assert (base / "docs" / "adr" / "0001-a.md").read_text() == "current adr\n"
184
+ rows = {Path(r["path"]).parent.parent.name: r for r in model["copies"]}
185
+ assert rows["ok"]["status"] == "fixed"
186
+ assert rows["ok"]["docs"] == []
187
+
188
+
189
+ def test_a_document_the_readme_links_to_is_shipped_too(tmp_path):
190
+ root = tmp_path
191
+ base = docs_tree(root)
192
+ (root / "repo" / "docs" / "extra.md").write_text("extra doc\n")
193
+ readme = root / "repo" / "README.md"
194
+ readme.write_text(readme.read_text() + "See [extra](docs/extra.md).\n")
195
+
196
+ _, model = report(root)
197
+ row = {Path(r["path"]).parent.parent.name: r for r in model["copies"]}["ok"]
198
+ assert "docs/extra.md (missing)" in row["docs"], row["docs"]
199
+
200
+ result, model = report(root, "--fix")
201
+ assert result.returncode == 0, result.stderr
202
+ assert (base / "docs" / "extra.md").read_text() == "extra doc\n"
203
+ row = {Path(r["path"]).parent.parent.name: r for r in model["copies"]}["ok"]
204
+ assert row["docs"] == []
205
+
206
+
207
+ def test_the_verify_script_the_instructions_run_is_shipped(tmp_path):
208
+ root = tmp_path
209
+ base = docs_tree(root)
210
+ script = root / "repo" / "scripts" / "verify_release.sh"
211
+ script.write_text("#!/bin/sh\necho verifying\n")
212
+ script.chmod(0o755)
213
+
214
+ _, model = report(root)
215
+ row = {Path(r["path"]).parent.parent.name: r for r in model["copies"]}["ok"]
216
+ assert "scripts/verify_release.sh (missing)" in row["docs"], row["docs"]
217
+
218
+ result, model = report(root, "--fix")
219
+ assert result.returncode == 0, result.stderr
220
+ shipped = base / "scripts" / "verify_release.sh"
221
+ assert shipped.read_text() == "#!/bin/sh\necho verifying\n"
222
+ assert shipped.stat().st_mode & 0o111, "the verify step is invoked directly"
223
+
224
+
225
+ def test_a_bare_vendored_copy_reads_no_docs(tmp_path):
226
+ root = tmp_path
227
+ make_tree(root)
228
+ add_repo_docs(root)
229
+ # No SKILL.md in these trees: missing docs are not a finding, and no
230
+ # documentation is invented for a tree that never shipped any.
231
+ result, model = report(root, "--fix")
232
+ assert result.returncode == 1 # the drift copy is still left alone
233
+ assert all(not row["docs"] for row in model["copies"])
234
+ assert not (root / "agents" / "ok" / "README.md").exists()
235
+ assert not (root / "agents" / "old" / "SECURITY.md").exists()
236
+
237
+
238
+ def test_scan_skips_vcs_directories(tmp_path):
239
+ make_tree(tmp_path)
240
+ buried = tmp_path / "agents" / ".git" / "scripts"
241
+ buried.mkdir(parents=True)
242
+ (buried / "rust_audit.py").write_bytes(OLD_ENGINE)
243
+ result, model = report(tmp_path)
244
+ assert result.returncode == 1
245
+ assert all(".git" not in row["path"] for row in model["copies"])
246
+
247
+
248
+ def test_bad_inputs_are_execution_errors(tmp_path):
249
+ make_tree(tmp_path)
250
+ missing_canonical = run("--canonical", str(tmp_path / "nope.py"),
251
+ "--root", str(tmp_path), "--json")
252
+ assert missing_canonical.returncode == 2
253
+ missing_root = run("--canonical",
254
+ str(tmp_path / "repo" / "scripts" / "rust_audit.py"),
255
+ "--root", str(tmp_path / "nope"), "--json")
256
+ assert missing_root.returncode == 2
257
+ blank = tmp_path / "blank.py"
258
+ blank.write_text("print('no version here')\n")
259
+ unversioned = run("--canonical", str(blank), "--root", str(tmp_path), "--json")
260
+ assert unversioned.returncode == 2
File without changes
File without changes
File without changes