fold/tests/unit/test_null_deref.cpp
2026-07-28 11:57:38 -04:00

710 lines
32 KiB
C++

#include <clang/Tooling/Tooling.h>
#include <catch2/catch_test_macros.hpp>
#include <memory>
#include <string>
#include <vector>
#include "fold/detect/cross_tu_cache.hpp"
#include "fold/detect/null_deref.hpp"
#include "fold/report/finding.hpp"
#include "fold/symbolic/budget.hpp"
namespace {
struct TestResult {
std::vector<fold::Finding> findings;
fold::SolverStats stats;
};
std::unique_ptr<clang::ASTUnit> parse(const std::string& code) {
return clang::tooling::buildASTFromCodeWithArgs(code, {"-std=c++20"}, "input.cpp");
}
TestResult run(const std::string& code) {
fold::detect::CrossTUCache::instance().clear();
auto unit = parse(code);
REQUIRE(unit);
fold::SolverBudget b;
fold::SolverStats s;
auto findings = fold::detectNullDerefInContext(unit->getASTContext(), b, s);
return {std::move(findings), s};
}
TestResult run(const std::string& code, const fold::NullDerefOptions& options) {
fold::detect::CrossTUCache::instance().clear();
auto unit = parse(code);
REQUIRE(unit);
fold::SolverBudget b;
fold::SolverStats s;
auto findings = fold::detectNullDerefInContext(unit->getASTContext(), b, s,
fold::SafetyObserver::QueryTracer{},
fold::WalkOptions{}, options);
return {std::move(findings), s};
}
} // namespace
TEST_CASE("null-deref: `int* p = nullptr; *p` is flagged", "[null][trivial]") {
auto [findings, stats] = run("int f(){ int* p = nullptr; return *p; }");
REQUIRE(findings.size() == 1);
REQUIRE(findings[0].kind == fold::FindingKind::NullDeref);
REQUIRE(findings[0].message.find("p=0x0") != std::string::npos);
// Local null: unconditional bug → stays at the Proved tier (no undischarged
// caller obligation, so not Latent).
REQUIRE(findings[0].confidence == fold::Confidence::Proved);
REQUIRE_FALSE(findings[0].evidence.latent);
REQUIRE(stats.queries == 1);
REQUIRE(stats.sat == 1);
}
TEST_CASE("null-deref: arrow on nullptr is flagged", "[null][arrow]") {
auto [findings, stats] = run("struct S { int x; }; int f(){ S* p = nullptr; return p->x; }");
REQUIRE(findings.size() == 1);
REQUIRE(stats.queries == 1);
REQUIRE(stats.sat == 1);
}
TEST_CASE("null-deref: non-null initializer is NOT flagged", "[null][safe]") {
auto [findings, stats] = run("int x; int f(){ int* p = &x; return *p; }");
REQUIRE(findings.empty());
REQUIRE(stats.queries == 0);
}
TEST_CASE("null-deref: parameter pointer is flagged via entry def", "[null][param]") {
auto [findings, stats] = run("int f(int* p){ return *p; }");
REQUIRE(findings.size() == 1);
REQUIRE(findings[0].kind == fold::FindingKind::NullDeref);
REQUIRE(findings[0].evidence.precondition_required);
REQUIRE(findings[0].evidence.latent);
REQUIRE_FALSE(findings[0].evidence.required_preconditions.empty());
// Reachability depends on an undischarged caller obligation → demoted from
// Proved to the Latent tier so it doesn't masquerade as a proven bug.
REQUIRE(findings[0].confidence == fold::Confidence::Latent);
REQUIRE(stats.queries == 1);
}
TEST_CASE("null-deref: leading DEBUGASSERT marks pointer parameter non-null", "[null][contract]") {
auto [findings, stats] =
run("#define DEBUGASSERT(x) ((void)0)\n"
"struct S { int x; };\n"
"int f(S* data){ DEBUGASSERT(data); if(!data->x) return 1; return 0; }");
REQUIRE(findings.empty());
REQUIRE(stats.queries == 0);
}
TEST_CASE("null-deref: fold_assume_nonnull marks internal pointer precondition",
"[null][contract][nonnull-assume]") {
auto [findings, stats] = run("extern \"C\" void fold_assume_nonnull(const void*);\n"
"int f(int* p){ fold_assume_nonnull(p); return *p; }");
REQUIRE(findings.empty());
REQUIRE(stats.queries == 0);
}
TEST_CASE("null-deref: generic contract calls become path assumptions",
"[null][contract][expects]") {
auto [findings, stats] = run("extern void Expects(bool);\n"
"extern void FOLD_PRECONDITION(bool, const char*);\n"
"int f(int* p, int* q){ Expects(p != nullptr);"
" FOLD_PRECONDITION(q != nullptr, \"q\"); return *p + *q; }");
REQUIRE(findings.empty());
REQUIRE(stats.queries == 0);
}
TEST_CASE("null-deref: member assignment does not clear base pointer contract",
"[null][contract][member-assign]") {
auto [findings, stats] =
run("#define DEBUGASSERT(x) ((void)0)\n"
"struct Inner { int y; }; struct S { Inner* asi; };\n"
"extern Inner* make();\n"
"int f(S* data){ DEBUGASSERT(data); data->asi = make(); if(!data->asi) return 1; "
"return 0; }");
REQUIRE(findings.empty());
REQUIRE(stats.queries == 0);
}
TEST_CASE("null-deref: reassigned-away-from-nullptr is NOT flagged", "[null][reassign]") {
// `p = &x;` flips p out of the nullable set, so the deref afterwards
// is no longer flagged.
auto [findings, stats] = run("int g; int f(){ int* p = nullptr; p = &g; return *p; }");
REQUIRE(findings.empty());
REQUIRE(stats.queries == 0);
}
TEST_CASE("null-deref: unknown reference mutation cannot produce Proved confidence",
"[null][call][havoc][precision]") {
auto [findings, stats] =
run("extern void mutate(int*&); int f(){ int* p = nullptr; mutate(p); return *p; }");
REQUIRE(findings.size() == 1);
REQUIRE(findings[0].kind == fold::FindingKind::NullDeref);
REQUIRE(findings[0].confidence == fold::Confidence::Heuristic);
REQUIRE(findings[0].evidence.proof_mode == "over-approximated");
REQUIRE(stats.sat == 1);
}
TEST_CASE("null-deref: later approximation in one expression does not taint an earlier sink",
"[null][call][batch][precision]") {
auto [findings, stats] = run("extern int mutate(int&); int f(){ int* p = nullptr; int x = 0; "
"return *p + mutate(x); }");
REQUIRE(findings.size() == 1);
REQUIRE(findings[0].kind == fold::FindingKind::NullDeref);
REQUIRE(findings[0].confidence == fold::Confidence::Proved);
REQUIRE(stats.sat == 1);
}
TEST_CASE("null-deref: new result is treated as non-null", "[null][new]") {
auto [findings, stats] = run("int f(){ int* p = new int(7); return *p; }");
REQUIRE(findings.empty());
REQUIRE(stats.queries == 0);
}
TEST_CASE("null-deref: nullable-allocation mode flags new result deref",
"[null][new][nullable-alloc]") {
fold::NullDerefOptions options;
options.nullable_allocations = true;
auto [findings, stats] = run("int f(){ int* p = new int(7); return *p; }", options);
REQUIRE(findings.size() == 1);
REQUIRE(findings[0].kind == fold::FindingKind::NullDeref);
REQUIRE(findings[0].confidence == fold::Confidence::Heuristic);
REQUIRE(findings[0].message.find("nullable-allocation mode") != std::string::npos);
REQUIRE(stats.abstract_sat == 1);
}
TEST_CASE("null-deref: cross-TU callee deref is found", "[null][crosstu]") {
fold::detect::CrossTUCache::instance().clear();
auto callee = parse("int sink(int* p){ return *p; }");
auto caller = parse("int sink(int*); int f(){ int* p = nullptr; return sink(p); }");
REQUIRE(callee);
REQUIRE(caller);
fold::primeNullDerefCrossTUCache(callee->getASTContext());
fold::SolverBudget b;
fold::SolverStats s;
auto findings = fold::detectNullDerefInContext(caller->getASTContext(), b, s);
REQUIRE(findings.size() == 1);
REQUIRE(findings[0].kind == fold::FindingKind::NullDeref);
REQUIRE(findings[0].confidence == fold::Confidence::Heuristic);
REQUIRE(findings[0].message.find("sink") != std::string::npos);
}
TEST_CASE("null-deref: cross-TU callee array-subscript deref is found", "[null][crosstu][array]") {
fold::detect::CrossTUCache::instance().clear();
auto callee = parse("int sink(int* p){ return p[0]; }");
auto caller = parse("int sink(int*); int f(){ int* p = nullptr; return sink(p); }");
REQUIRE(callee);
REQUIRE(caller);
fold::primeNullDerefCrossTUCache(callee->getASTContext());
fold::SolverBudget b;
fold::SolverStats s;
auto findings = fold::detectNullDerefInContext(caller->getASTContext(), b, s);
REQUIRE(findings.size() == 1);
REQUIRE(findings[0].kind == fold::FindingKind::NullDeref);
REQUIRE(findings[0].message.find("sink") != std::string::npos);
}
TEST_CASE("null-deref: tracked malloc result is treated as non-null", "[null][heap]") {
auto [findings, stats] = run("extern \"C\" void* malloc(unsigned long);\n"
"int f(){ int* p = (int*)malloc(sizeof(int)); return *p; }");
REQUIRE(findings.empty());
REQUIRE(stats.queries == 0);
}
TEST_CASE("null-deref: nullable-allocation mode flags malloc result deref",
"[null][heap][nullable-alloc]") {
fold::NullDerefOptions options;
options.nullable_allocations = true;
auto [findings, stats] = run("extern \"C\" void* malloc(unsigned long);\n"
"int f(){ int* p = (int*)malloc(sizeof(int)); return *p; }",
options);
REQUIRE(findings.size() == 1);
REQUIRE(findings[0].kind == fold::FindingKind::NullDeref);
REQUIRE(findings[0].confidence == fold::Confidence::Heuristic);
REQUIRE(findings[0].message.find("nullable-allocation mode") != std::string::npos);
REQUIRE(stats.abstract_sat == 1);
}
TEST_CASE("null-deref: malloc null check suppresses dereference finding", "[null][heap-guarded]") {
auto [findings, stats] = run(
"extern \"C\" void* malloc(unsigned long);\n"
"int f(){ int* p = (int*)malloc(sizeof(int)); if (p != nullptr) return *p; return 0; }");
REQUIRE(findings.empty());
REQUIRE(stats.queries == 0);
}
TEST_CASE("null-deref: nullable-allocation mode respects malloc null check",
"[null][heap][nullable-alloc][guarded]") {
fold::NullDerefOptions options;
options.nullable_allocations = true;
auto [findings, stats] =
run("extern \"C\" void* malloc(unsigned long);\n"
"int f(){ int* p = (int*)malloc(sizeof(int)); if (p != nullptr) return *p; return 0; }",
options);
REQUIRE(findings.empty());
REQUIRE(stats.abstract_sat == 0);
}
TEST_CASE("null-deref M19: early-return guard suppresses deref", "[null][guard][m19]") {
// Dominant FP class across yams/curl/OpenSSL audits. walkIf must detect
// the then-arm always exits and propagate `!cond` into the post-if path.
auto [findings, stats] = run("void f(int* p){ if (!p) return; *p = 1; }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref M19: else-return guard suppresses deref", "[null][guard][m19]") {
auto [findings, stats] = run("void f(int* p){ if (p) { *p = 1; } else { return; } }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref M19: noreturn-call guard suppresses deref", "[null][guard][m19]") {
auto [findings, stats] = run("[[noreturn]] extern \"C\" void abort(void);\n"
"void f(int* p){ if (!p) abort(); *p = 1; }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref: member compound-assign keeps base pointer non-null",
"[null][guard][compound]") {
// `c->m += x` is a read-modify-write through c, so reaching a later
// `c->m` deref proves c non-null on that path. The compound-assign arm
// reassigns the base var (vd == c) and cleared its non-null mark, so the
// trailing `return c->total` re-fired a false positive. Regression for the
// http_adapter_curl write_cb case where `ctx->downloaded += ...` preceded
// `ctx->total` (yams null/div triage #20-21).
auto [findings, stats] =
run("struct WC { int total; }; "
"int f(WC* c){ if (!c) return 0; c->total += 5; return c->total; }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref: compound-assign base fix does not mask a real nullable deref",
"[null][guard][compound]") {
// The base-non-null re-mark must be local to the guarded function: an
// unrelated unguarded parameter deref still fires.
auto [findings, stats] =
run("struct WC { int total; }; "
"int g(WC* c){ if (!c) return 0; c->total += 5; return c->total; } "
"int h(int* p){ return *p; }");
REQUIRE(findings.size() == 1);
REQUIRE(findings[0].kind == fold::FindingKind::NullDeref);
}
TEST_CASE("null-deref: pointer equality guard suppresses else-path deref",
"[null][guard][eq-null]") {
auto [findings, stats] = run("int f(int* b){ if (b == nullptr) return 0; return *b; }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref: pointer equality guard suppresses offset deref sentinel model",
"[null][guard][sentinel]") {
auto [findings, stats] = run("int f(int* b){ if (b == nullptr) return 0; return *(b + 1); }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref: disjunctive raw pointer guard suppresses later deref",
"[null][guard][or-return]") {
auto [findings, stats] =
run("bool f(int** raw){ if (!raw || !*raw) return false; **raw = 1; return true; }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref: if-init disjunctive raw pointer guard suppresses else-arm deref",
"[null][guard][or-return][if-init]") {
auto [findings, stats] = run("extern int** get();"
"bool f(){ if (int** raw = get(); !raw || !*raw) return false;"
" else { **raw = 1; return true; } }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref: non-adjacent early-return guard suppresses later deref",
"[null][guard][non-adjacent]") {
auto [findings, stats] = run("struct E { int value; };"
"int f(E* result, int n){ if (!result) return 0;"
" int total = 0; for (int i = 0; i < 3; ++i) total += n + i;"
" return total + result->value; }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref: main argc equality guard suppresses argv indexed access",
"[null][guard][argv]") {
auto [findings, stats] =
run("int main(int argc, char** argv){ if (argc == 2) return argv[1][0]; return 0; }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref M19: explicit nullptr deref still fires", "[null][guard][m19][positive]") {
// Pin that M19 doesn't over-suppress: an explicit `int* p = nullptr; *p`
// must still be flagged (the prior "trivial" test confirms the
// baseline; this asserts M19 didn't break it).
auto [findings, stats] = run("int f(){ int* p = nullptr; return *p; }");
REQUIRE_FALSE(findings.empty());
}
TEST_CASE("null-deref M20: sequential derefs of same pointer fire once", "[null][m20]") {
// Canonical M20 case: the first `*p` is flagged (p is param-nullable),
// but the second `*p` must not — the upstream deref implies p is
// non-null for the rest of the path.
auto [findings, _] = run("int f(int* p){ int a = *p; int b = *p; return a + b; }");
REQUIRE(findings.size() == 1);
}
TEST_CASE("null-deref M20: deref in if-arm doesn't re-flag after pre-if deref", "[null][m20]") {
// Pre-if deref marks `p` as non-null; the if-arm deref reuses the
// marking and doesn't re-fire.
auto [findings, _] = run("int f(int* p, int x){ int a = *p; if (x) return *p; return a; }");
REQUIRE(findings.size() == 1);
}
TEST_CASE("null-deref M20: reassign to nullptr re-flags", "[null][m20]") {
// Reassignment installs a fresh z3 expression for `p`; the prior
// `oldp != 0` constraint doesn't restrict the new binding, so the
// post-reassign deref correctly re-fires.
auto [findings, _] = run("int f(int* p){ int a = *p; p = nullptr; return *p; }");
REQUIRE(findings.size() == 2);
}
TEST_CASE("null-deref M19+M20: guard + sequential derefs both suppress",
"[null][guard][m19][m20]") {
// M19 kills the first deref (early-return guard); M20 ensures the
// second deref doesn't re-fire even though M19's suppression alone
// wouldn't cover it.
auto [findings, _] = run("int f(int* p){ if (!p) return 0; int a = *p; return *p + a; }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref M20.1: arrow-assignment marks base non-null", "[null][m20]") {
// `s->x = 1;` is an LHS pointer-deref; the encoder bails on the
// assignment but M20.1 marks `s` non-null first, so the downstream
// rvalue deref `s->y` isn't re-flagged.
auto [findings, _] = run("struct S { int x; int y; };\n"
"int f(S* s){ s->x = 1; return s->y; }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref M20.1: deref-assignment marks pointer non-null", "[null][m20]") {
auto [findings, _] = run("int f(int* p){ *p = 1; return *p; }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref M22: if(p) then-arm proves p non-null", "[null][m22]") {
// The `if(p)` condition proves p is non-null inside the then-arm.
// The deref `*p` after the check should NOT fire.
auto [findings, _] = run("int f(int* p){ if (p) { return *p; } return 0; }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref M22: if(p == NULL) else-arm proves p non-null", "[null][m22]") {
auto [findings, _] = run("int f(int* p){ if (p == nullptr) return 0; return *p; }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref M23: p = &q->field marks p non-null", "[null][m23]") {
// `aptr = &data->state.aptr` makes aptr non-null (address of a
// member of data). Subsequent `aptr->host` should not flag.
// The `if (!data) return 0` guard is needed since M27 now correctly
// observes `&data->state` as a deref of `data` itself.
auto [findings, _] = run("struct State { int host; }; struct Easy { State state; };\n"
"int f(Easy* data){ if (!data) return 0;"
" State* s = &data->state; return s->host; }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref M25: nested if(x) { *x->y } suppresses inner deref", "[null][m25]") {
// Outer if(a) → a non-null inside then-arm. The CFG walker now
// sees a non-null in the inner block too (was the M22 gap).
auto [findings, _] = run("struct S { int* cf; };\n"
"int f(S* a){ if (a) { if (a->cf) return *a->cf; } return 0; }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref M25: reassignment inside branch invalidates mark", "[null][m25]") {
auto [findings, _] = run("int f(int* p){ if (p) { p = nullptr; return *p; } return 0; }");
REQUIRE(findings.size() == 1);
}
TEST_CASE("null-deref M28: branch-merged non-null persists past if/else", "[null][m28]") {
// Both arms assign s to a non-null value via address-of-member.
// M23 marks s non-null in each branch's env. M28 intersects the
// two branches at phi-merge so the post-if env inherits the mark.
auto [findings, _] = run("struct R { int x; }; struct E { R a; R b; };\n"
"int f(E* d, int flag){\n"
" if (!d) return 0;\n"
" R* s;\n"
" if (flag) { s = &d->a; } else { s = &d->b; }\n"
" return s->x;\n"
"}");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref M27: &q->field marks q non-null", "[null][m27]") {
// Before M27, `curlx_dyn_uptr(&ts->request_data)` would encode
// the AddrOf and bail without descending into `ts->request_data`,
// so `ts` was never marked non-null. The follow-up `ts->nsent`
// re-fired. Now AddrOf encodes its subexpr first.
auto [findings, _] = run("struct T { int rdata; int nsent; };\n"
"extern int sink(int*);\n"
"int f(struct T* ts){ if (!ts) return 0;"
" sink(&ts->rdata); return ts->nsent; }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref M26: switch(p->x) marks p non-null in case bodies", "[null][m26]") {
// Before M26, walkSwitch never encoded the switch condition, so the
// deref of `ai` in `switch(ai->ai_family)` never triggered the M20
// mark. The subsequent `ai->ai_addr` in the case body re-fired.
auto [findings, _] =
run("struct Addr { int family; int addr; };\n"
"int f(struct Addr* ai){ if (!ai) return 0;"
" switch(ai->family){ case 1: return ai->addr; }"
" return 0; }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref M25: must-non-null intersects at join", "[null][m25]") {
// After `if (p) ; else ;`, the join point has p non-null on neither
// path. Must-analysis says p is NOT guaranteed non-null at the
// post-if deref. Pin that M25 doesn't over-suppress.
auto [findings, _] = run("int f(int* p){ if (p) { (void)0; } else { (void)0; }"
" return *p; }");
REQUIRE(findings.size() == 1);
}
TEST_CASE("null-deref M24: __attribute__((nonnull)) propagates", "[null][m24]") {
// sink has explicit nonnull annotation on param 0. Even though sink's
// body doesn't deref p, the annotation seeds the summary precondition
// so M20.2 marks the caller's p non-null after the call.
auto [findings, _] = run("extern \"C\" int sink(int* p) __attribute__((nonnull(1)));\n"
"int sink(int* p){ return p ? 1 : 0; }\n"
"int f(int* p){ sink(p); return *p; }");
// sink doesn't deref p internally (only checks p ? :) so the only
// genuine deref is the trailing *p in f. M24-propagated nonnull
// precondition + M20.2 mark + M21 suppress: 0 findings expected
// (the M21 also suppresses the sink callsite finding because the
// sink summary now records nonnull, and M21 checks env on the
// first call too — actually wait, the first call's env doesn't
// yet have p marked. Reasoning: sink(p) callsite fires (1 finding),
// M20.2 marks p, trailing *p suppressed by M20).
REQUIRE(findings.size() <= 1);
}
TEST_CASE("null-deref M21: env non-null suppresses callsite finding", "[null][m21]") {
// Two callsites of `sink(p)` (which deref's p). With M21:
// - 1 finding inside sink (genuine — sink derefs nullable param)
// - 1 callsite finding at the first sink(p) (param-nullable in f)
// - 0 callsite finding at the second sink(p) — M20.2 marked p
// non-null after the first call; M21 sees that mark and skips.
auto [findings, _] = run("int sink(int* p){ return *p; }\n"
"int f(int* p){ sink(p); return sink(p); }");
REQUIRE(findings.size() == 2);
}
TEST_CASE("null-deref M21: positive — single callsite still fires", "[null][m21][positive]") {
// Pin that M21 doesn't over-suppress: a single callsite with no
// upstream deref-via-call must still emit the callsite finding.
auto [findings, _] = run("int sink(int* p){ return *p; }\n"
"int f(int* p){ return sink(p); }");
REQUIRE(findings.size() == 2);
}
TEST_CASE("null-deref: strtol endptr write keeps parsed tail non-null", "[null][libcall][strtol]") {
auto [findings, _] =
run("extern \"C\" long strtol(const char*, char**, int);\n"
"int f(){ char input[] = \"7\"; char* end = nullptr; strtol(input, &end, 10);"
" return *end; }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref M30 Phase A: emitted Location is populated via LocationMapper",
"[null][m30]") {
// After the M30 ClangLocationMapper migration, detector findings still
// carry a populated Location (file/line/column). This proves the new
// renderLoc() path produces the same output the legacy
// `sm.getPresumedLoc()` path produced — Phase A is no-behavioral-change.
auto [findings, _] = run("int f(){ int* p = nullptr; return *p; }");
REQUIRE(findings.size() == 1);
REQUIRE_FALSE(findings[0].locations.empty());
const auto& loc = findings[0].locations.front();
REQUIRE_FALSE(loc.file.empty());
REQUIRE(loc.line > 0);
}
TEST_CASE("null-deref M20.2: callee-precondition nonnull propagates to caller", "[null][m20]") {
// `sink(p)` deref's p, so sink's summary records `nonnull` precondition
// for arg 0. M20.2 propagates that: after the call returns, p is
// marked non-null in the caller, so the trailing `*p` doesn't re-flag.
//
// Findings without M20.2 would be 3:
// (a) `*p` inside sink — true (sink body)
// (b) `sink(p)` callsite — flagged by analyzeNullCallee
// (c) trailing `*p` in f — M20.2 should suppress this
// With M20.2: 2 findings (a + b), c is suppressed.
//
// Verify by location: no f-side-trailing-deref finding (input.cpp:2:31).
auto [findings, _] = run("int sink(int* p){ return *p; }\n"
"int f(int* p){ sink(p); return *p; }");
REQUIRE(findings.size() == 2);
for (const auto& f : findings) {
for (const auto& l : f.locations) {
// The trailing `*p` in f sits at column ~31. Asserting absence
// pins M20.2's effect.
REQUIRE_FALSE((l.line == 2 && l.column >= 25));
}
}
}
TEST_CASE("null-deref: static callee parameter contract propagates transitively",
"[null][contract][static]") {
auto [findings, _] = run("#define DEBUGASSERT(x) ((void)0)\n"
"struct S { int x; };\n"
"static int leaf(S* s){ return s->x; }\n"
"static int mid(S* s){ return leaf(s); }\n"
"int api(S* s){ DEBUGASSERT(s); return mid(s); }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref: static callee parameter contract sees guarded call argument",
"[null][contract][static]") {
auto [findings, _] = run("struct S { int x; };\n"
"static int leaf(S* s){ return s->x; }\n"
"int api(S* s){ if(s) return leaf(s); return 0; }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref: static helper infers Curl_str pointer/length pair contract",
"[null][contract][curl-str]") {
auto [findings, _] = run(
"struct Curl_str { const char *str; unsigned long len; };\n"
"#define curlx_str(x) ((x)->str)\n"
"#define curlx_strlen(x) ((x)->len)\n"
"static int helper(const char *p, unsigned long n) {\n"
" if((n > 2) && p[0]) return 1;\n"
" return 0;\n"
"}\n"
"int api() { Curl_str s{\"abc\", 3}; return helper(curlx_str(&s), curlx_strlen(&s)); }\n");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref: static Curl_str pair contract does not suppress uncoupled calls",
"[null][contract][curl-str][positive]") {
auto [findings, _] =
run("struct Curl_str { const char *str; unsigned long len; };\n"
"#define curlx_str(x) ((x)->str)\n"
"static int helper(const char *p, unsigned long n) {\n"
" if((n > 2) && p[0]) return 1;\n"
" return 0;\n"
"}\n"
"int api(Curl_str *s, unsigned long n) { return helper(curlx_str(s), n); }\n");
REQUIRE_FALSE(findings.empty());
}
TEST_CASE("null-deref: static helper infers strlen pointer/length pair contract",
"[null][contract][strlen]") {
auto [findings, _] = run("extern \"C\" unsigned long strlen(const char *);\n"
"static int helper(const char *p, unsigned long n) {\n"
" if((n > 2) && p[0]) return 1;\n"
" return 0;\n"
"}\n"
"int api(const char *s) { return helper(s, strlen(s)); }\n");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref: callee initial null-return guard suppresses callsite precondition",
"[null][contract][guarded-callee]") {
auto [findings, _] = run("struct S { int x; };\n"
"void sink(S* s){ if(!s) return; (void)s->x; }\n"
"void f(S* maybe){ sink(maybe); }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref: short-circuit member access proves pointer non-null", "[null][guard]") {
auto [findings, _] = run("struct M { bool empty() const; };\n"
"int f(M* idfMap){ return (idfMap && !idfMap->empty()) ? 1 : 0; }");
REQUIRE(findings.empty());
}
// ---- M22 OR-pattern extension (surfaced by 2026-05-23 fold-on-fold pass) ----
// extractNullCheckedVar previously only handled the simple `if (!p) return;`
// pattern. The compound `if (!p || other_cond) return;` shape is idiomatic
// (e.g. class_dup.cpp:186 `if (!rd || rd->isImplicit()) return true;`) and
// just as guaranteed: the post-if env can only be reached when BOTH operands
// are false, so `p` is non-null there. Each of these tests pins one shape
// of that contract so a future refactor of cfg_helpers::nullCheckVar can't
// regress the inference.
TEST_CASE("null-deref: post-if non-null after `if (!p || cond) return;`",
"[null][or-guard][m22-extension]") {
auto [findings, _] = run("struct S { int x; };\n"
"int f(S* p, int other){\n"
" if (!p || other == 0) return -1;\n"
" return p->x;\n"
"}");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref: post-if non-null after `if (p == nullptr || cond) return;`",
"[null][or-guard][m22-extension]") {
auto [findings, _] = run("struct S { int x; };\n"
"int f(S* p, int other){\n"
" if (p == nullptr || other == 0) return -1;\n"
" return p->x;\n"
"}");
REQUIRE(findings.empty());
}
// Note: the call-site case (caller null-checks via `!rd || other` then
// passes rd to a helper that derefs param 0) is NOT a pure OR-pattern
// issue. fold analyzes the helper standalone — `static int helper(R* r)
// { return r->v; }` — and emits "Null pointer dereference: r is nullable"
// at the r->v site regardless of whether call sites prove non-null. That
// FP is the "callee-standalone-without-call-site-context" gap, separate
// from the M22 OR-pattern propagation tested above. Filed in
// docs/internal/perf-audit/fold-on-fold.md as a future detector-precision item.
TEST_CASE("null-deref: AND-pattern then-arm proves non-null (`if (p && other) deref`)",
"[null][and-guard][m22-extension]") {
// Symmetric AND case: entering the then-arm requires `p` non-null
// AND `other` true. Both halves of the conjunction must be known.
auto [findings, _] = run("struct S { int x; };\n"
"int f(S* p, int other){\n"
" if (p != nullptr && other != 0) return p->x;\n"
" return -1;\n"
"}");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref: deduplicates repeated helper callsite family", "[null][callsite]") {
auto [findings, _] = run("struct Cli { void reg(int); }; "
"static void helper(Cli* cli, int v){ cli->reg(v); } "
"void f(Cli* cli){ helper(cli, 1); helper(cli, 2); helper(cli, 3); }");
REQUIRE(findings.size() == 1);
REQUIRE(findings[0].message.find("helper") != std::string::npos);
}
TEST_CASE("null-deref: parenthesized `(p != 0) && (p->m)` guard is path-sensitively safe",
"[null][shortcircuit]") {
// The && guard proves p non-null for the RHS deref regardless of source
// text shape (parens, `!=0`). Previously only a same-line text heuristic
// caught `p && p->m`, so this parenthesized form false-positived.
auto [findings, _] = run("struct S { int m; }; "
"void f(S* p){ if ((p != 0) && (p->m == 5)) (void)p->m; }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref: `p == 0 || p->m` guard is path-sensitively safe", "[null][shortcircuit]") {
auto [findings, _] = run("struct S { int m; }; "
"void f(S* p){ if (p == 0 || p->m == 5) (void)0; }");
REQUIRE(findings.empty());
}
TEST_CASE("null-deref: bitwise `(p != 0) & (p->m)` is NOT suppressed (real deref)",
"[null][shortcircuit]") {
// Bitwise & evaluates both operands, so p->m really is dereferenced when
// p may be null — must still be flagged (the guard fix must not leak to &).
auto [findings, _] = run("struct S { int m; }; "
"void f(S* p){ if ((p != 0) & (p->m == 5)) (void)0; }");
REQUIRE(findings.size() >= 1);
REQUIRE(findings[0].kind == fold::FindingKind::NullDeref);
}