veripp 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.
- veripp/__init__.py +8 -0
- veripp/agent.py +426 -0
- veripp/baseline.py +161 -0
- veripp/cache.py +126 -0
- veripp/cli.py +1709 -0
- veripp/compdb.py +262 -0
- veripp/cppsig.py +1205 -0
- veripp/esbmc.py +634 -0
- veripp/harness.py +1335 -0
- veripp/include/veripp/contracts.hpp +105 -0
- veripp/llm.py +496 -0
- veripp/paths.py +28 -0
- veripp/sarif.py +175 -0
- veripp/scan.py +363 -0
- veripp/term.py +68 -0
- veripp/triage.py +243 -0
- veripp-0.2.0.data/data/share/veripp/examples/off_by_one.cpp +20 -0
- veripp-0.2.0.data/data/share/veripp/examples/ring_buffer.cpp +46 -0
- veripp-0.2.0.dist-info/METADATA +574 -0
- veripp-0.2.0.dist-info/RECORD +24 -0
- veripp-0.2.0.dist-info/WHEEL +5 -0
- veripp-0.2.0.dist-info/entry_points.txt +2 -0
- veripp-0.2.0.dist-info/licenses/LICENSE +201 -0
- veripp-0.2.0.dist-info/top_level.txt +1 -0
veripp/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""veripp: AI-operated formal verification for C++."""
|
|
2
|
+
|
|
3
|
+
try: # the version lives in pyproject.toml; do not duplicate it here
|
|
4
|
+
from importlib.metadata import version as _version
|
|
5
|
+
|
|
6
|
+
__version__ = _version("veripp")
|
|
7
|
+
except Exception: # running from a source tree with nothing installed
|
|
8
|
+
__version__ = "0+unknown"
|
veripp/agent.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""The agent loop: attempt -> triage -> escalate, under a hard budget.
|
|
2
|
+
|
|
3
|
+
Design invariants:
|
|
4
|
+
* The LLM never decides correctness. Every proposal (harness edit,
|
|
5
|
+
invariant, assumption) is re-checked by ESBMC.
|
|
6
|
+
* Every reported result carries the exact VerifyConfig it was obtained
|
|
7
|
+
under, plus the harness assumptions, so "verified" always means
|
|
8
|
+
"verified under these bounds and assumptions".
|
|
9
|
+
* The loop terminates: bounded iterations, wall time, and LLM calls.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field, replace
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from . import term
|
|
20
|
+
from .esbmc import Outcome, VerifyConfig, VerifyResult, run
|
|
21
|
+
from .harness import HarnessError, generate, reachability_variant
|
|
22
|
+
from .llm import LLMClient, NullLLM
|
|
23
|
+
from .triage import Diagnosis, TargetInfo, triage_counterexample
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Budget:
|
|
28
|
+
max_attempts: int = 8
|
|
29
|
+
max_llm_calls: int = 12
|
|
30
|
+
max_precondition_rounds: int = 2 # LLM-proposed preconditions per run
|
|
31
|
+
wall_time_s: int = 600
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class AgentReport:
|
|
36
|
+
final: VerifyResult
|
|
37
|
+
attempts: list[VerifyResult] = field(default_factory=list)
|
|
38
|
+
diagnosis: Diagnosis | None = None
|
|
39
|
+
narrative: str = ""
|
|
40
|
+
assumptions: list[str] = field(default_factory=list)
|
|
41
|
+
harness: Path | None = None
|
|
42
|
+
accepted_preconditions: list[str] = field(default_factory=list)
|
|
43
|
+
#: Bug classes the checker that produced this result is known to miss.
|
|
44
|
+
#: A "verified" is only as sound as the checker behind it.
|
|
45
|
+
unsound_probes: list[str] = field(default_factory=list)
|
|
46
|
+
#: True when the harness could not actually be reached under its own
|
|
47
|
+
#: assumptions, which makes any "verified" meaningless.
|
|
48
|
+
vacuous: bool = False
|
|
49
|
+
|
|
50
|
+
#: Termination, kept separate from the safety verdict on purpose. It is a
|
|
51
|
+
#: liveness property, and a safety proof says nothing about it: ESBMC
|
|
52
|
+
#: reports SUCCESSFUL under k-induction for a function that loops forever,
|
|
53
|
+
#: because an infinite loop violates no assertion. Folding the two would
|
|
54
|
+
#: let "verified" mean "terminates" to a reader, which it does not.
|
|
55
|
+
#: None -> not asked (no loop, or the safety check did not succeed).
|
|
56
|
+
terminates: bool | None = None
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def verified(self) -> bool:
|
|
60
|
+
return self.final.outcome is Outcome.VERIFIED and not self.vacuous
|
|
61
|
+
|
|
62
|
+
def _depth_bound_hint(self) -> str | None:
|
|
63
|
+
"""Flag a null the harness itself introduced.
|
|
64
|
+
|
|
65
|
+
Pointer fields are cut to null at --max-struct-depth, so a NULL
|
|
66
|
+
dereference may be that cut rather than a missing check in the code.
|
|
67
|
+
It is not safe to call it an artifact -- an unchecked pointer is a
|
|
68
|
+
real bug class -- but the reader should know which nulls are ours.
|
|
69
|
+
"""
|
|
70
|
+
prop = self.final.violated_property
|
|
71
|
+
if prop is None or "NULL pointer" not in prop.description:
|
|
72
|
+
return None
|
|
73
|
+
nulled = [a for a in self.assumptions if "is null" in a]
|
|
74
|
+
if not nulled:
|
|
75
|
+
return None
|
|
76
|
+
deeper = any("depth bound" in a for a in nulled)
|
|
77
|
+
advice = (
|
|
78
|
+
"re-run with a larger --max-struct-depth to tell the two apart"
|
|
79
|
+
if deeper
|
|
80
|
+
else "a caller would have set it; constrain it with --assume, or "
|
|
81
|
+
"target a function that does not take it"
|
|
82
|
+
)
|
|
83
|
+
return (
|
|
84
|
+
" NOTE: the harness left a pointer field null "
|
|
85
|
+
f"({nulled[0].split('`')[1] if '`' in nulled[0] else 'see assumptions'}"
|
|
86
|
+
f"), so this null may be the harness's rather than something a "
|
|
87
|
+
f"caller can produce. {advice.capitalize()}."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def summary(self) -> str:
|
|
91
|
+
if self.vacuous:
|
|
92
|
+
headline = term.style(
|
|
93
|
+
"VACUOUS (nothing was actually checked)", "yellow", "bold"
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
headline = term.verdict(self.final.outcome.value)
|
|
97
|
+
lines = [f"Result: {headline}", f" {self.final.config.describe()}"]
|
|
98
|
+
if self.vacuous:
|
|
99
|
+
lines.append(
|
|
100
|
+
" The assumptions made the call unreachable, so every property "
|
|
101
|
+
"held trivially. This is NOT a proof. Weaken the precondition(s) "
|
|
102
|
+
"below until the harness can run."
|
|
103
|
+
)
|
|
104
|
+
if self.harness:
|
|
105
|
+
lines.append(f" harness: {self.harness}")
|
|
106
|
+
if self.assumptions:
|
|
107
|
+
lines.append("Assumptions (a result is only as good as these):")
|
|
108
|
+
lines += [f" - {a}" for a in self.assumptions]
|
|
109
|
+
if self.final.outcome is Outcome.VERIFIED and not self.final.config.k_induction:
|
|
110
|
+
lines.append(
|
|
111
|
+
" This is a BOUNDED proof: it holds for executions within the "
|
|
112
|
+
"unwind bound above, not for all executions."
|
|
113
|
+
)
|
|
114
|
+
# Termination gets its own line and its own words. "Verified" above
|
|
115
|
+
# covers safety only; a reader should never have to know that to read
|
|
116
|
+
# this report correctly.
|
|
117
|
+
if self.terminates is True:
|
|
118
|
+
lines.append(" Termination: proved -- this function always finishes.")
|
|
119
|
+
elif self.terminates is False:
|
|
120
|
+
lines.append(
|
|
121
|
+
" Termination: NOT PROVED. That is not the same as "
|
|
122
|
+
"'loops forever' -- ESBMC proves termination but cannot refute "
|
|
123
|
+
"it, so this is an open question, not a bug."
|
|
124
|
+
)
|
|
125
|
+
stubbed = self.final.stubbed_calls
|
|
126
|
+
if stubbed:
|
|
127
|
+
names = ", ".join(stubbed[:8]) + ("..." if len(stubbed) > 8 else "")
|
|
128
|
+
if self.verified:
|
|
129
|
+
lines.append(
|
|
130
|
+
f" STUBBED CALLS (no body was available): {names}. ESBMC "
|
|
131
|
+
"havocs their return values but assumes they do not write "
|
|
132
|
+
"through pointer arguments -- if any of them does, this "
|
|
133
|
+
"result does not account for it."
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
lines.append(
|
|
137
|
+
f" STUBBED CALLS (no body was available): {names}. Their "
|
|
138
|
+
"effects were not modelled, so this counterexample may be "
|
|
139
|
+
"an artifact of the missing definition rather than a real "
|
|
140
|
+
"bug -- check it first."
|
|
141
|
+
)
|
|
142
|
+
lines.append(
|
|
143
|
+
" Link the defining source with --link, or point veripp at "
|
|
144
|
+
"compile_commands.json."
|
|
145
|
+
)
|
|
146
|
+
if self.verified and self.unsound_probes:
|
|
147
|
+
lines.append(
|
|
148
|
+
" CHECKER IS KNOWN-UNSOUND for: "
|
|
149
|
+
+ ", ".join(self.unsound_probes)
|
|
150
|
+
+ ". This 'verified' does NOT cover that class of bug; "
|
|
151
|
+
"upgrade esbmc and re-run (see `veripp doctor`)."
|
|
152
|
+
)
|
|
153
|
+
if self.verified and self.accepted_preconditions:
|
|
154
|
+
lines.append(
|
|
155
|
+
" CONDITIONAL: verified only under triage-proposed "
|
|
156
|
+
"precondition(s) the solver confirmed sufficient. Nothing "
|
|
157
|
+
"checks that real callers satisfy them - review before trusting:"
|
|
158
|
+
)
|
|
159
|
+
lines += [f" requires {p}" for p in self.accepted_preconditions]
|
|
160
|
+
prop = self.final.violated_property
|
|
161
|
+
if prop:
|
|
162
|
+
lines.append(f"Violated property: {prop.description}")
|
|
163
|
+
lines.append(f" at {prop.loc}")
|
|
164
|
+
if prop.expression:
|
|
165
|
+
lines.append(f" guard: {prop.expression}")
|
|
166
|
+
if prop.cwes:
|
|
167
|
+
lines.append(f" CWE: {', '.join(prop.cwes)}")
|
|
168
|
+
hint = self._depth_bound_hint()
|
|
169
|
+
if hint:
|
|
170
|
+
lines.append(hint)
|
|
171
|
+
inputs = self.final.input_summary()
|
|
172
|
+
if inputs:
|
|
173
|
+
lines.append("Counterexample inputs:")
|
|
174
|
+
lines += [f" {line}" for line in inputs]
|
|
175
|
+
if self.final.error:
|
|
176
|
+
lines.append(f"Error: {self.final.error}")
|
|
177
|
+
if self.diagnosis:
|
|
178
|
+
lines.append(f"Diagnosis: {self.diagnosis.kind}: {self.diagnosis.explanation}")
|
|
179
|
+
if self.narrative:
|
|
180
|
+
lines.append(self.narrative)
|
|
181
|
+
lines.append(f"Attempts: {len(self.attempts)}")
|
|
182
|
+
return "\n".join(lines)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# Escalation ladder for "not conclusive yet": widen the bound, then try to
|
|
186
|
+
# escape boundedness entirely.
|
|
187
|
+
_UNWIND_ESCALATIONS = [
|
|
188
|
+
lambda c: replace(c, unwind=c.unwind * 4),
|
|
189
|
+
lambda c: replace(c, unwind=c.unwind * 4),
|
|
190
|
+
lambda c: replace(c, k_induction=True),
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
# A timeout means the search was too expensive, so widening the bound is the
|
|
194
|
+
# wrong move: switch to incremental BMC, which reports shallow bugs early.
|
|
195
|
+
_TIMEOUT_ESCALATIONS = [
|
|
196
|
+
lambda c: replace(c, incremental_bmc=True, k_induction=False),
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
_COMMENT_RE = re.compile(r"/\*.*?\*/|//[^\n]*", re.DOTALL)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _strip_comments(text: str) -> str:
|
|
204
|
+
"""Drop comments before scanning for loop keywords.
|
|
205
|
+
|
|
206
|
+
Prose says "for" and "while" constantly ("loop for each element"), and a
|
|
207
|
+
match there would buy an extra verification run for nothing.
|
|
208
|
+
"""
|
|
209
|
+
return _COMMENT_RE.sub(" ", text)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
#: A function with no loop and no recursion terminates trivially, and asking
|
|
213
|
+
#: the checker costs a whole extra verification run. Cheap syntactic test:
|
|
214
|
+
#: only ask when there is something that could fail to terminate.
|
|
215
|
+
_LOOP_RE = re.compile(r"\b(while|for|goto)\b")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _might_not_terminate(target: "TargetInfo | None") -> bool:
|
|
219
|
+
"""Whether termination is worth asking about for this target.
|
|
220
|
+
|
|
221
|
+
Scans the original translation unit, not the harness: the harness only
|
|
222
|
+
`#include`s the source, so its own text has no loop in it even when the
|
|
223
|
+
code under test loops. Scanning the whole TU over-approximates -- a loop
|
|
224
|
+
in an unrelated function also triggers the question -- but a callee's loop
|
|
225
|
+
is just as able to hang the target, and the only cost of guessing yes is
|
|
226
|
+
one extra run. Guessing no would silently drop the question.
|
|
227
|
+
"""
|
|
228
|
+
if target is None:
|
|
229
|
+
return False
|
|
230
|
+
try:
|
|
231
|
+
text = target.source.read_text(encoding="utf-8", errors="replace")
|
|
232
|
+
except OSError:
|
|
233
|
+
return False
|
|
234
|
+
return bool(_LOOP_RE.search(_strip_comments(text)))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _check_termination(harness: Path, config: VerifyConfig) -> bool | None:
|
|
238
|
+
"""True if termination is proved, False if the checker could not, None if
|
|
239
|
+
it could not be asked.
|
|
240
|
+
|
|
241
|
+
ESBMC proves termination but does not refute it: a function that may loop
|
|
242
|
+
forever comes back UNKNOWN, not FAILED. So False here means "not proved",
|
|
243
|
+
never "proved not to terminate", and the reporting says so.
|
|
244
|
+
"""
|
|
245
|
+
from dataclasses import replace as _replace
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
result = run(harness, _replace(config, termination=True))
|
|
249
|
+
except (OSError, RuntimeError):
|
|
250
|
+
return None
|
|
251
|
+
return result.outcome is Outcome.VERIFIED
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def verify_with_agent(
|
|
255
|
+
source: Path,
|
|
256
|
+
base_config: VerifyConfig | None = None,
|
|
257
|
+
llm: LLMClient | None = None,
|
|
258
|
+
budget: Budget | None = None,
|
|
259
|
+
assumptions: list[str] | None = None,
|
|
260
|
+
harness: Path | None = None,
|
|
261
|
+
target: TargetInfo | None = None,
|
|
262
|
+
) -> AgentReport:
|
|
263
|
+
"""Main entry point: drive ESBMC to a conclusive answer if possible.
|
|
264
|
+
|
|
265
|
+
`target` (set when --function generated the harness) enables the
|
|
266
|
+
propose->check loop: triage may propose a precondition, the harness is
|
|
267
|
+
regenerated with it, and ESBMC re-runs. The solver, never the LLM,
|
|
268
|
+
decides whether the proposal stands.
|
|
269
|
+
"""
|
|
270
|
+
llm = llm or NullLLM()
|
|
271
|
+
budget = budget or Budget()
|
|
272
|
+
config = base_config or VerifyConfig()
|
|
273
|
+
started = time.monotonic()
|
|
274
|
+
context = dict(assumptions=list(assumptions or []), harness=harness)
|
|
275
|
+
preconditions: list[str] = []
|
|
276
|
+
last_diagnosis: Diagnosis | None = None
|
|
277
|
+
|
|
278
|
+
attempts: list[VerifyResult] = []
|
|
279
|
+
unwind_idx = 0
|
|
280
|
+
timeout_idx = 0
|
|
281
|
+
|
|
282
|
+
while True:
|
|
283
|
+
if len(attempts) >= budget.max_attempts:
|
|
284
|
+
return _inconclusive(attempts, "attempt budget exhausted", **context)
|
|
285
|
+
if time.monotonic() - started > budget.wall_time_s:
|
|
286
|
+
return _inconclusive(attempts, "wall-time budget exhausted", **context)
|
|
287
|
+
|
|
288
|
+
result = run(source, config)
|
|
289
|
+
attempts.append(result)
|
|
290
|
+
|
|
291
|
+
if result.outcome is Outcome.VERIFIED:
|
|
292
|
+
# Safety holds. Termination is a separate question, and the tool
|
|
293
|
+
# asks it rather than making the user find a flag: only when there
|
|
294
|
+
# is a loop to worry about, and only once safety succeeded, since
|
|
295
|
+
# proving that buggy code terminates helps nobody.
|
|
296
|
+
terminates = None
|
|
297
|
+
if _might_not_terminate(target):
|
|
298
|
+
terminates = _check_termination(source, config)
|
|
299
|
+
return AgentReport(
|
|
300
|
+
final=result,
|
|
301
|
+
attempts=attempts,
|
|
302
|
+
diagnosis=last_diagnosis,
|
|
303
|
+
accepted_preconditions=preconditions,
|
|
304
|
+
vacuous=_is_vacuous(source, config),
|
|
305
|
+
terminates=terminates,
|
|
306
|
+
**context,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if result.outcome is Outcome.COUNTEREXAMPLE:
|
|
310
|
+
diagnosis = triage_counterexample(target, source, result, llm)
|
|
311
|
+
last_diagnosis = diagnosis
|
|
312
|
+
if (
|
|
313
|
+
diagnosis.kind in ("missing_assumption", "harness_issue")
|
|
314
|
+
and diagnosis.proposed_precondition
|
|
315
|
+
and target is not None
|
|
316
|
+
and len(preconditions) < budget.max_precondition_rounds
|
|
317
|
+
):
|
|
318
|
+
# Regenerate the harness with the proposal; the re-run is the
|
|
319
|
+
# solver's verdict on it. Unwind may need widening once the
|
|
320
|
+
# precondition admits longer loops, so reset the ladder.
|
|
321
|
+
candidate = preconditions + [diagnosis.proposed_precondition]
|
|
322
|
+
try:
|
|
323
|
+
regenerated = generate(
|
|
324
|
+
target.source,
|
|
325
|
+
target.function,
|
|
326
|
+
target.options,
|
|
327
|
+
extra_preconditions=candidate,
|
|
328
|
+
)
|
|
329
|
+
except HarnessError:
|
|
330
|
+
# Proposal out of scope (guardrail refused it): report the
|
|
331
|
+
# counterexample as triaged, without the proposal.
|
|
332
|
+
return AgentReport(
|
|
333
|
+
final=result, attempts=attempts, diagnosis=diagnosis, **context
|
|
334
|
+
)
|
|
335
|
+
preconditions = candidate
|
|
336
|
+
source = regenerated.write(source.parent, tag=f"pre{len(preconditions)}")
|
|
337
|
+
context["assumptions"] = list(regenerated.assumptions)
|
|
338
|
+
context["harness"] = source
|
|
339
|
+
unwind_idx = 0
|
|
340
|
+
continue
|
|
341
|
+
return AgentReport(
|
|
342
|
+
final=result,
|
|
343
|
+
attempts=attempts,
|
|
344
|
+
diagnosis=diagnosis,
|
|
345
|
+
accepted_preconditions=[],
|
|
346
|
+
**context,
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
if result.outcome is Outcome.TOOL_ERROR:
|
|
350
|
+
# Escalating cannot fix a broken invocation; surface it immediately.
|
|
351
|
+
return _inconclusive(
|
|
352
|
+
attempts, f"esbmc could not be run: {result.error}", **context
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
if result.outcome is Outcome.TIMEOUT:
|
|
356
|
+
if timeout_idx < len(_TIMEOUT_ESCALATIONS):
|
|
357
|
+
config = _TIMEOUT_ESCALATIONS[timeout_idx](config)
|
|
358
|
+
timeout_idx += 1
|
|
359
|
+
continue
|
|
360
|
+
return _inconclusive(attempts, "esbmc timed out at every setting", **context)
|
|
361
|
+
|
|
362
|
+
if result.outcome in (Outcome.UNWIND_LIMIT, Outcome.UNKNOWN):
|
|
363
|
+
if unwind_idx < len(_UNWIND_ESCALATIONS):
|
|
364
|
+
config = _UNWIND_ESCALATIONS[unwind_idx](config)
|
|
365
|
+
unwind_idx += 1
|
|
366
|
+
continue
|
|
367
|
+
# Ladder exhausted: ask the LLM for loop invariants / lemmas.
|
|
368
|
+
proposal = llm.propose_invariants(source, result)
|
|
369
|
+
if proposal is not None:
|
|
370
|
+
source = proposal
|
|
371
|
+
config = replace(config, k_induction=True)
|
|
372
|
+
continue
|
|
373
|
+
return _inconclusive(
|
|
374
|
+
attempts, "escalation ladder and LLM proposals exhausted", **context
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
if result.outcome is Outcome.PARSE_ERROR:
|
|
378
|
+
fixed = llm.propose_frontend_fix(source, result)
|
|
379
|
+
if fixed is not None:
|
|
380
|
+
source = fixed
|
|
381
|
+
continue
|
|
382
|
+
return _inconclusive(
|
|
383
|
+
attempts,
|
|
384
|
+
f"ESBMC frontend rejected the input: {result.error or 'see raw output'}",
|
|
385
|
+
**context,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _is_vacuous(harness: Path, config: VerifyConfig) -> bool:
|
|
390
|
+
"""Did the harness's own assumptions make the call unreachable?
|
|
391
|
+
|
|
392
|
+
An unreachable program satisfies everything, so a "verified" from one is
|
|
393
|
+
worthless -- and neither ESBMC nor the LLM that proposed the precondition
|
|
394
|
+
can notice. Only a harness carrying assumptions can be vacuous, so the
|
|
395
|
+
extra run is skipped when there are none.
|
|
396
|
+
"""
|
|
397
|
+
try:
|
|
398
|
+
code = harness.read_text(encoding="utf-8")
|
|
399
|
+
except OSError:
|
|
400
|
+
return False
|
|
401
|
+
if "VERIPP_ASSUME" not in code and "VERIPP_REQUIRES" not in code:
|
|
402
|
+
return False
|
|
403
|
+
probe = harness.with_name(f"{harness.stem}.reachable{harness.suffix}")
|
|
404
|
+
try:
|
|
405
|
+
probe.write_text(reachability_variant(code), encoding="utf-8")
|
|
406
|
+
result = run(probe, config)
|
|
407
|
+
except (OSError, RuntimeError):
|
|
408
|
+
return False
|
|
409
|
+
# The probe's trailing assertion is always false, so a reachable harness
|
|
410
|
+
# must fail it. Verifying means nothing could reach it.
|
|
411
|
+
return result.outcome is Outcome.VERIFIED
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _inconclusive(
|
|
415
|
+
attempts: list[VerifyResult],
|
|
416
|
+
reason: str,
|
|
417
|
+
assumptions: list[str],
|
|
418
|
+
harness: Path | None,
|
|
419
|
+
) -> AgentReport:
|
|
420
|
+
return AgentReport(
|
|
421
|
+
final=attempts[-1],
|
|
422
|
+
attempts=attempts,
|
|
423
|
+
narrative=f"Inconclusive: {reason}. No claim is made about this code.",
|
|
424
|
+
assumptions=assumptions,
|
|
425
|
+
harness=harness,
|
|
426
|
+
)
|
veripp/baseline.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Accepted findings, so CI can fail on new ones only.
|
|
2
|
+
|
|
3
|
+
A verifier pointed at an existing codebase reports everything at once. cJSON
|
|
4
|
+
gives 33 counterexamples on the first run: fail the build on those and the
|
|
5
|
+
check is removed the next day, so the usual advice is to make it non-blocking
|
|
6
|
+
-- which turns it into a check nobody reads.
|
|
7
|
+
|
|
8
|
+
A baseline is the way out. Record what is already there, then fail only on
|
|
9
|
+
what appears after. The file is plain JSON and meant to be read in a pull
|
|
10
|
+
request: it is a record of accepted risk, and a reviewer should be able to see
|
|
11
|
+
what was accepted and why without running anything.
|
|
12
|
+
|
|
13
|
+
Findings are keyed on (file, function, property) and deliberately not on line
|
|
14
|
+
numbers, which change whenever anything above them moves. The signature is
|
|
15
|
+
stored for a reviewer but not keyed on, so adding a `const` does not silently
|
|
16
|
+
resurrect every finding in a file.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from datetime import date
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
BASELINE_VERSION = 1
|
|
27
|
+
DEFAULT_NAME = ".veripp-baseline"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BaselineError(Exception):
|
|
31
|
+
"""The baseline file could not be used."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Key:
|
|
36
|
+
"""What makes two findings the same finding."""
|
|
37
|
+
|
|
38
|
+
file: str
|
|
39
|
+
function: str
|
|
40
|
+
property: str
|
|
41
|
+
|
|
42
|
+
def as_dict(self) -> dict:
|
|
43
|
+
return {"file": self.file, "function": self.function, "property": self.property}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Entry:
|
|
48
|
+
key: Key
|
|
49
|
+
signature: str = ""
|
|
50
|
+
accepted: str = ""
|
|
51
|
+
reason: str = ""
|
|
52
|
+
|
|
53
|
+
def as_dict(self) -> dict:
|
|
54
|
+
out = self.key.as_dict()
|
|
55
|
+
out["signature"] = self.signature
|
|
56
|
+
out["accepted"] = self.accepted
|
|
57
|
+
if self.reason:
|
|
58
|
+
out["reason"] = self.reason
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Baseline:
|
|
64
|
+
entries: dict[Key, Entry] = field(default_factory=dict)
|
|
65
|
+
path: Path | None = None
|
|
66
|
+
|
|
67
|
+
# -- reading -----------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def load(cls, path: Path) -> Baseline:
|
|
71
|
+
try:
|
|
72
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
73
|
+
except FileNotFoundError as exc:
|
|
74
|
+
raise BaselineError(f"{path} not found") from exc
|
|
75
|
+
except json.JSONDecodeError as exc:
|
|
76
|
+
raise BaselineError(f"{path} is not valid JSON: {exc}") from exc
|
|
77
|
+
|
|
78
|
+
version = raw.get("version")
|
|
79
|
+
if version != BASELINE_VERSION:
|
|
80
|
+
# Refuse rather than guess: a baseline read wrongly suppresses real
|
|
81
|
+
# findings, which is the one failure mode that must never be quiet.
|
|
82
|
+
raise BaselineError(
|
|
83
|
+
f"{path} is version {version!r}, this veripp understands "
|
|
84
|
+
f"{BASELINE_VERSION}. Regenerate it with `veripp accept`."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
entries: dict[Key, Entry] = {}
|
|
88
|
+
for item in raw.get("findings", []):
|
|
89
|
+
try:
|
|
90
|
+
key = Key(item["file"], item["function"], item["property"])
|
|
91
|
+
except (KeyError, TypeError) as exc:
|
|
92
|
+
raise BaselineError(f"{path}: malformed entry {item!r}") from exc
|
|
93
|
+
entries[key] = Entry(
|
|
94
|
+
key=key,
|
|
95
|
+
signature=item.get("signature", ""),
|
|
96
|
+
accepted=item.get("accepted", ""),
|
|
97
|
+
reason=item.get("reason", ""),
|
|
98
|
+
)
|
|
99
|
+
return cls(entries=entries, path=path)
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def load_if_present(cls, path: Path | None) -> Baseline | None:
|
|
103
|
+
if path is None:
|
|
104
|
+
return None
|
|
105
|
+
return cls.load(path)
|
|
106
|
+
|
|
107
|
+
# -- writing -----------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def save(self, path: Path, note: str = "") -> None:
|
|
110
|
+
payload = {
|
|
111
|
+
"version": BASELINE_VERSION,
|
|
112
|
+
"generated": date.today().isoformat(),
|
|
113
|
+
"note": note or (
|
|
114
|
+
"Findings accepted as known. veripp fails CI only on findings "
|
|
115
|
+
"absent from this file. Review it like any other change: each "
|
|
116
|
+
"entry is a risk someone decided to carry."
|
|
117
|
+
),
|
|
118
|
+
# Sorted so the file is diffable and two people generating it get
|
|
119
|
+
# the same bytes.
|
|
120
|
+
"findings": [
|
|
121
|
+
entry.as_dict()
|
|
122
|
+
for _, entry in sorted(
|
|
123
|
+
self.entries.items(),
|
|
124
|
+
key=lambda kv: (kv[0].file, kv[0].function, kv[0].property),
|
|
125
|
+
)
|
|
126
|
+
],
|
|
127
|
+
}
|
|
128
|
+
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
129
|
+
|
|
130
|
+
# -- using -------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def covers(self, key: Key) -> bool:
|
|
133
|
+
return key in self.entries
|
|
134
|
+
|
|
135
|
+
def split(self, keys: list[Key]) -> tuple[list[Key], list[Key]]:
|
|
136
|
+
"""(new, known) for the findings of this run."""
|
|
137
|
+
new = [k for k in keys if k not in self.entries]
|
|
138
|
+
known = [k for k in keys if k in self.entries]
|
|
139
|
+
return new, known
|
|
140
|
+
|
|
141
|
+
def stale(self, keys: list[Key]) -> list[Key]:
|
|
142
|
+
"""Accepted findings that did not occur this run.
|
|
143
|
+
|
|
144
|
+
Worth surfacing: an entry that no longer matches anything grants
|
|
145
|
+
permission for a finding that cannot happen, and will go on granting
|
|
146
|
+
it to some future finding that happens to match.
|
|
147
|
+
"""
|
|
148
|
+
seen = set(keys)
|
|
149
|
+
return [k for k in self.entries if k not in seen]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def key_for(source: Path, function: str, property_text: str, root: Path | None = None) -> Key:
|
|
153
|
+
"""A finding's identity, with the path made relative so the baseline
|
|
154
|
+
survives being checked out somewhere else."""
|
|
155
|
+
path = Path(source)
|
|
156
|
+
base = root or Path.cwd()
|
|
157
|
+
try:
|
|
158
|
+
relative = path.resolve().relative_to(base.resolve())
|
|
159
|
+
except ValueError:
|
|
160
|
+
relative = path
|
|
161
|
+
return Key(str(relative), function, property_text)
|