fix(cypher): bound OPTIONAL fallback in expand_pattern_rels to a single ceiling - #1177
Conversation
7891465 to
a3adfbc
Compare
|
Thanks for isolating a genuine start-bound OPTIONAL expansion overflow. The current fix needs revision before further review:
This feedback is for reviewed head |
a3adfbc to
fe548db
Compare
|
Thanks revised to address all four points:
|
fe548db to
8763aa9
Compare
|
Thanks for the revision and for replacing the weak row-count assertion. I see current head |
|
Reviewed and approved in substance — I just cannot land it as-is, and the reason is our merge order rather than anything about your patch. The bug is real and I verified the index arithmetic by hand: the allocation is I merged your #1176 a few minutes ago, and both PRs insert their new tests at the same point in Could you rebase onto current Worth saying plainly: four PRs, four genuine memory-safety bugs, each with a test that actually fails without the fix. That is a real contribution to this codebase and I am glad to have them. One consequence to note for the record — once a hop saturates, subsequent OPTIONAL fallback rows are now silently dropped. That regime was undefined behaviour before, so this is strictly better; it just means results truncate rather than error on very dense graphs. |
8763aa9 to
de4aec9
Compare
|
Rebased onto current main, the conflict is resolved and the branch is now a clean fast-forward of main (git merge-base --is-ancestor upstream/main HEAD passes), so it applies cleanly. As you noted, the production hunks never conflicted; the only overlap was the test-registration area. I kept all four tests, #1176's cypher_exec_optional_empty_label_no_overflow and cypher_cross_join_alloc_rejects_overflow (now on main) alongside this PR's cypher_exec_optional_rel_ceiling_truncates_no_overflow and cypher_exec_optional_rel_leaf_fallback_survives each with its own registration. Verified locally: full cypher suite passes (166/166) under ASan+UBSan. Diff vs main is cypher.c +13/−2 (only the && new_count < max_new fallback guard + comments) and test_cypher.c +127 (just this PR's two tests, no duplication of #1176's). Head is now de4aec9. Ready when you are, thanks for the quick reviews across the set. |
|
Excellent find, and an exemplary report. This is a real heap out-of-bounds write in the agent-reachable query engine — I verified it byte-for-byte on current The bug. Your reachability analysis matches the code exactly: The fix is a pure guard with no buffer arithmetic touched. Post-fix the maximum index written is Special credit for the companion test. Rejecting a bare row-count assertion as too weak, and instead pinning a specific leaf's unbound-target row alongside a real expanded hub row, is exactly the discipline we want — it would catch an overcorrected fix that dropped all fallbacks. And reproducing the overflow under ASan first is the right venue, since the sanitizer legs are what actually bind this. This is your third overflow fix in this engine after #1173 and #1176. Genuinely appreciated — that engine is agent-reachable and these are the bugs that matter most there. One design question before merge, and I want to be clear it is a question rather than a blocker. Your guard converts the overflow into silently dropping OPTIONAL left-rows once a hop saturates. Under There is a lossless alternative: fallback rows are bounded at one per source, so sizing the hop buffer as I am not asking you to redo it on that basis alone — the status quo is heap corruption, your version is a strict improvement, and ceiling-consistency with the sibling function is a defensible choice. But the trade is worth making deliberately rather than inheriting. Would you prefer the Also needed: a trivial rebase. The conflict looks like context drift in Tiny nit while you are in there: the second test's comment says |
…ws (heap OOB) Signed-off-by: SEPURI-SAI-KRISHNA <saik20533@gmail.com>
de4aec9 to
c2a19b8
Compare
|
Went with the lossless variant, you're right that silently dropping the no-match rows is exactly backwards for OPTIONAL MATCH semantics. The hop buffer is now sized max_new + *bind_count (in size_t), and the fallback guard is gone. Since a source either feeds the expansion (≤ max_new total) or takes the fallback (≤ one per source, ≤ *bind_count) but never both, the two are additive and bounded by the allocation, no overflow, and no OPTIONAL row is ever dropped. The reproducer still bites with the old sizing: reverting the allocation to + 1 faults at the fallback write (ASan: heap-buffer-overflow, WRITE of size 1624) from the saturated-hub test; the lossless sizing clears it. I renamed that test to ..._saturated_no_overflow since the hop no longer truncates, the max_rows LIMIT caps output instead. Good catch on the bind_cap comment to, the value is right (max_rows 0 is defaulted to CYPHER_RESULT_CEILING in cbm_cypher_execute before bind_cap is computed, so it's 100000), but the defaulting was non-obvious; I've spelled that out in the comment. Rebased onto current main (head c2a19b8); cypher suite is 166/166 green under ASan+UBSan. |
|
Re-reviewed in full, and this is exactly the lossless variant we hoped for. Approved — it goes in as-is. The verification below is independent of your description, because a sizing proof is worth checking rather than accepting. The bound holds, and it holds more strongly than your argument claims. The two writers into That last part is the nice property: the bound does not actually depend on your "a source feeds either the expansion or the fallback, never both" exclusivity. That exclusivity is also true — Your saturated-hub arithmetic checks out too. 21 The companion test earns its place. One observation for a follow-up, with no action needed here: the sibling This is your third overflow fix in this engine after #1173 and #1176, and the quality has gone up each time — this one arrived with the proof already written. Thank you. The merge itself is queued behind a maintainer-side gate on my end rather than anything to do with your branch, which is |
|
Merged as Thank you again for taking the lossless route. Closing an attacker-influenced heap write is worth doing carefully, and you did it in the shape that gives up nothing: no The follow-up I mentioned stands on its own and is entirely optional — |
|
Thanks for merging, and for the pointer. I'll take the expand_from_bound_terminal (cypher.c:4552) follow-up as a separate PR, same lossless shape: size the hop bind_count*10 + bind_count and drop the new_count < max_new guard on the OPTIONAL fallback so no-match rows survive saturation, keeping the expansion itself capped at the ceiling. Will open it against current main with a RED→GREEN regression test that shows a dropped no-match row. |
…fills process_edges and expand_var_length carried the expansion budget in the LOOP condition (`ei < edge_count && *new_count < max_new`). Once new_count reached max_new they stopped iterating entirely, so match_count was never incremented for the remaining sources -- even though those sources had real neighbours. expand_pattern_rels' OPTIONAL fallback is ungated (it has a reserved slot per binding since the DeusData#1177 sizing fix), so it saw match_count == 0 and emitted an unbound row for every such source. The result is a false negative that reads as a positive assertion: MATCH (f:Function) OPTIONAL MATCH (f)-[:CALLS]->(c) WHERE c IS NULL reports a function as having no callers when it has callers, once an earlier function in the same scan saturated the budget. That is a dead-code query telling a user that live code is dead. The budget caps MATERIALISATION, never detection. Both loops now iterate the full candidate set, increment match_count whenever a real neighbour passes the filters, and gate only the write into new_bindings. The bound proof from DeusData#1177 is unchanged -- writes are still capped at max_new and fallbacks are still at most one per binding -- so allocation safety does not depend on this change. Cost: a saturated source still performs its node lookups and filter checks, the same ones the unsaturated path performs, and materialises nothing. Reported by @SEPURI-SAI-KRISHNA while working on the sibling bound-terminal path in DeusData#1378; this is the same defect in expand_pattern_rels, which was already live on main rather than introduced there. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
What does this PR do?
Fixes a heap buffer overflow (CWE-787) in
expand_pattern_rels(
src/cypher/cypher.c) when expanding anOPTIONAL MATCHrelationship pattern.The query text is agent-controlled via the MCP
querytool.The bug
The per-hop output buffer was sized
bind_cap * 10 + 1:The expansion helpers (
expand_fixed_length/expand_var_length, viaprocess_edges) correctly stop atmax_new = bind_cap * 10. But the OPTIONALfallback that keeps a row with the target left unbound was not bounded:
The
+ 1left room for a single fallback row after a saturated expansion.When one source binding saturates the expansion to
max_newand two or morelater sources take the OPTIONAL (no-match) path, the second fallback write runs
past the allocation.
Reachable whenever
bind_captracks the scanned node count — i.e. the number ofmatched start nodes exceeds the result limit — with e.g.:
That regime is hit on a repo with more start nodes than the supplied
max_rows(or, at the default limit, more than the 100000 result ceiling). A dense hub
(out-degree ≥
bind_cap * 10) saturates the buffer; the leaf nodes that followon the OPTIONAL path then overflow it.
The fix
Hold the OPTIONAL fallback to the same single global ceiling (
max_new = bind_cap * 10) the expansion helpers already respect, instead of growing theallocation. The buffer is sized for the ceiling, and every writer — expansion
and fallback alike — stops there:
Behavior at the ceiling is now defined: the hop truncates at
max_new(bounded success, no overflow, no error), matching how the sibling
expand_from_bound_terminalalready bounds its own fallback.Reproduced first (ASan, before the fix):
After the fix the full
cyphersuite passes with no sanitizer errors.Tests
Two regression tests:
cypher_exec_optional_rel_ceiling_truncates_no_overflow— a hub thatsaturates the expansion followed by leaf functions on the OPTIONAL path;
asserts the query still succeeds with a bounded result (defined truncation, no
overflow).
cypher_exec_optional_rel_leaf_fallback_survives— a non-saturating graph;asserts a specific leaf's OPTIONAL row survives with its target unbound and
that a real expanded hub row is also present (a
row_count > 0check would betoo weak — it can pass on hub rows alone).
Checklist
git commit -s) — required, CI rejectsunsigned commits (DCO, see CONTRIBUTING.md)
make -f Makefile.cbm test)