Skip to content

fix(cypher,store): prevent crashes from buffer overflow, OOM, and NULL stmts#2

Merged
map588 merged 1 commit intomainfrom
fix/crash-safety
Apr 6, 2026
Merged

fix(cypher,store): prevent crashes from buffer overflow, OOM, and NULL stmts#2
map588 merged 1 commit intomainfrom
fix/crash-safety

Conversation

@map588
Copy link
Copy Markdown
Owner

@map588 map588 commented Apr 5, 2026

Summary

  • Cypher lexer buffer overflow: lex_string_literal wrote to a 4KB stack buffer with no bounds check. String literals >4096 bytes caused stack overflow. Added max_blen guard to safely truncate.
  • Cypher parser OOM crashes: 7+ malloc/calloc calls in parse_props, parse_rel_types, parse_in_condition, and parse_case_expr had no NULL checks. Added guards with proper cleanup on allocation failure.
  • SQLite NULL statement dereference: 3 sites in cbm_store_schema_info and collect_pkg_names called sqlite3_prepare_v2 without checking the return code. If prepare fails, bind_text and sqlite3_step crash on NULL stmt. Added error checks.

Test plan

  • New test cypher_lex_string_overflow verifies 5000-char string literal doesn't crash
  • Full test suite passes (2719 tests) with ASan + UBSan
  • Fuzz test Cypher parser with long string literals
  • Test with corrupted/inaccessible database files

Copilot AI review requested due to automatic review settings April 5, 2026 00:57
@qodo-code-review
Copy link
Copy Markdown

Review Summary by Qodo

Fix crash safety issues: buffer overflow, OOM, and NULL statement dereference

🐞 Bug fix

Grey Divider

Walkthroughs

Description
• Prevent stack buffer overflow in Cypher lexer by bounds-checking string literals
• Add NULL checks for malloc/calloc in Cypher parser to prevent OOM crashes
• Add sqlite3_prepare_v2 return code validation to prevent NULL statement dereference
• Add comprehensive test for large string literal handling
Diagram
flowchart LR
  A["Cypher Lexer<br/>lex_string_literal"] -->|"Add max_blen<br/>bounds check"| B["Prevent Stack<br/>Overflow"]
  C["Cypher Parser<br/>parse_props/rel_types<br/>parse_in_condition<br/>parse_case_expr"] -->|"Add malloc/calloc<br/>NULL checks"| D["Prevent OOM<br/>Crashes"]
  E["SQLite Store<br/>cbm_store_get_schema<br/>collect_pkg_names"] -->|"Add prepare_v2<br/>return validation"| F["Prevent NULL<br/>Dereference"]
  B --> G["Improved Crash Safety"]
  D --> G
  F --> G
Loading

Grey Divider

File Changes

1. src/cypher/cypher.c 🐞 Bug fix +21/-0

Add buffer overflow and OOM safety checks

• Add max_blen bounds check in lex_string_literal to prevent stack buffer overflow on string
 literals exceeding 4096 bytes
• Add NULL checks after malloc calls in parse_props, parse_rel_types, parse_in_list, and
 parse_case_expr
• Ensure proper cleanup and error propagation when memory allocation fails

src/cypher/cypher.c


2. src/store/store.c 🐞 Bug fix +9/-3

Add SQLite prepare statement error handling

• Add return code validation for sqlite3_prepare_v2 calls in cbm_store_get_schema (2 sites)
• Add return code validation for sqlite3_prepare_v2 in collect_pkg_names
• Prevent NULL statement dereference by checking both return code and statement pointer

src/store/store.c


3. tests/test_cypher.c 🧪 Tests +27/-0

Add test for large string literal handling

• Add new test cypher_lex_string_overflow that verifies 5000-character string literal doesn't
 cause stack overflow
• Test validates string is safely truncated to 4095 characters (CBM_SZ_4K - 1)
• Register new test in cypher test suite

tests/test_cypher.c


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review bot commented Apr 5, 2026

Code Review by Qodo

🐞 Bugs (4) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX Issues (0)

Grey Divider


Action required

