Files
libvec/BUGS.md
T

28 KiB
Raw Blame History

libvec bug tracker

Verified char.c @ 2026-09-28 12:48:04, 3 independent passes. char.h 108 lines, utils.h 31, utils.c 12, CMakeLists.txt 32, main.c 55. Line numbers are the only field that drifts. Re-verify before trusting them.

Pass 4 @ 2026-09-28, push_back / pop_back (char.c:67-157), ASan. Pass 5 @ 2026-09-28, whole file re-read plus 10 harness runs, ASan. Line count moved 804 to 800 with the create rewrite; re-measured per pass.

Open 79 — 11 S1, 27 S2, 11 S3, 29 C 1 DECIDE · Fixed 12 (listed at the bottom, no rows) · 79 rows

Severity S1 memory safety · S2 wrong result / broken contract · S3 edge case · C cleanup · DECIDE open design question

How to read this

Each function is a ### with one table. Rows are sorted by severity (S1 first), so the worst thing in each function is on top. Fixed findings are not rows — they are a flat ID list at the bottom of the file.

Tracking state is a column, not a checkbox: a State cell carries more than "done or not done" — DECIDE has no checkbox equivalent, and it is load-bearing here. And task-list syntax inside a table cell is renderer-dependent, so a State column is the form that survives any markdown preview. If your preview does render checkboxes in tables and you want click-to-toggle, a leading Done column can be added without disturbing anything else.

Rules

  1. IDs are permanent. Never reused, never renumbered. An ID leaves the file only when its row is deleted outright, and only under rule 3.
  2. Locations drift freely. The only field updated in place.
  3. False findings are deleted, not archived. If a row turns out to be my own error, the row is removed. A new ID carries the accurate finding if there is one. No DISPUTED state, no corrections log — a tracker is not a diary.
  4. State is OPEN / DECIDE. FIXED means the row is gone and its ID is in the bottom list.
  5. Fixed findings live in the ID list at the bottom, nothing else.

Triage — the 11 open S1s, in suggested order

# ID Location Summary
1 RS-7 char.c:175, 200 reserve on a blank vector allocates 1024 but claims 5000. Ordinary size, live overflow
3 PB-8 char.c:89, 131 live heap overflow, 2-line repro
4 PB-14 char.c:131, 154 write through the freed buffer after delete, 2-line repro
5 RS-1 char.c:180-195 use-after-free in reserve's truncation loop, ASan confirmed
6 IN-1 char.c:783 inverted iter_end > iter_start
7 IN-2 char.c:783 dead bounds check sitting behind IN-1
8 DL-1 char.c:293, 297 const delete, double free on second call
9 DL-2 char.h:11-17 no ownership marker, struct copy double-frees
8 UT-2 utils.c:3-12 error_messages[0] / [1] are NULL
9 PB-3 char.c:106, 116 capacity committed before the realloc check
10 PB-15 char.c:87, 128-131 null-buffer path has no else, so arr == NULL && size > 0 writes to NULL. Defensive

Two of the eleven are the same class. RS-7 and PB-8 both end in a write past a small block, and both come from a struct field claiming more bytes than were allocated: capacity set from the request rather than the block (RS-7, char.c:200), calloc(1) with capacity untouched (PB-8, char.c:89). One rule kills both: never write _internal_capacity without writing the allocation it describes. Verify with a single postcondition — after any function that touches capacity, the block size and the field must agree. CR-8 was a third member of this class; it is fixed as of Pass 5, and create now carries the same postcondition in one place (CR-9, char.c:44).

Fix IN-1 and IN-2 in the same commit. The raw read in the 3-arg overload is unreachable only because IN-1's inversion empties the loop. Fixing IN-1 alone converts a clean rejection into a live out-of-bounds read, because IN-2's bound check stays dead behind the short-circuit.

PB-8 and PB-14 are the only two rows in this file with a sanitizer trace attached. Both reproduce in three lines from main. delete should null the struct's fields (DL-1) and push should size the materialization from _internal_capacity (PB-8); those two fixes close the whole S1 set for this function pair.

