rust-audit 0.2.5__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.
- {rust_audit-0.2.5 → rust_audit-0.2.6}/PKG-INFO +14 -2
- {rust_audit-0.2.5 → rust_audit-0.2.6}/README.md +13 -1
- {rust_audit-0.2.5 → rust_audit-0.2.6}/scripts/rust_audit.egg-info/PKG-INFO +14 -2
- {rust_audit-0.2.5 → rust_audit-0.2.6}/scripts/rust_audit.egg-info/SOURCES.txt +4 -0
- {rust_audit-0.2.5 → rust_audit-0.2.6}/scripts/rust_audit.py +31 -4
- {rust_audit-0.2.5 → rust_audit-0.2.6}/tests/test_check_skill_sync.py +87 -2
- rust_audit-0.2.6/tests/test_check_wheel_parity.py +109 -0
- rust_audit-0.2.6/tests/test_ci_workflows.py +127 -0
- rust_audit-0.2.6/tests/test_report_identity.py +102 -0
- {rust_audit-0.2.5 → rust_audit-0.2.6}/tests/test_skill_wrapper_cli.py +5 -0
- rust_audit-0.2.6/tests/test_sync_installed_copies.py +260 -0
- {rust_audit-0.2.5 → rust_audit-0.2.6}/LICENSE +0 -0
- {rust_audit-0.2.5 → rust_audit-0.2.6}/pyproject.toml +0 -0
- {rust_audit-0.2.5 → rust_audit-0.2.6}/resources/popular_crates.txt +0 -0
- {rust_audit-0.2.5 → rust_audit-0.2.6}/resources/suspicious_names.txt +0 -0
- {rust_audit-0.2.5 → rust_audit-0.2.6}/scripts/rust_audit.egg-info/dependency_links.txt +0 -0
- {rust_audit-0.2.5 → rust_audit-0.2.6}/scripts/rust_audit.egg-info/entry_points.txt +0 -0
- {rust_audit-0.2.5 → rust_audit-0.2.6}/scripts/rust_audit.egg-info/requires.txt +0 -0
- {rust_audit-0.2.5 → rust_audit-0.2.6}/scripts/rust_audit.egg-info/top_level.txt +0 -0
- {rust_audit-0.2.5 → rust_audit-0.2.6}/setup.cfg +0 -0
- {rust_audit-0.2.5 → rust_audit-0.2.6}/templates/deny.toml +0 -0
- {rust_audit-0.2.5 → rust_audit-0.2.6}/tests/test_interpreter_guard.py +0 -0
- {rust_audit-0.2.5 → rust_audit-0.2.6}/tests/test_packaging.py +0 -0
- {rust_audit-0.2.5 → rust_audit-0.2.6}/tests/test_production_e2e.py +0 -0
- {rust_audit-0.2.5 → 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.
|
|
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
|
|
@@ -65,7 +65,7 @@ gh attestation verify rust_audit-*-py3-none-any.whl -R Doken-Tokuyama/Rust-Audit
|
|
|
65
65
|
# 0) verify before use — check the exact audited bytes before executing them
|
|
66
66
|
# (key pin -> SSH signature -> checksum -> tag identity) and proceed only
|
|
67
67
|
# on VERIFIED (exit 0); see SECURITY.md for the pinned key and the chain
|
|
68
|
-
scripts/verify_release.sh v0.2.
|
|
68
|
+
scripts/verify_release.sh v0.2.6 ./rust_audit.py
|
|
69
69
|
|
|
70
70
|
python3 scripts/rust_audit.py selftest
|
|
71
71
|
python3 scripts/rust_audit.py scan <rust-project> --format terminal,md,json,sarif --fail-on high
|
|
@@ -93,6 +93,18 @@ python3 -m venv .venv && .venv/bin/pip install pytest
|
|
|
93
93
|
.venv/bin/python -m pytest tests/
|
|
94
94
|
```
|
|
95
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
|
+
|
|
96
108
|
See [SKILL.md](SKILL.md) for the rule catalog (R01–R21), the auto-fix policy,
|
|
97
109
|
the CI templates and the honest limitations.
|
|
98
110
|
|
|
@@ -35,7 +35,7 @@ gh attestation verify rust_audit-*-py3-none-any.whl -R Doken-Tokuyama/Rust-Audit
|
|
|
35
35
|
# 0) verify before use — check the exact audited bytes before executing them
|
|
36
36
|
# (key pin -> SSH signature -> checksum -> tag identity) and proceed only
|
|
37
37
|
# on VERIFIED (exit 0); see SECURITY.md for the pinned key and the chain
|
|
38
|
-
scripts/verify_release.sh v0.2.
|
|
38
|
+
scripts/verify_release.sh v0.2.6 ./rust_audit.py
|
|
39
39
|
|
|
40
40
|
python3 scripts/rust_audit.py selftest
|
|
41
41
|
python3 scripts/rust_audit.py scan <rust-project> --format terminal,md,json,sarif --fail-on high
|
|
@@ -63,6 +63,18 @@ python3 -m venv .venv && .venv/bin/pip install pytest
|
|
|
63
63
|
.venv/bin/python -m pytest tests/
|
|
64
64
|
```
|
|
65
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
|
+
|
|
66
78
|
See [SKILL.md](SKILL.md) for the rule catalog (R01–R21), the auto-fix policy,
|
|
67
79
|
the CI templates and the honest limitations.
|
|
68
80
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rust-audit
|
|
3
|
-
Version: 0.2.
|
|
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
|
|
@@ -65,7 +65,7 @@ gh attestation verify rust_audit-*-py3-none-any.whl -R Doken-Tokuyama/Rust-Audit
|
|
|
65
65
|
# 0) verify before use — check the exact audited bytes before executing them
|
|
66
66
|
# (key pin -> SSH signature -> checksum -> tag identity) and proceed only
|
|
67
67
|
# on VERIFIED (exit 0); see SECURITY.md for the pinned key and the chain
|
|
68
|
-
scripts/verify_release.sh v0.2.
|
|
68
|
+
scripts/verify_release.sh v0.2.6 ./rust_audit.py
|
|
69
69
|
|
|
70
70
|
python3 scripts/rust_audit.py selftest
|
|
71
71
|
python3 scripts/rust_audit.py scan <rust-project> --format terminal,md,json,sarif --fail-on high
|
|
@@ -93,6 +93,18 @@ python3 -m venv .venv && .venv/bin/pip install pytest
|
|
|
93
93
|
.venv/bin/python -m pytest tests/
|
|
94
94
|
```
|
|
95
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
|
+
|
|
96
108
|
See [SKILL.md](SKILL.md) for the rule catalog (R01–R21), the auto-fix policy,
|
|
97
109
|
the CI templates and the honest limitations.
|
|
98
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.
|
|
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}
|
|
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}
|
|
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 =
|
|
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")
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|