fold/bench/compare_vc_with_cbmc.py
trvon 3f1234f7bb
fold: bounded symbolic SAST for C++
Clang LibTooling + Z3 BV-64 encoding.
11 detectors: clone, equiv, class-dup, dead, null-deref,
div-by-zero, int-overflow, oob, int-truncation,
sign-conversion, taint-track.

Interfaces: CLI, MCP (JSON-RPC 2.0), LSP.
Output: SARIF 2.1.0, JSON, Markdown.
2026-05-14 23:14:54 -04:00

531 lines
18 KiB
Python

#!/usr/bin/env python3
"""Compare fold verdicts and VCs against CBMC or ESBMC.
This script is intentionally pragmatic:
- It always runs fold on a single input C file and extracts per-function verdicts.
- It runs CBMC or ESBMC with ``--show-vcc`` when either tool is installed.
- If the local toolchain does not expose raw SMT-LIB for one side, it still
produces a useful agreement table plus an explicit capture template showing
the command expected for a manual VC dump.
The current fold tree logs solver outcomes via ``--trace`` but does not yet ship
an always-on SMT dump flag. The harness therefore supports three extraction
paths for fold queries, in order:
1. Future ``trace_json`` records that already include ``smtlib``/``query``.
2. Pre-generated ``*.smt2`` files in ``--fold-smt-dir``.
3. A template note explaining how to supply the SMT capture.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import shlex
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_FOLD = REPO_ROOT / "build" / "release" / "src" / "cli" / "fold"
COMMON_CBMC_PATHS = [
Path("/opt/homebrew/bin/cbmc"),
Path("/usr/local/bin/cbmc"),
Path("/usr/bin/cbmc"),
]
COMMON_ESBMC_PATHS = [
Path("/opt/homebrew/bin/esbmc"),
Path("/usr/local/bin/esbmc"),
Path("/usr/bin/esbmc"),
]
COMMON_Z3_PATHS = [
Path("/opt/homebrew/bin/z3"),
Path("/usr/local/bin/z3"),
Path("/usr/bin/z3"),
]
TRACE_JSON_RE = re.compile(r"trace_json\s+(\{.*\})")
FUNC_DEF_RE = re.compile(
r"(?P<sig>(?:^|\n)\s*(?!if\b|for\b|while\b|switch\b)[A-Za-z_][\w\s\*]*?\s+"
r"(?P<name>[A-Za-z_]\w*)\s*\([^;{}]*\)\s*)\{",
re.MULTILINE,
)
@dataclass
class FunctionSpan:
name: str
start_line: int
end_line: int
def contains(self, line: int) -> bool:
return self.start_line <= line <= self.end_line
@dataclass
class FoldFinding:
rule: str
function: str
file: str
line: int
confidence: str
message: str
@dataclass
class TraceEvent:
detector: str
file: str
line: int
col: int
result: str
detail: str
smtlib: str = ""
query: str = ""
@dataclass
class ToolVerdict:
verdict: str = "UNKNOWN"
vc_text: str = ""
smtlib: str = ""
source: str = ""
note: str = ""
@dataclass
class ComparisonRow:
function: str
fold_verdict: str
checker_verdict: str
equivalent: str
def detect_binary(env_name: str, executable: str, candidates: list[Path]) -> Path | None:
env_bin = os.environ.get(env_name)
if env_bin:
path = Path(env_bin).expanduser()
if path.is_file():
return path.resolve()
which = shutil.which(executable)
if which:
return Path(which).resolve()
for candidate in candidates:
if candidate.is_file():
return candidate.resolve()
return None
def run(command: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run(command, cwd=cwd, capture_output=True, text=True)
def build_compile_db(source: Path, db_path: Path, cc: str) -> None:
entry = [{
"directory": str(source.parent),
"command": shlex.join([cc, "-std=c11", "-c", str(source)]),
"file": str(source),
}]
db_path.write_text(json.dumps(entry, indent=2) + "\n")
def extract_braced_block(text: str, open_brace: int) -> tuple[int, int] | None:
depth = 0
for idx in range(open_brace, len(text)):
ch = text[idx]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return open_brace, idx
return None
def discover_functions(source: Path) -> list[FunctionSpan]:
text = source.read_text()
spans: list[FunctionSpan] = []
for match in FUNC_DEF_RE.finditer(text):
name = match.group("name")
open_brace = text.find("{", match.start("sig"))
if open_brace < 0:
continue
block = extract_braced_block(text, open_brace)
if block is None:
continue
start_line = text.count("\n", 0, match.start("sig")) + 1
end_line = text.count("\n", 0, block[1]) + 1
spans.append(FunctionSpan(name=name, start_line=start_line, end_line=end_line))
return spans
def function_for_line(spans: list[FunctionSpan], line: int) -> str:
for span in spans:
if span.contains(line):
return span.name
return ""
def parse_fold_sarif(sarif_path: Path) -> list[FoldFinding]:
if not sarif_path.exists():
return []
doc = json.loads(sarif_path.read_text())
out: list[FoldFinding] = []
for run_doc in doc.get("runs", []):
for result in run_doc.get("results", []):
locations = result.get("locations", [])
if not locations:
continue
loc = locations[0]
physical = loc.get("physicalLocation", {})
artifact = physical.get("artifactLocation", {})
region = physical.get("region", {})
logical = loc.get("logicalLocations", [])
function_name = ""
if logical:
function_name = logical[0].get("fullyQualifiedName", "")
out.append(FoldFinding(
rule=result.get("ruleId", "fold.unknown").removeprefix("fold."),
function=function_name,
file=artifact.get("uri", ""),
line=int(region.get("startLine", 0) or 0),
confidence=result.get("properties", {}).get("confidence", "unknown"),
message=result.get("message", {}).get("text", ""),
))
return out
def parse_trace_events(log_text: str, spans: list[FunctionSpan]) -> dict[str, list[TraceEvent]]:
events: dict[str, list[TraceEvent]] = {}
for line in log_text.splitlines():
match = TRACE_JSON_RE.search(line)
if not match:
continue
try:
payload = json.loads(match.group(1))
except json.JSONDecodeError:
continue
function_name = payload.get("function", "")
if not function_name:
function_name = function_for_line(spans, int(payload.get("line", 0) or 0))
if not function_name:
continue
event = TraceEvent(
detector=str(payload.get("detector", "")),
file=str(payload.get("file", "")),
line=int(payload.get("line", 0) or 0),
col=int(payload.get("col", 0) or 0),
result=str(payload.get("result", "unknown")).upper(),
detail=str(payload.get("detail", "")),
smtlib=str(payload.get("smtlib", "") or ""),
query=str(payload.get("query", "") or ""),
)
events.setdefault(function_name, []).append(event)
return events
def find_fold_vc(function_name: str,
trace_events: list[TraceEvent],
fold_smt_dir: Path | None) -> tuple[str, str]:
for event in trace_events:
candidate = event.smtlib or event.query
if candidate:
return candidate, "trace_json"
if fold_smt_dir and fold_smt_dir.is_dir():
patterns = [
f"{function_name}.smt2",
f"{function_name}*.smt2",
f"*{function_name}*.smt2",
]
for pattern in patterns:
matches = sorted(fold_smt_dir.glob(pattern))
if matches:
return matches[0].read_text(), str(matches[0])
return "", ""
def fold_verdicts(findings: list[FoldFinding],
traces: dict[str, list[TraceEvent]],
spans: list[FunctionSpan],
fold_smt_dir: Path | None) -> dict[str, ToolVerdict]:
verdicts = {span.name: ToolVerdict() for span in spans}
for span in spans:
vc_text, source = find_fold_vc(span.name, traces.get(span.name, []), fold_smt_dir)
verdicts[span.name].vc_text = vc_text
verdicts[span.name].source = source
verdicts[span.name].smtlib = normalize_to_smtlib(vc_text)
for finding in findings:
if not finding.function:
finding.function = function_for_line(spans, finding.line)
if not finding.function:
continue
verdicts.setdefault(finding.function, ToolVerdict())
verdicts[finding.function].verdict = "BUG"
for name, events in traces.items():
verdict = verdicts.setdefault(name, ToolVerdict())
if verdict.verdict == "BUG":
continue
if any(event.result == "SAT" for event in events):
verdict.verdict = "BUG"
elif any(event.result == "UNSAT" for event in events):
verdict.verdict = "SAFE"
return verdicts
def cbmc_command(cbmc_bin: Path, source: Path, function_name: str, unwind: int) -> list[str]:
return [
str(cbmc_bin),
str(source),
"--function", function_name,
"--pointer-check",
"--bounds-check",
"--div-by-zero-check",
"--signed-overflow-check",
"--unwind", str(unwind),
"--show-vcc",
]
def esbmc_command(esbmc_bin: Path, source: Path, function_name: str, unwind: int) -> list[str]:
return [
str(esbmc_bin),
str(source),
"--function", function_name,
"--pointer-check",
"--bounds-check",
"--div-by-zero-check",
"--overflow",
"--unwind", str(unwind),
"--no-unwinding-assertions",
"--show-vcc",
]
def checker_verdict_from_output(text: str) -> str:
upper = text.upper()
if "VERIFICATION FAILED" in upper:
return "BUG"
if "VERIFICATION SUCCESSFUL" in upper:
return "SAFE"
return "UNKNOWN"
def extract_checker_vc(text: str) -> str:
smt_match = re.search(r"(\(set-logic\b.*)", text, re.DOTALL)
if smt_match:
return smt_match.group(1)
vcc_markers = [
"VERIFICATION CONDITIONS:",
"Verification conditions:",
"VCCs:",
]
for marker in vcc_markers:
idx = text.find(marker)
if idx >= 0:
return text[idx + len(marker):].strip()
return text.strip()
def normalize_to_smtlib(vc_text: str) -> str:
if not vc_text:
return ""
stripped = vc_text.strip()
if stripped.startswith("(set-logic") or "(assert" in stripped:
return stripped if stripped.endswith("\n") else stripped + "\n"
return ""
def run_checker(tool_name: str,
tool_bin: Path,
source: Path,
function_name: str,
unwind: int) -> ToolVerdict:
command = cbmc_command(tool_bin, source, function_name, unwind) if tool_name == "cbmc" else esbmc_command(tool_bin, source, function_name, unwind)
proc = run(command)
text = "\n".join(part for part in (proc.stdout, proc.stderr) if part)
vc_text = extract_checker_vc(text)
return ToolVerdict(
verdict=checker_verdict_from_output(text),
vc_text=vc_text,
smtlib=normalize_to_smtlib(vc_text),
source=f"{tool_name} --show-vcc",
note="",
)
def run_z3(z3_bin: Path, smtlib: str) -> str:
proc = subprocess.run([str(z3_bin), "-in"], input=smtlib, text=True, capture_output=True)
text = (proc.stdout or proc.stderr).strip().splitlines()
if not text:
return "unknown"
first = text[0].strip().lower()
if first in {"sat", "unsat", "unknown"}:
return first
return "unknown"
def explain_mismatch(fold_verdict: str, checker_verdict: str) -> str:
if fold_verdict == "BUG" and checker_verdict == "SAFE":
return "NO (fold over-approximates)"
if fold_verdict == "SAFE" and checker_verdict == "BUG":
return "NO (fold misses bounded bug)"
return "NO"
def print_template(source: Path, fold_bin: Path, checker_name: str) -> None:
checker_cmd = "cbmc" if checker_name == "cbmc" else "esbmc"
print("VC Equivalence Check")
print("====================")
print("No CBMC/ESBMC binary was found locally, so this run emitted a template.")
print("")
print("Expected commands:")
print(f" {fold_bin} scan --compile-db <compile_commands.json> --format sarif --output fold.sarif --trace {source}")
print(f" {checker_cmd} {source} --function <fn> --show-vcc <other-check-flags>")
print("")
print("If fold can dump SMT-LIB, pass it back in with:")
print(" python3 bench/compare_vc_with_cbmc.py <file.c> --fold-smt-dir <dir-with-smt2>")
def render_table(rows: list[ComparisonRow], checker_label: str) -> str:
lines = [
"VC Equivalence Check",
"====================",
f"Function | fold verdict | {checker_label} verdict | VCs equivalent?",
]
yes = 0
for row in rows:
if row.equivalent == "YES":
yes += 1
lines.append(f"{row.function:<17} | {row.fold_verdict:<12} | {row.checker_verdict:<11} | {row.equivalent}")
total = len(rows)
rate = 0.0 if total == 0 else (100.0 * yes / total)
lines.append("")
lines.append(f"Agreement rate: {rate:.0f}% ({yes}/{total})")
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path, help="Single input C file")
parser.add_argument("--fold-bin", type=Path, default=DEFAULT_FOLD)
parser.add_argument("--fold-smt-dir", type=Path, default=None,
help="Directory containing fold-emitted *.smt2 files")
parser.add_argument("--cc", default=os.environ.get("CC", "clang"))
parser.add_argument("--loop-unroll", type=int, default=3)
parser.add_argument("--keep-temp", action="store_true")
args = parser.parse_args()
source = args.source.resolve()
if not source.is_file():
print(f"missing source file: {source}", file=sys.stderr)
return 2
if source.suffix != ".c":
print(f"expected a C file, got: {source}", file=sys.stderr)
return 2
fold_bin = args.fold_bin.resolve()
if not fold_bin.is_file():
print(f"missing fold binary: {fold_bin}", file=sys.stderr)
return 2
cbmc_bin = detect_binary("CBMC_BIN", "cbmc", COMMON_CBMC_PATHS)
esbmc_bin = detect_binary("ESBMC_BIN", "esbmc", COMMON_ESBMC_PATHS)
z3_bin = detect_binary("Z3_BIN", "z3", COMMON_Z3_PATHS)
checker_name = "cbmc" if cbmc_bin else "esbmc" if esbmc_bin else ""
checker_bin = cbmc_bin or esbmc_bin
if checker_bin is None:
print_template(source, fold_bin, "cbmc")
return 0
spans = discover_functions(source)
if not spans:
print(f"no function definitions found in {source}", file=sys.stderr)
return 2
temp_dir_ctx = tempfile.TemporaryDirectory(prefix="fold-vc-compare-")
work_dir = Path(temp_dir_ctx.name)
try:
db_path = work_dir / "compile_commands.json"
sarif_path = work_dir / "fold.sarif"
fold_log = work_dir / "fold.log"
build_compile_db(source, db_path, args.cc)
fold_cmd = [
str(fold_bin), "scan",
"--compile-db", str(db_path),
"--format", "sarif",
"--output", str(sarif_path),
"--trace",
"--min-body-stmts", "1",
"--processes", "1",
"--loop-unroll", str(args.loop_unroll),
"--detectors", "dead,div-by-zero,null-deref,int-overflow,oob",
str(source),
]
fold_proc = run(fold_cmd, cwd=source.parent)
fold_text = "\n".join(part for part in (fold_proc.stdout, fold_proc.stderr) if part)
fold_log.write_text(fold_text)
findings = parse_fold_sarif(sarif_path)
traces = parse_trace_events(fold_text, spans)
fold_by_fn = fold_verdicts(findings, traces, spans, args.fold_smt_dir)
rows: list[ComparisonRow] = []
for span in spans:
checker = run_checker(checker_name, checker_bin, source, span.name, args.loop_unroll)
fold = fold_by_fn.get(span.name, ToolVerdict())
if fold.verdict == "UNKNOWN" or checker.verdict == "UNKNOWN":
continue
equivalent = ""
if fold.verdict != checker.verdict:
equivalent = explain_mismatch(fold.verdict, checker.verdict)
elif z3_bin and fold.smtlib and checker.smtlib:
fold_sat = run_z3(z3_bin, fold.smtlib)
checker_sat = run_z3(z3_bin, checker.smtlib)
equivalent = "YES" if fold_sat == checker_sat else f"NO ({fold_sat} vs {checker_sat})"
elif not z3_bin:
equivalent = "N/A (z3 not installed)"
elif not fold.smtlib:
equivalent = "N/A (missing fold SMT dump)"
elif not checker.smtlib:
equivalent = f"N/A ({checker_name} VC not SMT-LIB)"
else:
equivalent = "N/A"
rows.append(ComparisonRow(
function=span.name,
fold_verdict=fold.verdict,
checker_verdict=checker.verdict,
equivalent=equivalent,
))
checker_label = "CBMC" if checker_name == "cbmc" else "ESBMC"
print(render_table(rows, checker_label))
if rows and any(row.equivalent.startswith("N/A") for row in rows):
print("")
print("Notes:")
print(f"- fold trace log: {fold_log}")
print(f"- checker backend: {checker_name} ({checker_bin})")
print("- For full SMT equivalence, provide fold SMT dumps via --fold-smt-dir or extend fold trace_json with an smtlib/query field.")
return 0
finally:
if args.keep_temp:
print(f"kept temp dir: {work_dir}", file=sys.stderr)
else:
temp_dir_ctx.cleanup()
if __name__ == "__main__":
raise SystemExit(main())