Reproducer harness: clang -std=c2x -g -fsanitize=address -Isrc/lib -Isrc/lib/char t.c src/lib/char/char.c src/lib/utils/utils.c. -std=c2x is required — char.c uses nullptr, so the project does not compile as C17. Reproducers that ask for a multi-exabyte calloc additionally need ASAN_OPTIONS=allocator_may_return_null=1, or ASan aborts on the 2^61-byte calloc before the interesting line runs.

Defensive-only rows. RZ-2, RS-2, PB-15 and PB-18 are not reachable without an allocation of ~2^61 bytes, or a hand-built struct. Kept as hardening because the guards are cheap and the invariant is worth stating.


Bugs

_vec_char_push_back / _vec_char_pop_back — char.c:71, 136

ID Sev Location Finding State
PB-8 S1 char.c:89, 131 calloc(1) on the null-buffer path leaves _internal_capacity at 1024, so the 2nd push_back writes arr[1] one byte past a 1-byte block. ASan: heap-buffer-overflow WRITE of size 1 at char.c:131, block allocated at char.c:89 OPEN
PB-14 S1 char.c:75, 131, 154 neither function checks liveness. _vec_char_delete frees without clearing the struct (DL-1), so delete(&v); push_back(&v,'z') writes through the freed pointer. ASan: heap-use-after-free WRITE of size 1 at char.c:131, freed at char.c:297. Same for pop_back, which writes '\0' at char.c:154 OPEN
PB-15 S1 char.c:87, 128-131 the materialize branch is guarded by status == ERR_BUF_NULL && _internal_size == 0 and has no else. Any state with arr == NULL && _internal_size > 0 falls through to char.c:126, takes res = ptr->_internal_arr (NULL) and writes arr[size]. No in-file producer, but nothing checks the invariant either OPEN
PB-3 S1 char.c:106, 116 _internal_capacity assigned before the !res check; on OOM the struct overstates its capacity and the next call writes past the real block. Both branches OPEN
PB-9 S2 char.c:85-99 push_back materializes a null vector, making it a second path alongside reserve; the two disagree on allocation size (1 byte vs 1024) OPEN
PB-16 S2 char.c:116, 131 vs 154 the growth realloc leaves the new tail uninitialized and push_back never writes a terminator at _internal_size, so _vec_char_data hands back a non-NUL-terminated buffer — strlen(data(&v)) runs off the end. ASan: heap-buffer-overflow READ of 2049 bytes past a 2048-byte region grown at char.c:116. The lone '\0' write in either function is pop_back's vacated slot, so termination is accidental OPEN
PB-21 S2 char.c:81, 104 after any reserve the vector is permanently full: reserve sets max_size = capacity, so char.c:81 fails for good and push_back can only return ERR_MAX_PASSED. Duplicate view of AS-4, kept so this table stands alone OPEN
PB-17 S3 char.c:144-147 vs 87 pop_back refuses to materialize while push_back does. A blank vector reports ERR_BUF_NULL (21) and logs a spurious "Buffer :- NULL" instead of ERR_SIZE_ZERO (22), so a while (pop_back(&v) == SUCCESS) drain reports the wrong reason OPEN
PB-18 S3 char.c:103, 116, 120-124 _internal_capacity == 0 makes dst_cap 0, and realloc(arr, 0) frees the block. The !res check then returns ERR_ALLOC_FAIL with _internal_arr dangling and capacity already committed as 0; the next push writes to freed memory. Capacity 0 is unreachable from this file today, so defensive OPEN
PB-20 S3 char.c:154 only the vacated slot is zeroed, so bytes past _internal_size keep their values and are readable out of band through data(). clear zeroes the whole range and erase zeroes the tail; pop_back is the inconsistent one OPEN

PB-1 and PB-2 were originally described as firing "on the very next call" and as "the clamp silently does nothing". Both claims were wrong in detail: PB-1 needed capacity < size+1 <= max_size < 2*capacity, a band today's VEC_MAX never reaches, and PB-2's cause was cap being recomputed from the already-clobbered capacity, not the branch being skipped.

Pass-4 note. Four pre-existing rows here were my own errors and were deleted: a "dead else" that is the common path, two is_valid double-call claims in a function that hoists, and an overflow that VEC_MAX makes impossible. Found by diffing the wording against the source, not by testing. The three new S1/S2 rows were tested; the reproducer command is above.

