18 KiB
18 KiB
libvec bug tracker
Pass 12 — verified 2026-10-12 — char.c @ 957 lines · char.h @ 108
| Open | S1 | S2 | S3 | C | DECIDE | Fixed |
|---|---|---|---|---|---|---|
| 42 | 8 | 15 | 7 | 12 | 0 | 63 |
S1 memory safety · S2 wrong result / broken contract · S3 edge case · C cleanup · DECIDE open design question
Line numbers are the only field that drifts. Re-verify before trusting them.
Contents
Triage — the 8 open S1s
In suggested fixing order.
| # | ID | Location | Summary |
|---|---|---|---|
| 1 | IV-5 |
char.c:40-44 | __null_check's NULL-status branch derefs the very NULL it just tested, and seven call sites pass the caller's err through here → SEGV at 42. ASan Pass 12: SEGV in __null_check |
| 2 | FB-1 |
char.c:457-462, 475-480 | front/back guard inverted: populated vectors return 0 + ERR_OUT_OF_BOUNDS; drained vectors fall through to *nullptr → SEGV at 462/480. back probes index 0 |
| 3 | IN-10 |
char.c:886-893 | amount-insert never validates: guard at 887 reads a fresh local (always 0), NULL ptr derefs at 893 → SEGV |
| 4 | ER-4 |
char.c:541-542 | range-erase zeroing memset length is the whole _internal_size, not diff, so it writes past the buffer → heap-buffer-overflow WRITE at 541 |
| 5 | RZ-10 |
char.c:626-629, 712-715 | resize-shrink memset length is the whole old _internal_size, not old - new → heap-buffer-overflow WRITE at 628/714 |
| 6 | RZ-11 |
char.c:630-634, 716-720 | resize-growth memset length is _internal_arr[size] (an element value) instead of size - _internal_size; reads one past the block when size == capacity → heap-buffer-overflow READ at 633/719 |
| 7 | RS-1 |
char.c:264-269 | truncation zeroed through the post-realloc pointer: memset(&arr[capacity], 0, size - capacity) (267) writes past the shrunk block → heap-buffer-overflow WRITE at 267 |
| 8 | DL-2 |
char.h:8-14 | no ownership marker, by-value struct copy double-frees |
Findings
Core storage
_vec_char_push_back / _vec_char_pop_back — char.c:141, 202
1 S2
| ID | Sev | Location | Finding |
|---|---|---|---|
PB-9 |
S2 | char.c:161-173 vs 242-254 vs 839-853 | three blank-materialize paths, no shared shape. push_back: malloc(1), capacity=1, size=0, writes arr[0]. reserve: calloc(capacity) and commits capacity. insert one-arg: malloc(1), capacity=1, size=1, writes arr[0]. Same input state, three allocations, three struct results; insert and resize disagree with push_back on which blank states are accepted |
_vec_char_cpy — char.c:103 — Clear
_vec_char_reserve — char.c:221
1 S1
| ID | Sev | Location | Finding |
|---|---|---|---|
RS-1 |
S1 | char.c:264-269 | ptr->_internal_arr = res (264) runs before the truncation, so the discarded tail is zeroed through the NEW pointer — but realloc already shrunk the block to capacity bytes, and memset(&ptr->_internal_arr[capacity], 0, ptr->_internal_size - capacity) (267-268) writes past it. ASan Pass 12: heap-buffer-overflow WRITE at 267 — push 4, reserve(&v, 2). The tail to zero no longer exists after a shrinking realloc: the memset must run on the old buffer before realloc, or be dropped — spare slots are uninitialized by contract |
_vec_char_shrink_to_fit — char.c:277 — Clear
_vec_char_swap — char.c:295 — Clear
_vec_char_delete — char.c:364
1 S1
| ID | Sev | Location | Finding |
|---|---|---|---|
DL-2 |
S1 | char.h:8-14 | no ownership marker on the struct, so a by-value copy yields two owners of one buffer; each delete clears only its own struct's pointer, so the second free hits the first's freed block. ASan Pass 12: attempting double-free at char.c:376 — w = v; delete(&v); delete(&w) |
_vec_char_clear / _vec_char_print_vec — char.c:383, 89 — Clear
Validation & accessors
__null_check / __origin_override — char.c:37, 69
1 S1
| ID | Sev | Location | Finding |
|---|---|---|---|
IV-5 |
S1 | char.c:40-44 | the status == nullptr branch exists to catch a NULL err_t* but dereferences status on the next line (status->flags = ..., 42; status->origin = ..., 43). Seven call sites hand __null_check the caller's err verbatim (print_vec 92, pop_back 205, begin 425, end 438, front 451, back 469, empty 487), so any of those with a NULL err is a live null-deref. ASan Pass 12: SEGV in __null_check at char.c:42. Either return before the deref or drop the branch |
_vec_char_capacity / _vec_char_max_size / _vec_char_data / _vec_char_size — char.c:338, 351, 312, 325
1 C
| ID | Sev | Location | Finding |
|---|---|---|---|
AC-3 |
C | char.c:326-335 | new size accessor records its failure origin as "CAPACITY" (331), copied from _vec_char_capacity. It also takes a non-const _VecCharInternal* (326) while only reading, unlike its const siblings |
_vec_char_at — char.c:400
1 S3 · 1 C
| ID | Sev | Location | Finding |
|---|---|---|---|
AT-2 |
S3 | char.c:410-417 | the OOB path sets err (flags + origin AT) and returns nullptr, but still prints a raw line to stdout via printf (412-413), polluting program output and bypassing the error channel. The residual hazard moved to the callers: front/back dereference the nullptr (462, 480) → SEGV (FB-1) |
AT-3 |
C | char.c:400, 418 | at() returns a mutable char* into the buffer of a const _VecCharInternal* — const-laundering callers can write elements behind the API's back, bypassing push_back/resize bookkeeping; the pointer also invalidates on any realloc |
_vec_char_begin / _vec_char_end — char.c:421, 434
1 S2
| ID | Sev | Location | Finding |
|---|---|---|---|
BE-1 |
S2 | char.c:421-432, 434-445, 749-750, 799-800 | begin returns raw status.flags (bitmask 2/4) as an index on failure (429), and end's (size_t)(size - 1) (444) is reachable for a drained vector — size == 0 wraps to SIZE_MAX (harness Pass 12: end(drained) = 18446744073709551615, flags=0). Flags-as-index and SIZE_MAX-as-end are indistinguishable from real positions; SIZE_MAX + 1 wraps to 0 at 749-750, 799-800, so assign into a drained destination erases [0, 0) and fails with ERR_OUT_OF_BOUNDS (AS-1) |
_vec_char_front / _vec_char_back / _vec_char_empty — char.c:447, 465, 483
1 S1 · 1 S2
| ID | Sev | Location | Finding |
|---|---|---|---|
FB-1 |
S1 | char.c:457-462, 475-480 | the guard meant to kill the drained SEGV is inverted and probes the wrong index. at() returns nullptr on failure, so if (_vec_char_at(...) != nullptr) fires for valid vectors: front/back on a populated vector return 0 with ERR_OUT_OF_BOUNDS (harness Pass 12: front('ab')=0/flags=16, back=0/flags=16 — expected 97/98). For a drained vector at returns nullptr, the condition is false, control falls through to the deref — front → *_vec_char_at(ptr, 0, err) (462), back → *_vec_char_at(ptr, size - 1, err) (480) — SEGV (ASan Pass 12: SEGV at 462 and 480). back also probes index 0 (475) instead of size - 1. Condition must be == nullptr, and back must probe the last index |
AC-2 |
S2 | char.c:483-498 | empty() still returns 0 in both directions: a drained vector takes the size == 0 branch and return SUCCESS (493-496), a populated one falls through to return status.flags, also 0 (497) — so if (empty(&v)) is never true for a valid vector. Harness Pass 12: drained → 0/err 0, populated → 0/err 0 |
Algorithms
_vec_char_erase (both overloads) — char.c:501, 520
1 S1 · 1 S2 · 1 S3 · 1 C
| ID | Sev | Location | Finding |
|---|---|---|---|
ER-4 |
S1 | char.c:541-542 | the trailing-zero memset starts at _internal_size - diff but its length is sizeof(char) * ptr->_internal_size (the whole old size, though _internal_size is not decremented until 543), so it writes [size - diff, 2*size - diff) — past the block whenever more than diff elements remain. Should be diff bytes. ASan Pass 12: heap-buffer-overflow WRITE at 541 — 10 elements in a 13-byte block, erase(&v, 0, 1) |
ER-1 |
S2 | char.c:532-537 | erase on an empty vector never no-ops: a drained vector passes the __null_check guard now, then the range check (size < iter_end || iter_end <= iter_start) fires — [0, 1) because 0 < 1, and even [0, 0) because 0 <= 0. Harness Pass 12: erase(v, 0, 1) on drained → err=16, origin ERASE |
ER-3 |
S3 | char.c:511 | the single-arg overload silently returns on iter >= size with err untouched (harness Pass 12: erase index 99 → flags=0), while the range overload reports ERR_OUT_OF_BOUNDS for the same condition. A typo'd index is invisible to an err-driven caller |
ER-2 |
C | char.c:500, 519 | two stale // TODO: Fix return value comments remain (the second doubled as // // TODO) |
_vec_char_resize (both overloads) — char.c:547, 639
2 S1 · 2 S2 · 2 S3 · 3 C
| ID | Sev | Location | Finding |
|---|---|---|---|
RZ-10 |
S1 | char.c:626-629, 712-715 | shrink zeroing memset(&arr[size], 0, ptr->_internal_size) uses the whole old _internal_size as the length instead of old - new, so it writes past the block whenever 0 < size < old. Should be _internal_size - size bytes. ASan Pass 12: heap-buffer-overflow WRITE at 628 — 5 elements in a 6-byte block, resize(&v, 2) (also 714 for the 3-arg overload) |
RZ-11 |
S1 | char.c:630-634, 716-720 | growth fill memset(&arr[_internal_size], 0, ptr->_internal_arr[size]) passes _internal_arr[size] — an element value (or 0xbe uninitialized) — as the byte count instead of size - _internal_size, and when size == _internal_capacity that read is one past the block. ASan Pass 12: heap-buffer-overflow READ at 633 — 5 elements in a 6-byte block, resize(&v, 6) (also 719 for the 3-arg overload) |
RZ-3 |
S2 | char.c:573-596, 665-688 | the max_size save/restore (575/594, 667/686) is pure belt-and-braces now that reserve leaves max_size alone, and looks removable. Nothing documents that |
RZ-1 |
S2 | char.c:559-564, 651-656 | size == _internal_size returns ERR_OUT_OF_BOUNDS for a legitimate no-op; the code is also semantically wrong |
RZ-4 |
S3 | char.c:594-596 vs 686-688 | the 3-arg blank branch fills with val, the 2-arg with 0. data() exposes the difference |
RZ-2 |
S3 | char.c:605, 695 | unguarded tmpcp *= 2 wraps on overflow. Defensive |
RZ-5 |
C | char.c:584-589, 607-611, 618-623, 676-681, 699-703 | dead post-reserve capacity checks in the blank branches (584, 676) and the 2-arg growth branch (618); the 3-arg growth branch has no check at all, relying on reserve to be exact |
RZ-6 |
C | char.c:547-637 vs 639-723 | two ~90-line near-identical bodies, with the growth blocks copy-pasted between them |
RZ-7 |
C | char.c:590-593, 682-685 | the blank-branch fill loops should be memset |
_vec_char_assign (both overloads) — char.c:725, 783
4 S2 · 1 S3 · 1 C
| ID | Sev | Location | Finding |
|---|---|---|---|
AS-1 |
S2 | char.c:730-735, 749-750, 799-800 | fails on any empty destination. A blank destination trips the __null_check guard (730-735) → err=4, origin ASSIGN. A drained one passes the guard, then end() returns SIZE_MAX (BE-1) and end + 1 wraps to 0 (749, 799), so erase(0, 0) fails the range check → err=16. Two paths, same refusal |
AS-2 |
S2 | char.c:749-778, 799-823 | not atomic: erase → reserve → push_back loop. Reserve failure leaves the destination wiped; a push_back failure mid-loop leaves it partially filled. Still no rollback |
AS-3 |
S2 | char.c:757, 807 | no > _internal_max_size guard on the assign count |
AS-7 |
S2 | char.c:764-770 | the at() guard in assign1 is inverted: at() returns nullptr on failure, so the != nullptr branch fires for valid iterators — assign1 fails with ERR_OUT_OF_BOUNDS on the first element of every non-empty source, after erase already wiped the destination (harness Pass 12: dest {x,y} ← src {a,b} → flags=16, size=0). Condition must be == nullptr |
AS-9 |
S3 | char.c:788-792 | the null guard in the amount overload has no return, unlike assign1 — control falls through with err already flagged. Masked today only because the erase at 799 rejects the same state downstream |
AS-6 |
C | char.c:725-781 vs 783-825 | near-duplicate overloads differing only in the value source |
_vec_char_insert (all three overloads) — char.c:827, 882, 918
1 S1 · 2 S2 · 2 S3 · 2 C
| ID | Sev | Location | Finding |
|---|---|---|---|
IN-10 |
S1 | char.c:886-893 | the amount overload never calls __null_check: the guard at 887-892 tests a freshly-initialized local status, which is always 0, so it can never fire; ptr is then dereferenced raw at 893. Every other entry point validates first. ASan Pass 12: SEGV at char.c:893 — _vec_char_insert(nullptr, 0, 2, 'x', &e) |
IN-1 |
S2 | char.c:932-938 | inverted range check: iter_end > iter_start rejects every non-empty range with ERR_OUT_OF_BOUNDS and accepts only empty ranges, which then silently do nothing (loop at 945 never runs). Valid insert-range never inserts. Harness Pass 12: dest {a,b}, src {Z}, insert at 1 range [0,1) → err=16, size stays 2 |
IN-4 |
S2 | char.c:854-859, 884-893 | no max_size pre-check in the amount overload: the loop pushes until one-arg insert trips size+1 > max_size (854) and then returns, but everything already inserted stays — an oversized amount half-inserts and leaves the vector half-modified |
IN-5 |
S3 | char.c:899-904 | amount <= 0 on a size_t is always just == 0 |
IN-6 |
S3 | char.c:905 | iter + amount can overflow size_t, wrapping the loop bound and exiting early — a silent partial insert |
IN-8 |
C | char.c:945-955 | the loop reads iterator->_internal_arr[i] raw (948) instead of a bounds-checked accessor. Unreachable today: IN-1's inversion rejects every non-empty range, so the body never runs. The iter_end > iterator->_internal_size clause at 932 does bound the read once IN-1 is fixed, so this stays a style finding |
IN-9 |
C | char.c:905-914 | O(amount²) loop of single inserts where one grow + memmove would do |
Support
utils.h / utils.c
3 S2 · 1 C
| ID | Sev | Location | Finding |
|---|---|---|---|
UT-1 |
S2 | utils.h:9 | SIZE_MAX / sizeof(int) is what the char vector uses as _internal_max_size, capping it at 1/4 of the usable address space |
UT-6 |
S2 | utils.c:14-16 | vec_get_error_desc's default case is an expression statement — "Unknown"; with no return — so an out-of-range code falls off the end of a non-void function: UB, and the caller printf's a garbage char*. Current call sites only pass constants, so it is one cast away |
UT-7 |
S2 | utils.c:22-35 | vec_print_errs prints err.origin only on the SUCCESS branch (24), where it is always NULL on an untouched err_t — %s of NULL, UB — and the failure branch (27-35) never prints origin at all. The root-cause feature surfaces exactly backwards |
UT-4 |
C | utils.h:7-9, 63-65 | static const in a header gives every TU its own copy; ERR_SIZE_ZERO message mentions < 0 on a size_t; LOG/DEBUG are hardwired to stdout and single-arg; find_len is UB for pointers; CAPACITY (7) and VEC_FAULT_BITS (8) are now referenced by nothing in the tree |
char.h — Clear
Project
2 C
| ID | Sev | Location | Finding |
|---|---|---|---|
PR-8 |
C | char.c:72, 112 (and every guard) | no function checks err for NULL before the first error path derefs it; a NULL err_t* segfaults on the first failure. Now documented (char.c:27-30) — a cheap defensive check would still harden the API. IV-5 is the same hazard at the one site that tries to check |
PR-2 |
C | char.c:179, 500, 519 | mixed brace style (single-line if at 179 vs expanded blocks) and stale TODO comments at 500/519; .clang-format exists and is not run |
Fixed
63 fixed IDs — no rows
PB-1 PB-2 PB-3 PB-4 PB-6 PB-7 PB-8 PB-14 PB-15 PB-16 PB-17 PB-18 PB-20 PB-21
RS-2 RS-3 RS-4 RS-6 RS-7 RS-8
IV-1 IV-3 IV-4
CH-1 CH-3 CH-7
CR-3 CR-4 CR-6 CR-7 CR-8 CR-10 CR-11
SW-1 SW-2
FB-2
AC-1 DA-1
AT-1
DL-1 DL-3
UT-2 UT-3 UT-5
PR-1 PR-3 PR-4
CL-1 CL-2
RZ-8 RZ-9
AS-4 AS-8
ST-1 ST-2
IN-2 IN-11 IN-12
FX-2 FX-3 FX-4 FX-5
BE-2
Closed by owner decision (not counted fixed): CH-9 (leading _ intended private), PR-7 (main not the test harness), CH-4 (header includes), CH-5 (doc comments), CH-8 (struct naming), PR-5 (placeholder comments), CH-2 (const on by-value params for LSP/clarity).
ID map
| Prefix | Scope | Prefix | Scope |
|---|---|---|---|
PB- |
push_back, pop_back | AT- |
at |
RS- |
reserve | FB- |
front, back |
ST- |
shrink_to_fit | BE- |
begin, end |
CR- |
create → cpy | SW- |
swap |
AC- |
accessors | CL- |
clear, print_vec |
DA- |
data | IV- |
is_valid → __null_check |
DL- |
delete | UT- |
utils.h, utils.c |
ER- |
erase | CH- |
char.h |
RZ- |
resize | PR- |
project |
AS- |
assign | FX- |
retired first-pass IDs |
IN- |
insert |