Repository navigation
Make check_stack_depth() effective for embedders; guard recursive output paths (17) - #11
pyramation wants to merge 4 commits into
Conversation
…ive output paths libpg_query never calls set_stack_base(), so stack_base_ptr stays NULL and check_stack_depth() is a no-op: deep recursion in the JSON/protobuf output functions, the fingerprinter, the deparser and the PL/pgSQL JSON dump runs until the C stack overflows (a trap in WebAssembly builds). Add pg_query_set_stack_limit(bytes) so an embedder can set the reference point and limit at each entry, call check_stack_depth() at the top of _outNode, _fingerprintNode, dump_stmt/dump_expr and deparseExpr, and run pg_query_nodes_to_json in pg_query_parse_opts under PG_TRY so the 'stack depth limit exceeded' error is returned instead of being fatal.
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
|
Review complete. 🟠 3 high · 🟡 2 medium 💬 Inline comments (3)
📍 Findings outside the diff (2) — 🟠 2 high — defects on lines GitHub can't attach comments to🟠 High — Protobuf serialization unwrapped against new stack-depth error · pg_query_parse.c:195 · // src/pg_query_parse.c
195 result.parse_tree = pg_query_nodes_to_protobuf(parsetree_and_error.tree);The PR's new 🟠 High — Fingerprint paths lack // src/pg_query_fingerprint.c
350 _fingerprintNode(&ctx, parsetree_and_error.tree, NULL, NULL, 0);
351 }
The change wires PostgreSQL's stack-depth mechanism into the library: a new per-thread base pointer/limit setter is added, and every recursive serialization/deparse entry point gains a recursion guard. The guards throw
Reviewed commit: 5691182 |
There was a problem hiding this comment.
This PR adds a configurable stack-depth limit (pg_query_set_stack_limit) and check_stack_depth() guards to libpg_query's recursive walkers (parse, JSON, protobuf, fingerprint, plpgsql dump, deparse) to convert deep-nesting crashes into errors.
Key findings
- 🟠 Protobuf serialization unwrapped against new stack-depth error — pg_query_parse.c:195
- 🟠 Fingerprint paths lack
PG_TRYaround newcheck_stack_depth— pg_query_fingerprint.c:350 - 🟠 PL/pgSQL JSON dumper's
check_stack_depththrow has no handler — pg_query_json_plpgsql.c:143 - 🟡
pg_query_set_stack_limitlacks validation, declaration, per-thread docs — src_backend_tcop_postgres.c:832 - 🟡 Statement-level deparse recursion bypasses
deparseExprguard — postgres_deparse.c:787
| static void | ||
| dump_stmt(StringInfo out, PLpgSQL_stmt *node) | ||
| { | ||
| check_stack_depth(); |
There was a problem hiding this comment.
🟠 bug · high
PL/pgSQL JSON dumper's check_stack_depth throw has no handler
dump_stmt and dump_expr in src/pg_query_json_plpgsql.c:143 now call check_stack_depth(), which throws an ereport(ERROR) when nesting exceeds the stack-depth limit. The caller chain pg_query_parse_plpgsql → plpgsqlToJSON (src/pg_query_parse_plpgsql.c:492) has no PG_TRY — the enclosing PG_TRY at line 345 only covers compilation and has already exited by the time the dump runs. With no active exception handler, pg_re_throw promotes the error to FATAL and exits the host process instead of returning a PgQueryError.
📋 Prompt for AI Agents
In src/pg_query_parse_plpgsql.c, inside the loop of pg_query_parse_plpgsql (around lines 487-508), wrap func_json = plpgsqlToJSON(func_and_error.func); in a PG_TRY/PG_CATCH. In the PG_CATCH, MemoryContextSwitchTo the outer context ctx, CopyErrorData, build a malloc'd PgQueryError with strdup'd message/filename/funcname and NULL context, assign it to result.error, and FlushErrorState — mirroring the existing PG_CATCH at lines 369-388 — then skip appending the function and break out of the loop. This is needed because dump_stmt and dump_expr in src/pg_query_json_plpgsql.c now call check_stack_depth(), which throws an ereport(ERROR); with no handler the error becomes FATAL and exits the process on deeply nested PL/pgSQL input.
| pg_query_set_stack_limit(size_t bytes) | ||
| { | ||
| char here; | ||
|
|
||
| stack_base_ptr = &here; | ||
| max_stack_depth_bytes = (long) bytes; | ||
| max_stack_depth = (int) (bytes / 1024); | ||
| } |
There was a problem hiding this comment.
🟡 bug · medium
pg_query_set_stack_limit lacks validation, declaration, per-thread docs
The new exported pg_query_set_stack_limit accepts any size_t without validation (src/postgres/src_backend_tcop_postgres.c:832): with bytes == 0 it sets a non-NULL stack_base_ptr and a zero limit, so stack_is_too_deep() returns true immediately and every subsequent recursive call — even the shallowest query — fails with "stack depth limit exceeded"; with very large values, (int)(bytes / 1024) and (long) bytes overflow or wrap, silently disabling protection. The function is also not declared in the public header pg_query.h, so embedders have no ABI contract, and since all three state variables are __thread, the setting applies only to the calling thread — an embedder configuring from a setup thread gets silently different behavior on worker threads. Impact: an embedder passing an unvalidated config value gets spurious errors or silently no stack protection, with no documented contract to guide correct use.
📋 Prompt for AI Agents
- In pg_query.h, add a declaration
void pg_query_set_stack_limit(size_t bytes);near the other public entry points (around line 119), with a comment explaining that the stack base and limit are thread-local (__thread state in src/postgres/src_backend_tcop_postgres.c), so the function must be called on every thread that will run pg_query parsing, and that stack-depth checks remain inert until it is called. 2) In src/postgres/src_backend_tcop_postgres.c lines 832-839, guard the body: if bytes is below a safe minimum (e.g. 64 * 1024), either ignore the call or clamp to the existing default (100 * 1024) so that bytes = 0 cannot make stack_is_too_deep return true for every call, and clamp large values to (size_t)INT_MAX * 1024 so the int/long casts cannot overflow.
| // "a_expr" in gram.y | ||
| static void deparseExpr(DeparseState *state, Node *node, DeparseNodeContext context) | ||
| { | ||
| check_stack_depth(); |
There was a problem hiding this comment.
🟡 bug · medium
Statement-level deparse recursion bypasses deparseExpr guard
The PR's check_stack_depth() guard was added only at deparseExpr (src/postgres_deparse.c:787), but statement-level recursion does not funnel through it: deparseSubLink calls deparseSelectStmt directly (src/postgres_deparse.c:3548), and nested subqueries via deparseRangeSubselect (line 4165), set-operation operands (lines 2849, 2874), and rule/view actions form recursion cycles that never cross the guarded frame. A parse tree with deeply nested subqueries can therefore overflow the real native stack and crash the process with SIGSEGV instead of raising the intended "stack depth limit exceeded" error.
📋 Prompt for AI Agents
In src/postgres_deparse.c, add check_stack_depth(); as the first statement of deparseSelectStmt and deparseStmt — the functions that close statement-level recursion cycles (deparseSubLink at line 3548, deparseRangeSubselect at line 4165, and set-operation recursion at lines 2849/2874 call deparseSelectStmt directly without passing through the deparseExpr guard at line 787). Verify other direct self-recursive cycles (e.g. deparseConstraint, deparseAlterTableCmd via deparseSchemaStmt) are covered by these two insertion points, and add guards to any cycle still not crossing a guarded frame.
pg_query_plpgsql_reset_aliases() freed the names but kept the array, and nothing called it after the last compile, so valgrind reported a still-reachable block from pg_query_plpgsql_record_alias at exit. Free the array in reset and reset at the end of pg_query_parse_plpgsql().
Protobuf serialization, fingerprinting and the PL/pgSQL JSON dump call check_stack_depth() but ran outside PG_TRY, so a deep input with a stack limit set ended in "Terminating process due to FATAL error". Each path now catches the error and returns it in result.error. Fingerprint hash states come from the memory context so the error path does not leak them. Declares pg_query_set_stack_limit() in pg_query.h. test/stack_depth exits the process on all three paths before this change.
Summary
Port of #10 (
18-constructive) to17-constructive. In PG 17's extracted sources the stack-depth code lives insrc_backend_tcop_postgres.c, sopg_query_set_stack_limit()is added there; the rest is identical.check_stack_depth()is dead code in libpg_query becauseset_stack_base()is never called (stack_base_ptrstaysNULL), so deep recursion in the tree walkers overflows the C stack — amemory access out of boundstrap in the WebAssembly build. This PR:pg_query_set_stack_limit(size_t bytes)so an embedder can set the reference point and limit at each entry;check_stack_depth()at the top of_outNode(JSON + protobuf),_fingerprintNode,dump_stmt/dump_expr,deparseExpr(no-op for embedders that never call the setter);pg_query_nodes_to_jsoninpg_query_parse_optsunderPG_TRYso the error is returned asresult.errorinstead of being unhandled.make build,make testpass.Also: free the PL/pgSQL alias list (pre-existing valgrind failure)
The
valgrindCI jobs were already red before this PR (same still-reachable block frompg_query_plpgsql_record_aliason the branch base; proven on an unpatched18-constructiveworktree, same code here):pg_query_plpgsql_reset_aliases()freed the names but never the array, and nothing called it after the last compile. Second commit:resetfrees the array too andpg_query_parse_plpgsql()resets at the end.make test VALGRIND=1passes locally (exit 0).Review follow-up: stack-depth errors no longer exit the process
With a stack limit set, three paths call
check_stack_depth()outside anyPG_TRY, so a deep input ended inTerminating process due to FATAL errorinstead of an error result:pg_query_parse_protobuf_opts→pg_query_nodes_to_protobufpg_query_fingerprint_with_opts→_fingerprintNodepg_query_parse_plpgsql→plpgsqlToJSONEach now runs under
PG_TRY/PG_CATCHand returns the error inresult.error. Fingerprint XXH3 states are allocated from the memory context (palloc0, 64-byte aligned likeXXH_alignedMalloc), so the error path does not leak them.pg_query_set_stack_limit()is now declared inpg_query.h.New
test/stack_depth(runs inmake testand under valgrind). Before the fix all three cases exit with status 1 andTerminating process due to FATAL error; after it, all three returnstack depth limit exceeded.make testandmake test VALGRIND=1pass locally.Not changed: the review also asked for a guard on statement-level deparse recursion, and for validation of
pg_query_set_stack_limit's argument. I could not build a failing test for either (the deep-deparse crash I hit was in protobuf unpacking, not the deparser), so they are left out.Link to Devin session: https://app.devin.ai/sessions/bcd8b2f791164247acb7a659b481efc2
Open in Devin Desktop: https://app.devin.ai/desktop/session/bcd8b2f791164247acb7a659b481efc2?variant=devin
Requested by: @pyramation