_vec_char_create — char.c:23

Pass-5 note. create's growth block was rewritten this pass and the rewrite is correct. The invariant it now enforces in one place is size <= _internal_capacity <= _internal_max_size on return, held by the commit-back at char.c:44. Verified under ASan at size 0, 1, 1023, 1024, 1025, 2047, 2048, 2049, 2999, 5000 and VEC_MAX - 1: capacity >= size on every call, the calloc block matches the reported capacity, no overflow. One case to know about: create(NULL, VEC_MAX - 1) still returns capacity=4611686018427387902 over arr == NULL, because growth is correct and the calloc then fails. That is CR-3 below, and it is unchanged. dst_cap * 2 at char.c:42 cannot wrap, because the loop only doubles while the product stays under _internal_max_size. An intermediate revision of this fix omitted the commit-back and overflowed every size > CAPACITY; it never reached a build, so no ID was spent on it. Two create findings left the table this pass and their IDs are in the bottom list: CR-7, the second growth branch, which tested the ceiling (capacity*2 > max_size) instead of the loop's own postcondition — a superset that also fired after a successful grow, overwriting capacity with size and forcing the next push_back to reallocate for one byte; its > test also disagreed with the loop's >= exit on exactly one value, reachable only because VEC_MAX is odd. CR-8, the >= guard at char.c:32, which left size == VEC_MAX allocating 1024 bytes and copying VEC_MAX bytes into it. Both are gone because the branch they described no longer exists, not because either was re-filed under a new ID.

ID Sev Location Finding State
CR-6 S2 char.c:29, 39-45 null vector's _internal_capacity is set to CAPACITY (1024) while nothing is allocated. Currently masked by AC-1: capacity() returns ERR_BUF_NULL first, so the lie is not observable through the public API. The observable form of this same lie is PB-8, after push_back materializes the buffer OPEN
CR-3 S3 char.c:48-53 on calloc failure returns a struct with an already-grown capacity and arr == NULL. Defensive. Now reachable with an ordinary size, not just a huge one: create(NULL, 5000) grows capacity to 8192 at char.c:44, then the 8192-byte calloc fails and the struct survives claiming 8192 over a null buffer OPEN
CR-4 DECIDE char.c:58-65 no NUL terminator; is a const char* src, size_t size pair a byte range or a C string? Affects whether a from_cstr helper is needed DECIDE

_vec_char_is_valid — char.c:6

ID Sev Location Finding State
IV-1 S2 char.c:6 always returns non-zero, so if (_vec_char_is_valid(&v)) is true for every input including NULL. Every call site compares codes by luck OPEN
IV-3 S2 char.c:6-17 is_valid is the only validation primitive in the file and it requires a non-null buffer, not just a non-null vector. Every accessor that only needs to validate the vector pointer therefore inherits ERR_BUF_NULL. This is the root cause of AC-1, AC-2, SW-1 and ST-2 OPEN

_vec_char_reserve — char.c:156

ID Sev Location Finding State
RS-7 S1 char.c:175, 200 the ERR_BUF_NULL path allocates ptr->_internal_capacity bytes, not the requested capacity, then line 200 sets _internal_capacity = capacity regardless. So reserve(&v, 5000) on a blank vector callocs 1024 bytes and the struct claims 5000. ASan: heap-buffer-overflow WRITE at char.c:131, 0 bytes after a 1024-byte region allocated at char.c:175, reached by 1026 push_backs. The SUCCESS path at char.c:184 sizes the realloc correctly; only the null-buffer path is wrong. Second producer of PB-8, and unlike CR-7 this one needs no huge size — 5000 is ordinary OPEN
RS-1 S1 char.c:180, 186-193, 195 realloc result held in res, but the truncation loop writes ptr->_internal_arr, which is still the pre-realloc pointer. Use-after-free when a shrinking reserve moves the block. Introduced by the FX-3 fix. ASan confirmed: create("abcdefgh",8) then reserve(3) gives heap-use-after-free WRITE at char.c:194 into a block freed at char.c:184 OPEN
RS-2 S3 char.c:160-164 capacity == 0 checked before the NULL-vector check, so a NULL vector with capacity == 0 reports ERR_SIZE_ZERO instead of ERR_VECTOR_NULL. Defensive OPEN
RS-6 C char.c:158, 178, 195 clang -Wsometimes-uninitialized: res used uninitialized at 195 if the if/else if chain falls through. Not reachable today since is_valid returns exactly 3 values, but real fragility OPEN
RS-3 C char.c:197 max_size = capacity is correct under the hard-cap semantics but is documented nowhere. This is also the cause of ST-1 and AS-4 OPEN
RS-4 C char.c:188-191 element-at-a-time loop, should be memset OPEN

_vec_char_shrink_to_fit — char.c:202

ID Sev Location Finding State
ST-1 S2 char.c:202, 217 -> 197 calls reserve(ptr, _internal_size), and reserve sets max_size = capacity. So max_size is permanently capped to the current size and the vector can never grow again — every later push_back trips size+1 > max_size and returns ERR_MAX_PASSED. Silent brick. No save/restore, unlike resize (RZ-3) OPEN
ST-2 S3 char.c:204-211 returns ERR_SIZE_ZERO on an empty vector; shrinking an empty vector should be a successful no-op. Two unhoisted is_valid calls at 204 and 208 OPEN

_vec_char_swap — char.c:221

ID Sev Location Finding State
SW-1 S2 char.c:229-233 refuses NULL-buffer operands, so a blank vector can never be swapped against a populated one. Blocks the create(nullptr, 0) workflow outright OPEN
SW-2 C char.c:223 temp built via _vec_char_create(nullptr, 0); a plain initializer states the intent OPEN

_vec_char_delete — char.c:289

ID Sev Location Finding State
DL-1 S1 char.c:289, 293 takes const * so it cannot reset the vector; a second call is a double free. Only function still taking const in a mutable API OPEN
DL-2 S1 char.h:11-17 no ownership marker on the struct, so a by-value copy yields two owners of one buffer and delete cannot detect it OPEN
DL-3 C char.c:293 unnecessary (void*) cast; const on the struct does not propagate to the pointee OPEN

_vec_char_capacity / _vec_char_max_size / _vec_char_data — char.c:261, 275, 252

ID Sev Location Finding State
AC-1 S2 char.c:254, 267, 281 all three return ERR_BUF_NULL for a null vector, though none of them needs a buffer. capacity() should report capacity, not an error OPEN
DA-1 S2 char.c:251, 257 char* returned from a const _VecCharInternal*, laundering the const-correctness the mutable refactor was meant to establish OPEN

_vec_char_clear / _vec_char_print_vec — char.c:298, 341

ID Sev Location Finding State
CL-1 C char.c:310-313 element-at-a-time zeroing loop, should be memset OPEN

_vec_char_at — char.c:320

ID Sev Location Finding State
AT-1 S2 char.c:323, 328, 335 returns error codes (23/25, and 21) as a char; assign at char.c:642 pushes such a value into the vector as data OPEN
AT-2 C char.c:332 raw fprintf bypasses LOGERROR OPEN

_vec_char_begin / _vec_char_end — char.c:352, 359

ID Sev Location Finding State
BE-1 S2 char.c:355, 365, 367, 630, 668 FAILURE (== 1) returned as an index, indistinguishable from index 1. Single root cause of the + 1 at char.c:630 and char.c:668, of the assign-on-empty failures AS-1, and of the erase-on-empty failure ER-1 OPEN
BE-2 C char.c:364 (int) cast on a size_t return OPEN

_vec_char_front / _vec_char_back / _vec_char_empty — char.c:371, 379, 387

ID Sev Location Finding State
AC-2 S2 char.c:392, 393 inverted: SUCCESS (0) when empty, FAILURE (1) when not, so if (empty(v)) is false for an empty vector OPEN
FB-1 S2 char.c:373, 374, 381, 382 returns FAILURE (1) as a char; 1 is valid data OPEN
FB-2 C char.c:373, 381 validates only ERR_VECTOR_NULL, unlike every other accessor OPEN

_vec_char_erase (both overloads) — char.c:398, 412

ID Sev Location Finding State
ER-1 S2 char.c:417, 418 iter_start >= iter_end returns ERR_OUT_OF_BOUNDS, so erase(begin(), end()) on an empty vector always errors. This is what breaks both assign overloads. On an empty vector the actual trip is char.c:417 (iter_end > size) because end() returned 1 and the caller added 1 — see BE-1 OPEN
ER-2 C char.c:396, 410, 422-426 zeroing loop should be memset; two stale // TODO: Fix return value comments OPEN

_vec_char_resize (both overloads) — char.c:432, 518

ID Sev Location Finding State
RZ-8 S2 char.c:434-444, 520-530 both overloads reject a null-buffer vector with ERR_BUF_NULL before the _internal_size == 0 branch that would have materialized one, so resize cannot be the first call on a blank vector. push_back can. ASan: create(NULL,0) then resize(0,4) returns 21 and the vector is still empty, where push_back four times would have worked. Same shape as PB-17, opposite direction OPEN
RZ-3 S2 char.c:456, 471, 478, 497, 540, 557, 569, 579 the max_size save/restore is load-bearing (it undoes reserve's cap) but looks removable. Nothing documents that OPEN
RZ-1 S2 char.c:442-446, 528-532 size == _internal_size returns ERR_OUT_OF_BOUNDS for a legitimate no-op; the code is also semantically wrong OPEN
RZ-4 S3 char.c:501-504 vs 583-586 3-arg fills the discarded tail with val, 2-arg with '\0'. data() exposes the difference OPEN
RZ-2 S3 char.c:481, 567 unguarded tmpcp *= 2 wraps on overflow. Defensive OPEN
RZ-5 C char.c:462, 492, 548 dead post-reserve capacity checks; reserve always assigns capacity to its argument exactly OPEN
RZ-6 C char.c:432-515 vs 518-597 two ~84-line near-identical bodies, with the growth blocks copy-pasted between them OPEN
RZ-7 C char.c:444, 467, 501, 508, 530, 553, 583, 590 fill loops should be memset; misleading LOG("ERR :- ...") on what is now a no-op OPEN

_vec_char_assign (both overloads) — char.c:600, 652

ID Sev Location Finding State
AS-1 S2 char.c:630, 668 fails on an empty destination, via ER-1. Fatal now because status is propagated; the immutable version discarded it and continued OPEN
AS-2 S2 char.c:624-647, 661-687 not atomic: erase → reserve → push_back loop. Reserve failure leaves the destination wiped; a push_back failure mid-loop leaves it partially filled OPEN
AS-3 S2 char.c:624, 662, 679 no > _internal_max_size guard on the assign count OPEN
AS-4 S2 char.c:635, 673 the internal reserve caps max_size at the assign count with no save/restore, which resize does have OPEN
AS-6 C char.c:600-649 vs 652-688 near-duplicate overloads differing only in the value source OPEN
AS-7 S3 char.c:642 no check that at() succeeded before pushing its result. Currently correct only because the guards at char.c:611-618 and char.c:624 happen to make every index in range and the buffer non-null. Removing or loosening either guard turns this into AT-1's data-corruption case OPEN

_vec_char_insert (all three overloads) — char.c:691, 726, 759

ID Sev Location Finding State
IN-1 S1 char.c:779 inverted check: iter_end > iter_start should be iter_end <= iter_start, so every valid non-empty range is rejected and only an empty range is accepted OPEN
IN-2 S1 char.c:779 that clause short-circuits first, so the iter_end > iterator->_internal_size bound never fires OPEN
IN-4 S2 char.c:742, 747-754 no max_size pre-check, so an oversized amount partially inserts and then fails, leaving the vector half-modified OPEN
IN-6 S3 char.c:747 iter + amount can overflow size_t OPEN
IN-5 S3 char.c:742 amount <= 0 on a size_t is always just == 0 OPEN
IN-8 S2 char.c:747-754, 793 the loop copies via the raw iterator->_internal_arr[i] rather than a bounds-checked accessor. It is unreachable today only because IN-1's inversion makes iter_end <= iter_start always true, so the loop never runs. It becomes a live out-of-bounds read the moment IN-1 is fixed, unless IN-2 is fixed in the same change OPEN
IN-9 C char.c:747-754 O(amount²) loop of single inserts where one grow + memmove would do OPEN

utils.h / utils.c

ID Sev Location Finding State
UT-2 S1 utils.c:3-12 indices 0 and 1 are never assigned, so error_messages[0] and [1] are NULL. LOGERROR(SUCCESS) prints %s of NULL, and both are values functions return. Latent only because no caller exists yet OPEN
UT-1 S2 utils.h:29 SIZE_MAX / sizeof(int) is what the char vector uses, capping it at 1/4 of the usable address space OPEN
UT-3 S2 utils.c:3 SUCCESS/FAILURE overlap the error-index namespace, so 0 is both "success" and a table slot OPEN
UT-5 C utils.h:23 DEBUG prints the literal DEBUB: , so char.c:109 — the only DEBUG call in the tree, on the clamp path in push_back — emits a misspelled prefix OPEN
UT-4 C utils.h:7, 17, 20, 22-25, 27, 29 · utils.c:6, 10 static const in a header gives every TU its own copy; ERR_NULL is now dead (defined and given a message, never referenced from char.c); ERR_SIZE_ZERO message mentions < 0 on a size_t; extern const char* should be const char* const[]; LOG/LOGERROR are hardwired to stdout and single-arg; find_len is UB for pointers; CAPACITY is a magic number in a shared header OPEN

char.h

ID Sev Location Finding State
CH-9 S2 char.h:11-106 (29 identifiers) every public identifier is reserved by the C standard: _VecCharInternal and all 28 _vec_char_* functions begin with _ at file scope, which C11 7.1.3 reserves for the implementation. Undefined behaviour on collision with any libc or third-party name. clang -Weverything reports all 28 OPEN
CH-4 C char.h:6-8 <stdio.h>/<stdlib.h>/<string.h> in the header, only needed by the .c OPEN
CH-5 C char.h no doc comments on any function, despite the /**< */ style in utils.h OPEN
CH-7 C char.h:22-23 no from_cstr / NUL-termination path for the char specialization. See CR-4 OPEN
CH-8 C char.h:11-17 the public struct is named with a _ prefix and its four fields are named _internal_* instead of being PRIVATE, so the "internal" contract is expressed only by spelling. Combined with CH-9 this is the whole naming problem OPEN
CH-1 C char.h:32 vs char.c:156 param named max_size in the header, capacity in the definition OPEN
CH-2 C char.h:23-106 const on by-value params throughout (const size_t size, const char val, const size_t idx, …) OPEN
CH-3 C char.h no size() accessor, so callers reach into _internal_size directly OPEN

Project

ID Sev Location Finding State
PR-2 C char.c:1-3 mixed brace style (single-line if in erase/front/back/begin/empty vs expanded blocks); .clang-format exists and is not run OPEN
PR-3 C char.c:204, 208, 224, 225, 229, 230, 254, 255, 263, 267, 277, 281, 291, 300, 304, 322, 326, 343, 344, 354, 361, 373, 381, 389, 390, 400, 401, 415, 416, 434, 438, 520, 524, 603, 607, 611, 615, 654, 658, 693, 697, 763, 767, 771, 775 is_valid called 2-3× per function and logging each time, in every function except push_back, pop_back and reserve — those three hoist OPEN
PR-7 C CMakeLists.txt:28, main.c:20-54 the test_runner target exists but main() is entirely commented out, so nothing in the tree calls a single function in this tracker. This is the only place a regression in the 10 open S1s could be caught, and it is dead OPEN
PR-4 C main.c:23-53 100% commented out, references removed API Vec8_t and v1.size OPEN
PR-5 C include/libvec.h:4, CMakeLists.txt:12-14 empty placeholder; int/float/string are empty dirs already listed as comments in CMakeLists.txt OPEN

ID map

FX-1 … FX-5 are first-pass IDs. FX-1 was a duplicate of PB-6 and was never given a row.

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 SW- swap
AC- accessors CL- clear, print_vec
DA- data IV- is_valid
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

PB-1 PB-2 PB-4 PB-6 PB-7 FX-2 FX-3 FX-4 FX-5 PR-1 CR-7 CR-8