RFC 0008: Pointer validity: null dereferences, uninitialised pointers, invalid releases and replaced values
- Status: Accepted
- Authors: WeaveC authors
- Created: 2026-09-03
- Accepted: 2026-09-03
- Tracking issue: TBD
- Supersedes / superseded by: amends the RFC 0003 rule on where
effects are read from (Deriving a summary, third bullet: frees and
moves below a dereference are read from the exit state alone); extends
the RFC 0003 summary vocabulary (
replaced,requires,notnull, theresultroot) and the RFC 0006/0007 outcome facts (nonNullOn); resolves the RFC 0007 Future work item “Nullability (a later RFC)”.
Summary
Section titled “Summary”WeaveC answers “who owns this pointer and for how long”. It does not yet answer “is this pointer valid right now”. Four things fall through today:
- A callee that releases a value reachable from its arguments and then
overwrites the place it came from (
v->items = realloc(v->items, n),free(b->data); b->data = NULL;) is summarised as if nothing was released, because RFC 0003 reads such effects from the callee’s exit state. A caller’s own copy of the old value is then used without a report. This is a soundness hole in the guarantee RFC 0002 gives. - Nothing tracks nullness.
p = malloc(n); p[0] = 1;,q = strchr(s, ':'); *q,if (!n) log(); return n->v;and passing a possibly-null pointer tostrlenare all silent. - Reading a pointer variable or a pointer field of a local struct that was
never initialised is silent (Clang catches the variable case; nothing
catches
struct s x; free(x.data);). - Releasing something that was never allocated is silent:
free(buf)on a stack array,free("literal"),free(p + 1),free(&s),free(g)on a static array (which today crashes the checker).
This RFC closes all four with three new diagnostics (null-dereference,
use-of-uninitialized, invalid-release), one new state component
(nullness), two annotations (WEAVEC_NULLABLE, WEAVEC_NONNULL), and a
summary vocabulary extension (replaced, requires, notnull, a result
root for struct-by-value returns). Summary text format 3 → 4, sidecar
format 3 → 4.
Motivation
Section titled “Motivation”The README promises Rust-style memory safety for C. Rust’s Option<Box<T>>
and its refusal to read uninitialised memory or to free anything but a
Box are as much a part of that promise as the borrow checker. Of the
memory-safety CWEs that appear in real C CVE data, null dereference
(CWE-476) is the most frequent after out-of-bounds access, and “invalid
free” (CWE-590/761) is a steady presence; WeaveC reports neither.
Item 1 is worse than a missing feature. RFC 0003 chose to read consumption
of non-root paths from the exit state so that buf_destroy(&b), which
frees b.data and nulls the field, does not make the caller’s later
if (b.data) a use-after-free. The choice is right for the caller’s own
place. It is wrong for every other name the caller has for the old value:
struct vec { int *items; size_t len, cap; };static void vec_grow(struct vec *v) { v->items = realloc(v->items, 64); }
int f(struct vec *v) { int *first = &v->items[0]; /* borrows the old buffer */ int *p = v->items; /* a copy of the old pointer */ vec_grow(v); /* the old buffer is gone */ return *first + p[0]; /* silent today: two dangling reads */}vec_grow’s summary is v->items: written; stores{v->items = fresh(free)}.
The moved that realloc performed on v->items was recorded and then
erased by the reinitialisation, and the caller never hears of it. The same
happens with free(b->data); b->data = NULL; for a caller that copied
b->data first, and with free(b->data); if (c) b->data = NULL; for the
caller’s b->data itself (the store re-initialises the place at the call
site even though on the !c path it still holds the freed pointer). A
user who finds this on their own grow() helper has reason to distrust
every other report.
Soundness
Section titled “Soundness”Bugs caught
Section titled “Bugs caught”Replaced values (item 1). For a callee that consumes the value at a caller-visible path and later stores to the path:
static void grow(struct vec *v) { v->items = realloc(v->items, 64); }static void reset(struct box *b) { free(b->data); b->data = NULL; }static void maybe(struct box *b) { free(b->data); if (cond()) b->data = NULL; }
int *p = v->items; grow(v); p[0]; // use-after-move of 'p'char *q = b->data; reset(b); q[0]; // use-after-free of 'q'maybe(b); b->data[0]; // use-after-free of 'b->data'reset(b); if (b->data) ...; // clean: the place was replacedNull dereferences (item 2). A dereference (*p, p->f, p[i], a
write through p) of a place that may be null, or passing such a place to
a callee that dereferences its parameter unconditionally:
char *p = malloc(8); p[0] = 1; // 'malloc' may return NULLchar *c = strchr(s, ':'); *c = 0; // 'strchr' may return NULLif (!n) log("no node"); return n->v; // tested, then dereferencedstruct node *n = NULL; return n->v; // is nullchar *p = malloc(n); memset(p, 0, n); // passed to a dereferencing calleeint *x; if (!get(&x)) return; ... x[0] // clean: the outcome says x is non-nullA place may be null when it was assigned a null constant, received the
result (or a callee’s store) of a function whose summary lists null among
its returns, was copied from such a place, or is on the far side of a null
test that did not end the path (if (!n) log();). Parameters, loads from
fields, and results of unknown callees are trusted non-null unless tested
or annotated WEAVEC_NULLABLE; the guarantee for those is the caller’s or
the annotation’s.
Uninitialised pointers (item 3).
char *p; if (c) p = malloc(8); free(p); // 'p' may be uninitialisedstruct s x; free(x.data); // a pointer field of a localReads of a pointer variable declared without an initialiser, or of a pointer
field of a local record declared without one, before an assignment reaches
them on every path. Taking the address (&p), passing &p or &x to any
callee, or a callee’s store through the address counts as initialisation.
Invalid releases (item 4). Releasing (free, any f entry, any
inferred or annotated consuming parameter) or moving into an owning
parameter (realloc, WEAVEC_OWNED) a value that is known not to be the
start of a heap allocation:
char buf[8]; free(buf); // storage of a localstatic char g[8]; free(g); // storage of a globalstruct s x; free(&x); free(&x.d); // address of a local or its fieldchar *s = "abc"; free(s); // a string literalchar *p = malloc(8); free(p + 1); // into the middle of an allocationchar *q = strchr(p, 'x'); free(q); // an interior copy of an owned placeBugs deliberately not caught
Section titled “Bugs deliberately not caught”- Null through unknown code. A pointer returned by an unannotated
external function, or loaded from a field nobody here assigned, is
trusted non-null. The RFC 0003 boundary warning and
WEAVEC_NULLABLEare the remedies; treating every unknown pointer as nullable would report on nearly every line of real code and was rejected (RFC 0002, RFC 0006). - Null-tolerant callees are not required non-null. A callee that tests
its parameter before every dereference (
if (!b) return;) has norequiresfact; one that dereferences it on some path before any test does. The latter is a may-fact like every other summary effect, sovoid f(struct s *s, int c) { if (c) use(s->x); }called with a possibly-nullsandc == 0is reported. This is the RFC 0001 conservatism at joins, accepted as elsewhere. - Interior releases with an unknown base.
free(&p->f)andfree(strchr(s, c))wheresis a parameter are not reported: the model cannot tell&p->first_field(valid: same address) from&p->second_field, nor an interior pointer into a parameter’s object from the object’s start (sdsfreess - header). Only a base that this function knows to be an allocation (a resource record) makes an offset from it invalid. - Bounds. Whether
p + 1is in bounds remains out of scope (RFC 0001). - Uninitialised arrays and non-pointer scalars.
char *a[4]; use(a[1])aftera[0] = ...is not reported (element writes reinitialise the summary place); integer uninitialised reads are Clang’s-Wuninitialized. - Address-taken locals.
char *p; char **pp = &p; *pp = malloc(8); use(p)is not tracked: a local whose address is taken anywhere in the body is never marked uninitialised. fclose(NULL)and friends are reported through therequiresfact on the table entry, butfree(NULL)andrealloc(NULL, n)are not: ISO C defines them.
Accepted false positives
Section titled “Accepted false positives”- Tested-then-merged.
if (p == NULL) warn(); p->xis reported even whenwarnnever returns in practice (an unannotatednoreturnor alongjmp). Declare the functionnoreturnor restructure. This is the same heuristic Clang’s static analyzer and-Wnull-dereferenceuse. Superseded by RFC 0009, which infersnever-returnsfromwarn’s body, in the same unit or across the program; the false positive remains only for a callee the checker cannot see (a library function without the attribute). - Unchecked
malloc.p = malloc(n); memset(p, 0, n)is reported. Code that considers allocation failure unrecoverable should wrap the allocator in anxmallocthat aborts; its inferred summary then returnsfreshwithoutnulland nothing downstream is reported. The corpus will show how much of real code this is; the id is an error, and-Wno-error=weavec-null-dereferenceexists. - May-consume of a may-borrow.
const char *name = argc > 1 ? strdup(argv[1]) : "default"; ... if (argc > 1) free(name);is aninvalid-release: the checker cannot correlate the two tests (RFC 0006, Deliberately not caught: integer facts).WEAVEC_UNSAFEaround the release, orstrdupon both arms. - Replaced values are may-consumed. After
grow(v)every copy of the oldv->itemsis dead even ifreallocfailed (RFC 0006 already accepts this forp = realloc(p, n)).
Assumptions
Section titled “Assumptions”Those of RFCs 0001–0007. In addition: the nullness of a place is a property
of its value, so it copies with the pointer and is dropped when the place
is reassigned; the null alternative of a shipped table entry describes
the ISO C / POSIX contract (a platform that never returns NULL from
malloc is not modelled); and a WEAVEC_NONNULL annotation is trusted
like every other annotation (RFC 0001: annotations are authoritative).
Detailed design
Section titled “Detailed design”Replaced values (amending RFC 0003)
Section titled “Replaced values (amending RFC 0003)”RFC 0003 records consumption of parameter roots (and of paths under a reassigned parameter) as it happens and reads consumption of every other path from the exit state. This RFC keeps both sources and combines them per path:
- Event consumption: the value the caller’s memory held at path
Pon entry was released or moved on some path through the callee that returns. Recorded as it happens for every caller-visible path in the flow-sensitivestate.consumed(which replaces the RFC 0003eventEffectsside table), so that outcome classes see it (RFC 0006) and so that the exit state’sconsumedis the union over the returning paths. A block that ends in anoreturncall never hands control back (RFC 0003, What a summary describes): Lua’sos_exitdoeslua_close(L); exit(status);and must not tell every caller of alua_CFunctionthatL->l_Gis gone. Likewise a null edge retracts the consumption below the pointer (RFC 0007). - Exit consumption: at some return the place
Pstill holds the consumed value (the exit state’s move records, as RFC 0003).
The effect written to the summary is the event consumption. A new flag
replaced is set when the path has event consumption but no exit
consumption: on every path that released the value, the place was
reinitialised before returning. replaced is a must-fact and joins by
conjunction (a summary joined from several bodies is replaced only if
each is).
| Callee | Effect on param 0 *.data |
|---|---|
free(b->data); |
freed(free) |
free(b->data); b->data = NULL; |
written,freed(free),replaced |
free(b->data); if (c) b->data = NULL; |
written,freed(free) |
v->items = realloc(v->items, n); |
written,moved(free),replaced |
free(b->data); memcpy(b, &t, sizeof t); |
freed(free),replaced (+ *b: written) |
Parameter roots never get replaced: free(p); p = NULL; reassigns the
callee’s private copy, not the caller’s argument. Paths under a reassigned
parameter never get it either (they do not describe the argument, RFC
0003).
Applying a summary at a call (RFC 0003 step 1, RFC 0007 deepest-first)
changes as follows. For a consumed path P resolving to caller place c:
- with
replaced: the consume targets are the aliases ofcand of its mirrors (every other name for the old value), notcitself nor its mirrors. The placecis then reinitialised (its facts and the facts below it are dropped) so a following store toPlands on a clean cell; if the summary has no store toP,cholds an unknown value. - without
replaced: as today,c, its mirrors and its aliases are marked. If the summary also stores toP, the store is applied and then the move record oncis restored: the callee may have left the freed value there (free(b->data); if (c) b->data = NULL;).
Nothing else about summary application changes. PendingOutcome records
the consume targets as before, so an outcome test retracts the aliases’
records in the replaced case exactly as it retracts the place’s today.
Recording (recordConsume, consumptionAt, finalizeSummary): every
consume of a caller-visible place records into state.consumed;
consumptionAt is the union of state.consumed and the move records with
a summary path; finalizeSummary reads the unconditional effects from the
exit state’s consumed, adds the exit state’s moves (as today) and sets
replaced on each event-consumed path that is neither a parameter root,
nor under a reassigned parameter, nor moved in the exit state.
forgetBelowNull continues to erase state.consumed below a pointer on
its null edge.
Element consumes (amending RFC 0006). RFC 0006 applies a consume from a
summary with a whole witness, which matches every element: a callee that
does free(history[len]) on one path makes the second call in a caller’s
loop a double-free of *history. RFC 0007’s reading of a written effect
as a reinitialisation used to mask this; Replaced values (which keeps the
record unless the consume is replaced) exposes it. So the effect carries a
second must-fact, element: every consume of the path went through an
element access (its witness was not whole). A caller applies an element
consume with an unknown witness, which matches only a whole record:
callee(a); use(*a) and free(*a); callee(a) still report, callee(a); callee(a) does not, and neither does callee(a); use(a[j]) (RFC 0006,
Different-witness elements). A whole consume on either side of a join
clears the flag.
Struct-by-value results. A function returning a record hands the
caller its fields, but RFC 0003’s returns describe a pointer result only,
so struct buf make(void) { struct buf b = { malloc(8), 8 }; return b; }
leaves the caller’s b.data unknown and its leak unreported. A third
summary root, result, names the returned object: stores{result.data = fresh(free)}. It is recorded at each return of a record-typed place
(from the returned place’s storage, one store per pointer field path with
a known source, RFC 0003 sourceOf), and applied by copyRecord when the
value assigned to a record place is a call: each result store becomes a
pointer assignment to the corresponding field of the destination. No
effects or returns are ever rooted at result; remapGlobals keeps it;
the text form is store result .data fresh(free).
Nullness
Section titled “Nullness”Core. A new NullTracker (weavec/Core/Nullness.h) in
AnalysisState::nulls maps places to a NullRecord{ Nullness state; SourceLocation location; NullReason reason; std::string detail; } where
Nullness ::= Null | MaybeNull | NonNullNullReason ::= AssignedNull | CalleeResult | CalleeStore | Tested | DeclaredA place with no record has unknown nullness. The join per place:
| ⊔ | absent | NonNull | MaybeNull | Null |
|---|---|---|---|---|
| absent | absent | absent | MaybeNull | MaybeNull |
| NonNull | absent | NonNull | MaybeNull | MaybeNull |
| MaybeNull | MaybeNull | MaybeNull | MaybeNull | MaybeNull |
| Null | MaybeNull | MaybeNull | MaybeNull | Null |
with Null ⊑ MaybeNull, NonNull ⊑ absent ⊑ MaybeNull: finite, monotone.
When the result is MaybeNull the record kept is the one that said Null
or MaybeNull (this side first). NonNull is a positive fact used for
outcome classes (below) and to silence declared-nullable places; it never
reports. The RFC 0007 must-null set in ResourceTracker stays for the
leak logic; a place marked null there is also Null here.
Sources of facts (weavec::Analysis):
p = NULL,p = 0, an initialiser list zeroing a field:Null(AssignedNull).p = f(...)wheref’s summaryreturnsincludenull(the origin has aNullarm):MaybeNull(CalleeResult, detail'f'). Thenullalternative is what every shipped allocator (F), every searching function (0–9interior results) and theA–Jfunctions that can fail (fgets,gets,realpath,getcwd, …) now carry. An inferred summary listsnullwhen somereturnis a null constant or a copy of a place that isNull/MaybeNullat the return (return pafterp = malloc(n)without a test).- A callee’s store whose sources include
null(*out = malloc(n)): the destination isMaybeNull(CalleeStore). q = pcopiesp’s record toq(the fact is the value’s).- A null test (
p == NULL,!p,p, through&&/||/!/__builtin_expectas RFC 0006): on the null edgeNull(Tested), on the non-null edgeNonNull(and the exact copies ofplikewise). After the merge of the two edges the place isMaybeNullwith theTestedrecord. RFC 0011 (Deriving a pointer) narrows the copy rule: aNullorMaybeNullrecord travels along an exact alias edge only to a copy whose own record still equalsp’s, because the edge may hold on some paths only;NonNulltravels along every exact edge. - Tests of a call result select outcome classes (RFC 0006); a class the
callee marks in
nonNullOn(below) sets the corresponding caller placesNonNullon that edge, the waynullOnsets them null. - A parameter, variable or field declared
WEAVEC_NULLABLE:MaybeNull(Declared) when read with no record;WEAVEC_NONNULL:NonNull. - Any reassignment drops the record (
reinit), as doeswrittenbelow a pointer (forgetBelow).
Facts are recorded only for whole-place accesses (ref.element.isWhole());
a[i] = NULL says nothing about a[*] (RFC 0006, Element witnesses).
The check. In doRead, for every pointer place dereferenced on the way
to the accessed place (ref.derefs): a Null record reports dereference of '<p>', which is null; MaybeNull (or a declared-nullable place with no
record) reports dereference of '<p>', which may be null; with the
record’s note. The record is then cleared on the path so one bad pointer
reports once (p->a; p->b;), in both dataflow phases so the fixpoint is
unaffected. Dereferences inside an unsafe region are not reported (RFC
0004). Use-after-free on the same dereference wins (it is checked first);
a raw pointer is reported as raw.
Requirements. FunctionSummary::requiresNonNull is the set of
parameter indices the callee dereferences while their nullness is unknown
on some path: recorded when a dereferenced place, or an exact alias of it,
is a parameter root with no record; and when such a place is passed to a
callee that itself requires the corresponding parameter (so size_t len( const char *s) { return strlen(s); } requires s). WEAVEC_NONNULL on a
parameter adds it; WEAVEC_NULLABLE removes it (the body is checked
instead). In the shipped table every r/w parameter and every f/m
parameter of a family other than free requires non-null, except a short
list of functions documented to accept NULL (strtok’s first argument,
time, fflush, setlocale’s second argument, mmap’s first,
getcwd’s first, realpath’s second, select’s pointer arguments,
pthread_create’s attributes, sigaction’s pointers, nanosleep’s
second, waitpid’s status, accept’s and recvfrom’s address
arguments, and the like). At a call, an argument whose place is Null /
MaybeNull passed to a required parameter reports '<p>', which may be null, is passed to '<f>', which dereferences it (or which is null).
Requirements join by union (a may-fact).
Per-outcome non-null facts. FunctionSummary::nonNullOn mirrors RFC
0007’s nullOn: per outcome class, the caller-visible paths that are
NonNull at every return of that class, plus the tested place of return *out != NULL on the class that means non-null. PendingOutcome:: nonNullOn carries them to the caller’s edge; nonNullInAll() is applied
like nullInAll(). This is what makes if (!make(&p)) return -1; p[0]
clean when make stores {fresh, null} and returns *out != NULL.
Joins by intersection (a must-fact).
Performance. One map lookup per dereference and per assignment; the
tracker joins like ResourceTracker. No new places are interned. The
corpus timings in scripts/corpus/README.md are the measurement; see
Performance under Implementation notes for what they showed.
Uninitialised pointers
Section titled “Uninitialised pointers”MoveReason gains Uninitialized. handleDecl marks a pointer variable
declared without an initialiser (not static, not extern, and not
address-taken anywhere in the body, see Deliberately not caught)
Uninitialized at the declaration; for a record-typed local without an
initialiser it interns every pointer-typed field path (through nested
records, not through arrays or pointers, bounded by MaxPlaceDepth) and
marks each. The existing machinery does the rest: a read of the place, a
dereference through it, a consume of it, or a copy from it finds the record
(findMoved) and reports; an assignment (reinit), a callee’s store, a
written effect on the place’s object, a mutable borrow of the place for a
call (init(&p), memset(&x, 0, n), a call into unknown code) clears it.
reportUseOfMoved reports the new reason as use-of-uninitialized;
doConsume does not treat it as a double free. The record never reaches a
summary (consumptionAt, finalizeSummary and recordConsume skip it):
only locals can be uninitialised.
Invalid releases
Section titled “Invalid releases”In doConsume, before the consume is recorded and unless the place is
annotated WEAVEC_BORROWED/WEAVEC_MUT (RFC 0003’s annotation-mismatch
already covers it) or the region is unsafe, the place c being released
(Freed) or moved into an owning parameter (Moved) is checked:
- Borrowed storage.
cholds a loan whose place is the storage of a variable: its root is a variable and the path from the root crosses no dereference (buf[*],x,x.d,g, but not*p,p->f,p[*]). Message:'<c>' is released but points to '<x>', which is not a heap object, note'<x>' is declared here. - String literals. A string literal is a borrow of a per-function
synthetic place with static lifetime (so returning or storing a literal
is never
lifetime-too-short). A loan on it reports'<c>' is released but points to a string literal. - Interior pointers.
ResourceRecordgainsinterior: set when a record is copied to a destination through an interior copy (q = p + 1,q = strchr(p, c), RFC 0006 Alias exactness), cleared on a fresh allocation. A consume of a place whose record isinterior, or whose argument expression is pointer arithmetic on a place with a non-interior record, reports'<c>' is released but does not point to the start of its allocation, noteallocated here.
The consume proceeds after the report (the place is dead either way), as
for mismatched-release. checkContainerFree no longer assumes the freed
place’s declaration has pointer type (the crash on free(static_array)).
--dump-analysis
Section titled “--dump-analysis”The exit state gains nulls{p@3:14 maybe-null, q nonnull}; move records
print uninitialized; resource records print interior; summaries print
replaced and element among the flags, requires{s} after returns{...}, notnull{*out}
after a class’s null{...}, and result.data as a store destination.
Summary text format
Section titled “Summary text format”SummaryFormatVersion and SidecarFormatVersion become 4. New: the flags
replaced and element in effect lines; requires N lines (parameter index);
notnull <class> <path> lines; the path root result. Everything else is
unchanged; a version-3 sidecar is rejected by its header and the object
re-analysed.
Annotation surface
Section titled “Annotation surface”Two new macros in resources/include/weavec.h (header version 0.3 → 0.4),
spelled weavec.nullable and weavec.nonnull in Annotations.h:
| Macro | Attaches to | Meaning |
|---|---|---|
WEAVEC_NULLABLE |
parameter, return type, variable, field | The pointer may be null. A parameter is checked in the body (dereferences without a test report) and imposes no requirement on callers; a return adds null to the summary’s returns; a variable or field is MaybeNull whenever loaded without a fact. |
WEAVEC_NONNULL |
parameter, return type, variable, field | The pointer is never null. A parameter is a requires fact for callers; a return has no null alternative; a variable or field is never reported. |
Both expand to nothing under compilers without annotate. Both are
authoritative (RFC 0001); neither changes ownership. WEAVEC_NULLABLE and
WEAVEC_NONNULL on the same declaration is invalid-annotation.
Diagnostics
Section titled “Diagnostics”New ids, all errors by default:
| Id | Message | Notes |
|---|---|---|
null-dereference |
dereference of '<p>', which may be null / dereference of '<p>', which is null |
'<p>' may be null: it is the result of '<f>' here / '<p>' may be null: it is set by '<f>' here / '<p>' is assigned NULL here / '<p>' may be null: it is compared with NULL here / '<p>' is declared WEAVEC_NULLABLE here / '<p>' may be null: the result of '<f>' is declared WEAVEC_NULLABLE here |
null-dereference |
'<p>', which may be null, is passed to '<f>', which dereferences it (or which is null; a null pointer is passed to '<f>', which dereferences it for a null constant argument) |
the same note for <p>, plus '<f>' is declared here (omitted for an implicitly declared builtin) |
use-of-uninitialized |
use of '<p>' before it was initialized |
'<p>' is declared here (the variable, or the record variable for a field) |
invalid-release |
'<p>' is released but points to '<x>', which is not a heap object |
'<x>' is declared here |
invalid-release |
'<p>' is released but points to a string literal |
— |
invalid-release |
'<p>' is released but does not point to the start of its allocation |
allocated here |
Triggers are the snippets under Soundness. Each gets a unit test and a lit
test under test/Analysis/rfc0008-*.c.
Changed: use-after-free / use-after-move are now also reported for a
caller’s copy of a value a callee released and replaced (Replaced values);
their wording is unchanged and the note points at the call.
Drawbacks
Section titled “Drawbacks”- A fourth state component. Nullness is joined per block like the others; the cost is one more map per state. It is the smallest component by construction (only places with a fact have an entry).
- Two new sources of reports on unchecked code. Unchecked allocations and searches are common in quick C; every one is now an error at its first dereference. This is deliberate (it is the bug class), but it will dominate the corpus delta until triaged.
- Summary vocabulary growth.
replaced,requires,notnulland theresultroot are four more things every reader of the format handles. Each is orthogonal; a reader that ignoresrequires/notnullis exactly as sound as before, one that ignoresreplacedis more conservative (it marks the place too), andresultstores are unknown to an old reader only in the direction of losing a leak report. - Event recording widens
state.consumed. Every caller-visible consume is now in the flow-sensitive map; on Lua-sized summaries this is a few dozen entries per state.
Alternatives
Section titled “Alternatives”- Do nothing on item 1. Rejected: it is a silent soundness hole in the headline guarantee, and RFC 0007 already had to special-case its shadow (the “consumption below a store is the callee’s business” comment).
- Record all consumption as it happens, no
replaced. RFC 0003 rejected this becausebuf_destroy(&b); if (b.data)becomes a use-after-free. Applying the store after the consume fixes the unconditional re-null but not the conditional one, and the caller’s place must stay marked in that case. Hence the flag. - Two flags (
freed-valuevsfreed). Same information asfreed,replaced, but every plainfree(b->data)would spell two flags and every existing summary text would change.replacedis additive. - Nullness as a fourth ownership kind. Rejected by RFC 0001 and again
here: nullness is a property of the value, orthogonal to who owns it
(
Option<Box<T>>vsBox<T>, not a third thing). - Treat every unknown pointer as nullable (Clang analyzer’s
NullDereferencewithnullabilityextensions). Sound, unusable: it reports on every parameter dereference in every function. The tested-or-declared rule is what Clang’s own-Wnull-dereferenceand the analyzer’s default checker do, and what the corpus can absorb. - A separate
--nullabilitymode. The RFC 0005-Wmachinery already lets a project disable or demote each id; a mode flag would be a second way to say the same thing. - Invalid release by ownership kind alone (
Shared/Mutablekind ⇒ invalid). Kinds join toRawacross arms (RFC 0001) and lose the information; loans, literals and interior records are exact.
Prior art
Section titled “Prior art”- Rust:
Option<T>makes nullability a type;Box::from_rawon a non-Boxpointer is UB the type system prevents; reading an uninitialised local is a compile error. We recover the first as a flow-sensitive fact, the second asinvalid-release, the third asuse-of-uninitialized. - Clang:
-Wnull-dereference,-Wuninitialized/-Wsometimes-uninitialized, the_Nullable/_Nonnulltype qualifiers and-Wnullable-to-nonnull-conversion; the static analyzer’score.NullDereference,core.uninitialized.*,unix.Malloc’s “Argument to free() is the address of a stack/global variable / offset of N bytes from the start of memory allocated by malloc”. Our tablerequiresset is the analyzer’snonnullargument knowledge; our annotations are_Nonnull/_Nullablewithout changing the type. - Cyclone made nullable and non-null pointers different types (
*vs@), with inserted checks; Checked C’s_Nt_array_ptrlikewise. Both show that a per-value nullness fact is enough for the dereference check and that inference removes most annotations. - Infer (
NULL_DEREFERENCE,Pulse) reports exactly the “tested-then-dereferenced” and “unchecked allocation” shapes and found them to be the highest-signal null reports on real code. - RFC 0007 built the must-null set and per-outcome null stores; this RFC is the may-null side it left as future work.
Unresolved questions
Section titled “Unresolved questions”Resolved at acceptance:
| Question | Decision |
|---|---|
| Parameters: nullable by default? | No: trusted unless tested or WEAVEC_NULLABLE (see Alternatives). |
malloc may return null in the table? |
Yes: every F, every interior result, and the fallible A–J entries carry null. |
| Severity of the three ids | Error, like every other guarantee-class diagnostic; -Wno-error= per id. |
replaced as a flag vs new flags |
A flag; additive, most summaries unchanged. |
| Report the release of a may-borrow? | Yes, as for every other may-fact. |
Deferred to corpus testing:
- How much of the corpus delta is unchecked
malloc, and whether anxmalloc-style summary heuristic (an allocator wrapper that aborts on null) needs anything beyond the inferredreturns{fresh}it already gets. - Whether
requiresonw/rtable entries should be narrowed for functions with a size argument that may be zero (memcpy(NULL, NULL, 0)is UB in ISO C but tolerated by every libc). - Whether per-element nullness (
a[i] == NULLwitnesses) is worth the state.
Implementation notes
Section titled “Implementation notes”Where the code refines the design above:
replacedand may-consumes.replacedis decided over the paths that consumed:v->items = realloc(v->items, n); if (!bigger) return 0;iswritten,moved(free),replacedeven though the failure path consumed nothing, because on that path the place still holds a valid (old) value. The per-outcome classes carry the same flag (outcome positive{v->items: moved(free) replaced}).overwritten.AnalysisState::overwrittenrecords the caller-visible paths whose entry value has been replaced on every path reaching here (a whole-element write, notp = p + 1); a later consume of such a path is the function’s own value, not the caller’s, and is not recorded into the summary. It joins by intersection and a proper prefix covers the paths below it only when no dereference intervenes (overwriting a struct does not overwrite what its pointers point to).- Nullness of copies. The non-null edge of a test clears the fact on
the tested place and on its exact aliases (same value), as Sources of
facts says; the
nulledge marks only the tested place. - A dereference establishes non-null. When a place with no fact is
dereferenced, or passed to a callee that requires it, the requirement is
recorded (Requirements) and the place becomes
NonNull(Dereferenced) on the continuing path: it could not have continued otherwise. This is what Clang’s analyzer and Infer assume, and with the next rule it keeps cJSON’sbuffer_at_offset(b)at the top of a function from turning acannot_access_at_index(b, 0)retest further down into a maybe-nullb. - Redundant tests. A test never weakens a
NonNullfact: on the null edge ofp != NULLfor apalready known non-null the edge is infeasible and the fact is kept (the RFC 0007 must-null marking and the RFC 0006 retraction below the pointer still happen there; on a dead edge they only suppress reports). Without this, cJSON’scan_access_at_index(b, i)macro, which retestsb != NULLon every use, madebmaybe-null after every loop.(T *)0counts as a null constant in tests and assignments alike, although ISO C reserves the term for0and(void *)0(zlib’sbuf != (charf *)0). - Unchecked callees may write what they reach. A call into unchecked
code (RFC 0003 boundary, including a callee with nullness annotations
only) drops the nullness facts, and the RFC 0007 must-null flag, at the
storage of every argument passed by address (
fill(&lc)afterlinenoiseCompletions lc = { 0, NULL }) and below every pointer argument (f(p)may write*p, notp). Ownership facts are untouched: the boundary warning is what covers an unknown callee’s frees and moves. - A store that did not happen is not a null fact. RFC 0007’s
nullOnholds, besides the places null at every return of a class, everyfreshstore destination that holds nothing at any of them (Per-outcome null stores: “or holds nothing this function stored there”), so thatif (init(s) != 0) return -1;is not a leak. Forint grow(struct buf *b, unsigned n) { char *p = realloc(b->data, n); if (!p) return -1; b->data = p; return 0; }that putsb->dataintonull{...}onnegative, where the caller’s memory holds what it held before the call. Reading everynullOnplace asNull(as Nullness says of the must-null set) madeif (grow(b, n) == -1 && n > b->len) n = b->len; b->data[n]anull-dereference(linenoise’slinenoiseEditGrow, cJSON’sensure, zlib’sgz_init). At a call, anullOnpath that is the destination of afreshstore and of nonullstore can be there only through the relaxation (PendingOutcome::unheldOnly); selecting its class retracts the caller’s record (ResourceTracker::forget, what RFC 0007 says the caller may act on) and leaves nullness alone. Every othernullOnplace is marked must-null andNullas before (*out = malloc(n); if (!*out) return -1;stores{fresh, null}). The approximation is per path, not per class: a callee that stores both null and fresh into a path and has a third return where neither happened still saysNullthere. - Dereferenced call results.
f()->xwith no place for the result is checked bycheckResultDereferencefrom the call’s summary (returnswithnull) or itsWEAVEC_NULLABLEdeclaration; the message names the callee’s result. - Nullness annotations on unchecked callees. A declaration with
WEAVEC_NULLABLE/WEAVEC_NONNULLbut no ownership annotations is stillannotation-required(RFC 0003 is unchanged), but its nullness annotations are honoured: the result isMaybeNull(Declared) and aWEAVEC_NONNULLparameter is a requirement. Nullness annotations alone do not make a declaration “annotated” forannotation-required. _FORTIFY_SOURCE.__builtin___memcpy_chkand friends resolve to the table entry of the function they wrap (memcpy), sorequiresapplies to them and their declaration note is omitted (they are implicit).- Interior releases need a record. Rule 3 (Invalid releases) fires
for a place whose
ResourceRecordisinterior, or for arithmetic on a place that has a non-interior record.strchr(s, c)on a parameter with no record is not reported: nothing is known abouts.p + 0isp. - Uninitialised records and the dump. The record is dropped with the
local at scope exit, so it does not appear in
--dump-analysis’s exit state; the diagnostic is the evidence. Locals of a function whose summary is dumped never carryuninitializedintosummary:. - Diagnostic columns. A
use-of-uninitializedfor a consume (free(p)) is reported at the call, asuse-after-freeis; a copy (q = p) at the read. - Clang’s own
-Wfree-nonheap-object. Clang reportsfree(buf)on a named array too; WeaveC’sinvalid-releasecovers the cases Clang cannot see (through a copy, a call, a struct field) and reports the direct case once as well.
Performance
Section titled “Performance”The nullness tracker itself is within noise everywhere. Lua (34 units as
one program) nevertheless went from about 12 minutes (RFC 0007) to 52 on
the same machine and build (29 after the two fixes below), and the cause is
Replaced values, not nullness: luaV_execute calls a luaM_realloc-family helper at some 130
sites, RFC 0007 read each callee’s written effect on L->stack and its
relatives as a reinitialisation and dropped the record, and this RFC keeps
it, so every one of those calls now performs a consume of the path, of its
mirrors through L->twups ~ L, and of their aliases, and the function’s
states carry a hundred move records where they carried none. Two costs
around that were removed in the same change:
- The whole-program fixpoint (RFC 0005) rebuilt its database by
renumbering every member’s summaries whenever one member’s exports
changed; members are now kept numbered by the database’s own table
(
ProgramDatabase::renumbered,GlobalNames::extendTo) and a rebuild copies them. BorrowStateheld one loan per site of a borrow: a callee’sL->ci = borrow(L->base_ci)store applied at 128 call sites, times nine holder/place pairs through the mirrors, was 1,173 loans per state inluaV_execute, each copied at every block visit and merged at every join. It now holds one loan per borrow (place, holder, kind, lifetime) at the earliest site, which is the loan a conflict would have cited anyway (checkMovereturns the first in order). The join fell from 39% of the run to under 3%.
What remains is the consume fan-out: doConsume and mirrors are
two-thirds of the run, the same place enumerated once per mirror and once
more below each target. A consume-target cache per call, or the summary
carrying “may free below” as a mark on the object the way RFC 0007’s
Performance proposes for scalar writes, would remove it; both are left
to a later change with the RFC 0007 item.
Future work
Section titled “Future work”- Dereferenced fresh results leak.
make()->vwheremakereturnsfreshdrops the allocation; the discarded-call rule (RFC 0007) covers only statement-level discards. Aleakat the dereference is the natural extension. - Per-outcome stores (RFC 0007 Future work), which would let the
replacedrule keep the place’s record only on the classes where the store did not happen. - Nullability of fields inferred from constructors (
s->optis nullable becausemake_sstoresNULLthere): anullablestore fact in the summary rather than an annotation. - Arrays of pointers: element-witnessed uninitialised and null facts.
WEAVEC_NULLABLEon function-pointer types for callbacks that may be absent.