#include #include #include #include #include #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 findings; fold::SolverStats stats; }; std::unique_ptr 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); }