1. Escaped quote truncated wrong 🐞 Bug ≡ Correctness
Description
lex_string_literal() now skips escape handling once the buffer is full, so a backslash at/after the
truncation boundary can be ignored and an escaped quote (\") can incorrectly terminate the string
literal early. This can produce wrong tokens / parse failures for long strings with escapes near
byte 4095.
Code

src/cypher/cypher.c[R95-99]

+    const int max_blen = CBM_SZ_4K - 1;
    while (*pos < len && input[*pos] != quote) {
+        if (blen >= max_blen) { (*pos)++; continue; }
        if (input[*pos] == '\\' && *pos + SKIP_ONE < len) {
            (*pos)++;
Evidence
The new truncation fast-path advances only one character and bypasses the existing backslash-escape
handling. If the skipped character is a backslash and the next character is the quote, the loop
exits on the quote and treats it as the closing delimiter even though it was escaped in the input.

src/cypher/cypher.c[88-124]
tests/test_cypher.c[81-105]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`lex_string_literal()` truncates output once `blen` reaches `CBM_SZ_4K - 1`, but the new truncation path skips escape handling. If a long literal contains an escaped quote (e.g. `\"`) after the truncation boundary, the lexer can incorrectly treat that quote as the terminator.

## Issue Context
The lexer must keep *parsing* the literal correctly even when it stops *buffering* characters.

## Fix Focus Areas
- src/cypher/cypher.c[88-124]

## Suggested fix
Refactor the loop so position advancement always respects escapes, and buffering is conditional:
- Always detect `\\` and advance `*pos` by 2 (when possible) regardless of `blen`.
- Only write into `buf` / increment `blen` when `blen < max_blen`.
- Keep the current `buf[blen] = '\0'` termination behavior.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. OOM still crashes on realloc 🐞 Bug ☼ Reliability
Description
The new NULL checks cover initial malloc/calloc, but
parse_case_expr/parse_props/parse_rel_types/parse_in_list still call safe_realloc() without checking
for NULL. On realloc OOM, safe_realloc() frees the old buffer and returns NULL, and the next write
dereferences NULL causing a crash.
Code

src/cypher/cypher.c[R1077-1086]

    cbm_case_expr_t *kase = calloc(CBM_ALLOC_ONE, sizeof(cbm_case_expr_t));
+    if (!kase) {
+        return NULL;
+    }
    int bcap = CYP_INIT_CAP4;
    kase->branches = malloc(bcap * sizeof(cbm_case_branch_t));
+    if (!kase->branches) {
+        free(kase);
+        return NULL;
+    }
Evidence
safe_realloc() explicitly returns NULL on allocation failure and frees the original pointer.
parse_case_expr() (and others) assign safe_realloc() directly back to the live pointer and then
unconditionally write into it, so an OOM during growth will crash despite the new initial allocation
guards.

src/foundation/platform.h[18-32]
src/cypher/cypher.c[463-509]
src/cypher/cypher.c[572-607]
src/cypher/cypher.c[760-797]
src/cypher/cypher.c[1074-1109]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Several parser functions still crash on OOM during array growth because they use `safe_realloc()` without checking for NULL. `safe_realloc()` frees the old pointer on failure, so a failed growth loses the only reference and the next dereference crashes.

## Issue Context
This PR aims to prevent OOM crashes, so growth paths must be hardened too (not just initial allocations).

## Fix Focus Areas
- src/cypher/cypher.c[463-509]
- src/cypher/cypher.c[572-607]
- src/cypher/cypher.c[760-797]
- src/cypher/cypher.c[1074-1109]
- src/foundation/platform.h[18-32]

## Suggested fix
For each growth site:
1. Use a temporary pointer:
  - `void *tmp = realloc(ptr, new_size);`
  - if `!tmp`: perform local cleanup (free any elements already duplicated), set `p->error` if available, and return an error.
  - else assign `ptr = tmp`.
2. Alternatively, change `safe_realloc()` semantics to *not free* on failure (so callers can handle errors without losing the old buffer), then audit call sites accordingly.
3. Ensure callers propagate an error code distinct enough that higher layers can abort cleanly.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Schema error path leaks 🐞 Bug ☼ Reliability
Description
cbm_store_get_schema() now returns early if the second sqlite3_prepare_v2 fails, but it may already
have allocated out->node_labels from the first query. Callers like ui/layout3d.c only free schema
when cbm_store_get_schema returns OK, so this introduces a leak on that failure path.
Code

src/store/store.c[R2582-2584]

+        if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) {
+            return CBM_NOT_FOUND;
+        }
Evidence
cbm_store_get_schema allocates and stores node_labels before preparing the edge-type statement. With
the new early return on prepare failure, the function can exit with node_labels allocated.
ui/layout3d.c frees schema only inside the success branch, so it will leak those allocations when
cbm_store_get_schema returns an error after partially populating the schema struct.

src/store/store.c[2544-2605]
src/ui/layout3d.c[441-492]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`cbm_store_get_schema()` can now return early after partially filling `out` (e.g., after allocating `out->node_labels`). Some callers only free schema on success, so this introduces a leak.

## Issue Context
The function currently has two independent query blocks and returns directly inside the second block.

## Fix Focus Areas
- src/store/store.c[2544-2605]
- src/ui/layout3d.c[441-492]

## Suggested fix
Option A (preferred): make `cbm_store_get_schema()` self-cleaning on failure:
- Replace early `return CBM_NOT_FOUND;` with `goto cleanup;`.
- In `cleanup:`, call `sqlite3_finalize(stmt)` if non-NULL, and call `cbm_store_schema_free(out)` (or manual frees) before returning an error.

Option B: update callers to always call `cbm_store_schema_free(&schema)` regardless of return code (safe because it zeros and checks fields), but still consider Option A for robustness.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. Prepare failure masked as empty 🐞 Bug ✧ Quality
Description
collect_pkg_names() now returns 0 when sqlite3_prepare_v2 fails, which is indistinguishable from “no
matching packages.” This silently hides database errors and can make architecture results incorrect
without any error surfaced.
Code

src/store/store.c[R3290-3292]

+    if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) {
+        return 0;
+    }
Evidence
arch_layers() uses the returned value purely as a count; if prepare fails, it will proceed as though
there are simply zero routes/entrypoints. In contrast, other store helpers set a store error and
return a distinct error code on prepare failure, enabling callers to detect and report DB problems.

src/store/store.c[3286-3323]
src/store/store.c[2769-2776]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`collect_pkg_names()` returns `0` on `sqlite3_prepare_v2` failure, which is indistinguishable from a legitimate empty result set. This hides DB corruption/permission issues and makes debugging harder.

## Issue Context
`arch_layers()` consumes the return value as a count and does not otherwise validate these queries.

## Fix Focus Areas
- src/store/store.c[3286-3323]
- src/store/store.c[2769-2776]

## Suggested fix
- Change `collect_pkg_names()` to return a negative error code on prepare failure (e.g., `CBM_STORE_ERR` or `CBM_NOT_FOUND`) and call `store_set_error_sqlite(s, "collect_pkg_names")`.
- Update `arch_layers()` to treat a negative return as an error and propagate/return `CBM_STORE_ERR` (or whichever error convention is used in this module).
- If the intended behavior is “best-effort,” then at minimum log/set store error so the silent degradation is diagnosable.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the Cypher lexer/parser and store schema queries to avoid crash conditions under oversized inputs, OOM, and SQLite statement preparation failures.

Changes:

  • Add bounds checking to Cypher string literal lexing to prevent stack buffer overflow (with a regression test).
  • Add allocation-failure guards in several Cypher parser routines.
  • Add SQLite sqlite3_prepare_v2 return-code checks to avoid NULL statement dereferences.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
src/cypher/cypher.c Adds string-literal truncation guard and OOM checks in parser allocations.
src/store/store.c Adds sqlite3_prepare_v2 error handling to prevent NULL sqlite3_stmt* usage.
tests/test_cypher.c Adds a test ensuring long string literals don’t crash the lexer and are truncated.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/cypher/cypher.c Outdated
int blen = 0;
const int max_blen = CBM_SZ_4K - 1;
while (*pos < len && input[*pos] != quote) {
if (blen >= max_blen) { (*pos)++; continue; }
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The truncation guard increments *pos by 1 and continues without preserving escape semantics. If the buffer is full and the next input is a backslash escape (e.g. "), skipping only the backslash allows the following quote to be seen as the terminator, which can prematurely end the string and desync the lexer. When truncating, still consume escape sequences as a unit (skip both the backslash and the escaped char when present), rather than skipping a single byte unconditionally.

Suggested change
if (blen >= max_blen) { (*pos)++; continue; }
if (blen >= max_blen) {
if (input[*pos] == '\\' && *pos + SKIP_ONE < len) {
*pos += PAIR_LEN;
} else {
(*pos)++;
}
continue;
}

Copilot uses AI. Check for mistakes.
Comment thread src/cypher/cypher.c
Comment on lines 1077 to +1086
cbm_case_expr_t *kase = calloc(CBM_ALLOC_ONE, sizeof(cbm_case_expr_t));
if (!kase) {
return NULL;
}
int bcap = CYP_INIT_CAP4;
kase->branches = malloc(bcap * sizeof(cbm_case_branch_t));
if (!kase->branches) {
free(kase);
return NULL;
}
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parse_case_expr can now return NULL on allocation failure, but callers need a reliable way to detect that as a parse error. Today parse_return_item assigns item->variable = "CASE" unconditionally after calling parse_case_expr, and does not check for NULL, which can allow an OOM to be treated as a successful parse and lead to incorrect execution or downstream NULL handling issues. Consider setting a parser error / returning an error code when parse_case_expr fails, and ensure the caller propagates that failure.

Copilot uses AI. Check for mistakes.
Comment thread src/store/store.c
"BY COUNT(*) DESC;";
sqlite3_stmt *stmt = NULL;
sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL);
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) {
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the first (node labels) query succeeds and allocates out->node_labels, then the edge-types prepare failure returns immediately without freeing the already-populated fields in *out. Since callers typically only call cbm_store_schema_free() on CBM_STORE_OK, this leaks memory on prepare failures. Before returning here, free any partially populated schema (e.g., call cbm_store_schema_free(out) or manually clean up fields already set).

Suggested change
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) {
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) {
cbm_store_schema_free(out);

Copilot uses AI. Check for mistakes.
Comment thread src/store/store.c
Comment on lines 3289 to +3292
sqlite3_stmt *stmt = NULL;
sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL);
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) {
return 0;
}
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On sqlite3_prepare_v2 failure this now returns 0, which is indistinguishable from “query succeeded but returned 0 rows”. That masks database/SQL errors and can cause silent misclassification in arch_layers. Consider returning an error code (or a negative sentinel) and letting the caller decide whether to treat it as fatal vs. optional, and set the store error buffer for diagnostics.

