mirror of
https://github.com/Z3Prover/z3
synced 2026-03-17 02:30:01 +00:00
287 lines
9.3 KiB
Python
287 lines
9.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
memory_safety.py: run sanitizer checks on Z3 test suite.
|
|
|
|
Usage:
|
|
python memory_safety.py --sanitizer asan
|
|
python memory_safety.py --sanitizer ubsan --debug
|
|
"""
|
|
|
|
import argparse
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "shared"))
|
|
from z3db import Z3DB, setup_logging
|
|
|
|
logger = logging.getLogger("z3agent")
|
|
|
|
SANITIZER_FLAGS = {
|
|
"asan": "-fsanitize=address -fno-omit-frame-pointer",
|
|
"ubsan": "-fsanitize=undefined -fno-omit-frame-pointer",
|
|
}
|
|
|
|
ASAN_ERROR = re.compile(r"ERROR:\s*AddressSanitizer:\s*(\S+)")
|
|
UBSAN_ERROR = re.compile(r":\d+:\d+:\s*runtime error:\s*(.+)")
|
|
LEAK_ERROR = re.compile(r"ERROR:\s*LeakSanitizer:")
|
|
LOCATION = re.compile(r"(\S+\.(?:cpp|c|h|hpp)):(\d+)")
|
|
|
|
|
|
def check_dependencies():
|
|
"""Fail early if required build tools are not on PATH."""
|
|
missing = []
|
|
if not shutil.which("cmake"):
|
|
missing.append(("cmake", "sudo apt install cmake"))
|
|
if not shutil.which("make"):
|
|
missing.append(("make", "sudo apt install build-essential"))
|
|
|
|
cc = shutil.which("clang") or shutil.which("gcc")
|
|
if not cc:
|
|
missing.append(("clang or gcc", "sudo apt install clang"))
|
|
|
|
if missing:
|
|
print("required tools not found:", file=sys.stderr)
|
|
for tool, install in missing:
|
|
print(f" {tool}: {install}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def find_repo_root() -> Path:
|
|
d = Path.cwd()
|
|
for _ in range(10):
|
|
if (d / "CMakeLists.txt").exists() and (d / "src").is_dir():
|
|
return d
|
|
parent = d.parent
|
|
if parent == d:
|
|
break
|
|
d = parent
|
|
logger.error("could not locate Z3 repository root")
|
|
sys.exit(1)
|
|
|
|
|
|
def build_is_configured(build_dir: Path, sanitizer: str) -> bool:
|
|
"""Check whether the build directory already has a matching cmake config."""
|
|
cache = build_dir / "CMakeCache.txt"
|
|
if not cache.is_file():
|
|
return False
|
|
expected = SANITIZER_FLAGS[sanitizer].split()[0]
|
|
return expected in cache.read_text()
|
|
|
|
|
|
def configure(build_dir: Path, sanitizer: str, repo_root: Path) -> bool:
|
|
"""Run cmake with the requested sanitizer flags."""
|
|
flags = SANITIZER_FLAGS[sanitizer]
|
|
build_dir.mkdir(parents=True, exist_ok=True)
|
|
cmd = [
|
|
"cmake", str(repo_root),
|
|
f"-DCMAKE_C_FLAGS={flags}",
|
|
f"-DCMAKE_CXX_FLAGS={flags}",
|
|
f"-DCMAKE_EXE_LINKER_FLAGS={flags}",
|
|
"-DCMAKE_BUILD_TYPE=Debug",
|
|
"-DZ3_BUILD_TEST=ON",
|
|
]
|
|
logger.info("configuring %s build in %s", sanitizer, build_dir)
|
|
logger.debug("cmake command: %s", " ".join(cmd))
|
|
proc = subprocess.run(cmd, cwd=build_dir, capture_output=True, text=True)
|
|
if proc.returncode != 0:
|
|
logger.error("cmake failed:\n%s", proc.stderr)
|
|
return False
|
|
return True
|
|
|
|
|
|
def compile_tests(build_dir: Path) -> bool:
|
|
"""Compile the test-z3 target."""
|
|
nproc = os.cpu_count() or 4
|
|
cmd = ["make", f"-j{nproc}", "test-z3"]
|
|
logger.info("compiling test-z3 (%d parallel jobs)", nproc)
|
|
proc = subprocess.run(cmd, cwd=build_dir, capture_output=True, text=True)
|
|
if proc.returncode != 0:
|
|
logger.error("compilation failed:\n%s", proc.stderr[-2000:])
|
|
return False
|
|
return True
|
|
|
|
|
|
def run_tests(build_dir: Path, timeout: int) -> dict:
|
|
"""Execute test-z3 under sanitizer runtime and capture output."""
|
|
test_bin = build_dir / "test-z3"
|
|
if not test_bin.is_file():
|
|
logger.error("test-z3 not found at %s", test_bin)
|
|
return {"stdout": "", "stderr": "binary not found", "exit_code": -1,
|
|
"duration_ms": 0}
|
|
|
|
env = os.environ.copy()
|
|
env["ASAN_OPTIONS"] = "detect_leaks=1:halt_on_error=0:print_stacktrace=1"
|
|
env["UBSAN_OPTIONS"] = "print_stacktrace=1:halt_on_error=0"
|
|
|
|
cmd = [str(test_bin), "/a"]
|
|
logger.info("running: %s", " ".join(cmd))
|
|
start = time.monotonic()
|
|
try:
|
|
proc = subprocess.run(
|
|
cmd, capture_output=True, text=True, timeout=timeout,
|
|
cwd=build_dir, env=env,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
ms = int((time.monotonic() - start) * 1000)
|
|
logger.warning("test-z3 timed out after %dms", ms)
|
|
return {"stdout": "", "stderr": "timeout", "exit_code": -1,
|
|
"duration_ms": ms}
|
|
|
|
ms = int((time.monotonic() - start) * 1000)
|
|
logger.debug("exit_code=%d duration=%dms", proc.returncode, ms)
|
|
return {
|
|
"stdout": proc.stdout,
|
|
"stderr": proc.stderr,
|
|
"exit_code": proc.returncode,
|
|
"duration_ms": ms,
|
|
}
|
|
|
|
|
|
def parse_findings(output: str) -> list:
|
|
"""Extract sanitizer error reports from combined stdout and stderr."""
|
|
findings = []
|
|
lines = output.split("\n")
|
|
|
|
for i, line in enumerate(lines):
|
|
entry = None
|
|
|
|
m = ASAN_ERROR.search(line)
|
|
if m:
|
|
entry = {"category": "asan", "message": m.group(1),
|
|
"severity": "high"}
|
|
|
|
if not entry:
|
|
m = LEAK_ERROR.search(line)
|
|
if m:
|
|
entry = {"category": "leak",
|
|
"message": "detected memory leaks",
|
|
"severity": "high"}
|
|
|
|
if not entry:
|
|
m = UBSAN_ERROR.search(line)
|
|
if m:
|
|
entry = {"category": "ubsan", "message": m.group(1),
|
|
"severity": "medium"}
|
|
|
|
if not entry:
|
|
continue
|
|
|
|
file_path, line_no = None, None
|
|
window = lines[max(0, i - 2):i + 5]
|
|
for ctx in window:
|
|
loc = LOCATION.search(ctx)
|
|
if loc and "/usr/" not in loc.group(1):
|
|
file_path = loc.group(1)
|
|
line_no = int(loc.group(2))
|
|
break
|
|
|
|
entry["file"] = file_path
|
|
entry["line"] = line_no
|
|
entry["raw"] = line.strip()
|
|
findings.append(entry)
|
|
|
|
return findings
|
|
|
|
|
|
def deduplicate(findings: list) -> list:
|
|
"""Remove duplicate reports at the same category, file, and line."""
|
|
seen = set()
|
|
result = []
|
|
for f in findings:
|
|
key = (f["category"], f["file"], f["line"], f["message"])
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
result.append(f)
|
|
return result
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(prog="memory-safety")
|
|
parser.add_argument("--sanitizer", choices=["asan", "ubsan", "both"],
|
|
default="asan",
|
|
help="sanitizer to enable (default: asan)")
|
|
parser.add_argument("--build-dir", default=None,
|
|
help="path to build directory")
|
|
parser.add_argument("--timeout", type=int, default=600,
|
|
help="seconds before killing test run")
|
|
parser.add_argument("--skip-build", action="store_true",
|
|
help="reuse existing instrumented build")
|
|
parser.add_argument("--db", default=None,
|
|
help="path to z3agent.db")
|
|
parser.add_argument("--debug", action="store_true")
|
|
args = parser.parse_args()
|
|
|
|
setup_logging(args.debug)
|
|
check_dependencies()
|
|
repo_root = find_repo_root()
|
|
|
|
sanitizers = ["asan", "ubsan"] if args.sanitizer == "both" else [args.sanitizer]
|
|
all_findings = []
|
|
|
|
db = Z3DB(args.db)
|
|
|
|
for san in sanitizers:
|
|
if args.build_dir:
|
|
build_dir = Path(args.build_dir)
|
|
else:
|
|
build_dir = repo_root / "build" / f"sanitizer-{san}"
|
|
|
|
run_id = db.start_run("memory-safety", f"sanitizer={san}")
|
|
db.log(f"sanitizer: {san}, build: {build_dir}", run_id=run_id)
|
|
|
|
if not args.skip_build:
|
|
needs_configure = not build_is_configured(build_dir, san)
|
|
if needs_configure and not configure(build_dir, san, repo_root):
|
|
db.finish_run(run_id, "error", 0, exit_code=1)
|
|
print(f"FAIL: cmake configuration failed for {san}")
|
|
continue
|
|
if not compile_tests(build_dir):
|
|
db.finish_run(run_id, "error", 0, exit_code=1)
|
|
print(f"FAIL: compilation failed for {san}")
|
|
continue
|
|
|
|
result = run_tests(build_dir, args.timeout)
|
|
combined = result["stdout"] + "\n" + result["stderr"]
|
|
findings = deduplicate(parse_findings(combined))
|
|
|
|
for f in findings:
|
|
db.log_finding(
|
|
run_id,
|
|
category=f["category"],
|
|
message=f["message"],
|
|
severity=f["severity"],
|
|
file=f["file"],
|
|
line=f["line"],
|
|
details={"raw": f["raw"]},
|
|
)
|
|
|
|
status = "clean" if not findings else "findings"
|
|
if result["exit_code"] == -1:
|
|
status = "timeout" if "timeout" in result["stderr"] else "error"
|
|
|
|
db.finish_run(run_id, status, result["duration_ms"], result["exit_code"])
|
|
all_findings.extend(findings)
|
|
print(f"{san}: {len(findings)} finding(s), {result['duration_ms']}ms")
|
|
|
|
if all_findings:
|
|
print(f"\nTotal: {len(all_findings)} finding(s)")
|
|
for f in all_findings:
|
|
loc = f"{f['file']}:{f['line']}" if f["file"] else "unknown location"
|
|
print(f" [{f['severity']}] {f['category']}: {f['message']} at {loc}")
|
|
db.close()
|
|
sys.exit(1)
|
|
else:
|
|
print("\nNo sanitizer findings.")
|
|
db.close()
|
|
sys.exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|