countersign-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
countersign/engine.py ADDED
@@ -0,0 +1,309 @@
1
+ # audited on 20260903
2
+ """The gate: run every check, write every result to the register.
3
+
4
+ One verify run does five things, in this order:
5
+
6
+ 1. Records what it is about to read (config, claims, git state) with SHA-256
7
+ fingerprints, so a later reproduce can prove it read the same files.
8
+ This entry is on disk before any check runs: a run that is killed
9
+ halfway still left a trace of having started.
10
+ 2. Runs the marker scan and appends every finding to the register.
11
+ 3. Runs every declared claim's command and appends each verdict as soon as
12
+ it is known. A claim the config requires but the file does not declare
13
+ is recorded as missing.
14
+ 4. When a base revision is given, diffs the claims file against it and
15
+ records every change; a weakened claim fails the gate unless the config
16
+ says otherwise.
17
+ 5. Records the verdict and the register head.
18
+
19
+ The verdict rule is deliberately simple enough to check by hand: any
20
+ finding, any failed, timed-out or missing claim, or any weakened claim
21
+ means the run is not countersigned. A skipped check (no claims file
22
+ declared) is reported as a skip on the receipt, never silently folded into
23
+ a pass.
24
+
25
+ A claims file or config that cannot be honoured raises before anything is
26
+ written; a run that dies after it started appends a ``run_aborted`` entry
27
+ on its way out so the register never shows a start without an ending.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import subprocess
33
+ import uuid
34
+ from dataclasses import dataclass, field
35
+ from datetime import datetime, timezone
36
+ from pathlib import Path
37
+
38
+ from . import __version__
39
+ from .claims import NOT_PASSED, ClaimResult, load_claims, missing_claim, run_claim
40
+ from .claimsdiff import ClaimChange, diff_against_ref
41
+ from .config import Config, ConfigError, file_sha256
42
+ from .register import Register
43
+ from .stubscan import Finding, scan_tree
44
+
45
+ PASS_VERDICT = "pass"
46
+ FAIL_VERDICT = "fail"
47
+
48
+ TEST_EXCLUSION_NOTE = "test files were excluded from the marker scan by policy (exclude_tests = true)"
49
+ GIT_NOT_AVAILABLE = "git not available"
50
+ GIT_NOT_A_REPOSITORY = "not a git repository"
51
+ GIT_NO_COMMITS = "no commits yet"
52
+
53
+
54
+ @dataclass
55
+ class GateResult:
56
+ run_id: str
57
+ recorded_at: str
58
+ verdict: str
59
+ config_path: str
60
+ config_sha256: str
61
+ claims_sha256: str | None
62
+ git_commit: str
63
+ files_scanned: int
64
+ git_dirty: bool | None = None
65
+ tests_excluded: bool = True
66
+ findings: list[Finding] = field(default_factory=list)
67
+ exemptions: int = 0
68
+ claim_results: list[ClaimResult] | None = None
69
+ claims_status: str = "skipped"
70
+ claims_base: str | None = None
71
+ claims_diff: list[ClaimChange] | None = None
72
+ claims_base_problem: str | None = None
73
+ register_index: int = 0
74
+ register_hash: str = ""
75
+ duration_ms: int = 0
76
+ notes: list[str] = field(default_factory=list)
77
+
78
+ @property
79
+ def failed_claims(self) -> list[ClaimResult]:
80
+ if self.claim_results is None:
81
+ return []
82
+ return [c for c in self.claim_results if c.status in NOT_PASSED]
83
+
84
+ @property
85
+ def weakened_claims(self) -> list[ClaimChange]:
86
+ return [c for c in (self.claims_diff or []) if c.weakened]
87
+
88
+
89
+ def _git(root: Path, *args: str) -> subprocess.CompletedProcess[str] | None:
90
+ try:
91
+ return subprocess.run(
92
+ ["git", *args],
93
+ cwd=str(root), capture_output=True, text=True, timeout=15,
94
+ )
95
+ except (OSError, subprocess.TimeoutExpired):
96
+ return None
97
+
98
+
99
+ def _git_state(root: Path) -> tuple[str, bool | None]:
100
+ """(commit, dirty). ``dirty`` is None when it could not be determined.
101
+
102
+ A receipt that names a commit while the scanned files differ from that
103
+ commit would misstate what was checked, so the working tree state is
104
+ recorded next to the hash. Untracked files count as dirty: they are
105
+ scanned, and the commit does not contain them.
106
+ """
107
+ inside = _git(root, "rev-parse", "--is-inside-work-tree")
108
+ if inside is None:
109
+ return GIT_NOT_AVAILABLE, None
110
+ if inside.returncode != 0 or inside.stdout.strip() != "true":
111
+ return GIT_NOT_A_REPOSITORY, None
112
+ head = _git(root, "rev-parse", "--verify", "HEAD")
113
+ if head is None:
114
+ return GIT_NOT_AVAILABLE, None
115
+ if head.returncode != 0:
116
+ # An initialised repository with nothing committed: every scanned
117
+ # file is uncommitted by definition.
118
+ return GIT_NO_COMMITS, True
119
+ commit = head.stdout.strip()
120
+ status = _git(root, "status", "--porcelain", "--untracked-files=normal")
121
+ if status is None or status.returncode != 0:
122
+ return commit, None
123
+ return commit, bool(status.stdout.strip())
124
+
125
+
126
+ def run_gate(config: Config, *, register: Register | None = None, claims_base: str | None = None) -> GateResult:
127
+ """Run every check. Appends evidence to the register; writes nothing else."""
128
+ config_path = Path(config.config_path)
129
+ if not config_path.is_file():
130
+ raise ConfigError(f"no config file at {config_path}; the run would have nothing to fingerprint")
131
+ if claims_base and not config.claims_file:
132
+ raise ConfigError("a claims base revision was given but no claims file is configured")
133
+
134
+ started = datetime.now(timezone.utc)
135
+ run_id = f"{started.strftime('%Y%m%dT%H%M%S')}-{uuid.uuid4().hex[:8]}"
136
+ register = register or Register(config.register_path())
137
+
138
+ # Everything that can refuse the run refuses here, before any evidence
139
+ # is written: a bad config, claims file or base revision is a usage
140
+ # error, not a run.
141
+ files = config.collect_files()
142
+ claims_path = config.claims_path()
143
+ claims_sha256 = file_sha256(claims_path) if claims_path else None
144
+ config_sha256 = file_sha256(config_path)
145
+
146
+ notes: list[str] = []
147
+ if config.exclude_tests:
148
+ notes.append(TEST_EXCLUSION_NOTE)
149
+
150
+ claims = None
151
+ if not config.claims_file:
152
+ notes.append("the claims check was skipped by request (no claims file configured); a skipped check is not a passed check")
153
+ elif claims_path is None:
154
+ notes.append(f"no claims file found at {config.claims_file}; the claims check was skipped, not passed")
155
+ else:
156
+ claims = load_claims(claims_path)
157
+
158
+ declared_ids = {c.claim_id for c in (claims or [])}
159
+ missing_ids = [claim_id for claim_id in config.required_claims if claim_id not in declared_ids]
160
+ if missing_ids:
161
+ notes.append(
162
+ f"{len(missing_ids)} claim(s) required by the config are not declared: {', '.join(missing_ids)}; "
163
+ "each is recorded as missing and fails the gate"
164
+ )
165
+
166
+ changes: list[ClaimChange] | None = None
167
+ base_problem: str | None = None
168
+ if claims_base:
169
+ changes, base_problem = diff_against_ref(config.root, claims_base, config.claims_file or "", claims)
170
+ if base_problem:
171
+ notes.append(f"claims at {claims_base} could not be parsed ({base_problem}); every current claim is shown as added")
172
+
173
+ git_commit, git_dirty = _git_state(config.root)
174
+
175
+ started_entry = register.append(
176
+ "run_started",
177
+ {
178
+ "run_id": run_id,
179
+ "countersign_version": __version__,
180
+ "git_commit": git_commit,
181
+ "git_dirty": git_dirty,
182
+ "inputs": [
183
+ {"role": "config", "path": str(config_path), "sha256": config_sha256},
184
+ *(
185
+ [{"role": "claims", "path": str(claims_path), "sha256": claims_sha256}]
186
+ if claims_path
187
+ else []
188
+ ),
189
+ ],
190
+ "files_scanned": len(files),
191
+ "tests_excluded": config.exclude_tests,
192
+ "required_claims": list(config.required_claims),
193
+ "claims_base": claims_base,
194
+ "notes": notes,
195
+ },
196
+ at=started,
197
+ )
198
+
199
+ try:
200
+ findings, exemptions, inert_markers, files_scanned = scan_tree(config, files)
201
+ if inert_markers:
202
+ notes.append(
203
+ f"{inert_markers} exemption marker(s) sit on lines no rule flags; they suppress nothing today "
204
+ "and are not counted as used, but would suppress a finding if those lines changed"
205
+ )
206
+ for finding in findings:
207
+ register.append(
208
+ "finding",
209
+ {
210
+ "run_id": run_id,
211
+ "path": finding.path,
212
+ "line": finding.line,
213
+ "rule_id": finding.rule_id,
214
+ "why": finding.why,
215
+ "evidence": finding.evidence,
216
+ },
217
+ )
218
+
219
+ claim_results: list[ClaimResult] | None = None
220
+ claims_status = "skipped"
221
+ if claims is not None or missing_ids:
222
+ claim_results = []
223
+ claims_status = "ran"
224
+ for claim in claims or []:
225
+ result = run_claim(claim, config.root, config.timeout_s, config.max_output_bytes)
226
+ claim_results.append(result)
227
+ register.append("claim", {"run_id": run_id, **_claim_body(result)})
228
+ for claim_id in missing_ids:
229
+ result = missing_claim(claim_id)
230
+ claim_results.append(result)
231
+ register.append("claim", {"run_id": run_id, **_claim_body(result)})
232
+
233
+ if changes is not None:
234
+ register.append(
235
+ "claims_diff",
236
+ {
237
+ "run_id": run_id,
238
+ "base": claims_base,
239
+ "base_problem": base_problem,
240
+ "changes": [change.__dict__ for change in changes],
241
+ },
242
+ )
243
+ except BaseException as exc:
244
+ register.append("run_aborted", {"run_id": run_id, "reason": f"{type(exc).__name__}: {exc}"[:500]})
245
+ raise
246
+
247
+ verdict = FAIL_VERDICT if findings else PASS_VERDICT
248
+ if any(result.status in NOT_PASSED for result in (claim_results or [])):
249
+ verdict = FAIL_VERDICT
250
+ weakened = [c for c in (changes or []) if c.weakened]
251
+ if weakened:
252
+ if config.fail_on_weakened:
253
+ verdict = FAIL_VERDICT
254
+ notes.append(f"{len(weakened)} claim(s) weakened against {claims_base}; the gate fails on weakened claims (fail_on_weakened = true)")
255
+ else:
256
+ notes.append(f"{len(weakened)} claim(s) weakened against {claims_base}; recorded, not failed (fail_on_weakened = false)")
257
+
258
+ finished = datetime.now(timezone.utc)
259
+ head = register.append(
260
+ "run_finished",
261
+ {
262
+ "run_id": run_id,
263
+ "verdict": verdict,
264
+ "findings": len(findings),
265
+ "exemptions": exemptions,
266
+ "claims_status": claims_status,
267
+ "claims_total": len(claim_results) if claim_results is not None else 0,
268
+ "claims_failed": len([r for r in (claim_results or []) if r.status in NOT_PASSED]),
269
+ "claims_weakened": len(weakened),
270
+ },
271
+ at=finished,
272
+ )
273
+
274
+ return GateResult(
275
+ run_id=run_id,
276
+ recorded_at=finished.isoformat(),
277
+ verdict=verdict,
278
+ config_path=str(config_path),
279
+ config_sha256=config_sha256,
280
+ claims_sha256=claims_sha256,
281
+ git_commit=started_entry["body"]["git_commit"],
282
+ git_dirty=git_dirty,
283
+ files_scanned=files_scanned,
284
+ tests_excluded=config.exclude_tests,
285
+ findings=findings,
286
+ exemptions=exemptions,
287
+ claim_results=claim_results,
288
+ claims_status=claims_status,
289
+ claims_base=claims_base,
290
+ claims_diff=changes,
291
+ claims_base_problem=base_problem,
292
+ register_index=head["index"],
293
+ register_hash=head["hash"],
294
+ duration_ms=int((finished - started).total_seconds() * 1000),
295
+ notes=notes,
296
+ )
297
+
298
+
299
+ def _claim_body(result: ClaimResult) -> dict:
300
+ return {
301
+ "claim_id": result.claim_id,
302
+ "statement": result.statement,
303
+ "command": result.command,
304
+ "expect": result.expect,
305
+ "status": result.status,
306
+ "exit_code": result.exit_code,
307
+ "duration_ms": result.duration_ms,
308
+ "output_excerpt": result.output_excerpt,
309
+ }