Copilot uses AI. Check for mistakes.
Comment thread src/cypher/cypher.c
Comment on lines +95 to 99
const int max_blen = CBM_SZ_4K - 1;
while (*pos < len && input[*pos] != quote) {
if (blen >= max_blen) { (*pos)++; continue; }
if (input[*pos] == '\\' && *pos + SKIP_ONE < len) {
(*pos)++;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Escaped quote truncated wrong 🐞 Bug ≡ Correctness

lex_string_literal() now skips escape handling once the buffer is full, so a backslash at/after the
truncation boundary can be ignored and an escaped quote (\") can incorrectly terminate the string
literal early. This can produce wrong tokens / parse failures for long strings with escapes near
byte 4095.
Agent Prompt
## Issue description
`lex_string_literal()` truncates output once `blen` reaches `CBM_SZ_4K - 1`, but the new truncation path skips escape handling. If a long literal contains an escaped quote (e.g. `\"`) after the truncation boundary, the lexer can incorrectly treat that quote as the terminator.

## Issue Context
The lexer must keep *parsing* the literal correctly even when it stops *buffering* characters.

## Fix Focus Areas
- src/cypher/cypher.c[88-124]

## Suggested fix
Refactor the loop so position advancement always respects escapes, and buffering is conditional:
- Always detect `\\` and advance `*pos` by 2 (when possible) regardless of `blen`.
- Only write into `buf` / increment `blen` when `blen < max_blen`.
- Keep the current `buf[blen] = '\0'` termination behavior.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/cypher/cypher.c
Comment on lines 1077 to +1086
cbm_case_expr_t *kase = calloc(CBM_ALLOC_ONE, sizeof(cbm_case_expr_t));
if (!kase) {
return NULL;
}
int bcap = CYP_INIT_CAP4;
kase->branches = malloc(bcap * sizeof(cbm_case_branch_t));
if (!kase->branches) {
free(kase);
return NULL;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Oom still crashes on realloc 🐞 Bug ☼ Reliability

The new NULL checks cover initial malloc/calloc, but
parse_case_expr/parse_props/parse_rel_types/parse_in_list still call safe_realloc() without checking
for NULL. On realloc OOM, safe_realloc() frees the old buffer and returns NULL, and the next write
dereferences NULL causing a crash.
Agent Prompt
## Issue description
Several parser functions still crash on OOM during array growth because they use `safe_realloc()` without checking for NULL. `safe_realloc()` frees the old pointer on failure, so a failed growth loses the only reference and the next dereference crashes.

## Issue Context
This PR aims to prevent OOM crashes, so growth paths must be hardened too (not just initial allocations).

## Fix Focus Areas
- src/cypher/cypher.c[463-509]
- src/cypher/cypher.c[572-607]
- src/cypher/cypher.c[760-797]
- src/cypher/cypher.c[1074-1109]
- src/foundation/platform.h[18-32]

## Suggested fix
For each growth site:
1. Use a temporary pointer:
   - `void *tmp = realloc(ptr, new_size);`
   - if `!tmp`: perform local cleanup (free any elements already duplicated), set `p->error` if available, and return an error.
   - else assign `ptr = tmp`.
2. Alternatively, change `safe_realloc()` semantics to *not free* on failure (so callers can handle errors without losing the old buffer), then audit call sites accordingly.
3. Ensure callers propagate an error code distinct enough that higher layers can abort cleanly.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/store/store.c
Comment on lines +2582 to +2584
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) {
return CBM_NOT_FOUND;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Schema error path leaks 🐞 Bug ☼ Reliability

cbm_store_get_schema() now returns early if the second sqlite3_prepare_v2 fails, but it may already
have allocated out->node_labels from the first query. Callers like ui/layout3d.c only free schema
when cbm_store_get_schema returns OK, so this introduces a leak on that failure path.
Agent Prompt
## Issue description
`cbm_store_get_schema()` can now return early after partially filling `out` (e.g., after allocating `out->node_labels`). Some callers only free schema on success, so this introduces a leak.

## Issue Context
The function currently has two independent query blocks and returns directly inside the second block.

## Fix Focus Areas
- src/store/store.c[2544-2605]
- src/ui/layout3d.c[441-492]

## Suggested fix
Option A (preferred): make `cbm_store_get_schema()` self-cleaning on failure:
- Replace early `return CBM_NOT_FOUND;` with `goto cleanup;`.
- In `cleanup:`, call `sqlite3_finalize(stmt)` if non-NULL, and call `cbm_store_schema_free(out)` (or manual frees) before returning an error.

Option B: update callers to always call `cbm_store_schema_free(&schema)` regardless of return code (safe because it zeros and checks fields), but still consider Option A for robustness.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@map588 map588 force-pushed the fix/crash-safety branch from 77923d6 to c58796c Compare April 5, 2026 01:22
…L stmts

- cypher: Add bounds check in lex_string_literal to prevent stack buffer
  overflow on string literals >4096 bytes. Escape sequences are always
  parsed correctly even past the truncation boundary.
- cypher: Add malloc/calloc NULL checks in parse_props, parse_rel_types,
  parse_in_condition, and parse_case_expr. Growth paths use non-destructive
  realloc (temp pointer) so accumulated elements can be freed on OOM
  instead of leaking through safe_realloc's free-on-failure semantics.
- store: Add sqlite3_prepare_v2 return code checks at 3 sites in
  cbm_store_schema_info and collect_pkg_names. Partially prepared
  statements are finalized before returning. Schema function cleans up
  partially populated output on failure. collect_pkg_names returns
  CBM_NOT_FOUND (not 0) to distinguish errors from empty results.
@map588 map588 force-pushed the fix/crash-safety branch from c58796c to c8661b9 Compare April 5, 2026 03:07
@map588 map588 merged commit a7333ff into main Apr 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants