fix: close the memory-error paths the clang-analyzer lane surfaced

The memory-diagnostics report's priority-4 lane (path-sensitive clang-analyzer,
memory checks only) run over all 111 production files. 21 findings triaged;
the real ones, all cold-path (none can explain #581's per-query residual):

LEAKS
- mcp get_architecture: scope_path leaked on the missing-store early return
  (REQUIRE_STORE frees only `project`); allocate after the gate.
- pass_definitions: cancellation mid-extraction leaked the pass-owned result
  cache including already-extracted entries; mirror the end-of-pass cleanup.
- store package-boundary scan: the row-scan abort path freed the node arrays
  but not the boundary accumulators or their duplicated package strings.
- cbm quarantine set: a duplicate path line leaked the replaced value (and a
  fresh key copy -- the table borrows key pointers); a partial strdup failure
  leaked the surviving half. Reuse the stored key for duplicates.
- pass_githistory: unchecked malloc/strdup -- an OOM dereferenced NULL and a
  failed strdup leaked the index cell. Allocate before claiming the slot.

NULL/UB
- cli config subcommand: NULL argv with nonzero argc slipped the guard (the
  inner `argv &&` shielded only the help comparison) into argv[0].
- store bfs_multi: a negative max_results broke out before any row was
  written, then freed fields of an unwritten negative-index slot. Clamp.
- pass_calls emit_http_async_edge: the service-pattern call sites pass a NULL
  target behind a hand-duplicated URL predicate; a drift between the copies
  turned target->id into a null deref. The callee is now total.
- sqlite_writer: both leaf-array OOM paths left leaf_count stale with a NULL
  array, walking pb_finalize_* into leaves[0]; consistent empty state routes
  them to the existing root=0 failure return.

HARDENED (invariants true but invisible to path-sensitive analysis)
- Leiden CSR + aggregate arrays, SCC adjacency: calloc + endpoint guards, so
  a future degree/collection miscount degrades benignly instead of UB.
- SCC cycle fill: the ncyc==0 no-slot invariant made local.

RECORDED FALSE POSITIVES (no code change)
- yaml sequence starts (loop bound == alloc bound), cypher agg arrays (same
  count both sides), mcp read_message ch (assigned by fgetc each iteration),
  pkgmap clean buffer, mcp csize (Tarjan: ncomp>=1 when nverts>=1), vendored
  verstable x2.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>

GATE + LANES (user decision: runner cost accepted)
- make lint-mem (local triage) and lint-mem-ci (gating: vendored-filtered,
  any remaining finding fails). The gate is green because every false
  positive above was restructured for provability -- calloc'd fill-cursor
  arrays, explicit Tarjan invariant, zeroed buffer tails, min-1-element
  allocations -- never suppressed.
- make diag: pinned newest-LLVM ASan/UBSan lane with straighter stacks.
- CI: lint-mem job (_lint.yml) and test-diag job (_test.yml), both on the
  pinned LLVM 22 apt toolchain. Cost disclosure: roughly +25-40 min and
  +25-60 min (ccache-warm) per push respectively.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-08-03 03:23:18 +02:00
parent 925e662e67
commit 80afcd6bbe
14 changed files with 255 additions and 27 deletions
+25
View File
@@ -49,3 +49,28 @@ jobs:
- name: Lint (cppcheck + clang-format, no clang-tidy — enforced locally)
run: scripts/lint.sh --ci CLANG_FORMAT=clang-format-20
# Memory-analyzer gate (user decision 2026-08-03: runner cost accepted).
# Path-sensitive clang-analyzer over the memory checks only: leak paths,
# null derefs, uninitialized reads. Its first run produced 9 real fixes;
# the gate is green because every false positive was RESTRUCTURED for
# provability (never suppressed — the NOLINT ban applies here too).
# Vendored-tree diagnostics are path-filtered, mirroring .cppcheck.
lint-mem:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install build deps
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev
- name: Install LLVM 22 (pinned analyzer toolchain)
run: |
wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc
echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-22 main" | sudo tee /etc/apt/sources.list.d/llvm-22.list
sudo apt-get update
sudo apt-get install -y clang-tidy-22
- name: Memory-analyzer gate
run: make -f Makefile.cbm lint-mem-ci CLANG_TIDY=clang-tidy-22
+41
View File
@@ -183,6 +183,47 @@ jobs:
# platform can surface a race the others miss. Windows has no TSan runtime on
# any toolchain (documented irreducible gap); this shared-code coverage is
# its substitute.
# Memory-diagnostics lane (user decision 2026-08-03: runner cost accepted).
# Newest pinned LLVM sanitizer runtime + straighter stacks over the full
# parallel suite — catches what an older compiler-rt can miss. Same
# canonical scripts/test.sh wave as every other leg.
test-diag:
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install deps
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev ccache
- name: Install LLVM 22 (pinned diagnostic toolchain)
run: |
wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc
echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-22 main" | sudo tee /etc/apt/sources.list.d/llvm-22.list
sudo apt-get update
sudo apt-get install -y clang-22
- name: Compiler cache (content-verified, restore)
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ github.workspace }}/.ccache
key: ccache-diag-${{ github.ref }}-${{ github.sha }}
restore-keys: |
ccache-diag-${{ github.ref }}-
ccache-diag-
- name: Test (clang-22, ASan+UBSan, -fno-optimize-sibling-calls)
run: >
scripts/test.sh CC=clang-22 CXX=clang++-22
SANITIZE="-fsanitize=address,undefined -fno-omit-frame-pointer -fno-optimize-sibling-calls"
- name: Compiler cache (save)
if: always()
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ github.workspace }}/.ccache
key: ccache-diag-${{ github.ref }}-${{ github.sha }}
test-tsan:
strategy:
fail-fast: false
+41
View File
@@ -1062,6 +1062,47 @@ lint: lint-tidy lint-cppcheck lint-format lint-no-suppress
lint-ci: lint-cppcheck lint-format lint-no-suppress
@echo "=== CI linters passed ==="
# ── Local memory-diagnostic lanes (not PR-CI gates by decision: the diag
# build doubles a runner's bill for a marginal delta, and a gating analyzer
# lane needs a suppression story the NOLINT ban deliberately forbids.
# Run these on the local ladder; both found real bugs on first use.) ──
# Newest pinned toolchain + ASan/UBSan + straighter stacks. Separate BUILD_DIR
# so the shipping-toolchain build stays untouched.
DIAG_LLVM_BIN ?= /opt/homebrew/opt/llvm/bin
diag:
@echo "=== diagnostic lane: $(shell $(DIAG_LLVM_BIN)/clang --version | head -1) ==="
$(MAKE) -f Makefile.cbm build/diag/test-runner \
CC=$(DIAG_LLVM_BIN)/clang CXX=$(DIAG_LLVM_BIN)/clang++ BUILD_DIR=build/diag \
SANITIZE="-fsanitize=address,undefined -fno-omit-frame-pointer -fno-optimize-sibling-calls"
./build/diag/test-runner
# Path-sensitive memory analysis: leak paths, null derefs, uninitialized reads.
# Non-gating: triage findings against the code (this lane's first run produced
# 9 real fixes and 6 recorded false positives — see the analyzer batch commit).
LINT_MEM_CHECKS = -*,clang-analyzer-unix.Malloc,clang-analyzer-unix.MallocSizeof,clang-analyzer-core.NullDereference,clang-analyzer-core.CallAndMessage,clang-analyzer-core.UndefinedBinaryOperatorResult,clang-analyzer-core.uninitialized.*,clang-analyzer-cplusplus.NewDelete,clang-analyzer-cplusplus.NewDeleteLeaks
lint-mem:
@echo "=== clang-analyzer memory lane (non-gating; triage, don't suppress) ==="
@printf '%s\n' $(LINT_SRCS) | xargs -P 10 -I{} $(CLANG_TIDY) --quiet \
--checks='$(LINT_MEM_CHECKS)' {} -- $(CFLAGS_COMMON) $(SYSROOT_FLAG) 2>/dev/null; true
# Gating variant: identical run, but vendored-tree diagnostics are excluded by
# an explicit path filter (analyzer diagnostics bypass --header-filter by
# design; this mirrors the .cppcheck vendored suppression) and any remaining
# finding fails the target. A finding here is a leak path, null deref, or
# uninitialized read the analyzer can prove — triage it against the code; a
# false positive gets restructured-for-clarity or recorded in the analyzer
# batch commit, never NOLINT'd.
lint-mem-ci:
@echo "=== clang-analyzer memory gate ==="
@printf '%s\n' $(LINT_SRCS) | xargs -P 4 -I{} $(CLANG_TIDY) --quiet \
--checks='$(LINT_MEM_CHECKS)' {} -- $(CFLAGS_COMMON) $(SYSROOT_FLAG) 2>/dev/null \
| grep -E "error:.*clang-analyzer" | grep -v "/vendored/" > lint-mem-findings.txt; \
if [ -s lint-mem-findings.txt ]; then \
echo "=== memory-analyzer findings (gate FAILS) ==="; \
cat lint-mem-findings.txt; rm -f lint-mem-findings.txt; exit 1; \
fi; rm -f lint-mem-findings.txt; echo "=== memory gate clean ==="
# ── Security audit (6 layers) ────────────────────────────────────
# Run all security checks: static audit, binary strings, UI, install, network
+16 -3
View File
@@ -653,10 +653,23 @@ static void cbm_quarantine_load(void) {
* never freed: the set lives for the whole (short-lived worker) process.
* The value stores the phase so cbm_index_quarantine_phase() can report
* "crash" vs "hang"; membership (cbm_index_is_quarantined) is value != NULL. */
char *key = cbm_strdup(line);
char *pval = cbm_strdup(phase);
if (key && pval) {
cbm_ht_set(set, key, (void *)pval);
if (!pval) {
continue;
}
if (cbm_ht_has(set, line)) {
/* Duplicate path line: reuse the stored key (the table borrows key
* pointers, so a fresh copy would leak on replace) and free the
* value it displaces. */
free(cbm_ht_set(set, line, (void *)pval));
} else {
char *key = cbm_strdup(line);
if (key) {
cbm_ht_set(set, key, (void *)pval);
} else {
/* Partial failure: don't leak the value copy. */
free(pval);
}
}
}
(void)fclose(f);
+10
View File
@@ -513,8 +513,15 @@ static void pb_flush_leaf(PageBuilder *pb) {
pb->leaf_cap = old_cap == 0 ? INITIAL_LEAF_CAP : old_cap * GROWTH_FACTOR;
void *tmp = realloc(pb->leaves, (size_t)pb->leaf_cap * sizeof(PageRef));
if (!tmp) {
/* Leave a CONSISTENT empty state: leaf_count stale at >=1 with a
* NULL leaves array walked pb_finalize_* straight into
* leaves[0] (null deref, clang-analyzer traced it) whenever the
* final cell block was already flushed. Empty state routes every
* finalize path to its existing root=0 failure return. */
free(pb->leaves);
pb->leaves = NULL;
pb->leaf_count = 0;
pb->leaf_cap = 0;
return;
}
pb->leaves = (PageRef *)tmp;
@@ -1050,8 +1057,11 @@ static bool pb_ensure_leaf_cap(PageBuilder *pb) {
pb->leaf_cap = pb->leaf_cap == 0 ? INITIAL_LEAF_CAP : pb->leaf_cap * GROWTH_FACTOR;
void *tmp = realloc(pb->leaves, (size_t)pb->leaf_cap * sizeof(PageRef));
if (!tmp) {
/* Same consistent-empty contract as pb_flush_leaf's growth path. */
free(pb->leaves);
pb->leaves = NULL;
pb->leaf_count = 0;
pb->leaf_cap = 0;
return false;
}
pb->leaves = (PageRef *)tmp;
+4 -1
View File
@@ -6445,7 +6445,10 @@ int cbm_config_delete(cbm_config_t *cfg, const char *key) {
/* ── Config CLI subcommand ────────────────────────────────────── */
int cbm_cmd_config(int argc, char **argv) {
if (argc == 0 || (argv && (strcmp(argv[0], "--help") == 0 || strcmp(argv[0], "-h") == 0))) {
/* NULL argv with a nonzero argc previously slipped past this guard (the
* inner `argv &&` shielded only the help comparison) and dereferenced
* argv[0] below -- caught by the clang-analyzer lane. */
if (argc == 0 || !argv || strcmp(argv[0], "--help") == 0 || strcmp(argv[0], "-h") == 0) {
printf("Usage: codebase-memory-mcp config <command> [args]\n\n");
printf("Commands:\n");
printf(" list Show all config values\n");
+5 -2
View File
@@ -2557,8 +2557,11 @@ static int yaml_sequence_parse_block(const yaml_doc_t *doc, size_t begin_line, s
const char *identity_value,
yaml_mapping_sequence_target_t *target) {
size_t range_len = end_line - begin_line;
size_t *starts = range_len ? (size_t *)calloc(range_len, sizeof(*starts)) : NULL;
if (range_len && !starts) {
/* Minimum one element: a NULL-for-empty-range starts is only safe through
* the loop-bound == alloc-bound invariant, which a path-sensitive
* analyzer (rightly) refuses to assume across expressions. */
size_t *starts = (size_t *)calloc(range_len ? range_len : 1, sizeof(*starts));
if (!starts) {
return YAML_ERROR;
}
size_t start_count = 0U;
+5 -2
View File
@@ -4204,8 +4204,11 @@ static void ret_agg_init_group(ret_agg_entry_t *entry, const char *key, int item
entry->group_vals = calloc(item_count, sizeof(const char *));
entry->sums = calloc(item_count, sizeof(double));
entry->counts = calloc(item_count, sizeof(int));
entry->mins = malloc(item_count * sizeof(double));
entry->maxs = malloc(item_count * sizeof(double));
/* calloc: the sentinel loop below overwrites every slot, but the bound
* equality with the accumulate loop is invisible to path-sensitive
* analysis; zeroed backing keeps any modeled mismatch defined. */
entry->mins = calloc(item_count, sizeof(double));
entry->maxs = calloc(item_count, sizeof(double));
entry->collect_lists = calloc(item_count, sizeof(char **));
entry->collect_counts = calloc(item_count, sizeof(int));
for (int ci = 0; ci < item_count; ci++) {
+41 -7
View File
@@ -4598,9 +4598,17 @@ static bool scc_build(const int64_t *src, const int64_t *tgt, int ecount, scc_gr
memset(g, 0, sizeof(*g));
return false;
}
/* two-pass CSR fill */
/* two-pass CSR fill. Every endpoint is in ids[] by construction (ids is
* built from these same edge arrays), so a negative lookup is unreachable
* -- but that invariant is invisible to path-sensitive analysis, and a
* guard keeps a future collection bug from becoming an OOB write. Both
* passes must skip identically or the cursors desync. */
for (int i = 0; i < ecount; i++) {
int u = scc_id_index(g->ids, nv, src[i]);
int v = scc_id_index(g->ids, nv, tgt[i]);
if (u < 0 || v < 0) {
continue;
}
g->adj_head[u + 1]++;
}
for (int i = 0; i < nv; i++) {
@@ -4617,13 +4625,18 @@ static bool scc_build(const int64_t *src, const int64_t *tgt, int ecount, scc_gr
for (int i = 0; i < nv; i++) {
cursor[i] = g->adj_head[i];
}
int filled = 0;
for (int i = 0; i < ecount; i++) {
int u = scc_id_index(g->ids, nv, src[i]);
int v = scc_id_index(g->ids, nv, tgt[i]);
if (u < 0 || v < 0) {
continue;
}
g->adj[cursor[u]++] = v;
filled++;
}
free(cursor);
g->nedges = ecount;
g->nedges = filled;
return true;
}
@@ -4856,6 +4869,14 @@ static int arch_compute_cycles(cbm_store_t *store, const char *project, int64_t
scc_free(&g);
return CBM_STORE_ERR;
}
/* Tarjan assigns every vertex a component, so ncomp >= 1 whenever
* nverts >= 1 -- state it, so the zero-size-allocation path below is
* provably confined to the empty graph. */
if (g.nverts > 0 && ncomp <= 0) {
free(comp);
scc_free(&g);
return CBM_STORE_ERR;
}
/* size per component */
int *csize = calloc((size_t)ncomp, sizeof(int));
if (!csize) {
@@ -4915,10 +4936,15 @@ static int arch_compute_cycles(cbm_store_t *store, const char *project, int64_t
scc_free(&g);
return CBM_STORE_ERR;
}
for (int v = 0; v < g.nverts; v++) {
int sl = slot[comp[v]];
if (sl >= 0) {
members[sl][fill[sl]++] = g.ids[v];
/* With ncyc == 0 no slot is ever >= 0 (the slot loop can't assign one),
* so members -- NULL in that case -- is never indexed; make that
* invariant local instead of cross-loop so it is checkable. */
if (ncyc > 0) {
for (int v = 0; v < g.nverts; v++) {
int sl = slot[comp[v]];
if (sl >= 0) {
members[sl][fill[sl]++] = g.ids[v];
}
}
}
free(fill);
@@ -4961,9 +4987,12 @@ static void arch_node_qn(cbm_store_t *store, int64_t id, char *out, size_t outsz
static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) {
char *project = get_project_arg(args);
char *scope_path = cbm_mcp_get_string_arg(args, "path");
cbm_store_t *store = resolve_store(srv, project);
/* REQUIRE_STORE returns without freeing anything but `project`, so every
* other allocation must come after it (scope_path leaked here before
* caught by the clang-analyzer unix.Malloc lane). */
REQUIRE_STORE(store, project);
char *scope_path = cbm_mcp_get_string_arg(args, "path");
char *not_indexed = verify_project_indexed(store, project);
if (not_indexed) {
@@ -11408,6 +11437,11 @@ static int read_bounded_line(FILE *in, char **line, size_t *cap, size_t max_byte
if (!grown) {
return CBM_NOT_FOUND;
}
/* Zero the tail: every byte of the buffer is then defined in any
* caller's model (parse_content_length reads up to one byte past
* the matched prefix), and a future over-read degrades to reading
* NULs instead of undefined memory. One memset per growth step. */
memset(grown + len, 0, new_cap - len);
*line = grown;
*cap = new_cap;
}
+7 -1
View File
@@ -345,7 +345,13 @@ static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
* substring coincidence in the resolved QN (e.g. "SalesforceRestClient"
* matches the "RestClient" HTTP lib). Emit a plain CALLS edge — unless a
* weak TS/JS member-call match should be suppressed (#592/#606). */
if (suppress_plain_calls) {
/* !target: the service-pattern call sites pass NULL (the route node is
* synthesized below) behind a hand-duplicated copy of the is_url/
* is_topic predicate above. While the copies agree this branch is
* unreachable for them -- but a drift between the two would turn
* target->id into a null dereference (clang-analyzer traced exactly
* that), and with no callee node there is nothing to emit anyway. */
if (suppress_plain_calls || !target) {
return;
}
char esc_callee[CBM_SZ_256];
+12
View File
@@ -695,6 +695,18 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t
* complete in-memory graph in Phase 2. */
for (int i = 0; i < file_count; i++) {
if (cbm_pipeline_check_cancel(ctx)) {
/* Cancellation mid-extraction: release the cache this pass owns,
* including results already extracted into it (the normal cleanup
* at the end of the pass does the same) -- clang-analyzer caught
* this return leaking the whole cache. */
if (owns_local_cache) {
for (int j = 0; j < file_count; j++) {
if (local_cache[j]) {
cbm_free_result(local_cache[j]);
}
}
free(local_cache);
}
return CBM_NOT_FOUND;
}
+14 -3
View File
@@ -387,7 +387,9 @@ int cbm_pipeline_githistory_compute(const char *repo_path, cbm_githistory_result
* Single hash-table pass over the same commit set used for coupling so
* we don't re-scan history. NULL on OOM is fine — the caller still
* gets the couplings. */
cbm_file_temporal_t *ft_arr = malloc(MAX_FILE_TEMPORAL * sizeof(cbm_file_temporal_t));
cbm_file_temporal_t *ft_arr =
calloc(MAX_FILE_TEMPORAL, sizeof(cbm_file_temporal_t)); /* defined reads
even under the analyzer's cross-iteration view of the index map */
if (ft_arr) {
int ft_count = 0;
CBMHashTable *file_idx = cbm_ht_create(CBM_SZ_1K);
@@ -404,14 +406,23 @@ int cbm_pipeline_githistory_compute(const char *repo_path, cbm_githistory_result
ft_arr[*idx].last_modified = cf[c].timestamp;
}
} else if (ft_count < MAX_FILE_TEMPORAL) {
/* Allocate the index cell and key BEFORE claiming a slot:
* the previous unchecked malloc dereferenced NULL on
* allocation failure, and a failed strdup would have
* leaked the cell. */
int *nidx = malloc(sizeof(int));
char *key = nidx ? strdup(fp) : NULL;
if (!nidx || !key) {
free(nidx);
continue;
}
int new_idx = ft_count++;
snprintf(ft_arr[new_idx].file_path, sizeof(ft_arr[new_idx].file_path), "%s",
fp);
ft_arr[new_idx].change_count = 1;
ft_arr[new_idx].last_modified = cf[c].timestamp;
int *nidx = malloc(sizeof(int));
*nidx = new_idx;
cbm_ht_set(file_idx, strdup(fp), nidx);
cbm_ht_set(file_idx, key, nidx);
}
}
}
+1 -1
View File
@@ -1987,7 +1987,7 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t
*as2 = '\0';
}
/* Convert "::" → "/" (drop the doubled colon cleanly). */
char clean[1024];
char clean[1024] = ""; /* first byte defined even on the zero-write path */
size_t ci = 0;
for (const char *p = mp; *p && ci + 1 < sizeof(clean); p++) {
if (*p == ':') {
+33 -7
View File
@@ -4266,6 +4266,13 @@ int cbm_store_bfs_multi(cbm_store_t *s, const int64_t *seed_ids, int seed_count,
int cap = ST_INIT_CAP_16;
int n = 0;
/* A negative ceiling would break out of the scan before any row is
* written and then free fields of an unwritten (negative-index) slot --
* clang-analyzer traced that path into a garbage free. Clamp: zero rows
* is the total behavior for a non-positive ceiling. */
if (max_results < 0) {
max_results = 0;
}
cbm_node_hop_t *visited = malloc(cap * sizeof(cbm_node_hop_t));
int scan_rc16;
while ((scan_rc16 = sqlite3_step(stmt)) == SQLITE_ROW) {
@@ -5496,6 +5503,16 @@ static int arch_boundaries(cbm_store_t *s, const char *project, const char *path
}
free(nids);
free(npkgs);
/* The boundary accumulators (and the package strings accum_boundary
* duplicated into them) were leaked on this abort path -- caught by
* the clang-analyzer unix.Malloc lane. Mirror the normal cleanup. */
for (int bi = 0; bi < bn; bi++) {
free(bfroms[bi]);
free(btos[bi]);
}
free(bfroms);
free(btos);
free(bcounts);
return CBM_STORE_ERR;
}
sqlite3_finalize(estmt);
@@ -6249,8 +6266,12 @@ static int lg_build(int n, const int *wsi, const int *wdi, const double *ww, int
off[i + 1] += off[i];
}
int total = off[n];
int *nbr = malloc((size_t)(total > 0 ? total : 1) * sizeof(int));
double *w = malloc((size_t)(total > 0 ? total : 1) * sizeof(double));
/* calloc, not malloc: every slot IS written (off[n] equals the summed
* degrees, two writes per edge), but that invariant is invisible to
* path-sensitive analysis, and zero-filled backing turns any future
* degree-miscount bug into a benign self-loop instead of UB. */
int *nbr = calloc((size_t)(total > 0 ? total : 1), sizeof(int));
double *w = calloc((size_t)(total > 0 ? total : 1), sizeof(double));
if (!nbr || !w) {
free(off);
free(k);
@@ -6472,9 +6493,11 @@ static int leiden_aggregate(const cbm_lg_t *g, const int *refined, int r_count,
int n = g->n;
double *k2 = calloc((size_t)r_count, sizeof(double));
int *gcount = calloc((size_t)r_count, sizeof(int));
int *gstart = malloc(((size_t)r_count + 1) * sizeof(int));
int *members = malloc((size_t)n * sizeof(int));
int *fill = malloc((size_t)r_count * sizeof(int));
/* calloc: the fill-cursor pattern writes every slot (same degree-sum
* invariant as the CSR builds), invisible to path-sensitive analysis. */
int *gstart = calloc((size_t)r_count + 1, sizeof(int));
int *members = calloc((size_t)n, sizeof(int));
int *fill = calloc((size_t)r_count, sizeof(int));
double *acc = calloc((size_t)r_count, sizeof(double));
int *dirty = malloc((size_t)r_count * sizeof(int));
int *off2 = malloc(((size_t)r_count + 1) * sizeof(int));
@@ -6529,8 +6552,11 @@ static int leiden_aggregate(const cbm_lg_t *g, const int *refined, int r_count,
}
}
int total = off2[r_count];
int *nbr2 = malloc((size_t)(total > 0 ? total : 1) * sizeof(int));
double *w2 = malloc((size_t)(total > 0 ? total : 1) * sizeof(double));
/* calloc for the same reason as the base CSR build: full-fill holds by
* the degree-sum invariant, invisible to path-sensitive analysis; zeroed
* backing degrades a future miscount into a self-loop, not UB. */
int *nbr2 = calloc((size_t)(total > 0 ? total : 1), sizeof(int));
double *w2 = calloc((size_t)(total > 0 ? total : 1), sizeof(double));
if (!nbr2 || !w2) {
free(k2);
free(gcount);