fold/tests/unit/test_proved_requires_exact_model.cpp
trvon 29a292c6d5
fix(oob,uaf): re-check portfolio verdicts on the reporting solver
Problem: with --portfolio the OOB and UAF detectors took the portfolio's
SAT/UNSAT answer as final. The portfolio solves on its own contexts, so the
reporting solver was never checked: OOB read its counterexample from an
unchecked solver (i=0 under `i > 5`), and a SAT the reporting solver would
not reproduce was still emitted. Other detectors already fail closed through
reconcilePortfolioRecheck.

Fix: add symbolic::recheckPortfolioVerdict, which re-checks a decided verdict
on the reporting solver and reconciles the two (disagreement or timeout ->
unknown), and apply it at the four OOB and two UAF portfolio sites. The
now-redundant UNSAT-only artifact re-checks in OOB checkHazard are removed.

Test: test_proved_requires_exact_model "[portfolio]" requires the portfolio
counterexample to satisfy the path (was i=0, now i=6).
2026-09-24 21:32:16 -06:00

211 lines
9 KiB
C++

#include <clang/AST/RecursiveASTVisitor.h>
#include <clang/Frontend/ASTUnit.h>
#include <clang/Tooling/Tooling.h>
#include <catch2/catch_test_macros.hpp>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include "fold/detect/dead_statement.hpp"
#include "fold/detect/double_free.hpp"
#include "fold/detect/oob_access.hpp"
#include "fold/detect/static_ptr_asymmetry.hpp"
#include "fold/detect/unused_function.hpp"
#include "fold/detect/use_after_free.hpp"
#include "fold/report/finding.hpp"
#include "fold/symbolic/budget.hpp"
#include "fold/symbolic/encoder_type_queries.hpp"
// docs/research/soundness.md: `proved` means an exact bounded model
// established the property. These detectors assigned Proved without one:
// from a heap-model flag (double-free), a cross-function pattern
// (static-ptr-asymmetry), an AST fact (unused-function, dead-statement), or a
// SAT result whose encoding precision was never checked (stdlib OOB copy,
// use-after-free under an unknown call).
namespace {
std::unique_ptr<clang::ASTUnit> parse(const std::string& code) {
return clang::tooling::buildASTFromCodeWithArgs(code, {"-std=c++20"}, "input.cpp");
}
std::optional<fold::Confidence> confidenceOf(const std::vector<fold::Finding>& findings,
fold::FindingKind kind) {
for (const auto& f : findings)
if (f.kind == kind)
return f.confidence;
return std::nullopt;
}
const std::string kLibc = "extern \"C\" void* malloc(unsigned long);\n"
"extern \"C\" void free(void*);\n"
"extern \"C\" void* memcpy(void*, const void*, unsigned long);\n";
} // namespace
TEST_CASE("double-free without a solver query is Heuristic", "[proved-contract][double-free]") {
auto unit =
parse(kLibc + "void f() { void* p = malloc(8); if (!p) return; free(p); free(p); }");
REQUIRE(unit);
fold::SolverBudget b;
fold::SolverStats s;
const auto c = confidenceOf(fold::detectDoubleFreeInContext(unit->getASTContext(), b, s),
fold::FindingKind::DoubleFree);
REQUIRE(c.has_value());
CHECK(*c == fold::Confidence::Heuristic);
}
TEST_CASE("static-ptr-asymmetry is a pattern, not a proof", "[proved-contract][static-ptr]") {
auto unit = parse("struct S { int v; };\n"
"static S* g_state = nullptr;\n"
"int checked() { if (!g_state) return 0; return g_state->v; }\n"
"int unchecked() { return g_state->v; }\n");
REQUIRE(unit);
const auto c = confidenceOf(fold::detectStaticPtrAsymmetryInContext(unit->getASTContext()),
fold::FindingKind::NullDeref);
REQUIRE(c.has_value());
CHECK(*c == fold::Confidence::Heuristic);
}
TEST_CASE("unused static function is an AST fact: Syntactic", "[proved-contract][unused]") {
auto unit = parse("static int helper(int x) { return x + 1; }\nint api(int x) { return x; }\n");
REQUIRE(unit);
const auto c = confidenceOf(fold::detectUnusedFunctionsInContext(unit->getASTContext()),
fold::FindingKind::UnusedFunction);
REQUIRE(c.has_value());
CHECK(*c == fold::Confidence::Syntactic);
}
TEST_CASE("statement after return is an AST fact: Syntactic", "[proved-contract][dead-stmt]") {
auto unit = parse("int f(int x) { return x; x += 1; }\n");
REQUIRE(unit);
const auto c = confidenceOf(fold::detectDeadStatementsInContext(unit->getASTContext()),
fold::FindingKind::DeadStatement);
REQUIRE(c.has_value());
CHECK(*c == fold::Confidence::Syntactic);
}
TEST_CASE("stdlib copy overflow without encoding precision is Heuristic",
"[proved-contract][oob]") {
auto unit = parse(kLibc + "void f(const char* src) { char buf[8]; memcpy(buf, src, 16); }");
REQUIRE(unit);
fold::SolverBudget b;
fold::SolverStats s;
const auto c = confidenceOf(fold::detectOOBAccessInContext(unit->getASTContext(), b, s),
fold::FindingKind::OOBAccess);
REQUIRE(c.has_value());
CHECK(*c == fold::Confidence::Heuristic);
}
TEST_CASE("heap use-after-free keeps Proved only on an exact encoding", "[proved-contract][uaf]") {
// The SAT-backed heap use-after-free surfaces through the OOB detector
// (`uaf-access`).
auto uafConfidence = [](const std::string& code) -> std::optional<fold::Confidence> {
auto unit = parse(code);
REQUIRE(unit);
fold::SolverBudget b;
fold::SolverStats s;
for (const auto& f : fold::detectOOBAccessInContext(unit->getASTContext(), b, s))
if (f.id.rfind("uaf-access:", 0) == 0)
return f.confidence;
return std::nullopt;
};
SECTION("exact: stays Proved (control)") {
const auto c = uafConfidence(
kLibc + "int f() { int* p = (int*)malloc(sizeof(int)); free(p); return p[0]; }");
REQUIRE(c.has_value());
CHECK(*c == fold::Confidence::Proved);
}
SECTION("an over-approximated operation on the path: Heuristic") {
const auto c = uafConfidence(kLibc + "extern int opaque(int);\n"
"int f(int k) { int* p = (int*)malloc(sizeof(int));"
" int n = opaque(k); free(p); return p[n - n]; }");
REQUIRE(c.has_value());
CHECK(*c == fold::Confidence::Heuristic);
}
}
namespace {
// Value of the first sizeof/alignof in `code`, as the encoder would see it.
std::optional<std::uint64_t> firstTypeTrait(const std::string& code, bool c_mode = false) {
auto unit = c_mode ? clang::tooling::buildASTFromCodeWithArgs(code, {"-std=c11"}, "input.c")
: parse(code);
REQUIRE(unit);
struct Finder : clang::RecursiveASTVisitor<Finder> {
const clang::UnaryExprOrTypeTraitExpr* found = nullptr;
bool VisitUnaryExprOrTypeTraitExpr(clang::UnaryExprOrTypeTraitExpr* e) {
if (!found)
found = e;
return true;
}
} finder;
finder.TraverseDecl(unit->getASTContext().getTranslationUnitDecl());
REQUIRE(finder.found);
return fold::symbolic::typeTraitConstant(unit->getASTContext(), finder.found);
}
} // namespace
TEST_CASE("type-trait constants are exact for complete types only", "[proved-contract][sizeof]") {
// sizeof was an unsupported node, which marked every `malloc(sizeof(T))`
// path imprecise and so could never back a Proved finding.
CHECK(firstTypeTrait("unsigned long f() { return sizeof(int); }") == 4u);
CHECK(firstTypeTrait(
"struct S { char c; double d; }; unsigned long f() { return sizeof(S); }") == 16u);
CHECK(firstTypeTrait("unsigned long f() { return alignof(double); }") == 8u);
// A VLA's size is a runtime value: not a constant.
CHECK_FALSE(firstTypeTrait("unsigned long f(int n) { char buf[n]; return sizeof(buf); }",
/*c_mode=*/true)
.has_value());
}
TEST_CASE("constant-index array OOB keeps Proved only on an exact encoding",
"[proved-contract][oob]") {
auto oobConfidence = [](const std::string& code) -> std::optional<fold::Confidence> {
auto unit = parse(code);
REQUIRE(unit);
fold::SolverBudget b;
fold::SolverStats s;
for (const auto& f : fold::detectOOBAccessInContext(unit->getASTContext(), b, s))
if (f.id.rfind("oob-access:", 0) == 0)
return f.confidence;
return std::nullopt;
};
SECTION("exact: stays Proved (control)") {
const auto c = oobConfidence("int f() { int a[4] = {0}; return a[4]; }");
REQUIRE(c.has_value());
CHECK(*c == fold::Confidence::Proved);
}
SECTION("an over-approximated operation on the path: Heuristic") {
const auto c = oobConfidence("extern int opaque(int);\n"
"int f(int k) { int a[4] = {0}; int n = opaque(k);"
" if (n > 2) return a[4]; return 0; }");
REQUIRE(c.has_value());
CHECK(*c == fold::Confidence::Heuristic);
}
}
TEST_CASE("portfolio OOB verdicts are re-checked on the reporting solver",
"[proved-contract][oob][portfolio]") {
// The portfolio answers on its own solvers; the model used to be read from
// the reporting solver without re-checking it, so the counterexample did
// not satisfy the path (i=0 under `i > 5`).
auto unit = parse("int f(int i) { int a[4] = {0}; if (i > 5) return a[i]; return 0; }");
REQUIRE(unit);
fold::SolverBudget b;
b.portfolio = true;
fold::SolverStats s;
bool found = false;
for (const auto& f : fold::detectOOBAccessInContext(unit->getASTContext(), b, s)) {
if (f.id.rfind("oob-access:", 0) != 0)
continue;
found = true;
REQUIRE(f.evidence.counterexample.size() == 1);
CHECK(f.evidence.counterexample[0].first == "i");
CHECK(std::stoll(f.evidence.counterexample[0].second) > 5);
}
CHECK(found);
}